CoffinDetailsStep: rewrite to match VenueDetailStep layout

- Two-panel detail-toggles: gallery + specs (left), product info + CTA (right)
- Colour swatch picker (circular buttons, controlled selection, no price impact)
- Allowance-aware pricing: fully covered shows "no change to plan total",
  partially covered shows breakdown with additional cost to plan
- Product details as semantic dl list (bold label above value)
- Removed: old specs grid, termsText prop, priceNote prop, info bubble
- Added: CoffinColour type, allowanceAmount prop, onAddCoffin callback
- Fixed heading hierarchy (h1 → h2, price as p not h5) — 0 a11y violations
- Stories: FullyCovered, PartiallyCovered, NoAllowance, NoColours, Minimal,
  PrePlanning, Loading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 11:26:29 +11:00
parent 89d6652935
commit c7a8d9e906
3 changed files with 325 additions and 104 deletions

View File

@@ -1,3 +1,4 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { CoffinDetailsStep } from './CoffinDetailsStep'; import { CoffinDetailsStep } from './CoffinDetailsStep';
import type { CoffinProfile } from './CoffinDetailsStep'; import type { CoffinProfile } from './CoffinDetailsStep';
@@ -34,19 +35,59 @@ const nav = (
); );
const sampleCoffin: CoffinProfile = { const sampleCoffin: CoffinProfile = {
name: 'Cedar Classic', name: 'Richmond Rosewood Coffin',
imageUrl: 'https://images.unsplash.com/photo-1618220179428-22790b461013?w=600&h=400&fit=crop', imageUrl: 'https://images.unsplash.com/photo-1618220179428-22790b461013?w=600&h=400&fit=crop',
description: images: [
'A beautifully crafted solid cedar coffin with a natural satin finish. Handmade with care and attention to detail.', {
specs: [ src: 'https://images.unsplash.com/photo-1618220179428-22790b461013?w=600&h=400&fit=crop',
{ label: 'Range', value: 'Solid Timber' }, alt: 'Richmond Rosewood Coffin — front view',
{ label: 'Shape', value: 'Coffin' }, },
{ label: 'Wood', value: 'Cedar' }, {
{ label: 'Finish', value: 'Satin' }, src: 'https://placehold.co/600x400/F5F5F0/8B8B7E?text=Interior+Lining',
{ label: 'Hardware', value: 'Brass' }, alt: 'Richmond Rosewood Coffin — interior',
},
{
src: 'https://placehold.co/600x400/F5F5F0/8B8B7E?text=Handle+Detail',
alt: 'Richmond Rosewood Coffin — handle detail',
},
{
src: 'https://placehold.co/600x400/F5F5F0/8B8B7E?text=Side+View',
alt: 'Richmond Rosewood Coffin — side view',
},
], ],
price: 2800, description:
priceNote: 'Selecting this coffin does not change your plan total.', 'Flat lid MDF coffin with wide mould on sides and lid, fitted with 6 silver handles with a satin trim, paper veneered wood grain finish with a gloss finish.',
specs: [
{ label: 'Range', value: 'Custom Board' },
{ label: 'Shape', value: 'Coffin' },
{ label: 'Colour', value: 'Rosewood' },
{ label: 'Finish', value: 'Gloss' },
{ label: 'Hardware', value: 'Silver' },
],
price: 1750,
colours: [
{ id: 'natural', name: 'Natural Oak', hex: '#D4A76A' },
{ id: 'walnut', name: 'Walnut', hex: '#6B4226' },
{ id: 'ebony', name: 'Ebony', hex: '#2C2C2C' },
{ id: 'mahogany', name: 'Mahogany', hex: '#4E2728' },
{ id: 'white', name: 'White Wash', hex: '#E8E4DC' },
],
};
// ─── Wrapper for controlled colour state ────────────────────────────────────
const ColourWrapper: React.FC<React.ComponentProps<typeof CoffinDetailsStep>> = (props) => {
const [selectedColourId, setSelectedColourId] = React.useState(
props.selectedColourId ?? props.coffin.colours?.[0]?.id,
);
return (
<CoffinDetailsStep
{...props}
selectedColourId={selectedColourId}
onColourChange={setSelectedColourId}
/>
);
}; };
// ─── Meta ──────────────────────────────────────────────────────────────────── // ─── Meta ────────────────────────────────────────────────────────────────────
@@ -63,15 +104,79 @@ const meta: Meta<typeof CoffinDetailsStep> = {
export default meta; export default meta;
type Story = StoryObj<typeof CoffinDetailsStep>; type Story = StoryObj<typeof CoffinDetailsStep>;
// ─── Default ──────────────────────────────────────────────────────────────── // ─── Fully covered (allowance >= price) ─────────────────────────────────────
/** Coffin details — simplified view with profile + CTA (D-G) */ /** Allowance fully covers this coffin — no additional cost */
export const Default: Story = { export const FullyCovered: Story = {
render: (args) => <ColourWrapper {...args} />,
args: { args: {
coffin: sampleCoffin, coffin: { ...sampleCoffin, price: 900 },
onContinue: () => alert('Continue'), onAddCoffin: () => alert('Add coffin'),
onBack: () => alert('Back to coffin selection'), onBack: () => alert('Back to coffin selection'),
onSaveAndExit: () => alert('Save'), onSaveAndExit: () => alert('Save'),
allowanceAmount: 1000,
navigation: nav,
},
};
// ─── Partially covered (allowance < price) ──────────────────────────────────
/** Allowance partially covers this coffin — shows additional cost */
export const PartiallyCovered: Story = {
render: (args) => <ColourWrapper {...args} />,
args: {
coffin: sampleCoffin,
onAddCoffin: () => alert('Add coffin'),
onBack: () => alert('Back to coffin selection'),
onSaveAndExit: () => alert('Save'),
allowanceAmount: 500,
navigation: nav,
},
};
// ─── No allowance ───────────────────────────────────────────────────────────
/** No package allowance — price shown without context */
export const NoAllowance: Story = {
render: (args) => <ColourWrapper {...args} />,
args: {
coffin: sampleCoffin,
onAddCoffin: () => alert('Add coffin'),
onBack: () => alert('Back'),
onSaveAndExit: () => alert('Save'),
navigation: nav,
},
};
// ─── No colours ─────────────────────────────────────────────────────────────
/** Coffin with no colour options */
export const NoColours: Story = {
args: {
coffin: {
...sampleCoffin,
colours: undefined,
},
onAddCoffin: () => alert('Add coffin'),
onBack: () => alert('Back'),
onSaveAndExit: () => alert('Save'),
allowanceAmount: 500,
navigation: nav,
},
};
// ─── Minimal ────────────────────────────────────────────────────────────────
/** Coffin with no specs, colours, or description */
export const Minimal: Story = {
args: {
coffin: {
name: 'Standard White',
imageUrl: 'https://placehold.co/600x400/F5F5F0/8B8B7E?text=Standard+White',
price: 1200,
},
onAddCoffin: () => alert('Add coffin'),
onBack: () => alert('Back'),
navigation: nav, navigation: nav,
}, },
}; };
@@ -80,40 +185,27 @@ export const Default: Story = {
/** Pre-planning variant — softer copy */ /** Pre-planning variant — softer copy */
export const PrePlanning: Story = { export const PrePlanning: Story = {
render: (args) => <ColourWrapper {...args} />,
args: { args: {
coffin: sampleCoffin, coffin: sampleCoffin,
onContinue: () => alert('Continue'), onAddCoffin: () => alert('Select coffin'),
onBack: () => alert('Back'), onBack: () => alert('Back'),
isPrePlanning: true, isPrePlanning: true,
navigation: nav, allowanceAmount: 500,
},
};
// ─── Minimal info ───────────────────────────────────────────────────────────
/** Coffin with no specs or description */
export const MinimalInfo: Story = {
args: {
coffin: {
name: 'Standard White',
imageUrl: 'https://placehold.co/600x400/F5F5F0/8B8B7E?text=Standard+White',
price: 1200,
},
onContinue: () => alert('Continue'),
onBack: () => alert('Back'),
navigation: nav, navigation: nav,
}, },
}; };
// ─── Loading ──────────────────────────────────────────────────────────────── // ─── Loading ────────────────────────────────────────────────────────────────
/** Continue button loading */ /** CTA button in loading state */
export const Loading: Story = { export const Loading: Story = {
args: { args: {
coffin: sampleCoffin, coffin: sampleCoffin,
onContinue: () => {}, onAddCoffin: () => {},
onBack: () => alert('Back'), onBack: () => alert('Back'),
loading: true, loading: true,
allowanceAmount: 500,
navigation: nav, navigation: nav,
}, },
}; };

View File

@@ -6,9 +6,18 @@ import { ImageGallery } from '../../molecules/ImageGallery';
import type { GalleryImage } from '../../molecules/ImageGallery'; import type { GalleryImage } from '../../molecules/ImageGallery';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
/** Colour option for a coffin */
export interface CoffinColour {
id: string;
name: string;
/** Hex colour value for the swatch */
hex: string;
}
/** Coffin specification key-value pair */ /** Coffin specification key-value pair */
export interface CoffinSpec { export interface CoffinSpec {
label: string; label: string;
@@ -19,26 +28,38 @@ export interface CoffinSpec {
export interface CoffinProfile { export interface CoffinProfile {
name: string; name: string;
imageUrl: string; imageUrl: string;
/** Additional gallery images (if provided, imageUrl becomes the first) */ /** Additional gallery images */
images?: GalleryImage[]; images?: GalleryImage[];
/** Short description shown on the right panel */
description?: string; description?: string;
/** Product specifications (Range, Shape, Colour, Finish, Hardware) */
specs?: CoffinSpec[]; specs?: CoffinSpec[];
price: number; price: number;
priceNote?: string; /** Available colour options */
colours?: CoffinColour[];
} }
/** Package coffin allowance amount (shown in info bubble, omit if no allowance) */
export type CoffinAllowanceAmount = number;
/** Props for the CoffinDetailsStep page component */ /** Props for the CoffinDetailsStep page component */
export interface CoffinDetailsStepProps { export interface CoffinDetailsStepProps {
/** Callback when the Continue button is clicked */ /** The selected coffin profile */
onContinue: () => void; coffin: CoffinProfile;
/** Callback when "Add Coffin" is clicked */
onAddCoffin: () => void;
/** Callback for back navigation */ /** Callback for back navigation */
onBack?: () => void; onBack?: () => void;
/** Callback for save-and-exit */ /** Callback for save-and-exit */
onSaveAndExit?: () => void; onSaveAndExit?: () => void;
/** Whether the Continue button is in a loading state */ /** Whether the CTA is in a loading state */
loading?: boolean; loading?: boolean;
/** The selected coffin profile */ /** Selected colour ID (controlled) */
coffin: CoffinProfile; selectedColourId?: string;
/** Colour change callback */
onColourChange?: (colourId: string) => void;
/** Package coffin allowance amount (shown in info bubble, omit if no allowance) */
allowanceAmount?: number;
/** Whether this is a pre-planning flow */ /** Whether this is a pre-planning flow */
isPrePlanning?: boolean; isPrePlanning?: boolean;
/** Navigation bar */ /** Navigation bar */
@@ -53,27 +74,72 @@ export interface CoffinDetailsStepProps {
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
// ─── Internal: Colour Swatch ────────────────────────────────────────────────
const SWATCH_SIZE = 36;
const ColourSwatch: React.FC<{
colour: CoffinColour;
selected: boolean;
onClick: () => void;
}> = ({ colour, selected, onClick }) => (
<Box
component="button"
type="button"
onClick={onClick}
aria-label={colour.name}
aria-pressed={selected}
sx={{
width: SWATCH_SIZE,
height: SWATCH_SIZE,
borderRadius: '50%',
backgroundColor: colour.hex,
border: '2px solid',
borderColor: selected ? 'primary.main' : 'var(--fa-color-border-default)',
outline: selected ? '2px solid' : 'none',
outlineColor: 'primary.main',
outlineOffset: 2,
cursor: 'pointer',
padding: 0,
transition: 'border-color 150ms, outline 150ms',
'&:hover': {
borderColor: selected ? 'primary.main' : 'text.secondary',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: 'primary.main',
outlineOffset: 2,
},
}}
/>
);
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
/** /**
* Step 11 — Coffin Details for the FA arrangement wizard. * Step 11 — Coffin Details for the FA arrangement wizard.
* *
* Detail-toggles layout: coffin image + description on the left, * Detail-toggles layout matching VenueDetailStep pattern:
* name, specs, price, and CTA on the right. * scrollable left panel with image gallery and specs,
* sticky right panel with name, description, colour picker,
* price, allowance info, and CTA.
* *
* Customisation options (handles, lining, nameplate) have been * Reached by clicking a coffin card on CoffinsStep.
* deferred as a future enhancement (D-G). * Colour selection does not affect price.
* *
* Pure presentation component — props in, callbacks out. * Pure presentation component — props in, callbacks out.
* *
* Spec: documentation/steps/steps/11_coffin_details.yaml * Spec: documentation/steps/steps/11_coffin_details.yaml
*/ */
export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
onContinue, coffin,
onAddCoffin,
onBack, onBack,
onSaveAndExit, onSaveAndExit,
loading = false, loading = false,
coffin, selectedColourId,
onColourChange,
allowanceAmount,
isPrePlanning = false, isPrePlanning = false,
navigation, navigation,
progressStepper, progressStepper,
@@ -81,6 +147,13 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
hideHelpBar, hideHelpBar,
sx, sx,
}) => { }) => {
const selectedColour = coffin.colours?.find((c) => c.id === selectedColourId);
// Allowance impact
const hasAllowance = allowanceAmount != null;
const isFullyCovered = hasAllowance && allowanceAmount >= coffin.price;
const additionalCost = hasAllowance && !isFullyCovered ? coffin.price - allowanceAmount : 0;
return ( return (
<WizardLayout <WizardLayout
variant="detail-toggles" variant="detail-toggles"
@@ -94,94 +167,144 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
sx={sx} sx={sx}
secondaryPanel={ secondaryPanel={
<Box sx={{ position: 'sticky', top: 24 }}> <Box sx={{ position: 'sticky', top: 24 }}>
{/* Coffin name */} {/* ─── Coffin name ─── */}
<Typography variant="h4" component="h1" sx={{ mb: 1 }}> <Typography variant="h4" component="h1" sx={{ mb: 1 }}>
{coffin.name} {coffin.name}
</Typography> </Typography>
{/* Price */} {/* ─── Short description ─── */}
<Typography variant="h5" color="primary" sx={{ mb: 1 }}> {coffin.description && (
${coffin.price.toLocaleString('en-AU')}
</Typography>
{coffin.priceNote && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{coffin.priceNote} {coffin.description}
</Typography> </Typography>
)} )}
{/* Add coffin CTA */} {/* ─── Colour picker ─── */}
{coffin.colours && coffin.colours.length > 0 && onColourChange && (
<Box sx={{ mb: 3 }}>
<Typography
variant="labelSm"
color="text.secondary"
sx={{ mb: 1.5, display: 'block' }}
>
Colour
</Typography>
<Box
role="group"
aria-label="Coffin colour"
sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 1 }}
>
{coffin.colours.map((colour) => (
<ColourSwatch
key={colour.id}
colour={colour}
selected={colour.id === selectedColourId}
onClick={() => onColourChange(colour.id)}
/>
))}
</Box>
{selectedColour && (
<Typography variant="body2" color="text.primary">
{selectedColour.name}
</Typography>
)}
</Box>
)}
{/* ─── Price ─── */}
<Typography
variant="h5"
component="p"
color="primary"
sx={{ mb: hasAllowance ? 0.5 : 3 }}
>
${coffin.price.toLocaleString('en-AU', { minimumFractionDigits: 2 })}
</Typography>
{/* ─── Allowance impact ─── */}
{isFullyCovered && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Included in your package allowance no change to your plan total.
</Typography>
)}
{hasAllowance && !isFullyCovered && (
<Box sx={{ mb: 3 }}>
<Typography variant="body2" color="text.secondary">
${allowanceAmount.toLocaleString('en-AU')} package allowance applied
</Typography>
<Typography variant="label" color="primary">
+${additionalCost.toLocaleString('en-AU', { minimumFractionDigits: 2 })} to your
plan total
</Typography>
</Box>
)}
{/* ─── Add Coffin CTA ─── */}
<Box <Box
component="form" component="form"
noValidate noValidate
aria-busy={loading} aria-busy={loading}
onSubmit={(e: React.FormEvent) => { onSubmit={(e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!loading) onContinue(); if (!loading) onAddCoffin();
}} }}
> >
<Button <Button type="submit" variant="contained" size="large" fullWidth loading={loading}>
type="submit" {isPrePlanning ? 'Select coffin' : 'Add coffin'}
variant="contained"
size="large"
fullWidth
loading={loading}
sx={{ mb: 3 }}
>
{isPrePlanning ? 'Select this coffin' : 'Add coffin'}
</Button> </Button>
</Box> </Box>
{/* ─── Save and exit ─── */}
{onSaveAndExit && ( {onSaveAndExit && (
<Button <>
variant="text" <Divider sx={{ my: 3 }} />
color="secondary" <Button variant="text" color="secondary" fullWidth onClick={onSaveAndExit}>
fullWidth Save and continue later
onClick={onSaveAndExit} </Button>
sx={{ mb: 3 }} </>
>
Save and continue later
</Button>
)}
{/* Specs */}
{coffin.specs && coffin.specs.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gap: 0.5,
columnGap: 2,
}}
>
{coffin.specs.map((spec) => (
<React.Fragment key={spec.label}>
<Typography variant="labelSm" color="text.secondary">
{spec.label}
</Typography>
<Typography variant="body2">{spec.value}</Typography>
</React.Fragment>
))}
</Box>
)} )}
</Box> </Box>
} }
> >
{/* Left panel: image gallery + description */} {/* ═══════ LEFT PANEL: scrollable content ═══════ */}
{/* ─── Image gallery ─── */}
<ImageGallery <ImageGallery
images={ images={
coffin.images && coffin.images.length > 0 coffin.images && coffin.images.length > 0
? coffin.images ? coffin.images
: [{ src: coffin.imageUrl, alt: `Photo of ${coffin.name}` }] : [{ src: coffin.imageUrl, alt: `Photo of ${coffin.name}` }]
} }
heroHeight={{ xs: 280, md: 400 }} heroHeight={{ xs: 280, md: 420 }}
sx={{ mb: 3 }} sx={{ mb: 3 }}
/> />
{coffin.description && ( {/* ─── Product details ─── */}
<Typography variant="body1" color="text.secondary"> {coffin.specs && coffin.specs.length > 0 && (
{coffin.description} <>
</Typography> <Divider sx={{ my: 3 }} />
<Box
component="dl"
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1.5,
m: 0,
}}
>
{coffin.specs.map((spec) => (
<Box key={spec.label}>
<Typography component="dt" variant="label" sx={{ display: 'block', mb: 0.25 }}>
{spec.label}
</Typography>
<Typography component="dd" variant="body2" color="text.secondary" sx={{ m: 0 }}>
{spec.value}
</Typography>
</Box>
))}
</Box>
</>
)} )}
</WizardLayout> </WizardLayout>
); );

View File

@@ -1,2 +1,8 @@
export { CoffinDetailsStep, default } from './CoffinDetailsStep'; export { CoffinDetailsStep, default } from './CoffinDetailsStep';
export type { CoffinDetailsStepProps, CoffinProfile, CoffinSpec } from './CoffinDetailsStep'; export type {
CoffinDetailsStepProps,
CoffinProfile,
CoffinSpec,
CoffinColour,
CoffinAllowanceAmount,
} from './CoffinDetailsStep';