Unify PackagesStep across tiers + polish pass
Consolidate the three tier pages (PackagesStep, UnverifiedPackageT2, UnverifiedPackageT3) into a single tier-aware PackagesStep with providerTier: 'verified' | 'tier3' | 'tier2'. Copy, CTA label, price disclaimer, and itemised-unavailable state all derive from tier via an internal TIER_COPY map. Extract NearbyPackageCard as a molecule (was duplicated inline in T2 and T3). Inherits Card atom's default elevated variant so shadow matches the primary ServiceOption cards in the same column. Add showAllFromProvider variant for the "See N more packages from this provider" flow — flat list, no grouping, no secondary list, preference filter dropped. Polish pass on PackagesStep + PackageDetail: - PackageDetail header band warm → white; added card drop-shadow. - onCompare prop wire-through (button was built in but never exposed). - Price disclaimer info-box: padding/gap/line-height tuned, icon alignment fixed (mt: '3px' matches codebase convention for 16px icons paired with body2 text). - Left-column vertical rhythm: 48px gaps between provider card / subheading / list; 128px gap (Divider my: 8) between primary and secondary sections to separate groupings. - Mobile drill-in navigation via useMediaQuery + display toggles. onSelectPackage widened to accept string | null; Back button swaps to "Back to packages" when a package is selected on mobile. Scrolls to top on drill-in. - "See all" link copy: "See N more packages from this provider →" (overflow count, no provider name — sidesteps long-name wrapping). - Verified provider image: placeholder URL → real local asset (hparsonsvenue.jpg, resized 2048×1366/591KB → 640×427/52KB). Delete legacy PackageSelectPage story in PackageDetail.stories.tsx (predated the real page components). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { NearbyPackageCard } from './NearbyPackageCard';
|
||||
|
||||
const meta: Meta<typeof NearbyPackageCard> = {
|
||||
title: 'Molecules/NearbyPackageCard',
|
||||
component: NearbyPackageCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 480, width: '100%' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NearbyPackageCard>;
|
||||
|
||||
/** Default — full metadata including rating and review count */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
packageName: 'Everyday Cremation',
|
||||
price: 4200,
|
||||
providerName: 'H.Parsons Funerals',
|
||||
location: 'Wentworth',
|
||||
rating: 4.5,
|
||||
reviewCount: 32,
|
||||
onClick: () => alert('Navigate to provider package'),
|
||||
},
|
||||
};
|
||||
|
||||
/** Without rating — provider has no reviews yet */
|
||||
export const WithoutRating: Story = {
|
||||
args: {
|
||||
packageName: 'Simple Farewell',
|
||||
price: 3800,
|
||||
providerName: 'Riverstone Funerals',
|
||||
location: 'Mildura',
|
||||
onClick: () => alert('Navigate to provider package'),
|
||||
},
|
||||
};
|
||||
|
||||
/** Non-interactive — no onClick */
|
||||
export const Static: Story = {
|
||||
args: {
|
||||
packageName: 'Everyday Cremation',
|
||||
price: 4200,
|
||||
providerName: 'H.Parsons Funerals',
|
||||
location: 'Wentworth',
|
||||
rating: 4.5,
|
||||
reviewCount: 32,
|
||||
},
|
||||
};
|
||||
|
||||
/** Stacked — as rendered in the similar-packages list */
|
||||
export const Stacked: Story = {
|
||||
render: () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<NearbyPackageCard
|
||||
packageName="Everyday Cremation"
|
||||
price={4200}
|
||||
providerName="H.Parsons Funerals"
|
||||
location="Wentworth"
|
||||
rating={4.5}
|
||||
reviewCount={32}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<NearbyPackageCard
|
||||
packageName="Traditional Farewell"
|
||||
price={6800}
|
||||
providerName="Mackay Family Funerals"
|
||||
location="Parramatta"
|
||||
rating={4.7}
|
||||
reviewCount={58}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<NearbyPackageCard
|
||||
packageName="Simple Cremation"
|
||||
price={3500}
|
||||
providerName="Coastal Funerals"
|
||||
location="Cronulla"
|
||||
rating={4.3}
|
||||
reviewCount={14}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
106
src/components/molecules/NearbyPackageCard/NearbyPackageCard.tsx
Normal file
106
src/components/molecules/NearbyPackageCard/NearbyPackageCard.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
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 type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Props for the FA NearbyPackageCard molecule */
|
||||
export interface NearbyPackageCardProps {
|
||||
/** Package display name */
|
||||
packageName: string;
|
||||
/** Package price in dollars */
|
||||
price: number;
|
||||
/** Provider display name */
|
||||
providerName: string;
|
||||
/** Provider location (suburb, city) */
|
||||
location: string;
|
||||
/** Provider rating (e.g. 4.5). Omit to hide. */
|
||||
rating?: number;
|
||||
/** Number of reviews */
|
||||
reviewCount?: number;
|
||||
/** Click handler — navigates to that provider's PackagesStep with this package loaded */
|
||||
onClick?: () => void;
|
||||
/** MUI sx prop */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compact card representing a package offered by a nearby verified provider.
|
||||
*
|
||||
* Surfaced in the "Similar packages from verified providers nearby" section
|
||||
* of the unverified-tier PackagesStep pages. Clicking the card is a route
|
||||
* change to that verified provider's PackagesStep with this package loaded.
|
||||
*
|
||||
* Composes Card + Typography.
|
||||
*/
|
||||
export const NearbyPackageCard = React.forwardRef<HTMLDivElement, NearbyPackageCardProps>(
|
||||
({ packageName, price, providerName, location, rating, reviewCount, onClick, sx }, ref) => {
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
interactive={!!onClick}
|
||||
padding="none"
|
||||
onClick={onClick}
|
||||
sx={[{ p: 'var(--fa-card-padding-compact)' }, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||
>
|
||||
{/* Package name + price */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="span">
|
||||
{packageName}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="labelLg"
|
||||
component="span"
|
||||
color="primary"
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
${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">
|
||||
{providerName}
|
||||
</Typography>
|
||||
{rating != null && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
·
|
||||
</Typography>
|
||||
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{rating}
|
||||
{reviewCount != null ? ` (${reviewCount})` : ''}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
·
|
||||
</Typography>
|
||||
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{location}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NearbyPackageCard.displayName = 'NearbyPackageCard';
|
||||
export default NearbyPackageCard;
|
||||
1
src/components/molecules/NearbyPackageCard/index.ts
Normal file
1
src/components/molecules/NearbyPackageCard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NearbyPackageCard, type NearbyPackageCardProps } from './NearbyPackageCard';
|
||||
@@ -1,17 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { PackageDetail } from './PackageDetail';
|
||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||
import { Chip } from '../../atoms/Chip';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Navigation } from '../Navigation';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
const DEMO_IMAGE =
|
||||
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
|
||||
|
||||
const essentials = [
|
||||
{
|
||||
@@ -117,41 +106,6 @@ const extras = {
|
||||
const termsText =
|
||||
'* This package includes a funeral service at a chapel or a church with a funeral procession following to the crematorium. It includes many of the most commonly selected funeral options preselected for you. Many people choose this package for the extended funeral rituals — of course, you can tailor the funeral service to meet your needs and budget as you go through the selections.';
|
||||
|
||||
const packages = [
|
||||
{
|
||||
id: 'everyday',
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 900,
|
||||
description:
|
||||
'Our most popular package with all essential services included. Suitable for a traditional chapel or church service.',
|
||||
},
|
||||
{
|
||||
id: 'deluxe',
|
||||
name: 'Deluxe Funeral Package',
|
||||
price: 1200,
|
||||
description: 'An enhanced package with premium coffin and additional floral arrangements.',
|
||||
},
|
||||
{
|
||||
id: 'essential',
|
||||
name: 'Essential Funeral Package',
|
||||
price: 600,
|
||||
description: 'A simple, dignified service covering all necessary arrangements.',
|
||||
},
|
||||
{
|
||||
id: 'catholic',
|
||||
name: 'Catholic Service',
|
||||
price: 950,
|
||||
description:
|
||||
'A service tailored for Catholic traditions including prayers and church ceremony.',
|
||||
},
|
||||
];
|
||||
|
||||
const funeralTypes = ['All', 'Cremation', 'Burial', 'Memorial', 'Catholic', 'Direct Cremation'];
|
||||
|
||||
const FALogoNav = () => (
|
||||
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
|
||||
);
|
||||
|
||||
const meta: Meta<typeof PackageDetail> = {
|
||||
title: 'Organisms/PackageDetail',
|
||||
component: PackageDetail,
|
||||
@@ -222,132 +176,3 @@ export const WithoutExtras: Story = {
|
||||
onCompare: () => alert('Compare'),
|
||||
},
|
||||
};
|
||||
|
||||
// --- Package Select Page Layout ----------------------------------------------
|
||||
|
||||
/** Full page layout — left: package list, right: detail panel */
|
||||
export const PackageSelectPage: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 'none', width: '100%' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
render: () => {
|
||||
const [selectedPkg, setSelectedPkg] = useState('everyday');
|
||||
const [activeFilter, setActiveFilter] = useState('Cremation');
|
||||
const [comparing, setComparing] = useState(false);
|
||||
|
||||
const handleCompare = () => {
|
||||
setComparing(true);
|
||||
setTimeout(() => setComparing(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Navigation
|
||||
logo={<FALogoNav />}
|
||||
items={[
|
||||
{ label: 'Provider Portal', href: '/provider-portal' },
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
||||
gap: { xs: 3, md: 4 },
|
||||
maxWidth: 'lg',
|
||||
mx: 'auto',
|
||||
px: { xs: 2, md: 4 },
|
||||
py: { xs: 2, md: 4 },
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* Left column */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="text"
|
||||
color="secondary"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 2, ml: -1 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Typography variant="h2" sx={{ mb: 3 }}>
|
||||
Select a package
|
||||
</Typography>
|
||||
|
||||
<ProviderCardCompact
|
||||
name="H.Parsons"
|
||||
location="Wentworth"
|
||||
imageUrl={DEMO_IMAGE}
|
||||
rating={4.5}
|
||||
reviewCount={11}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{/* Funeral type filter */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{funeralTypes.map((type) => (
|
||||
<Chip
|
||||
key={type}
|
||||
label={type}
|
||||
variant={activeFilter === type ? 'filled' : 'outlined'}
|
||||
selected={activeFilter === type}
|
||||
onClick={() => setActiveFilter(type)}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||
Packages
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-label="Available packages"
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
{packages.map((pkg) => (
|
||||
<ServiceOption
|
||||
key={pkg.id}
|
||||
name={pkg.name}
|
||||
price={pkg.price}
|
||||
description={pkg.description}
|
||||
selected={selectedPkg === pkg.id}
|
||||
onClick={() => setSelectedPkg(pkg.id)}
|
||||
maxDescriptionLines={2}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right column: package detail */}
|
||||
<Box sx={{ position: { md: 'sticky' }, top: { md: 96 } }}>
|
||||
<PackageDetail
|
||||
name={packages.find((p) => p.id === selectedPkg)?.name ?? ''}
|
||||
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
||||
sections={[
|
||||
{ heading: 'Essentials', items: essentials },
|
||||
{ heading: 'Optionals', items: optionals },
|
||||
]}
|
||||
total={6966}
|
||||
extras={extras}
|
||||
terms={termsText}
|
||||
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
||||
onCompare={handleCompare}
|
||||
compareLoading={comparing}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -141,6 +141,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||
boxShadow: 'var(--fa-card-shadow-default)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
@@ -149,7 +150,7 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
{/* Header band — warm bg to separate from content */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--fa-color-surface-warm)',
|
||||
bgcolor: 'background.paper',
|
||||
px: { xs: 2, sm: 3 },
|
||||
pt: 3,
|
||||
pb: 2.5,
|
||||
@@ -178,10 +179,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
gap: 1.25,
|
||||
mt: 1.5,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
|
||||
borderRadius: 'var(--fa-border-radius-sm, 6px)',
|
||||
border: '1px solid',
|
||||
@@ -189,10 +190,10 @@ export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps
|
||||
}}
|
||||
>
|
||||
<InfoOutlinedIcon
|
||||
sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
|
||||
sx={{ fontSize: 16, color: 'text.secondary', mt: '3px', flexShrink: 0 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.5 }}>
|
||||
{priceDisclaimer}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { PackagesStep } from './PackagesStep';
|
||||
import type { PackageData, PackagesStepProvider } from './PackagesStep';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
import Box from '@mui/material/Box';
|
||||
import { PackagesStep } from './PackagesStep';
|
||||
import type { NearbyVerifiedPackage, PackageData, PackagesStepProvider } from './PackagesStep';
|
||||
import { Navigation } from '../../organisms/Navigation';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,10 +35,19 @@ const nav = (
|
||||
/>
|
||||
);
|
||||
|
||||
const mockProvider: PackagesStepProvider = {
|
||||
// ─── Mock data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const verifiedProvider: PackagesStepProvider = {
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wentworth, NSW',
|
||||
imageUrl: '/images/placeholder/hparsonsvenue.jpg',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
};
|
||||
|
||||
const unverifiedProvider: PackagesStepProvider = {
|
||||
name: 'H.Parsons Funeral Directors',
|
||||
location: 'Wentworth, NSW',
|
||||
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
|
||||
rating: 4.6,
|
||||
reviewCount: 7,
|
||||
};
|
||||
@@ -147,6 +156,110 @@ const otherPackages: PackageData[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const manyOtherPackages: PackageData[] = [
|
||||
...otherPackages,
|
||||
{
|
||||
id: 'memorial',
|
||||
name: 'Memorial Service',
|
||||
price: 2400,
|
||||
description: 'A celebration-of-life service without burial or cremation on the same day.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ name: 'Professional Service Fee', price: 1200 },
|
||||
{ name: 'Venue coordination', price: 600 },
|
||||
{ name: 'Memorial book', price: 100 },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 2400,
|
||||
},
|
||||
{
|
||||
id: 'graveside',
|
||||
name: 'Graveside Service',
|
||||
price: 2900,
|
||||
description: 'A simple graveside committal, ideal for smaller family gatherings.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ name: 'Professional Mortuary Care', price: 1000 },
|
||||
{ name: 'Professional Service Fee', price: 1100 },
|
||||
{ name: 'Cemetery coordination', price: 400 },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 2900,
|
||||
},
|
||||
{
|
||||
id: 'prepaid-basic',
|
||||
name: 'Prepaid Basic Plan',
|
||||
price: 3600,
|
||||
description: 'Lock in today’s price for a basic cremation package, paid over 12 months.',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Essentials',
|
||||
items: [
|
||||
{ name: 'Locked-in pricing', price: 0, priceLabel: 'Complimentary' },
|
||||
{ name: 'Professional Service Fee', price: 1200 },
|
||||
{ name: 'Professional Mortuary Care', price: 1000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 3600,
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
const tier2Packages: PackageData[] = [
|
||||
{
|
||||
id: 't2-standard',
|
||||
name: 'Standard Funeral Service',
|
||||
price: 5200,
|
||||
description:
|
||||
'A full-service package based on publicly available information. Breakdown not available — make an enquiry to confirm what is included.',
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
id: 't2-basic',
|
||||
name: 'Basic Cremation',
|
||||
price: 3400,
|
||||
description:
|
||||
'An entry-level package based on publicly available information. Pricing is indicative only.',
|
||||
sections: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const meta: Meta<typeof PackagesStep> = {
|
||||
@@ -161,45 +274,24 @@ const meta: Meta<typeof PackagesStep> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PackagesStep>;
|
||||
|
||||
// ─── Interactive (default) ──────────────────────────────────────────────────
|
||||
// ─── Verified ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Matched + other packages — select a package, see detail, click Make Arrangement */
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={mockProvider}
|
||||
packages={matchedPackages}
|
||||
otherPackages={otherPackages}
|
||||
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 = {
|
||||
/** Verified provider — matching packages + up to 3 other packages from the same provider */
|
||||
export const Verified: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={mockProvider}
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
otherPackages={otherPackages}
|
||||
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onProviderClick={() => alert('Open provider profile')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
@@ -207,21 +299,127 @@ export const WithSelection: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── No other packages (all match) ─────────────────────────────────────────
|
||||
// ─── Verified — with "See all" link ─────────────────────────────────────────
|
||||
|
||||
/** All packages match filters — no "Other packages" section */
|
||||
export const AllMatching: Story = {
|
||||
/** Verified provider with 5+ other packages — shows first 3 + "See all N packages" link */
|
||||
export const VerifiedWithManyOtherPackages: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
secondaryList={{ kind: 'same-provider-more', packages: manyOtherPackages }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onSeeAllPackages={() => alert('Route to showAllFromProvider variant')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── "Show all from provider" variant ───────────────────────────────────────
|
||||
|
||||
/** Flat "All packages from [Provider]" view — no grouping, selected package preserved */
|
||||
export const AllFromProvider: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
const allPackages = [...matchedPackages, ...manyOtherPackages];
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={allPackages}
|
||||
showAllFromProvider
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Tier 3 (itemised breakdown) ────────────────────────────────────────────
|
||||
|
||||
/** Tier 3 unverified — itemised breakdown + "Make an enquiry" + nearby verified alternatives */
|
||||
export const Tier3: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={unverifiedProvider}
|
||||
providerTier="tier3"
|
||||
packages={matchedPackages}
|
||||
secondaryList={{ kind: 'nearby-verified', packages: nearbyVerifiedPackages }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Make an enquiry')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onNearbyPackageClick={(id) => alert(`Route to nearby package: ${id}`)}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Tier 2 (price only, no breakdown) ──────────────────────────────────────
|
||||
|
||||
/** Tier 2 unverified — price only, detail panel shows "Itemised Pricing Unavailable" */
|
||||
export const Tier2: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>('t2-standard');
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={unverifiedProvider}
|
||||
providerTier="tier2"
|
||||
packages={tier2Packages}
|
||||
secondaryList={{ kind: 'nearby-verified', packages: nearbyVerifiedPackages }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Make an enquiry')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onNearbyPackageClick={(id) => alert(`Route to nearby package: ${id}`)}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Edge cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** No selection yet — empty detail panel */
|
||||
export const NoSelection: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={mockProvider}
|
||||
packages={[...matchedPackages, ...otherPackages]}
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onProviderClick={() => alert('Open provider profile')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
@@ -229,7 +427,27 @@ export const AllMatching: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Pre-planning ───────────────────────────────────────────────────────────
|
||||
/** Verified provider with no "other packages" — primary list only */
|
||||
export const VerifiedNoSecondary: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Pre-planning flow — softer copy */
|
||||
export const PrePlanning: Story = {
|
||||
@@ -238,13 +456,15 @@ export const PrePlanning: Story = {
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={mockProvider}
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
otherPackages={otherPackages}
|
||||
secondaryList={{ kind: 'same-provider-more', packages: otherPackages }}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
onArrange={() => alert('Open ArrangementDialog')}
|
||||
onProviderClick={() => alert('Open provider profile')}
|
||||
onCompare={() => alert('Open compare view')}
|
||||
onProviderClick={() => alert('Open provider profile (future)')}
|
||||
onBack={() => alert('Back')}
|
||||
navigation={nav}
|
||||
isPrePlanning
|
||||
@@ -253,16 +473,15 @@ export const PrePlanning: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Validation error ───────────────────────────────────────────────────────
|
||||
|
||||
/** Error shown when no package selected */
|
||||
/** Validation error */
|
||||
export const WithError: Story = {
|
||||
render: () => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<PackagesStep
|
||||
provider={mockProvider}
|
||||
provider={verifiedProvider}
|
||||
providerTier="verified"
|
||||
packages={matchedPackages}
|
||||
selectedPackageId={selectedId}
|
||||
onSelectPackage={setSelectedId}
|
||||
|
||||
@@ -1,68 +1,117 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { WizardLayout } from '../../templates/WizardLayout';
|
||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
||||
import { NearbyPackageCard } from '../../molecules/NearbyPackageCard';
|
||||
import { PackageDetail } from '../../organisms/PackageDetail';
|
||||
import type { PackageSection } from '../../organisms/PackageDetail';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import type { PackageData, PackagesStepProvider, ProviderTier, SecondaryList } from './types';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
export type {
|
||||
PackageData,
|
||||
PackagesStepProvider,
|
||||
NearbyVerifiedPackage,
|
||||
ProviderTier,
|
||||
SecondaryList,
|
||||
} from './types';
|
||||
|
||||
/** Provider summary for the compact card */
|
||||
export interface PackagesStepProvider {
|
||||
/** Provider name */
|
||||
name: string;
|
||||
/** Location */
|
||||
location: string;
|
||||
/** Image URL */
|
||||
imageUrl?: string;
|
||||
/** Rating */
|
||||
rating?: number;
|
||||
/** Review count */
|
||||
reviewCount?: number;
|
||||
// ─── Tier copy map ───────────────────────────────────────────────────────────
|
||||
|
||||
interface TierCopy {
|
||||
heading: string;
|
||||
subheading: (isPrePlanning: boolean) => string;
|
||||
arrangeLabel: string;
|
||||
priceDisclaimer?: string;
|
||||
itemizedUnavailable: boolean;
|
||||
emptyDetailMessage: string;
|
||||
}
|
||||
|
||||
/** Package data for the selection list */
|
||||
export interface PackageData {
|
||||
/** 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;
|
||||
}
|
||||
const TIER_COPY: Record<ProviderTier, TierCopy> = {
|
||||
verified: {
|
||||
heading: 'Choose a funeral package',
|
||||
subheading: (isPrePlanning) =>
|
||||
isPrePlanning
|
||||
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
||||
: 'Each package includes a set of services. You can customise your selections in the next steps.',
|
||||
arrangeLabel: 'Make Arrangement',
|
||||
itemizedUnavailable: false,
|
||||
emptyDetailMessage: "Select a package to see what's included.",
|
||||
},
|
||||
tier3: {
|
||||
heading: 'Explore available packages',
|
||||
subheading: (isPrePlanning) =>
|
||||
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.',
|
||||
arrangeLabel: 'Make an enquiry',
|
||||
priceDisclaimer:
|
||||
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
|
||||
itemizedUnavailable: false,
|
||||
emptyDetailMessage: "Select a package to see what's included.",
|
||||
},
|
||||
tier2: {
|
||||
heading: 'Explore available packages',
|
||||
subheading: (isPrePlanning) =>
|
||||
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.',
|
||||
arrangeLabel: 'Make an enquiry',
|
||||
priceDisclaimer:
|
||||
"Prices are estimates based on publicly available information and may not reflect the provider's current pricing.",
|
||||
itemizedUnavailable: true,
|
||||
emptyDetailMessage: 'Select a package to see more details.',
|
||||
},
|
||||
};
|
||||
|
||||
// Show at most this many "other packages from this provider" inline before
|
||||
// switching to "top N + See all →" behaviour.
|
||||
const SAME_PROVIDER_INLINE_LIMIT = 3;
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Props for the PackagesStep page component */
|
||||
export interface PackagesStepProps {
|
||||
/** Provider summary shown at top of the list panel */
|
||||
/** Provider shown at the top of the list panel */
|
||||
provider: PackagesStepProvider;
|
||||
/** Packages matching the user's filters from the previous step */
|
||||
/** Provider tier — drives copy, CTA label, disclaimer, itemised-unavailable state */
|
||||
providerTier: ProviderTier;
|
||||
/** Packages in the primary list (filtered by user preferences, or all when `showAllFromProvider`) */
|
||||
packages: PackageData[];
|
||||
/** Other packages from this provider that didn't match filters (shown in secondary group) */
|
||||
otherPackages?: PackageData[];
|
||||
/** Secondary list below the primary one — same-provider-more or nearby-verified. Suppressed when `showAllFromProvider` is true. */
|
||||
secondaryList?: SecondaryList;
|
||||
/** 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) */
|
||||
/** Callback when a primary-list package is selected (or cleared via mobile back) */
|
||||
onSelectPackage: (id: string | null) => void;
|
||||
/** Callback when "Make Arrangement" / "Make an enquiry" is clicked */
|
||||
onArrange: () => void;
|
||||
/** Callback when the provider card is clicked (opens provider profile popup) */
|
||||
/** Callback when the "Compare" button on the PackageDetail panel is clicked */
|
||||
onCompare?: () => void;
|
||||
/** Callback when a nearby-verified package card is clicked (route change to that provider) */
|
||||
onNearbyPackageClick?: (id: string) => void;
|
||||
/**
|
||||
* Callback when "See all N packages from [Provider]" is clicked.
|
||||
* Expected to route to the same PackagesStep with `showAllFromProvider` set.
|
||||
* Only used when secondaryList.kind === 'same-provider-more' and list length > 3.
|
||||
*/
|
||||
onSeeAllPackages?: () => void;
|
||||
/** Callback when the provider card is clicked (future: opens provider profile) */
|
||||
onProviderClick?: () => void;
|
||||
/** Callback for the Back button */
|
||||
onBack: () => void;
|
||||
/**
|
||||
* When true, renders the "All packages from [Provider]" variant:
|
||||
* flat list, no grouping, no secondary list, no "Matching your preferences" heading.
|
||||
* Caller passes the full package list in `packages`.
|
||||
*/
|
||||
showAllFromProvider?: boolean;
|
||||
/** Validation error */
|
||||
error?: string;
|
||||
/** Whether the arrange action is loading */
|
||||
@@ -75,23 +124,60 @@ export interface PackagesStepProps {
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Accent bar + label — used for both "Matching your preferences" and "Other packages from [X]". */
|
||||
function GroupHeading({
|
||||
label,
|
||||
emphasis = 'primary',
|
||||
}: {
|
||||
label: string;
|
||||
emphasis?: 'primary' | 'secondary';
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 20,
|
||||
borderRadius: 1,
|
||||
bgcolor: emphasis === 'primary' ? 'primary.main' : 'text.secondary',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: emphasis === 'primary' ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 3 — Package selection page for the FA arrangement wizard.
|
||||
* Package selection step — tier-aware, unified page component.
|
||||
*
|
||||
* 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.
|
||||
* Handles all three provider tiers (verified, tier3, tier2) via the
|
||||
* `providerTier` prop. Header copy, CTA label, price disclaimer, and
|
||||
* itemised-unavailable state are derived from tier.
|
||||
*
|
||||
* Packages are split into two groups:
|
||||
* - **Matching your preferences**: packages that matched the user's filters
|
||||
* from the providers step
|
||||
* - **Other packages from [Provider]**: remaining packages outside those
|
||||
* filters, shown below a divider for passive discovery
|
||||
* Left column layout varies by `secondaryList`:
|
||||
* - `same-provider-more` (verified): primary "Matching your preferences"
|
||||
* list + "Other packages from [Provider]" list. If >3 other packages,
|
||||
* shows top 3 + "See all N packages from [Provider] →" link that routes
|
||||
* to the same page with `showAllFromProvider`.
|
||||
* - `nearby-verified` (unverified tiers): primary list + "Similar packages
|
||||
* from verified providers nearby" list (NearbyPackageCard).
|
||||
*
|
||||
* Selecting a package reveals its detail. Clicking "Make Arrangement"
|
||||
* on the detail panel triggers the ArrangementDialog (D-E).
|
||||
* When `showAllFromProvider` is true, renders a flat "All packages from
|
||||
* [Provider]" list with no grouping and no secondary list. The caller
|
||||
* preserves `selectedPackageId` across this navigation.
|
||||
*
|
||||
* Pure presentation component — props in, callbacks out.
|
||||
*
|
||||
@@ -99,191 +185,265 @@ export interface PackagesStepProps {
|
||||
*/
|
||||
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
||||
provider,
|
||||
providerTier,
|
||||
packages,
|
||||
otherPackages = [],
|
||||
secondaryList,
|
||||
selectedPackageId,
|
||||
onSelectPackage,
|
||||
onArrange,
|
||||
onCompare,
|
||||
onNearbyPackageClick,
|
||||
onSeeAllPackages,
|
||||
onProviderClick,
|
||||
onBack,
|
||||
showAllFromProvider = false,
|
||||
error,
|
||||
loading = false,
|
||||
navigation,
|
||||
isPrePlanning = false,
|
||||
sx,
|
||||
}) => {
|
||||
const allPackages = [...packages, ...otherPackages];
|
||||
const selectedPackage = allPackages.find((p) => p.id === selectedPackageId);
|
||||
const hasOtherPackages = otherPackages.length > 0;
|
||||
const copy = TIER_COPY[providerTier];
|
||||
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
|
||||
|
||||
const subheading = isPrePlanning
|
||||
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
||||
: 'Each package includes a set of services. You can customise your selections in the next steps.';
|
||||
// Mobile drill-in: when a package is selected on mobile, swap the list view
|
||||
// for the detail view. Back button clears selection to return to the list.
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const mobileShowDetail = isMobile && selectedPackageId != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (mobileShowDetail) window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}, [mobileShowDetail]);
|
||||
|
||||
const handleLayoutBack = mobileShowDetail ? () => onSelectPackage(null) : onBack;
|
||||
const layoutBackLabel = mobileShowDetail ? 'Back to packages' : 'Back';
|
||||
|
||||
// Secondary list suppressed in "show all" mode.
|
||||
const activeSecondaryList = showAllFromProvider ? undefined : secondaryList;
|
||||
const hasSecondary = Boolean(activeSecondaryList);
|
||||
|
||||
// For same-provider-more, show top N inline; surface "See all" when over limit.
|
||||
const sameProviderPackages =
|
||||
activeSecondaryList?.kind === 'same-provider-more' ? activeSecondaryList.packages : [];
|
||||
const sameProviderOverflow = sameProviderPackages.length > SAME_PROVIDER_INLINE_LIMIT;
|
||||
const sameProviderVisible = sameProviderOverflow
|
||||
? sameProviderPackages.slice(0, SAME_PROVIDER_INLINE_LIMIT)
|
||||
: sameProviderPackages;
|
||||
|
||||
const heading = showAllFromProvider ? `All packages from ${provider.name}` : copy.heading;
|
||||
const subheading = showAllFromProvider
|
||||
? `Every package ${provider.name} offers, including those outside your preferences.`
|
||||
: copy.subheading(isPrePlanning);
|
||||
|
||||
const primaryListAriaLabel = showAllFromProvider
|
||||
? `All packages from ${provider.name}`
|
||||
: 'Funeral packages';
|
||||
|
||||
return (
|
||||
<WizardLayout
|
||||
variant="list-detail"
|
||||
navigation={navigation}
|
||||
showBackLink
|
||||
backLabel="Back"
|
||||
onBack={onBack}
|
||||
backLabel={layoutBackLabel}
|
||||
onBack={handleLayoutBack}
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
<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's included.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Provider compact card — clickable to open provider profile */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<ProviderCardCompact
|
||||
name={provider.name}
|
||||
location={provider.location}
|
||||
imageUrl={provider.imageUrl}
|
||||
rating={provider.rating}
|
||||
reviewCount={provider.reviewCount}
|
||||
onClick={onProviderClick}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Heading */}
|
||||
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||
Choose a funeral package
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ─── Matching packages ─── */}
|
||||
{hasOtherPackages && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
display: {
|
||||
xs: mobileShowDetail ? 'block' : 'none',
|
||||
md: 'block',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 20,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'primary.main',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
Matching your preferences
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
{/* ─── Other packages (passive discovery) ─── */}
|
||||
{hasOtherPackages && (
|
||||
<>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{selectedPackage ? (
|
||||
<PackageDetail
|
||||
name={selectedPackage.name}
|
||||
price={selectedPackage.price}
|
||||
sections={selectedPackage.sections}
|
||||
total={selectedPackage.total}
|
||||
extras={selectedPackage.extras}
|
||||
terms={selectedPackage.terms}
|
||||
onArrange={onArrange}
|
||||
onCompare={onCompare}
|
||||
arrangeDisabled={loading}
|
||||
arrangeLabel={copy.arrangeLabel}
|
||||
priceDisclaimer={copy.priceDisclaimer}
|
||||
itemizedUnavailable={copy.itemizedUnavailable}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 20,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'text.secondary',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
minHeight: 300,
|
||||
bgcolor: 'var(--fa-color-brand-50)',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
Other packages from {provider.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-label={`Other packages from ${provider.name}`}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3, opacity: 0.85 }}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
{copy.emptyDetailMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{/* List column — hidden on mobile when a package is selected (drill-in) */}
|
||||
<Box
|
||||
sx={{
|
||||
display: {
|
||||
xs: mobileShowDetail ? 'none' : 'block',
|
||||
md: 'block',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Provider compact card */}
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<ProviderCardCompact
|
||||
name={provider.name}
|
||||
location={provider.location}
|
||||
imageUrl={provider.imageUrl}
|
||||
rating={provider.rating}
|
||||
reviewCount={provider.reviewCount}
|
||||
onClick={onProviderClick}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Heading + subheading */}
|
||||
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||
{heading}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 6 }}>
|
||||
{subheading}
|
||||
</Typography>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
|
||||
role="alert"
|
||||
>
|
||||
{otherPackages.map((pkg) => (
|
||||
<ServiceOption
|
||||
key={pkg.id}
|
||||
name={pkg.name}
|
||||
description={pkg.description}
|
||||
price={pkg.price}
|
||||
selected={selectedPackageId === pkg.id}
|
||||
onClick={() => onSelectPackage(pkg.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* ─── Primary packages ─── */}
|
||||
{/* Show "Matching your preferences" heading only when a secondary list follows */}
|
||||
{hasSecondary && !showAllFromProvider && <GroupHeading label="Matching your preferences" />}
|
||||
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-label={primaryListAriaLabel}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* ─── Secondary: same-provider-more ─── */}
|
||||
{activeSecondaryList?.kind === 'same-provider-more' && sameProviderPackages.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 8 }} />
|
||||
<GroupHeading label={`Other packages from ${provider.name}`} emphasis="secondary" />
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-label={`Other packages from ${provider.name}`}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
mb: sameProviderOverflow ? 2 : 3,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
{sameProviderVisible.map((pkg) => (
|
||||
<ServiceOption
|
||||
key={pkg.id}
|
||||
name={pkg.name}
|
||||
description={pkg.description}
|
||||
price={pkg.price}
|
||||
selected={selectedPackageId === pkg.id}
|
||||
onClick={() => onSelectPackage(pkg.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{sameProviderOverflow && onSeeAllPackages && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Link
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onSeeAllPackages}
|
||||
underline="hover"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
See {sameProviderPackages.length - SAME_PROVIDER_INLINE_LIMIT} more packages from
|
||||
this provider
|
||||
<ArrowForwardIcon sx={{ fontSize: 16 }} aria-hidden />
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ─── Secondary: nearby-verified ─── */}
|
||||
{activeSecondaryList?.kind === 'nearby-verified' &&
|
||||
activeSecondaryList.packages.length > 0 && (
|
||||
<>
|
||||
<Divider sx={{ my: 8 }} />
|
||||
<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 }}
|
||||
>
|
||||
{activeSecondaryList.packages.map((pkg) => (
|
||||
<NearbyPackageCard
|
||||
key={pkg.id}
|
||||
packageName={pkg.packageName}
|
||||
price={pkg.price}
|
||||
providerName={pkg.providerName}
|
||||
location={pkg.location}
|
||||
rating={pkg.rating}
|
||||
reviewCount={pkg.reviewCount}
|
||||
onClick={onNearbyPackageClick ? () => onNearbyPackageClick(pkg.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</WizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
97
src/components/pages/PackagesStep/types.ts
Normal file
97
src/components/pages/PackagesStep/types.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { PackageSection } from '../../organisms/PackageDetail';
|
||||
|
||||
// ─── Tier ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Provider tier — drives header copy, CTA label, disclaimer text, and
|
||||
* whether the PackageDetail panel shows an itemised breakdown.
|
||||
*
|
||||
* - `verified`: Paid-listing provider. Full data, "Make Arrangement" CTA.
|
||||
* - `tier3`: Unverified provider with itemised breakdown scraped from public info.
|
||||
* - `tier2`: Unverified provider with total price only (no itemised breakdown).
|
||||
*/
|
||||
export type ProviderTier = 'verified' | 'tier3' | 'tier2';
|
||||
|
||||
// ─── Provider ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PackagesStepProvider {
|
||||
/** Provider name */
|
||||
name: string;
|
||||
/** Location */
|
||||
location: string;
|
||||
/** Hero image — typically only supplied for verified providers */
|
||||
imageUrl?: string;
|
||||
/** Rating */
|
||||
rating?: number;
|
||||
/** Review count */
|
||||
reviewCount?: number;
|
||||
}
|
||||
|
||||
// ─── Package data ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Package data for the selection list.
|
||||
*
|
||||
* For `tier2` providers, callers should pass `sections: []` (and optionally
|
||||
* omit `total`); the detail panel switches to "Itemised Pricing Unavailable"
|
||||
* automatically based on the `providerTier` prop.
|
||||
*/
|
||||
export interface PackageData {
|
||||
/** Unique package ID */
|
||||
id: string;
|
||||
/** Package display name */
|
||||
name: string;
|
||||
/** Package price in dollars */
|
||||
price: number;
|
||||
/** Short description shown on the option card */
|
||||
description?: string;
|
||||
/** Line-item sections for the detail panel (empty for tier2) */
|
||||
sections: PackageSection[];
|
||||
/** Total price shown between main sections and extras */
|
||||
total?: number;
|
||||
/** Extra-cost items shown after the total */
|
||||
extras?: PackageSection;
|
||||
/** Terms and conditions */
|
||||
terms?: string;
|
||||
}
|
||||
|
||||
/** A package offered by a nearby verified provider (promoted on unverified pages). */
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Secondary list ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Discriminated union for the second list below the primary packages.
|
||||
*
|
||||
* - `same-provider-more`: Other packages from the same (verified) provider.
|
||||
* Rendered as a ServiceOption list. If more than 3, the list shows the
|
||||
* first 3 + a "See all N packages from [Provider]" link that navigates
|
||||
* to the same PackagesStep with preference filters off.
|
||||
* - `nearby-verified`: Similar packages from nearby verified providers,
|
||||
* promoted on unverified-tier pages. Rendered as NearbyPackageCard list.
|
||||
* Clicking a card is a route change to that provider's PackagesStep.
|
||||
*/
|
||||
export type SecondaryList =
|
||||
| {
|
||||
kind: 'same-provider-more';
|
||||
packages: PackageData[];
|
||||
}
|
||||
| {
|
||||
kind: 'nearby-verified';
|
||||
packages: NearbyVerifiedPackage[];
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,318 +0,0 @@
|
||||
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">
|
||||
·
|
||||
</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">
|
||||
·
|
||||
</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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './UnverifiedPackageT2';
|
||||
export * from './UnverifiedPackageT2';
|
||||
@@ -1,249 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,333 +0,0 @@
|
||||
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'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">
|
||||
·
|
||||
</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">
|
||||
·
|
||||
</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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './UnverifiedPackageT3';
|
||||
export * from './UnverifiedPackageT3';
|
||||
Reference in New Issue
Block a user