VenueStep click-to-navigate, new VenueDetailStep, CoffinDetailsStep detail-toggles

- VenueStep: simplified to click-to-navigate (like ProvidersStep)
  - Removed selection state, Continue button, inline detail, service toggles
  - Clicking a venue card triggers onSelectVenue navigation
- VenueDetailStep: new page with detail-toggles layout
  - Left: venue image, description, features
  - Right: name, location, type, price, Add Venue CTA, address, religions, service toggles
- CoffinDetailsStep: switched from centered-form to detail-toggles layout
  - Left: coffin image, description
  - Right: name, price, Add Coffin CTA, specs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 20:12:12 +11:00
parent f890110195
commit ac6828d925
7 changed files with 726 additions and 583 deletions

View File

@@ -3,16 +3,12 @@ import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { VenueCard } from '../../molecules/VenueCard';
import { AddOnOption } from '../../molecules/AddOnOption';
import { FilterPanel } from '../../molecules/FilterPanel';
import { Collapse } from '../../atoms/Collapse';
import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -24,65 +20,30 @@ export interface Venue {
location: string;
capacity?: number;
price?: number;
description?: string;
features?: string[];
religions?: string[];
address?: string;
}
/** Service add-on toggle */
export interface VenueService {
id: string;
name: string;
description?: string;
price?: number;
}
/** Form values for the venue step */
export interface VenueStepValues {
/** Search query */
search: string;
/** Active filter chip keys */
activeFilters: string[];
/** Selected venue ID */
selectedVenueId: string | null;
/** Photo presentation enabled */
photoDisplay: boolean;
/** Livestream enabled */
streaming: boolean;
/** Recording enabled (depends on streaming) */
recording: boolean;
}
/** Field-level error messages */
export interface VenueStepErrors {
selectedVenueId?: string;
}
/** Props for the VenueStep page component */
export interface VenueStepProps {
/** Current form values */
values: VenueStepValues;
/** Callback when any field value changes */
onChange: (values: VenueStepValues) => 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?: VenueStepErrors;
/** Whether the Continue button is in a loading state */
loading?: boolean;
/** Available venues */
/** List of venues to display */
venues: Venue[];
/** Available service add-ons shown after venue selection */
services?: VenueService[];
/** Callback when a venue card is clicked — triggers navigation to VenueDetailStep */
onSelectVenue: (id: string) => void;
/** Search query value */
searchQuery: string;
/** Callback when search query changes */
onSearchChange: (query: string) => void;
/** Callback when search is submitted */
onSearch?: (query: string) => void;
/** Filter chip options */
filterOptions?: Array<{ key: string; label: string }>;
/** Active filter keys */
activeFilters?: string[];
/** Callback when a filter chip is toggled */
onFilterToggle?: (key: string) => void;
/** Callback to clear all filters */
onFilterClear?: () => void;
/** Callback for back navigation */
onBack: () => void;
/** Location name for the results count */
locationName?: string;
/** Whether this is a pre-planning flow */
@@ -91,12 +52,6 @@ export interface VenueStepProps {
mapPanel?: React.ReactNode;
/** Navigation bar — passed through to WizardLayout */
navigation?: React.ReactNode;
/** Progress stepper — passed through to WizardLayout */
progressStepper?: React.ReactNode;
/** Running total — passed through to WizardLayout */
runningTotal?: React.ReactNode;
/** Hide the help bar */
hideHelpBar?: boolean;
/** MUI sx prop for the root */
sx?: SxProps<Theme>;
}
@@ -104,77 +59,50 @@ export interface VenueStepProps {
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 7 — Service Venue for the FA arrangement wizard.
* Step 7 — Service Venue selection for the FA arrangement wizard.
*
* Consolidated from 3 baseline steps (venues + venue details + venue services)
* into a single step with progressive disclosure (Rec #5).
* List + Map split layout. Left panel shows a scrollable list of
* venue cards with search and filter button. Right panel is a
* slot for map integration.
*
* Three phases:
* 1. Venue card grid with search/filters (grid-sidebar layout)
* 2. Selected venue detail (inline Collapse below grid)
* 3. Service toggles (photo presentation, streaming, recording)
* Click-to-navigate: clicking a venue card triggers navigation
* to VenueDetailStep — no selection state or Continue button.
*
* Pure presentation component — props in, callbacks out.
*
* Spec: documentation/steps/steps/07_venue_consolidated.yaml
*/
export const VenueStep: React.FC<VenueStepProps> = ({
values,
onChange,
onContinue,
onBack,
onSaveAndExit,
errors,
loading = false,
venues,
services = [],
onSelectVenue,
searchQuery,
onSearchChange,
onSearch,
filterOptions = [
{ key: 'features', label: 'Venue Features' },
{ key: 'religion', label: 'Religion' },
],
activeFilters = [],
onFilterToggle,
onFilterClear,
onBack,
locationName,
isPrePlanning = false,
mapPanel,
navigation,
progressStepper,
runningTotal,
hideHelpBar,
sx,
}) => {
const selectedVenue = venues.find((v) => v.id === values.selectedVenueId) ?? null;
const hasSelection = selectedVenue !== null;
const handleVenueSelect = (venueId: string) => {
onChange({ ...values, selectedVenueId: venueId });
};
const handleFilterToggle = (key: string) => {
const next = values.activeFilters.includes(key)
? values.activeFilters.filter((f) => f !== key)
: [...values.activeFilters, key];
onChange({ ...values, activeFilters: next });
};
const handleToggle = (field: 'photoDisplay' | 'streaming' | 'recording', checked: boolean) => {
const next = { ...values, [field]: checked };
// Disable recording when streaming is turned off
if (field === 'streaming' && !checked) {
next.recording = false;
}
onChange(next);
};
const subheading = isPrePlanning
? 'Browse available venues. Your choice can be changed later.'
: 'Choose a venue for the funeral service. You can filter by location, features, and religion.';
return (
<WizardLayout
variant="list-map"
navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink={!!onBack}
showBackLink
backLabel="Back"
onBack={onBack}
hideHelpBar={hideHelpBar}
sx={sx}
secondaryPanel={
mapPanel || (
@@ -196,253 +124,109 @@ export const VenueStep: React.FC<VenueStepProps> = ({
)
}
>
{/* Sticky header — stays pinned while card list scrolls */}
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
bgcolor: 'background.default',
pt: 2,
pb: 1,
mx: { xs: -2, md: -3 },
px: { xs: 2, md: 3 },
}}
>
{/* Sticky header — stays pinned while card list scrolls */}
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
bgcolor: 'background.default',
pt: 2,
pb: 1,
mx: { xs: -2, md: -3 },
px: { xs: 2, md: 3 },
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Where would you like the service?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{subheading}
</Typography>
{/* Location search */}
<TextField
placeholder="Search a town or suburb..."
aria-label="Search venues by town or suburb"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && onSearch) {
e.preventDefault();
onSearch(searchQuery);
}
}}
fullWidth
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
),
}}
sx={{ mb: 1.5 }}
/>
{/* Filters — right-aligned below search */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<FilterPanel activeCount={activeFilters.length} onClear={onFilterClear}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{filterOptions.map((filter) => (
<Chip
key={filter.key}
label={filter.label}
selected={activeFilters.includes(filter.key)}
onClick={onFilterToggle ? () => onFilterToggle(filter.key) : undefined}
variant="outlined"
size="small"
/>
))}
</Box>
</FilterPanel>
</Box>
{/* Results count */}
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 0, display: 'block' }}
aria-live="polite"
>
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
Where would you like the service?
</Typography>
{venues.length} venue{venues.length !== 1 ? 's' : ''}
{locationName ? ` near ${locationName}` : ''} found
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{isPrePlanning
? 'Browse available venues. Your choice can be changed later.'
: 'Choose a venue for the funeral service. You can filter by location, features, and religion.'}
</Typography>
{/* ─── Location search ─── */}
<TextField
placeholder="Search a town or suburb..."
aria-label="Search venues by town or suburb"
value={values.search}
onChange={(e) => onChange({ ...values, search: e.target.value })}
fullWidth
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
),
}}
sx={{ mb: 1.5 }}
{/* Venue list — click-to-navigate */}
<Box
role="list"
aria-label="Available venues"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, pb: 3 }}
>
{venues.map((venue) => (
<VenueCard
key={venue.id}
name={venue.name}
imageUrl={venue.imageUrl}
location={venue.location}
capacity={venue.capacity}
price={venue.price}
onClick={() => onSelectVenue(venue.id)}
aria-label={`${venue.name}, ${venue.location}${venue.price ? `, $${venue.price}` : ''}`}
/>
))}
{/* ─── Filters — right-aligned below search ─── */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<FilterPanel activeCount={values.activeFilters.length} onClear={onFilterClear}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{filterOptions.map((filter) => (
<Chip
key={filter.key}
label={filter.label}
onClick={() => handleFilterToggle(filter.key)}
selected={values.activeFilters.includes(filter.key)}
/>
))}
</Box>
</FilterPanel>
</Box>
{/* ─── Results count ─── */}
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 0, display: 'block' }}
aria-live="polite"
>
{venues.length} venue{venues.length !== 1 ? 's' : ''}
{locationName ? ` near ${locationName}` : ''} found
</Typography>
</Box>
{/* ─── Venue card grid ─── */}
<Box
role="radiogroup"
aria-label="Available venues"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
>
{venues.map((venue, index) => (
<VenueCard
key={venue.id}
name={venue.name}
imageUrl={venue.imageUrl}
location={venue.location}
capacity={venue.capacity}
price={venue.price}
selected={venue.id === values.selectedVenueId}
onClick={() => handleVenueSelect(venue.id)}
role="radio"
aria-checked={venue.id === values.selectedVenueId}
tabIndex={
values.selectedVenueId === null
? index === 0
? 0
: -1
: venue.id === values.selectedVenueId
? 0
: -1
}
/>
))}
{venues.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
No venues found in this area.
</Typography>
<Typography variant="body2" color="text.secondary">
Try adjusting your search or clearing filters.
</Typography>
</Box>
)}
</Box>
{/* Validation error */}
{errors?.selectedVenueId && (
<Typography
variant="body2"
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
role="alert"
>
{errors.selectedVenueId}
</Typography>
)}
{/* ─── Selected venue detail (progressive disclosure) ─── */}
<Collapse in={hasSelection}>
{selectedVenue && (
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 2,
p: 3,
mb: 3,
bgcolor: 'var(--fa-color-surface-warm)',
}}
aria-live="polite"
>
<Typography variant="h5" sx={{ mb: 1 }}>
{selectedVenue.name}
</Typography>
{selectedVenue.address && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 2 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography variant="body2" color="text.secondary">
{selectedVenue.address}
</Typography>
</Box>
)}
{selectedVenue.description && (
<Typography variant="body2" sx={{ mb: 2 }}>
{selectedVenue.description}
</Typography>
)}
{/* Features */}
{selectedVenue.features && selectedVenue.features.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 1 }}>
Features
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{selectedVenue.features.map((feature) => (
<Box key={feature} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleOutlineIcon
sx={{ fontSize: 16, color: 'success.main' }}
aria-hidden
/>
<Typography variant="body2">{feature}</Typography>
</Box>
))}
</Box>
</Box>
)}
{/* Supported religions */}
{selectedVenue.religions && selectedVenue.religions.length > 0 && (
<Box sx={{ mb: 1 }}>
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 1 }}>
Supported service styles
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selectedVenue.religions.map((r) => (
<Chip key={r} label={r} size="small" />
))}
</Box>
</Box>
)}
</Box>
)}
</Collapse>
{/* ─── Service toggles (after venue selection) ─── */}
<Collapse in={hasSelection}>
<Box sx={{ mb: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5" sx={{ mb: 1 }}>
Venue services
{venues.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
No venues found in this area.
</Typography>
<Typography variant="body2" color="text.secondary">
Try adjusting your search or clearing filters.
</Typography>
<AddOnOption
name="Photo presentation"
description="Display a photo slideshow during the service"
price={services.find((s) => s.id === 'photo')?.price}
checked={values.photoDisplay}
onChange={(c) => handleToggle('photoDisplay', c)}
/>
<AddOnOption
name="Livestream of funeral service"
description="Allow family and friends to watch the service remotely"
price={services.find((s) => s.id === 'streaming')?.price}
checked={values.streaming}
onChange={(c) => handleToggle('streaming', c)}
/>
<Collapse in={values.streaming}>
<AddOnOption
name="Recording of funeral service"
description="Receive a recording of the service to keep"
price={services.find((s) => s.id === 'recording')?.price}
checked={values.recording}
onChange={(c) => handleToggle('recording', c)}
/>
</Collapse>
</Box>
</Collapse>
{/* CTAs */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, pb: 2 }}>
{onSaveAndExit && (
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
Save and exit
</Button>
)}
<Button type="submit" variant="contained" size="large" loading={loading}>
Continue
</Button>
</Box>
)}
</Box>
</WizardLayout>
);