Add UnverifiedPackageT2/T3 pages, FuneralFinder pre-planning timeframe, PackageDetail variants

- UnverifiedPackageT3: package step for unverified providers (no image,
  estimated pricing disclaimer, "Make an enquiry" CTA, nearby verified
  providers section)
- UnverifiedPackageT2: same but with "Itemised Pricing Unavailable" notice
  replacing the line-item breakdown
- PackageDetail: new props — arrangeLabel, priceDisclaimer, itemizedUnavailable
- FuneralFinderV3: pre-planning follow-up question ("How soon might you
  need this?"), responsive sizing fixes, compulsory validation
- HomePage: fix finder container width (flex stretch + 500px cap)
- .gitignore: exclude Claude/Playwright artifacts, working docs, screenshots

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 10:35:28 +11:00
parent eb6cf6a185
commit 68889af9c2
10 changed files with 1320 additions and 36 deletions

16
.gitignore vendored
View File

@@ -7,3 +7,19 @@ tokens/export/
.env.* .env.*
.DS_Store .DS_Store
*.tgz *.tgz
# Claude / Playwright artifacts
.playwright-mcp/
.claude/
# Build logs
build-storybook.log
# Working docs (not for sharing)
documentation/
DESIGN.md
venue-services-snapshot.md
temp-db/
# Root-level screenshots
/*.png

View File

@@ -14,6 +14,7 @@ import { Divider } from '../../atoms/Divider';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
type Status = 'immediate' | 'preplanning'; type Status = 'immediate' | 'preplanning';
type Timeframe = 'soon' | 'future';
type FuneralType = type FuneralType =
| 'cremation-funeral' | 'cremation-funeral'
| 'cremation-only' | 'cremation-only'
@@ -26,6 +27,8 @@ type FuneralType =
export interface FuneralFinderV3SearchParams { export interface FuneralFinderV3SearchParams {
/** User's current situation */ /** User's current situation */
status: Status; status: Status;
/** Pre-planning timeframe — only present when status is 'preplanning' */
timeframe?: Timeframe;
/** Type of funeral selected (defaults to show-all if not chosen) */ /** Type of funeral selected (defaults to show-all if not chosen) */
funeralType: FuneralType; funeralType: FuneralType;
/** Suburb or postcode */ /** Suburb or postcode */
@@ -61,6 +64,11 @@ const STATUS_OPTIONS: { key: Status; title: string; description: string }[] = [
}, },
]; ];
const TIMEFRAME_OPTIONS: { key: Timeframe; label: string }[] = [
{ key: 'soon', label: 'In the coming weeks or months' },
{ key: 'future', label: 'No set timeframe, I\u2019m planning ahead' },
];
const FUNERAL_TYPE_OPTIONS: { value: FuneralType; label: string }[] = [ const FUNERAL_TYPE_OPTIONS: { value: FuneralType; label: string }[] = [
{ value: 'cremation-funeral', label: 'Cremation with funeral' }, { value: 'cremation-funeral', label: 'Cremation with funeral' },
{ {
@@ -150,12 +158,14 @@ const StatusCard = React.forwardRef<
sx={{ sx={{
fontWeight: 600, fontWeight: 600,
display: 'block', display: 'block',
mb: 0.75, fontSize: { xs: '0.875rem', sm: '1rem' },
mb: description ? 0.75 : 0,
color: selected ? 'var(--fa-color-text-brand, #B0610F)' : 'text.primary', color: selected ? 'var(--fa-color-text-brand, #B0610F)' : 'text.primary',
}} }}
> >
{title} {title}
</Typography> </Typography>
{description && (
<Typography <Typography
variant="caption" variant="caption"
component="span" component="span"
@@ -167,6 +177,7 @@ const StatusCard = React.forwardRef<
> >
{description} {description}
</Typography> </Typography>
)}
</Box> </Box>
)); ));
StatusCard.displayName = 'StatusCard'; StatusCard.displayName = 'StatusCard';
@@ -195,9 +206,9 @@ const fieldBaseSx = {
}; };
const fieldInputStyles = { const fieldInputStyles = {
py: '14px', py: '12px',
px: 2, px: 2,
fontSize: '1rem', fontSize: '0.9375rem',
fontFamily: 'var(--fa-font-family-body)', fontFamily: 'var(--fa-font-family-body)',
}; };
@@ -256,10 +267,12 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
// ─── State ─────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────
const [status, setStatus] = React.useState<Status | ''>('immediate'); const [status, setStatus] = React.useState<Status | ''>('immediate');
const [timeframe, setTimeframe] = React.useState<Timeframe | ''>('');
const [funeralType, setFuneralType] = React.useState<FuneralType | ''>(''); const [funeralType, setFuneralType] = React.useState<FuneralType | ''>('');
const [location, setLocation] = React.useState(''); const [location, setLocation] = React.useState('');
const [errors, setErrors] = React.useState<{ const [errors, setErrors] = React.useState<{
status?: boolean; status?: boolean;
timeframe?: boolean;
location?: boolean; location?: boolean;
}>({}); }>({});
@@ -290,6 +303,8 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
} }
}, [location, errors.location]); }, [location, errors.location]);
const isPrePlanning = status === 'preplanning';
// ─── Radiogroup keyboard nav (WAI-ARIA pattern) ────────── // ─── Radiogroup keyboard nav (WAI-ARIA pattern) ──────────
const activeStatusIndex = status ? STATUS_OPTIONS.findIndex((o) => o.key === status) : 0; const activeStatusIndex = status ? STATUS_OPTIONS.findIndex((o) => o.key === status) : 0;
@@ -305,10 +320,18 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
: Math.max(current - 1, 0); : Math.max(current - 1, 0);
if (next !== current) { if (next !== current) {
cardRefs.current[next]?.focus(); cardRefs.current[next]?.focus();
setStatus(STATUS_OPTIONS[next].key); const newStatus = STATUS_OPTIONS[next].key;
setStatus(newStatus);
if (newStatus !== 'preplanning') setTimeframe('');
} }
}; };
const handleStatusClick = (key: Status) => {
setStatus(key);
if (key !== 'preplanning') setTimeframe('');
if (errors.timeframe) setErrors((prev) => ({ ...prev, timeframe: false }));
};
// ─── Handlers ──────────────────────────────────────────── // ─── Handlers ────────────────────────────────────────────
const handleFuneralType = (e: SelectChangeEvent<string>) => { const handleFuneralType = (e: SelectChangeEvent<string>) => {
setFuneralType(e.target.value as FuneralType); setFuneralType(e.target.value as FuneralType);
@@ -323,6 +346,14 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
}); });
return; return;
} }
if (status === 'preplanning' && !timeframe) {
setErrors({ timeframe: true });
statusSectionRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
return;
}
if (location.trim().length < 3) { if (location.trim().length < 3) {
setErrors({ location: true }); setErrors({ location: true });
locationSectionRef.current?.scrollIntoView({ locationSectionRef.current?.scrollIntoView({
@@ -335,6 +366,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
setErrors({}); setErrors({});
onSearch?.({ onSearch?.({
status, status,
...(status === 'preplanning' && timeframe ? { timeframe } : {}),
funeralType: funeralType || 'show-all', funeralType: funeralType || 'show-all',
location: location.trim(), location: location.trim(),
}); });
@@ -355,7 +387,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
py: { xs: 4, sm: 5 }, py: { xs: 4, sm: 5 },
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 4, gap: { xs: 3, sm: 4 },
}, },
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
@@ -368,6 +400,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
sx={{ sx={{
fontFamily: 'var(--fa-font-family-display)', fontFamily: 'var(--fa-font-family-display)',
fontWeight: 600, fontWeight: 600,
fontSize: { xs: '1.25rem', sm: '1.5rem' },
mb: subheading ? 1 : 0, mb: subheading ? 1 : 0,
}} }}
> >
@@ -404,7 +437,7 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
title={opt.title} title={opt.title}
description={opt.description} description={opt.description}
selected={status === opt.key} selected={status === opt.key}
onClick={() => setStatus(opt.key)} onClick={() => handleStatusClick(opt.key)}
tabIndex={i === activeStatusIndex ? 0 : -1} tabIndex={i === activeStatusIndex ? 0 : -1}
onKeyDown={handleStatusKeyDown} onKeyDown={handleStatusKeyDown}
/> />
@@ -425,6 +458,63 @@ export const FuneralFinderV3 = React.forwardRef<HTMLDivElement, FuneralFinderV3P
</Typography> </Typography>
)} )}
</Box> </Box>
{/* ── Timeframe follow-up (pre-planning only) ────────── */}
{isPrePlanning && (
<Box
sx={{
mt: 3,
overflow: 'hidden',
animation: 'fadeSlideIn 200ms ease-out',
'@keyframes fadeSlideIn': {
'0%': { opacity: 0, transform: 'translateY(-8px)' },
'100%': { opacity: 1, transform: 'translateY(0)' },
},
}}
>
<SectionLabel id={`${id}-timeframe`}>How Soon Might You Need This?</SectionLabel>
<Box
role="radiogroup"
aria-labelledby={`${id}-timeframe`}
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 2,
mt: 2,
'& button': { fontSize: '0.8125rem' },
'& button .MuiTypography-root': { fontSize: '0.8125rem' },
}}
>
{TIMEFRAME_OPTIONS.map((opt) => (
<StatusCard
key={opt.key}
title={opt.label}
description=""
selected={timeframe === opt.key}
onClick={() => {
setTimeframe(opt.key);
if (errors.timeframe) setErrors((prev) => ({ ...prev, timeframe: false }));
}}
/>
))}
</Box>
<Box aria-live="polite" sx={{ textAlign: 'center' }}>
{errors.timeframe && (
<Typography
variant="caption"
role="alert"
sx={{
color: 'var(--fa-color-text-brand, #B0610F)',
display: 'block',
mt: 1,
}}
>
Please select a timeframe
</Typography>
)}
</Box>
</Box>
)}
</Box> </Box>
{/* ── Funeral Type ────────────────────────────────────── */} {/* ── Funeral Type ────────────────────────────────────── */}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
@@ -50,6 +51,12 @@ export interface PackageDetailProps {
arrangeDisabled?: boolean; arrangeDisabled?: boolean;
/** Whether the compare button is in loading state */ /** Whether the compare button is in loading state */
compareLoading?: boolean; compareLoading?: boolean;
/** Custom label for the arrange CTA button (default: "Make Arrangement") */
arrangeLabel?: string;
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
priceDisclaimer?: string;
/** When true, replaces the itemised breakdown with an "Itemised Pricing Unavailable" notice */
itemizedUnavailable?: boolean;
/** MUI sx prop for the root element */ /** MUI sx prop for the root element */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -116,6 +123,9 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
onCompare, onCompare,
arrangeDisabled = false, arrangeDisabled = false,
compareLoading = false, compareLoading = false,
arrangeLabel = 'Make Arrangement',
priceDisclaimer,
itemizedUnavailable = false,
sx, sx,
}, },
ref, ref,
@@ -159,6 +169,32 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
${price.toLocaleString('en-AU')} ${price.toLocaleString('en-AU')}
</Typography> </Typography>
{/* Price disclaimer */}
{priceDisclaimer && (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
mt: 1.5,
px: 1.5,
py: 1,
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
borderRadius: 'var(--fa-border-radius-sm, 6px)',
border: '1px solid',
borderColor: 'divider',
}}
>
<InfoOutlinedIcon
sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
{priceDisclaimer}
</Typography>
</Box>
)}
{/* CTA buttons */} {/* CTA buttons */}
<Box <Box
sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1.5, mt: 2.5 }} sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1.5, mt: 2.5 }}
@@ -170,7 +206,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
disabled={arrangeDisabled} disabled={arrangeDisabled}
onClick={onArrange} onClick={onArrange}
> >
Make Arrangement {arrangeLabel}
</Button> </Button>
{onCompare && ( {onCompare && (
<Button <Button
@@ -189,6 +225,35 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
{/* Package contents */} {/* Package contents */}
<Box sx={{ px: { xs: 2, sm: 3 }, py: 3 }}> <Box sx={{ px: { xs: 2, sm: 3 }, py: 3 }}>
{itemizedUnavailable ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
bgcolor: 'var(--fa-color-surface-warm, #FEF9F5)',
borderRadius: 'var(--fa-border-radius-md, 8px)',
border: '1px solid',
borderColor: 'divider',
px: 3,
py: 4,
}}
>
<InfoOutlinedIcon
sx={{ fontSize: 28, color: 'var(--fa-color-brand-500)', mb: 1.5 }}
aria-hidden
/>
<Typography variant="h6" component="p" sx={{ mb: 1 }}>
Itemised Pricing Unavailable
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360 }}>
This provider has shared their overall package price, but has not provided a
detailed breakdown of what is included.
</Typography>
</Box>
) : (
<>
{/* Main sections — included in the package price */} {/* Main sections — included in the package price */}
{sections.map((section, idx) => ( {sections.map((section, idx) => (
<Box key={section.heading} sx={{ mb: idx < sections.length - 1 ? 3 : 0 }}> <Box key={section.heading} sx={{ mb: idx < sections.length - 1 ? 3 : 0 }}>
@@ -209,6 +274,8 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
/> />
</> </>
)} )}
</>
)}
</Box> </Box>
{/* Terms & Conditions footer */} {/* Terms & Conditions footer */}

View File

@@ -271,13 +271,14 @@ export const HomePage = React.forwardRef<HTMLDivElement, HomePageProps>(
sx={{ sx={{
position: 'relative', position: 'relative',
zIndex: 2, zIndex: 2,
width: '100%',
px: 2, px: 2,
pt: 2, pt: 2,
pb: 0, pb: 0,
mb: { xs: -14, md: -18 }, mb: { xs: -14, md: -18 },
}} }}
> >
<Box sx={{ maxWidth: finderSlot ? 800 : 520, mx: 'auto' }}> <Box sx={{ width: '100%', maxWidth: finderSlot ? 500 : 520, mx: 'auto' }}>
{finderSlot || ( {finderSlot || (
<FuneralFinderV3 <FuneralFinderV3
heading="Find your local providers" heading="Find your local providers"

View File

@@ -0,0 +1,206 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { UnverifiedPackageT2 } from './UnverifiedPackageT2';
import type {
UnverifiedPackageT2Data,
UnverifiedPackageT2Provider,
NearbyVerifiedPackage,
} from './UnverifiedPackageT2';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ─────────────────────────────────────────────────────────────────
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>
);
const nav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const mockProvider: UnverifiedPackageT2Provider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
rating: 4.6,
reviewCount: 7,
};
const mockPackages: UnverifiedPackageT2Data[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 2700,
description:
'A funeral service at a chapel or church with a funeral procession, including commonly selected options.',
},
{
id: 'deluxe',
name: 'Deluxe Funeral Package',
price: 4900,
description: 'A comprehensive package with premium inclusions and expanded service options.',
},
{
id: 'catholic',
name: 'Catholic Service',
price: 3200,
description:
'Tailored for Catholic funeral traditions including a Requiem Mass and graveside prayers.',
},
];
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
{
id: 'rankins-standard',
packageName: 'Standard Cremation Package',
price: 2450,
providerName: 'Rankins Funerals',
location: 'Warrawong, NSW',
rating: 4.8,
reviewCount: 23,
},
{
id: 'easy-essential',
packageName: 'Essential Funeral Service',
price: 1950,
providerName: 'Easy Funerals',
location: 'Sydney, NSW',
rating: 4.5,
reviewCount: 42,
},
{
id: 'killick-classic',
packageName: 'Classic Farewell Package',
price: 3100,
providerName: 'Killick Family Funerals',
location: 'Shellharbour, NSW',
rating: 4.9,
reviewCount: 15,
},
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof UnverifiedPackageT2> = {
title: 'Pages/UnverifiedPackageT2',
component: UnverifiedPackageT2,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof UnverifiedPackageT2>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Select a package to see the "Itemised Pricing Unavailable" detail panel */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onNearbyPackageClick={(id) => alert(`View nearby package: ${id}`)}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── With selection ─────────────────────────────────────────────────────────
/** Package selected — detail panel shows price + unavailable notice */
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onNearbyPackageClick={(id) => alert(`View nearby package: ${id}`)}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── No nearby packages ────────────────────────────────────────────────────
/** Only this provider's packages — no nearby verified section */
export const NoNearbyPackages: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Make an enquiry')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT2
provider={mockProvider}
packages={mockPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => {}}
onBack={() => alert('Back')}
error="Please choose a package to continue."
navigation={nav}
/>
);
},
};

View File

@@ -0,0 +1,318 @@
import React from 'react';
import Box from '@mui/material/Box';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { PackageDetail } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Card } from '../../atoms/Card';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Provider summary for the compact card */
export interface UnverifiedPackageT2Provider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
/** Package data — price only, no itemised breakdown */
export interface UnverifiedPackageT2Data {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description */
description?: string;
}
/** A similar package from a nearby verified provider */
export interface NearbyVerifiedPackage {
/** Unique ID */
id: string;
/** Package name */
packageName: string;
/** Package price in dollars */
price: number;
/** Provider name */
providerName: string;
/** Provider location */
location: string;
/** Provider rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
}
/** Props for the UnverifiedPackageT2 page component */
export interface UnverifiedPackageT2Props {
/** Provider summary shown at top of the list panel (no image — unverified provider) */
provider: UnverifiedPackageT2Provider;
/** Packages with price only (no itemised breakdown) */
packages: UnverifiedPackageT2Data[];
/** Similar packages from nearby verified providers */
nearbyPackages?: NearbyVerifiedPackage[];
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Callback when "Make an enquiry" is clicked */
onArrange: () => void;
/** Callback when a nearby verified package is clicked */
onNearbyPackageClick?: (id: string) => void;
/** Callback when the provider card is clicked */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/** Validation error */
error?: string;
/** Whether the enquiry action is loading */
loading?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* UnverifiedPackageT2 — Package selection page for Tier 2 unverified providers.
*
* Similar to T3 but the provider has only shared overall package prices,
* not itemised breakdowns. The detail panel shows an "Itemized Pricing
* Unavailable" notice instead of line items.
*
* Two sections:
* - **This provider's packages**: price-only, no breakdown available
* - **Similar packages from verified providers nearby**: promoted alternatives
*
* Pure presentation component — props in, callbacks out.
*/
export const UnverifiedPackageT2: React.FC<UnverifiedPackageT2Props> = ({
provider,
packages,
nearbyPackages = [],
selectedPackageId,
onSelectPackage,
onArrange,
onNearbyPackageClick,
onProviderClick,
onBack,
error,
loading = false,
navigation,
isPrePlanning = false,
sx,
}) => {
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
const hasNearbyPackages = nearbyPackages.length > 0;
const subheading = isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.';
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={[]}
onArrange={onArrange}
arrangeDisabled={loading}
arrangeLabel="Make an enquiry"
priceDisclaimer="Prices are estimates based on publicly available information and may not reflect the provider's current pricing."
itemizedUnavailable
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 4,
}}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
Select a package to see more details.
</Typography>
</Box>
)
}
>
{/* Provider compact card — no image for unverified */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Explore available packages
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Error message */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{error}
</Typography>
)}
{/* ─── Packages ─── */}
<Box
role="radiogroup"
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* ─── Similar packages from nearby verified providers ─── */}
{hasNearbyPackages && (
<>
<Divider sx={{ mb: 2.5 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers nearby
</Typography>
</Box>
<Box
aria-label="Similar packages from nearby verified providers"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{nearbyPackages.map((pkg) => (
<Card
key={pkg.id}
variant="outlined"
interactive={!!onNearbyPackageClick}
padding="none"
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
sx={{ p: 'var(--fa-card-padding-compact)' }}
>
{/* Package name + price */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
mb: 1,
}}
>
<Typography variant="h6" component="span">
{pkg.packageName}
</Typography>
<Typography
variant="labelLg"
component="span"
color="primary"
sx={{ whiteSpace: 'nowrap' }}
>
${pkg.price.toLocaleString('en-AU')}
</Typography>
</Box>
{/* Provider info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
{pkg.rating != null && (
<>
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.rating}
{pkg.reviewCount != null ? ` (${pkg.reviewCount})` : ''}
</Typography>
</>
)}
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.location}
</Typography>
</Box>
</Card>
))}
</Box>
</>
)}
</WizardLayout>
);
};
UnverifiedPackageT2.displayName = 'UnverifiedPackageT2';
export default UnverifiedPackageT2;

View File

@@ -0,0 +1,2 @@
export { default } from './UnverifiedPackageT2';
export * from './UnverifiedPackageT2';

View File

@@ -0,0 +1,249 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { UnverifiedPackageT3 } from './UnverifiedPackageT3';
import type {
UnverifiedPackageT3Data,
UnverifiedPackageT3Provider,
NearbyVerifiedPackage,
} from './UnverifiedPackageT3';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
// ─── Helpers ─────────────────────────────────────────────────────────────────
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>
);
const nav = (
<Navigation
logo={<FALogo />}
items={[
{ label: 'FAQ', href: '/faq' },
{ label: 'Contact Us', href: '/contact' },
{ label: 'Log in', href: '/login' },
]}
/>
);
const mockProvider: UnverifiedPackageT3Provider = {
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
rating: 4.6,
reviewCount: 7,
};
const matchedPackages: UnverifiedPackageT3Data[] = [
{
id: 'everyday',
name: 'Everyday Funeral Package',
price: 2700,
description:
'This package includes a funeral service at a chapel or a church with a funeral procession. It includes many of the most commonly selected funeral options.',
sections: [
{
heading: 'Essentials',
items: [
{ name: 'Accommodation', price: 500 },
{ name: 'Death registration certificate', price: 150 },
{ name: 'Doctor fee for Cremation', price: 150 },
{ name: 'NSW Government Levy - Cremation', price: 83 },
{ name: 'Professional Mortuary Care', price: 1200 },
{ name: 'Professional Service Fee', price: 1120 },
],
},
{
heading: 'Complimentary Items',
items: [
{ name: 'Dressing Fee', price: 0 },
{ name: 'Viewing Fee', price: 0 },
],
},
],
total: 2700,
extras: {
heading: 'Extras',
items: [
{ name: 'Allowance for Flowers', price: 150, isAllowance: true },
{ name: 'Allowance for Master of Ceremonies', price: 500, isAllowance: true },
{ name: 'After Business Hours Service Surcharge', price: 150 },
{ name: 'After Hours Prayers', price: 1920 },
{ name: 'Coffin Bearing by Funeral Directors', price: 1500 },
{ name: 'Digital Recording', price: 500 },
],
},
terms:
'This package includes a funeral service at a chapel or a church with a funeral procession. Pricing may vary based on additional selections.',
},
];
const nearbyVerifiedPackages: NearbyVerifiedPackage[] = [
{
id: 'rankins-standard',
packageName: 'Standard Cremation Package',
price: 2450,
providerName: 'Rankins Funerals',
location: 'Warrawong, NSW',
rating: 4.8,
reviewCount: 23,
},
{
id: 'easy-essential',
packageName: 'Essential Funeral Service',
price: 1950,
providerName: 'Easy Funerals',
location: 'Sydney, NSW',
rating: 4.5,
reviewCount: 42,
},
{
id: 'killick-classic',
packageName: 'Classic Farewell Package',
price: 3100,
providerName: 'Killick Family Funerals',
location: 'Shellharbour, NSW',
rating: 4.9,
reviewCount: 15,
},
];
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof UnverifiedPackageT3> = {
title: 'Pages/UnverifiedPackageT3',
component: UnverifiedPackageT3,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof UnverifiedPackageT3>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Matched + other packages — select a package, see detail, click Make Arrangement */
export const Default: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── With selection ─────────────────────────────────────────────────────────
/** Package already selected — detail panel visible */
export const WithSelection: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>('everyday');
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── No other packages (all match) ─────────────────────────────────────────
/** No nearby verified packages — only this provider's packages */
export const NoNearbyPackages: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning flow — softer copy */
export const PrePlanning: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
nearbyPackages={nearbyVerifiedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => alert('Open ArrangementDialog')}
onProviderClick={() => alert('Open provider profile')}
onBack={() => alert('Back')}
navigation={nav}
isPrePlanning
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** Error shown when no package selected */
export const WithError: Story = {
render: () => {
const [selectedId, setSelectedId] = useState<string | null>(null);
return (
<UnverifiedPackageT3
provider={mockProvider}
packages={matchedPackages}
selectedPackageId={selectedId}
onSelectPackage={setSelectedId}
onArrange={() => {}}
onBack={() => alert('Back')}
error="Please choose a package to continue."
navigation={nav}
/>
);
},
};

View File

@@ -0,0 +1,333 @@
import React from 'react';
import Box from '@mui/material/Box';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { ServiceOption } from '../../molecules/ServiceOption';
import { PackageDetail } from '../../organisms/PackageDetail';
import type { PackageSection } from '../../organisms/PackageDetail';
import { Typography } from '../../atoms/Typography';
import { Card } from '../../atoms/Card';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Provider summary for the compact card */
export interface UnverifiedPackageT3Provider {
/** Provider name */
name: string;
/** Location */
location: string;
/** Image URL */
imageUrl?: string;
/** Rating */
rating?: number;
/** Review count */
reviewCount?: number;
}
/** Package data for the selection list */
export interface UnverifiedPackageT3Data {
/** Unique package ID */
id: string;
/** Package display name */
name: string;
/** Package price in dollars */
price: number;
/** Short description */
description?: string;
/** Line item sections for the detail panel */
sections: PackageSection[];
/** Total price (may differ from base price with extras) */
total?: number;
/** Extra items section (after total) */
extras?: PackageSection;
/** Terms and conditions */
terms?: string;
}
/** A similar package from a nearby verified provider */
export interface NearbyVerifiedPackage {
/** Unique ID */
id: string;
/** Package name */
packageName: string;
/** Package price in dollars */
price: number;
/** Provider name */
providerName: string;
/** Provider location */
location: string;
/** Provider rating */
rating?: number;
/** Number of reviews */
reviewCount?: number;
}
/** Props for the UnverifiedPackageT3 page component */
export interface UnverifiedPackageT3Props {
/** Provider summary shown at top of the list panel (no image — unverified provider) */
provider: UnverifiedPackageT3Provider;
/** Packages matching the user's filters from the previous step */
packages: UnverifiedPackageT3Data[];
/** Similar packages from nearby verified providers */
nearbyPackages?: NearbyVerifiedPackage[];
/** Currently selected package ID */
selectedPackageId: string | null;
/** Callback when a package is selected */
onSelectPackage: (id: string) => void;
/** Callback when "Make Arrangement" is clicked (opens ArrangementDialog) */
onArrange: () => void;
/** Callback when a nearby verified package is clicked */
onNearbyPackageClick?: (id: string) => void;
/** Callback when the provider card is clicked (opens provider profile popup) */
onProviderClick?: () => void;
/** Callback for the Back button */
onBack: () => void;
/** Validation error */
error?: string;
/** Whether the arrange action is loading */
loading?: boolean;
/** Navigation bar */
navigation?: React.ReactNode;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* UnverifiedPackageT3 — Package selection page for unverified (Tier 3) providers.
*
* List + Detail split layout. Left panel shows the selected provider
* (compact) and selectable package cards. Right panel shows the full
* detail breakdown of the selected package with "Make Arrangement" CTA.
*
* Two sections:
* - **This provider's packages**: estimated pricing from publicly available info
* - **Similar packages from verified providers nearby**: promoted alternatives
* with verified pricing, ratings, and location
*
* Selecting a package reveals its detail. Clicking "Make an enquiry"
* on the detail panel initiates contact with the unverified provider.
*
* Pure presentation component — props in, callbacks out.
*/
export const UnverifiedPackageT3: React.FC<UnverifiedPackageT3Props> = ({
provider,
packages,
nearbyPackages = [],
selectedPackageId,
onSelectPackage,
onArrange,
onNearbyPackageClick,
onProviderClick,
onBack,
error,
loading = false,
navigation,
isPrePlanning = false,
sx,
}) => {
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
const hasNearbyPackages = nearbyPackages.length > 0;
const subheading = isPrePlanning
? 'Browse estimated packages to get a sense of what this provider offers. Nothing is committed.'
: 'These packages are based on publicly available information. Make an enquiry to confirm details and pricing.';
return (
<WizardLayout
variant="list-detail"
navigation={navigation}
showBackLink
backLabel="Back"
onBack={onBack}
sx={sx}
secondaryPanel={
selectedPackage ? (
<PackageDetail
name={selectedPackage.name}
price={selectedPackage.price}
sections={selectedPackage.sections}
total={selectedPackage.total}
extras={selectedPackage.extras}
terms={selectedPackage.terms}
onArrange={onArrange}
arrangeDisabled={loading}
arrangeLabel="Make an enquiry"
priceDisclaimer="Prices are estimates based on publicly available information and may not reflect the provider's current pricing."
/>
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 300,
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 4,
}}
>
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
Select a package to see what&apos;s included.
</Typography>
</Box>
)
}
>
{/* Provider compact card — clickable to open provider profile */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
rating={provider.rating}
reviewCount={provider.reviewCount}
onClick={onProviderClick}
/>
</Box>
{/* Heading */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Explore available packages
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Error message */}
{error && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{error}
</Typography>
)}
{/* ─── Packages ─── */}
<Box
role="radiogroup"
aria-label="Funeral packages"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{packages.map((pkg) => (
<ServiceOption
key={pkg.id}
name={pkg.name}
description={pkg.description}
price={pkg.price}
selected={selectedPackageId === pkg.id}
onClick={() => onSelectPackage(pkg.id)}
/>
))}
{packages.length === 0 && (
<Box sx={{ py: 4, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
No packages match your current preferences.
</Typography>
</Box>
)}
</Box>
{/* ─── Similar packages from nearby verified providers ─── */}
{hasNearbyPackages && (
<>
<Divider sx={{ mb: 2.5 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
Similar packages from verified providers nearby
</Typography>
</Box>
<Box
aria-label="Similar packages from nearby verified providers"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{nearbyPackages.map((pkg) => (
<Card
key={pkg.id}
variant="outlined"
interactive={!!onNearbyPackageClick}
padding="none"
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
sx={{ p: 'var(--fa-card-padding-compact)' }}
>
{/* Package name + price */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 2,
mb: 1,
}}
>
<Typography variant="h6" component="span">
{pkg.packageName}
</Typography>
<Typography
variant="labelLg"
component="span"
color="primary"
sx={{ whiteSpace: 'nowrap' }}
>
${pkg.price.toLocaleString('en-AU')}
</Typography>
</Box>
{/* Provider info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
{pkg.providerName}
</Typography>
{pkg.rating != null && (
<>
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{pkg.rating}
{pkg.reviewCount != null ? ` (${pkg.reviewCount})` : ''}
</Typography>
</>
)}
<Typography variant="body2" color="text.secondary">
&middot;
</Typography>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{pkg.location}
</Typography>
</Box>
</Card>
))}
</Box>
</>
)}
</WizardLayout>
);
};
UnverifiedPackageT3.displayName = 'UnverifiedPackageT3';
export default UnverifiedPackageT3;

View File

@@ -0,0 +1,2 @@
export { default } from './UnverifiedPackageT3';
export * from './UnverifiedPackageT3';