Files
Parsons/src/components/pages/CoffinsStep/CoffinsStep.tsx
Richie 7a06f89e84 ConfirmationStep redesign, page tweaks across wizard
- ConfirmationStep: animated SVG tick, "What happens next" warm card,
  bullet-point layout, contactPhone prop, link-based secondary actions
- VenueStep + ProvidersStep: sticky search bar padding fix, off-white
  bg behind card lists
- IntroStep, CemeteryStep, CrematoriumStep, DateTimeStep: add divider
  under subheading for visual separation
- CoffinsStep: h4 heading (matches VenueStep/ProvidersStep list layout),
  sidebar headings h5 → h6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:09:12 +11:00

620 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import Paper from '@mui/material/Paper';
import Slider from '@mui/material/Slider';
import Pagination from '@mui/material/Pagination';
import InputAdornment from '@mui/material/InputAdornment';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { Card } from '../../atoms/Card';
import { Badge } from '../../atoms/Badge';
import { Collapse } from '../../atoms/Collapse';
import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider';
import { Link } from '../../atoms/Link';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A coffin available for selection */
export interface Coffin {
id: string;
name: string;
imageUrl: string;
/** Additional images (handles, fixtures, interior) shown as thumbnails */
thumbnails?: string[];
price: number;
category: string;
isPopular?: boolean;
/** Number of colour/finish variants available */
colourCount?: number;
}
/** Filter category option (supports one level of subcategories) */
export interface CoffinCategory {
value: string;
label: string;
/** Subcategories — shown when parent is selected */
children?: CoffinCategory[];
}
/** Sort direction */
export type CoffinSortBy = 'popularity' | 'price_asc' | 'price_desc';
/** Form values for the coffins step */
export interface CoffinsStepValues {
/** Active category filter */
categoryFilter: string;
/** Price range [min, max] for slider filter */
priceRange: [number, number];
/** Sort order */
sortBy: CoffinSortBy;
/** Current page (1-indexed) */
page: number;
}
/** Props for the CoffinsStep page component */
export interface CoffinsStepProps {
/** Current filter/sort values */
values: CoffinsStepValues;
/** Callback when any filter/sort value changes */
onChange: (values: CoffinsStepValues) => void;
/** Called when a coffin card is clicked — navigates to CoffinDetailsStep */
onSelectCoffin: (coffinId: string) => void;
/** Callback for back navigation */
onBack?: () => void;
/** Callback for save-and-exit */
onSaveAndExit?: () => void;
/** Available coffins (already filtered/sorted/paginated by parent) */
coffins: Coffin[];
/** Total count before pagination (for display) */
totalCount?: number;
/** Total pages */
totalPages?: number;
/** Category filter options */
categories?: CoffinCategory[];
/** Min price for the range slider */
minPrice?: number;
/** Max price for the range slider */
maxPrice?: number;
/** Package coffin allowance amount (shown in info bubble, omit if no allowance) */
allowanceAmount?: number;
/** Max coffins per page before pagination (default 20) */
pageSize?: number;
/** 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>;
}
// ─── Category menu item style ───────────────────────────────────────────────
const categoryItemSx = (isActive: boolean): SxProps<Theme> => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
textAlign: 'left',
border: 'none',
background: 'none',
cursor: 'pointer',
py: 0.75,
px: 1.5,
borderRadius: 1,
fontSize: '0.875rem',
fontWeight: isActive ? 600 : 400,
color: isActive ? 'primary.main' : 'text.primary',
bgcolor: isActive ? 'var(--fa-color-brand-50)' : 'transparent',
'&:hover': {
bgcolor: isActive ? 'var(--fa-color-brand-50)' : 'var(--fa-color-surface-subtle)',
},
fontFamily: 'inherit',
});
// ─── Coffin card with thumbnail hover ───────────────────────────────────────
const CoffinCard: React.FC<{
coffin: Coffin;
onClick: () => void;
}> = ({ coffin, onClick }) => {
const [previewImage, setPreviewImage] = React.useState(coffin.imageUrl);
const allImages = [coffin.imageUrl, ...(coffin.thumbnails || [])];
const hasThumbnails = allImages.length > 1;
return (
<Card
interactive
padding="none"
onClick={onClick}
sx={{
overflow: 'hidden',
cursor: 'pointer',
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Main image on subtle background — handles white-bg product photos */}
<Box
sx={{
position: 'relative',
height: 180,
bgcolor: 'var(--fa-color-surface-subtle)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
flexShrink: 0,
}}
>
<Box
component="img"
src={previewImage}
alt={coffin.name}
sx={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
{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>
{/* Thumbnail strip — hover swaps main preview */}
{hasThumbnails && (
<Box
sx={{ display: 'flex', gap: 0.5, px: 2, pt: 1, flexShrink: 0 }}
onMouseLeave={() => setPreviewImage(coffin.imageUrl)}
>
{allImages.map((img, i) => (
<Box
key={i}
component="img"
src={img}
alt=""
onMouseEnter={() => setPreviewImage(img)}
sx={{
width: 48,
height: 36,
objectFit: 'cover',
borderRadius: 0.5,
border: 2,
borderColor: previewImage === img ? 'primary.main' : 'transparent',
opacity: previewImage === img ? 1 : 0.7,
transition: 'border-color 0.15s, opacity 0.15s',
'&:hover': { opacity: 1 },
}}
/>
))}
</Box>
)}
{/* Content — flex: 1 ensures all cards are the same height */}
<Box
sx={{ p: 2, pt: hasThumbnails ? 1 : 2, flex: 1, display: 'flex', flexDirection: 'column' }}
>
<Typography variant="h6" maxLines={2} sx={{ mb: 0.5 }}>
{coffin.name}
</Typography>
<Typography variant="h6" color="primary">
${coffin.price.toLocaleString('en-AU')}
</Typography>
{coffin.colourCount != null && coffin.colourCount > 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 'auto', pt: 0.5, display: 'block' }}
>
{coffin.colourCount} Colour option{coffin.colourCount !== 1 ? 's' : ''} available
</Typography>
)}
</Box>
</Card>
);
};
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 10 — Coffin browsing for the FA arrangement wizard.
*
* Grid-sidebar layout: filter sidebar (left, stacked on mobile) + coffin
* card grid (right). Both panels scroll independently on desktop; sidebar
* scrollbar appears on hover.
*
* Clicking a coffin card navigates to CoffinDetailsStep — no Continue
* button. Sidebar contains heading, allowance info, category menu with
* expandable subcategories, price range slider with editable inputs,
* sort control, and save-and-exit link.
*
* Cards show product image on subtle background (for white-bg photos),
* thumbnail strip with hover preview, name, price, and colour count.
* All cards are equal height regardless of thumbnail/colour presence.
*
* Uses Australian terminology: "coffin" not "casket".
*
* Pure presentation component — props in, callbacks out.
* Filtering, sorting, and pagination logic handled by parent.
*
* Spec: documentation/steps/steps/10_coffins.yaml
*/
export const CoffinsStep: React.FC<CoffinsStepProps> = ({
values,
onChange,
onSelectCoffin,
onBack,
onSaveAndExit,
coffins,
totalCount,
totalPages = 1,
categories = [
{ value: 'all', label: 'All' },
{
value: 'solid_timber',
label: 'Solid Timber',
children: [
{ value: 'cedar', label: 'Cedar' },
{ value: 'oak', label: 'Oak' },
{ value: 'mahogany', label: 'Mahogany' },
{ value: 'teak', label: 'Teak' },
],
},
{
value: 'custom_board',
label: 'Custom Board',
children: [
{ value: 'printed', label: 'Printed' },
{ value: 'painted', label: 'Painted' },
],
},
{ value: 'environmental', label: 'Environmental' },
{ value: 'designer', label: 'Designer' },
{ value: 'protective_metal', label: 'Protective Metal' },
],
minPrice = 0,
maxPrice = 15000,
allowanceAmount,
pageSize = 20,
isPrePlanning = false,
navigation,
progressStepper,
runningTotal,
hideHelpBar,
sx,
}) => {
// Cap displayed coffins to pageSize (default 20 per page)
const visibleCoffins = coffins.slice(0, pageSize);
const displayCount = totalCount ?? coffins.length;
const derivedTotalPages = totalPages > 1 ? totalPages : Math.ceil(coffins.length / pageSize);
// ─── Price input local state (commits on blur / Enter) ───
const [priceMinInput, setPriceMinInput] = React.useState(String(values.priceRange[0]));
const [priceMaxInput, setPriceMaxInput] = React.useState(String(values.priceRange[1]));
// Sync local state when the slider (or clear) changes the value
const rangeMin = values.priceRange[0];
const rangeMax = values.priceRange[1];
React.useEffect(() => {
setPriceMinInput(String(rangeMin));
setPriceMaxInput(String(rangeMax));
}, [rangeMin, rangeMax]);
const commitPriceRange = () => {
let lo = parseInt(priceMinInput, 10);
let hi = parseInt(priceMaxInput, 10);
if (isNaN(lo)) lo = minPrice;
if (isNaN(hi)) hi = maxPrice;
lo = Math.max(minPrice, Math.min(lo, maxPrice));
hi = Math.max(minPrice, Math.min(hi, maxPrice));
if (lo > hi) [lo, hi] = [hi, lo];
const newRange: [number, number] = [lo, hi];
if (newRange[0] !== values.priceRange[0] || newRange[1] !== values.priceRange[1]) {
onChange({ ...values, priceRange: newRange, page: 1 });
}
};
// ─── Derived state ───
const hasActiveFilters =
values.categoryFilter !== 'all' ||
values.priceRange[0] !== minPrice ||
values.priceRange[1] !== maxPrice;
// Determine which parent category is expanded (if filter matches parent or child)
const expandedParent =
categories.find(
(cat) =>
cat.value === values.categoryFilter ||
cat.children?.some((child) => child.value === values.categoryFilter),
)?.value ?? null;
const handleClearFilters = () => {
onChange({
...values,
categoryFilter: 'all',
priceRange: [minPrice, maxPrice],
page: 1,
});
};
return (
<WizardLayout
variant="grid-sidebar"
navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink={!!onBack}
backLabel="Back"
onBack={onBack}
hideHelpBar={hideHelpBar}
sx={sx}
secondaryPanel={
/* ─── Grid area (right panel) ─── */
<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="list"
aria-label="Available coffins"
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
},
gap: 2,
mb: 3,
}}
>
{visibleCoffins.map((coffin) => (
<Box key={coffin.id} role="listitem">
<CoffinCard coffin={coffin} onClick={() => onSelectCoffin(coffin.id)} />
</Box>
))}
{visibleCoffins.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center', gridColumn: '1 / -1' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
No coffins match your selected filters.
</Typography>
<Typography variant="body2" color="text.secondary">
Try adjusting the category or price range.
</Typography>
</Box>
)}
</Box>
{/* Pagination */}
{derivedTotalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
count={derivedTotalPages}
page={values.page}
onChange={(_, page) => onChange({ ...values, page })}
color="primary"
/>
</Box>
)}
</Box>
}
>
{/* ─── Sidebar (left panel) ─── */}
{/* Heading — matches VenueStep / ProvidersStep list layout */}
<Typography variant="h4" component="h1" sx={{ mb: 0.5, pt: 2 }} tabIndex={-1}>
Choose a coffin
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{isPrePlanning
? 'Browse the range to get an idea of styles and pricing.'
: 'Browse our selection of bespoke designer coffins.'}
</Typography>
{/* Allowance info bubble */}
{allowanceAmount != null && (
<Paper
variant="outlined"
sx={{
p: 2,
mb: 3,
display: 'flex',
gap: 1.5,
alignItems: 'flex-start',
bgcolor: 'var(--fa-color-surface-warm)',
borderColor: 'var(--fa-color-brand-200)',
}}
>
<InfoOutlinedIcon
sx={{ fontSize: 20, color: 'var(--fa-color-brand-500)', flexShrink: 0, mt: 0.25 }}
/>
<Typography variant="body2">
Your package includes a <strong>${allowanceAmount.toLocaleString('en-AU')}</strong>{' '}
allowance for coffins which will be applied to your order after you have made a
selection.
</Typography>
</Paper>
)}
<Divider sx={{ mb: 3 }} />
{/* ─── Categories menu — single-select with expandable subcategories ─── */}
<Typography variant="h6" sx={{ mb: 1.5 }}>
Categories
</Typography>
<Box component="nav" aria-label="Filter by category" sx={{ mb: 3 }}>
{categories.map((cat) => {
const isParentActive =
cat.value === values.categoryFilter ||
cat.children?.some((child) => child.value === values.categoryFilter);
const isExactActive = cat.value === values.categoryFilter;
const isExpanded = expandedParent === cat.value;
return (
<React.Fragment key={cat.value}>
{/* Parent category */}
<Box
component="button"
type="button"
onClick={() => onChange({ ...values, categoryFilter: cat.value, page: 1 })}
sx={categoryItemSx(!!isExactActive)}
>
<span>{cat.label}</span>
{cat.children && cat.children.length > 0 && (
<ExpandMoreIcon
sx={{
fontSize: 18,
color: isParentActive ? 'primary.main' : 'text.secondary',
transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
}}
/>
)}
</Box>
{/* Subcategories — expand when parent is active */}
{cat.children && cat.children.length > 0 && (
<Collapse in={isExpanded}>
<Box sx={{ pl: 2 }}>
{cat.children.map((child) => (
<Box
key={child.value}
component="button"
type="button"
onClick={() =>
onChange({ ...values, categoryFilter: child.value, page: 1 })
}
sx={categoryItemSx(child.value === values.categoryFilter)}
>
<span>{child.label}</span>
</Box>
))}
</Box>
</Collapse>
)}
</React.Fragment>
);
})}
</Box>
<Divider sx={{ mb: 3 }} />
{/* ─── Price range slider with editable inputs ─── */}
<Typography variant="h6" sx={{ mb: 1.5 }}>
Price
</Typography>
<Box sx={{ px: 1, mb: 1.5 }}>
<Slider
value={values.priceRange}
onChange={(_, newValue) =>
onChange({ ...values, priceRange: newValue as [number, number], page: 1 })
}
min={minPrice}
max={maxPrice}
step={100}
valueLabelDisplay="auto"
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
color="primary"
/>
</Box>
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
<TextField
size="small"
value={priceMinInput}
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{ inputMode: 'numeric', 'aria-label': 'Minimum price' }}
sx={{ flex: 1 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: 'center' }}>
</Typography>
<TextField
size="small"
value={priceMaxInput}
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitPriceRange}
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
inputProps={{ inputMode: 'numeric', 'aria-label': 'Maximum price' }}
sx={{ flex: 1 }}
/>
</Box>
<Divider sx={{ mb: 3 }} />
{/* ─── Sort by ─── */}
<Typography variant="h6" sx={{ mb: 2 }}>
Sort by
</Typography>
<TextField
select
value={values.sortBy}
onChange={(e) => onChange({ ...values, sortBy: e.target.value as CoffinSortBy })}
fullWidth
size="small"
>
<MenuItem value="popularity">Popularity</MenuItem>
<MenuItem value="price_asc">Price: Low to High</MenuItem>
<MenuItem value="price_desc">Price: High to Low</MenuItem>
</TextField>
{/* Clear filters — at bottom of sidebar, under sort */}
{hasActiveFilters && (
<>
<Divider sx={{ my: 3 }} />
<Link
component="button"
onClick={handleClearFilters}
underline="hover"
sx={{ fontSize: '0.875rem' }}
>
Clear all filters
</Link>
</>
)}
{/* Save and continue later — bottom of sidebar */}
{onSaveAndExit && (
<>
<Divider sx={{ my: 3 }} />
<Link
component="button"
onClick={onSaveAndExit}
underline="hover"
sx={{ fontSize: '0.875rem', color: 'text.secondary' }}
>
Save and continue later
</Link>
</>
)}
</WizardLayout>
);
};
CoffinsStep.displayName = 'CoffinsStep';
export default CoffinsStep;