- VenueStep: centered-form → list-map (venue cards left, map slot right) Matches ProvidersStep pattern with vertical card stack + map placeholder - CoffinsStep: centered-form → grid-sidebar (filter sidebar left, card grid right) Filters now in dedicated sidebar, cards fill the wider main area - CoffinDetailsStep: centered-form → detail-toggles (profile left, options right) Coffin image + specs on left, option RadioGroups on right Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
import React from 'react';
|
||
import Box from '@mui/material/Box';
|
||
import TextField from '@mui/material/TextField';
|
||
import MenuItem from '@mui/material/MenuItem';
|
||
import Pagination from '@mui/material/Pagination';
|
||
import type { SxProps, Theme } from '@mui/material/styles';
|
||
import { WizardLayout } from '../../templates/WizardLayout';
|
||
import { Card } from '../../atoms/Card';
|
||
import { Badge } from '../../atoms/Badge';
|
||
import { Typography } from '../../atoms/Typography';
|
||
import { Button } from '../../atoms/Button';
|
||
import { Divider } from '../../atoms/Divider';
|
||
|
||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||
|
||
/** A coffin available for selection */
|
||
export interface Coffin {
|
||
id: string;
|
||
name: string;
|
||
imageUrl: string;
|
||
price: number;
|
||
category: string;
|
||
isPopular?: boolean;
|
||
}
|
||
|
||
/** Filter category option */
|
||
export interface CoffinCategory {
|
||
value: string;
|
||
label: string;
|
||
}
|
||
|
||
/** Price range filter option */
|
||
export interface CoffinPriceRange {
|
||
value: string;
|
||
label: string;
|
||
}
|
||
|
||
/** Form values for the coffins step */
|
||
export interface CoffinsStepValues {
|
||
/** Selected coffin ID */
|
||
selectedCoffinId: string | null;
|
||
/** Active category filter */
|
||
categoryFilter: string;
|
||
/** Active price filter */
|
||
priceFilter: string;
|
||
/** Current page (1-indexed) */
|
||
page: number;
|
||
}
|
||
|
||
/** Field-level error messages */
|
||
export interface CoffinsStepErrors {
|
||
selectedCoffinId?: string;
|
||
}
|
||
|
||
/** Props for the CoffinsStep page component */
|
||
export interface CoffinsStepProps {
|
||
/** Current form values */
|
||
values: CoffinsStepValues;
|
||
/** Callback when any field value changes */
|
||
onChange: (values: CoffinsStepValues) => void;
|
||
/** Callback when the Continue button is clicked */
|
||
onContinue: () => void;
|
||
/** Callback for back navigation */
|
||
onBack?: () => void;
|
||
/** Callback for save-and-exit */
|
||
onSaveAndExit?: () => void;
|
||
/** Field-level validation errors */
|
||
errors?: CoffinsStepErrors;
|
||
/** Whether the Continue button is in a loading state */
|
||
loading?: boolean;
|
||
/** Available coffins (already filtered by parent) */
|
||
coffins: Coffin[];
|
||
/** Total count before pagination (for display) */
|
||
totalCount?: number;
|
||
/** Total pages */
|
||
totalPages?: number;
|
||
/** Category filter options */
|
||
categories?: CoffinCategory[];
|
||
/** Price range filter options */
|
||
priceRanges?: CoffinPriceRange[];
|
||
/** Whether this is a pre-planning flow */
|
||
isPrePlanning?: boolean;
|
||
/** Navigation bar */
|
||
navigation?: React.ReactNode;
|
||
/** Progress stepper */
|
||
progressStepper?: React.ReactNode;
|
||
/** Running total */
|
||
runningTotal?: React.ReactNode;
|
||
/** Hide the help bar */
|
||
hideHelpBar?: boolean;
|
||
/** MUI sx prop */
|
||
sx?: SxProps<Theme>;
|
||
}
|
||
|
||
// ─── Component ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Step 10 — Coffin selection for the FA arrangement wizard.
|
||
*
|
||
* Grid-sidebar layout: filter sidebar (left/top on mobile) + coffin
|
||
* card grid (main area). Cards show image, name, price, and optional
|
||
* "Most Popular" badge (Rec #10). Pagination at the bottom.
|
||
*
|
||
* Uses Australian terminology: "coffin" not "casket" (both types may
|
||
* appear in the catalogue — the product type is shown per card).
|
||
*
|
||
* Pure presentation component — props in, callbacks out.
|
||
* Filtering and pagination logic handled by parent.
|
||
*
|
||
* Spec: documentation/steps/steps/10_coffins.yaml
|
||
*/
|
||
export const CoffinsStep: React.FC<CoffinsStepProps> = ({
|
||
values,
|
||
onChange,
|
||
onContinue,
|
||
onBack,
|
||
onSaveAndExit,
|
||
errors,
|
||
loading = false,
|
||
coffins,
|
||
totalCount,
|
||
totalPages = 1,
|
||
categories = [
|
||
{ value: 'all', label: 'All categories' },
|
||
{ value: 'solid_timber', label: 'Solid Timber' },
|
||
{ value: 'custom_board', label: 'Custom Board' },
|
||
{ value: 'environmental', label: 'Environmental' },
|
||
{ value: 'designer', label: 'Designer' },
|
||
{ value: 'protective_metal', label: 'Protective Metal' },
|
||
],
|
||
priceRanges = [
|
||
{ value: 'all', label: 'All prices' },
|
||
{ value: 'under_2000', label: 'Under $2,000' },
|
||
{ value: '2000_4000', label: '$2,000 – $4,000' },
|
||
{ value: 'over_4000', label: 'Over $4,000' },
|
||
],
|
||
isPrePlanning = false,
|
||
navigation,
|
||
progressStepper,
|
||
runningTotal,
|
||
hideHelpBar,
|
||
sx,
|
||
}) => {
|
||
const displayCount = totalCount ?? coffins.length;
|
||
|
||
const handleFilterChange = (field: 'categoryFilter' | 'priceFilter', value: string) => {
|
||
onChange({ ...values, [field]: value, page: 1 });
|
||
};
|
||
|
||
// ─── Sidebar content (filters) ───
|
||
const sidebar = (
|
||
<Box sx={{ py: { xs: 0, md: 2 } }}>
|
||
<Typography variant="h5" sx={{ mb: 2, display: { xs: 'none', md: 'block' } }}>
|
||
Filters
|
||
</Typography>
|
||
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<TextField
|
||
select
|
||
label="Categories"
|
||
value={values.categoryFilter}
|
||
onChange={(e) => handleFilterChange('categoryFilter', e.target.value)}
|
||
fullWidth
|
||
>
|
||
{categories.map((cat) => (
|
||
<MenuItem key={cat.value} value={cat.value}>
|
||
{cat.label}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
|
||
<TextField
|
||
select
|
||
label="Price range"
|
||
value={values.priceFilter}
|
||
onChange={(e) => handleFilterChange('priceFilter', e.target.value)}
|
||
fullWidth
|
||
>
|
||
{priceRanges.map((range) => (
|
||
<MenuItem key={range.value} value={range.value}>
|
||
{range.label}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
|
||
// ─── Main content (card grid) ───
|
||
const mainContent = (
|
||
<Box
|
||
component="form"
|
||
noValidate
|
||
onSubmit={(e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
onContinue();
|
||
}}
|
||
>
|
||
{/* Page heading */}
|
||
<Typography variant="h4" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
|
||
Choose a coffin
|
||
</Typography>
|
||
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||
{isPrePlanning
|
||
? 'Browse the range to get an idea of styles and pricing. You can change your selection later.'
|
||
: 'Browse the range available with your selected provider. Use the filters to narrow your options.'}
|
||
</Typography>
|
||
|
||
<Typography variant="caption" color="text.secondary" sx={{ mb: 3, display: 'block' }}>
|
||
Selecting a coffin within your package allowance won't change your total. Coffins
|
||
outside the allowance will adjust the price.
|
||
</Typography>
|
||
|
||
{/* Results count */}
|
||
<Typography
|
||
variant="caption"
|
||
color="text.secondary"
|
||
sx={{ mb: 2, display: 'block' }}
|
||
aria-live="polite"
|
||
>
|
||
Showing {displayCount} coffin{displayCount !== 1 ? 's' : ''}
|
||
</Typography>
|
||
|
||
{/* Coffin card grid */}
|
||
<Box
|
||
role="radiogroup"
|
||
aria-label="Available coffins"
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: {
|
||
xs: '1fr',
|
||
sm: 'repeat(2, 1fr)',
|
||
lg: 'repeat(3, 1fr)',
|
||
},
|
||
gap: 2,
|
||
mb: 3,
|
||
}}
|
||
>
|
||
{coffins.map((coffin, index) => (
|
||
<Card
|
||
key={coffin.id}
|
||
interactive
|
||
selected={coffin.id === values.selectedCoffinId}
|
||
padding="none"
|
||
onClick={() => onChange({ ...values, selectedCoffinId: coffin.id })}
|
||
role="radio"
|
||
aria-checked={coffin.id === values.selectedCoffinId}
|
||
tabIndex={
|
||
values.selectedCoffinId === null
|
||
? index === 0
|
||
? 0
|
||
: -1
|
||
: coffin.id === values.selectedCoffinId
|
||
? 0
|
||
: -1
|
||
}
|
||
sx={{ overflow: 'hidden' }}
|
||
>
|
||
{/* Image */}
|
||
<Box
|
||
sx={{
|
||
position: 'relative',
|
||
height: 180,
|
||
backgroundImage: `url(${coffin.imageUrl})`,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
backgroundColor: 'var(--fa-color-surface-subtle)',
|
||
}}
|
||
role="img"
|
||
aria-label={`Photo of ${coffin.name}`}
|
||
>
|
||
{coffin.isPopular && (
|
||
<Box sx={{ position: 'absolute', top: 8, left: 8 }}>
|
||
<Badge variant="soft" color="brand" aria-label="Most popular choice">
|
||
Most Popular
|
||
</Badge>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Content */}
|
||
<Box sx={{ p: 2 }}>
|
||
<Typography variant="h6" maxLines={2} sx={{ mb: 0.5 }}>
|
||
{coffin.name}
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||
{coffin.category}
|
||
</Typography>
|
||
<Typography variant="h6" color="primary">
|
||
${coffin.price.toLocaleString('en-AU')}
|
||
</Typography>
|
||
</Box>
|
||
</Card>
|
||
))}
|
||
</Box>
|
||
|
||
{/* Validation error */}
|
||
{errors?.selectedCoffinId && (
|
||
<Typography variant="body2" color="error" sx={{ mb: 2 }} role="alert">
|
||
{errors.selectedCoffinId}
|
||
</Typography>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
|
||
<Pagination
|
||
count={totalPages}
|
||
page={values.page}
|
||
onChange={(_, page) => onChange({ ...values, page })}
|
||
color="primary"
|
||
/>
|
||
</Box>
|
||
)}
|
||
|
||
<Divider sx={{ my: 3 }} />
|
||
|
||
{/* CTAs */}
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
flexDirection: { xs: 'column-reverse', sm: 'row' },
|
||
gap: 2,
|
||
}}
|
||
>
|
||
{onSaveAndExit ? (
|
||
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
|
||
Save and continue later
|
||
</Button>
|
||
) : (
|
||
<Box />
|
||
)}
|
||
<Button type="submit" variant="contained" size="large" loading={loading}>
|
||
Continue
|
||
</Button>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
|
||
return (
|
||
<WizardLayout
|
||
variant="grid-sidebar"
|
||
navigation={navigation}
|
||
progressStepper={progressStepper}
|
||
runningTotal={runningTotal}
|
||
showBackLink={!!onBack}
|
||
backLabel="Back"
|
||
onBack={onBack}
|
||
hideHelpBar={hideHelpBar}
|
||
sx={sx}
|
||
secondaryPanel={mainContent}
|
||
>
|
||
{sidebar}
|
||
</WizardLayout>
|
||
);
|
||
};
|
||
|
||
CoffinsStep.displayName = 'CoffinsStep';
|
||
export default CoffinsStep;
|