Add PackagesStep page (wizard step 3)
List-detail split: ProviderCardCompact + selectable ServiceOption cards (left), PackageDetail breakdown (right). Budget range filter (4 options), "Most Popular" badge on qualifying packages. Radiogroup selection pattern (ServiceOption has built-in role="radio"). Mobile Continue button visible when detail panel stacks below. Grief-sensitive copy, pre-planning variant. Pure presentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
303
src/components/pages/PackagesStep/PackagesStep.stories.tsx
Normal file
303
src/components/pages/PackagesStep/PackagesStep.stories.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// ─── 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: PackagesStepProvider = {
|
||||||
|
name: 'H.Parsons Funeral Directors',
|
||||||
|
location: 'Wentworth, NSW',
|
||||||
|
imageUrl: 'https://placehold.co/120x80/E8E0D6/8B6F47?text=H.Parsons',
|
||||||
|
rating: 4.6,
|
||||||
|
reviewCount: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPackages: PackageData[] = [
|
||||||
|
{
|
||||||
|
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.',
|
||||||
|
popular: true,
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deluxe',
|
||||||
|
name: 'Deluxe Funeral Package',
|
||||||
|
price: 4900,
|
||||||
|
description:
|
||||||
|
'A comprehensive package with premium inclusions, higher-quality coffin selection, and expanded service options for families wanting a more personalised farewell.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Essentials',
|
||||||
|
items: [
|
||||||
|
{ name: 'Accommodation', price: 750 },
|
||||||
|
{ name: 'Death registration certificate', price: 150 },
|
||||||
|
{ name: 'Professional Mortuary Care', price: 1500 },
|
||||||
|
{ name: 'Professional Service Fee', price: 1500 },
|
||||||
|
{ name: 'Premium Coffin', price: 1000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 4900,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'essential',
|
||||||
|
name: 'Essential Funeral Package',
|
||||||
|
price: 1800,
|
||||||
|
description:
|
||||||
|
'A simple, dignified option covering the essential requirements for a cremation service.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Essentials',
|
||||||
|
items: [
|
||||||
|
{ name: 'Death registration certificate', price: 150 },
|
||||||
|
{ name: 'Professional Mortuary Care', price: 800 },
|
||||||
|
{ name: 'Professional Service Fee', price: 850 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1800,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'catholic',
|
||||||
|
name: 'Catholic Service',
|
||||||
|
price: 3200,
|
||||||
|
description:
|
||||||
|
'Tailored for Catholic funeral traditions including a Requiem Mass, graveside prayers, and coordination with parish requirements.',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Essentials',
|
||||||
|
items: [
|
||||||
|
{ name: 'Accommodation', price: 500 },
|
||||||
|
{ name: 'Professional Mortuary Care', price: 1200 },
|
||||||
|
{ name: 'Professional Service Fee', price: 1200 },
|
||||||
|
{ name: 'Church coordination', price: 300 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const meta: Meta<typeof PackagesStep> = {
|
||||||
|
title: 'Pages/PackagesStep',
|
||||||
|
component: PackagesStep,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof PackagesStep>;
|
||||||
|
|
||||||
|
// ─── Interactive (default) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fully interactive — browse, filter, select a package, see detail */
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [budget, setBudget] = useState('all');
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
budget === 'all'
|
||||||
|
? mockPackages
|
||||||
|
: mockPackages.filter((p) => {
|
||||||
|
const [min, max] = budget.split('-').map(Number);
|
||||||
|
return p.price >= min && p.price <= (max || Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
setError('Please choose a package to continue.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(undefined);
|
||||||
|
alert(`Continue with package: ${selectedId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={mockProvider}
|
||||||
|
packages={filtered}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={(id) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
budgetFilter={budget}
|
||||||
|
onBudgetFilterChange={setBudget}
|
||||||
|
onContinue={handleContinue}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
error={error}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── With selection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Package already selected — detail panel visible */
|
||||||
|
export const WithSelection: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>('everyday');
|
||||||
|
const [budget, setBudget] = useState('all');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={mockProvider}
|
||||||
|
packages={mockPackages}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
budgetFilter={budget}
|
||||||
|
onBudgetFilterChange={setBudget}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Pre-planning ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Pre-planning flow — softer helper text */
|
||||||
|
export const PrePlanning: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [budget, setBudget] = useState('all');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={mockProvider}
|
||||||
|
packages={mockPackages}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
budgetFilter={budget}
|
||||||
|
onBudgetFilterChange={setBudget}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
isPrePlanning
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Filtered empty ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Budget filter yielding no results */
|
||||||
|
export const FilteredEmpty: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [budget, setBudget] = useState('7000-10000');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={mockProvider}
|
||||||
|
packages={[]}
|
||||||
|
selectedPackageId={null}
|
||||||
|
onSelectPackage={() => {}}
|
||||||
|
budgetFilter={budget}
|
||||||
|
onBudgetFilterChange={setBudget}
|
||||||
|
onContinue={() => {}}
|
||||||
|
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);
|
||||||
|
const [budget, setBudget] = useState('all');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PackagesStep
|
||||||
|
provider={mockProvider}
|
||||||
|
packages={mockPackages}
|
||||||
|
selectedPackageId={selectedId}
|
||||||
|
onSelectPackage={setSelectedId}
|
||||||
|
budgetFilter={budget}
|
||||||
|
onBudgetFilterChange={setBudget}
|
||||||
|
onContinue={() => {}}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
error="Please choose a package to continue."
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
289
src/components/pages/PackagesStep/PackagesStep.tsx
Normal file
289
src/components/pages/PackagesStep/PackagesStep.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
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 { Badge } from '../../atoms/Badge';
|
||||||
|
import { Button } from '../../atoms/Button';
|
||||||
|
|
||||||
|
// ─── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
/** Whether this is a "Most Popular" package */
|
||||||
|
popular?: boolean;
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Budget filter option */
|
||||||
|
export interface BudgetOption {
|
||||||
|
/** Option value */
|
||||||
|
value: string;
|
||||||
|
/** Display label */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the PackagesStep page component */
|
||||||
|
export interface PackagesStepProps {
|
||||||
|
/** Provider summary shown at top of the list panel */
|
||||||
|
provider: PackagesStepProvider;
|
||||||
|
/** Available packages */
|
||||||
|
packages: PackageData[];
|
||||||
|
/** Currently selected package ID */
|
||||||
|
selectedPackageId: string | null;
|
||||||
|
/** Callback when a package is selected */
|
||||||
|
onSelectPackage: (id: string) => void;
|
||||||
|
/** Current budget filter value */
|
||||||
|
budgetFilter: string;
|
||||||
|
/** Callback when budget filter changes */
|
||||||
|
onBudgetFilterChange: (value: string) => void;
|
||||||
|
/** Budget filter options */
|
||||||
|
budgetOptions?: BudgetOption[];
|
||||||
|
/** Callback for the Continue button */
|
||||||
|
onContinue: () => void;
|
||||||
|
/** Callback for the Back button */
|
||||||
|
onBack: () => void;
|
||||||
|
/** Validation error */
|
||||||
|
error?: string;
|
||||||
|
/** Whether Continue is loading */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Navigation bar */
|
||||||
|
navigation?: React.ReactNode;
|
||||||
|
/** Whether this is a pre-planning flow */
|
||||||
|
isPrePlanning?: boolean;
|
||||||
|
/** MUI sx prop */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_BUDGET_OPTIONS: BudgetOption[] = [
|
||||||
|
{ value: 'all', label: 'All packages' },
|
||||||
|
{ value: '2000-4000', label: '$2,000 \u2013 $4,000' },
|
||||||
|
{ value: '4000-7000', label: '$4,000 \u2013 $7,000' },
|
||||||
|
{ value: '7000-10000', label: '$7,000 \u2013 $10,000+' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 — Package selection page for the FA arrangement wizard.
|
||||||
|
*
|
||||||
|
* List + Detail split layout. Left panel shows the selected provider
|
||||||
|
* (compact), a budget filter, and selectable package cards. Right panel
|
||||||
|
* shows the full detail breakdown of the selected package.
|
||||||
|
*
|
||||||
|
* Packages are displayed as ServiceOption cards in a radiogroup pattern.
|
||||||
|
* "Most Popular" badge on qualifying packages reduces decision paralysis.
|
||||||
|
*
|
||||||
|
* Pure presentation component — props in, callbacks out.
|
||||||
|
*
|
||||||
|
* Spec: documentation/steps/steps/03_packages.yaml
|
||||||
|
*/
|
||||||
|
export const PackagesStep: React.FC<PackagesStepProps> = ({
|
||||||
|
provider,
|
||||||
|
packages,
|
||||||
|
selectedPackageId,
|
||||||
|
onSelectPackage,
|
||||||
|
budgetFilter,
|
||||||
|
onBudgetFilterChange,
|
||||||
|
budgetOptions = DEFAULT_BUDGET_OPTIONS,
|
||||||
|
onContinue,
|
||||||
|
onBack,
|
||||||
|
error,
|
||||||
|
loading = false,
|
||||||
|
navigation,
|
||||||
|
isPrePlanning = false,
|
||||||
|
sx,
|
||||||
|
}) => {
|
||||||
|
const selectedPackage = packages.find((p) => p.id === selectedPackageId);
|
||||||
|
|
||||||
|
const subheading =
|
||||||
|
'Each package includes a set of services. You can customise your selections in the next steps.';
|
||||||
|
const helperText = isPrePlanning
|
||||||
|
? 'Compare packages to find what suits your wishes. Nothing is committed until you confirm.'
|
||||||
|
: 'Prices shown include the base services listed. Additional options may change the total.';
|
||||||
|
|
||||||
|
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={onContinue}
|
||||||
|
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 */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<ProviderCardCompact
|
||||||
|
name={provider.name}
|
||||||
|
location={provider.location}
|
||||||
|
imageUrl={provider.imageUrl}
|
||||||
|
rating={provider.rating}
|
||||||
|
reviewCount={provider.reviewCount}
|
||||||
|
/>
|
||||||
|
</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: 1 }}>
|
||||||
|
{subheading}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 3, display: 'block' }}>
|
||||||
|
{helperText}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Budget filter */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={budgetFilter}
|
||||||
|
onChange={(e) => onBudgetFilterChange(e.target.value)}
|
||||||
|
label="Budget range"
|
||||||
|
sx={{ width: { xs: '100%', sm: 240 } }}
|
||||||
|
>
|
||||||
|
{budgetOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<Typography variant="body2" color="error" sx={{ mb: 2 }} role="alert">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Package list — radiogroup pattern */}
|
||||||
|
<Box
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Funeral packages"
|
||||||
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
||||||
|
>
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<Box key={pkg.id} sx={{ position: 'relative' }}>
|
||||||
|
{pkg.popular && (
|
||||||
|
<Badge
|
||||||
|
variant="filled"
|
||||||
|
color="brand"
|
||||||
|
size="small"
|
||||||
|
aria-label="Most popular choice"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -8,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Most Popular
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ServiceOption
|
||||||
|
name={pkg.name}
|
||||||
|
description={pkg.description}
|
||||||
|
price={pkg.price}
|
||||||
|
selected={selectedPackageId === pkg.id}
|
||||||
|
onClick={() => onSelectPackage(pkg.id)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{packages.length === 0 && (
|
||||||
|
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
No packages match the selected budget range.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile: Continue button (desktop uses PackageDetail's CTA) */}
|
||||||
|
<Box sx={{ display: { xs: 'flex', md: 'none' }, justifyContent: 'flex-end', pb: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
onClick={onContinue}
|
||||||
|
disabled={!selectedPackageId}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</WizardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PackagesStep.displayName = 'PackagesStep';
|
||||||
|
export default PackagesStep;
|
||||||
2
src/components/pages/PackagesStep/index.ts
Normal file
2
src/components/pages/PackagesStep/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './PackagesStep';
|
||||||
|
export * from './PackagesStep';
|
||||||
Reference in New Issue
Block a user