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:
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;
|
||||
Reference in New Issue
Block a user