Add VenueStep page (wizard step 7) + VenueCard selected prop

- Consolidated 3 baseline steps (venue select + venue detail + venue services) into 1
- CSS Grid venue card layout (1 col mobile, 2 col desktop) with radiogroup ARIA
- VenueCard extended with selected, role, aria-checked, tabIndex props
- Progressive disclosure: venue detail panel + service toggles after selection
- Service toggles via AddOnOption: photo presentation, livestream, recording
- Recording depends on streaming (auto-disabled when streaming off)
- Search input + filter chips for venue filtering
- Results count with aria-live, validation error with role="alert"
- Pre-planning variant with softer copy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:53:36 +11:00
parent 2004fe10c0
commit c28f8a2f29
4 changed files with 720 additions and 1 deletions

View File

@@ -0,0 +1,409 @@
import React from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import SearchIcon from '@mui/icons-material/Search';
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 { Collapse } from '../../atoms/Collapse';
import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A venue available for selection */
export interface Venue {
id: string;
name: string;
imageUrl: string;
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 */
venues: Venue[];
/** Available service add-ons shown after venue selection */
services?: VenueService[];
/** Filter chip options */
filterOptions?: Array<{ key: string; label: string }>;
/** Location name for the results count */
locationName?: string;
/** Whether this is a pre-planning flow */
isPrePlanning?: boolean;
/** 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>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Step 7 — Service Venue for the FA arrangement wizard.
*
* Consolidated from 3 baseline steps (venues + venue details + venue services)
* into a single step with progressive disclosure (Rec #5).
*
* 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)
*
* 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 = [],
filterOptions = [
{ key: 'features', label: 'Venue Features' },
{ key: 'religion', label: 'Religion' },
],
locationName,
isPrePlanning = false,
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);
};
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}>
Where would you like the service?
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
{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>
<Box
component="form"
noValidate
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
onContinue();
}}
>
{/* ─── Search + Filters ─── */}
<Box sx={{ mb: 3 }}>
<TextField
placeholder="Search a town or suburb..."
value={values.search}
onChange={(e) => onChange({ ...values, search: e.target.value })}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: 'text.secondary' }} />
</InputAdornment>
),
}}
sx={{ mb: 2 }}
/>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{filterOptions.map((filter) => (
<Chip
key={filter.key}
label={filter.label}
onClick={() => handleFilterToggle(filter.key)}
selected={values.activeFilters.includes(filter.key)}
/>
))}
</Box>
</Box>
{/* ─── Results count ─── */}
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }} aria-live="polite">
Found {venues.length} venue{venues.length !== 1 ? 's' : ''}
{locationName ? ` near ${locationName}` : ''}
</Typography>
{/* ─── Venue card grid ─── */}
<Box
role="radiogroup"
aria-label="Available venues"
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
},
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
}
/>
))}
</Box>
{/* Validation error */}
{errors?.selectedVenueId && (
<Typography variant="body2" color="error" sx={{ mb: 2 }} 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
</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>
<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>
);
};
VenueStep.displayName = 'VenueStep';
export default VenueStep;