Add CoffinsStep page (wizard step 10)

- Coffin card grid (1/2/3 col responsive) with radiogroup pattern
- Category + price range filter dropdowns (reset page on filter change)
- "Most Popular" badge on popular coffins (Rec #10)
- Card shows image, name, category, price
- Pagination for large catalogues
- Australian terminology: "coffin" not "casket"
- Results count with aria-live, validation error with role="alert"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:59:09 +11:00
parent 41901ed81d
commit 26a6d615da
3 changed files with 559 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { CoffinsStep } from './CoffinsStep';
import type { CoffinsStepValues, CoffinsStepErrors, Coffin } from './CoffinsStep';
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' },
]}
/>
);
const sampleCoffins: Coffin[] = [
{
id: 'cedar-classic',
name: 'Cedar Classic',
imageUrl: 'https://images.unsplash.com/photo-1618220179428-22790b461013?w=400&h=300&fit=crop',
price: 2800,
category: 'Solid Timber',
isPopular: true,
},
{
id: 'oak-heritage',
name: 'Oak Heritage',
imageUrl: 'https://images.unsplash.com/photo-1555041469-a586c1029ad9?w=400&h=300&fit=crop',
price: 3500,
category: 'Solid Timber',
},
{
id: 'eco-willow',
name: 'Eco Willow Basket',
imageUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop',
price: 1200,
category: 'Environmental',
isPopular: true,
},
{
id: 'maple-serenity',
name: 'Maple Serenity',
imageUrl: 'https://images.unsplash.com/photo-1449247709967-d4461a6a6103?w=400&h=300&fit=crop',
price: 4200,
category: 'Solid Timber',
},
{
id: 'custom-rose',
name: 'Custom Rose Garden',
imageUrl: 'https://images.unsplash.com/photo-1490750967868-88aa4f44baee?w=400&h=300&fit=crop',
price: 1800,
category: 'Custom Board',
},
{
id: 'bronze-eternal',
name: 'Bronze Eternal',
imageUrl: 'https://images.unsplash.com/photo-1432462770865-65b70566d673?w=400&h=300&fit=crop',
price: 6500,
category: 'Protective Metal',
},
];
const defaultValues: CoffinsStepValues = {
selectedCoffinId: null,
categoryFilter: 'all',
priceFilter: 'all',
page: 1,
};
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof CoffinsStep> = {
title: 'Pages/CoffinsStep',
component: CoffinsStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof CoffinsStep>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Full catalogue with filters */
export const Default: Story = {
render: () => {
const [values, setValues] = useState<CoffinsStepValues>({ ...defaultValues });
const [errors, setErrors] = useState<CoffinsStepErrors>({});
const handleContinue = () => {
if (!values.selectedCoffinId) {
setErrors({ selectedCoffinId: 'Please choose a coffin to continue.' });
return;
}
alert(`Selected: ${values.selectedCoffinId}`);
};
return (
<CoffinsStep
values={values}
onChange={(v) => {
setValues(v);
setErrors({});
}}
onContinue={handleContinue}
onBack={() => alert('Back')}
onSaveAndExit={() => alert('Save')}
errors={errors}
coffins={sampleCoffins}
totalCount={19}
totalPages={2}
navigation={nav}
/>
);
},
};
// ─── Coffin selected ────────────────────────────────────────────────────────
/** A coffin is already selected */
export const CoffinSelected: Story = {
render: () => {
const [values, setValues] = useState<CoffinsStepValues>({
...defaultValues,
selectedCoffinId: 'cedar-classic',
});
return (
<CoffinsStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
coffins={sampleCoffins}
navigation={nav}
/>
);
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning variant */
export const PrePlanning: Story = {
render: () => {
const [values, setValues] = useState<CoffinsStepValues>({ ...defaultValues });
return (
<CoffinsStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
coffins={sampleCoffins}
isPrePlanning
navigation={nav}
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** No coffin selected with error */
export const WithError: Story = {
render: () => {
const [values, setValues] = useState<CoffinsStepValues>({ ...defaultValues });
return (
<CoffinsStep
values={values}
onChange={setValues}
onContinue={() => {}}
errors={{ selectedCoffinId: 'Please choose a coffin to continue.' }}
coffins={sampleCoffins}
navigation={nav}
/>
);
},
};

View File

@@ -0,0 +1,350 @@
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 });
};
return (
<WizardLayout
variant="centered-form"
navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink={!!onBack}
backLabel="Back"
onBack={onBack}
hideHelpBar={hideHelpBar}
sx={sx}
>
{/* Page heading */}
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
Choose a coffin
</Typography>
<Typography variant="body1" 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="body2" color="text.secondary" sx={{ mb: 4 }}>
Selecting a coffin within your package allowance won&apos;t change your total. Coffins
outside the allowance will adjust the price.
</Typography>
<Box
component="form"
noValidate
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
onContinue();
}}
>
{/* ─── Filters ─── */}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
mb: 3,
}}
>
<TextField
select
label="Categories"
value={values.categoryFilter}
onChange={(e) => handleFilterChange('categoryFilter', e.target.value)}
sx={{ minWidth: 200 }}
>
{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)}
sx={{ minWidth: 200 }}
>
{priceRanges.map((range) => (
<MenuItem key={range.value} value={range.value}>
{range.label}
</MenuItem>
))}
</TextField>
</Box>
{/* ─── Results count ─── */}
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }} 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)',
md: '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>
</WizardLayout>
);
};
CoffinsStep.displayName = 'CoffinsStep';
export default CoffinsStep;

View File

@@ -0,0 +1,9 @@
export { CoffinsStep, default } from './CoffinsStep';
export type {
CoffinsStepProps,
CoffinsStepValues,
CoffinsStepErrors,
Coffin,
CoffinCategory,
CoffinPriceRange,
} from './CoffinsStep';