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

@@ -20,8 +20,14 @@ export interface VenueCardProps {
capacity?: number;
/** Venue hire price in dollars */
price?: number;
/** Whether this card is the selected venue (brand border + warm bg) */
selected?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** HTML/ARIA passthrough for radiogroup patterns */
role?: string;
'aria-checked'?: boolean;
tabIndex?: number;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
@@ -57,13 +63,32 @@ const CONTENT_GAP = 'var(--fa-venue-card-content-gap)';
* ```
*/
export const VenueCard = React.forwardRef<HTMLDivElement, VenueCardProps>(
({ name, imageUrl, location, capacity, price, onClick, sx }, ref) => {
(
{
name,
imageUrl,
location,
capacity,
price,
selected = false,
onClick,
role,
'aria-checked': ariaChecked,
tabIndex,
sx,
},
ref,
) => {
return (
<Card
ref={ref}
interactive
selected={selected}
padding="none"
onClick={onClick}
role={role}
aria-checked={ariaChecked}
tabIndex={tabIndex}
sx={[
{
overflow: 'hidden',

View File

@@ -0,0 +1,277 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { VenueStep } from './VenueStep';
import type { VenueStepValues, VenueStepErrors, Venue, VenueService } from './VenueStep';
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 sampleVenues: Venue[] = [
{
id: 'west-chapel',
name: 'West Chapel',
imageUrl: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=400&h=300&fit=crop',
location: 'Strathfield',
capacity: 120,
price: 900,
description:
'A modern chapel with natural light and flexible seating arrangements. Surrounded by established gardens.',
features: ['Air conditioning', 'Wheelchair accessible', 'On-site parking', 'Audio system'],
religions: ['No Religion', 'Civil Celebrant', 'Anglican', 'Catholic', 'Uniting Church'],
address: '42 The Boulevarde, Strathfield NSW 2135',
},
{
id: 'rose-garden',
name: 'Rose Garden Chapel',
imageUrl: 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?w=400&h=300&fit=crop',
location: 'Homebush',
capacity: 80,
price: 750,
description:
'An intimate setting surrounded by heritage rose gardens. Ideal for smaller gatherings.',
features: ['Garden access', 'Wheelchair accessible', 'Audio system'],
religions: ['No Religion', 'Civil Celebrant', 'Buddhism', 'Hinduism'],
address: '15 Olympic Park Road, Homebush NSW 2140',
},
{
id: 'memorial-hall',
name: 'Strathfield Memorial Hall',
imageUrl: 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=400&h=300&fit=crop',
location: 'Strathfield',
capacity: 250,
price: 1200,
description:
'A grand hall suited for large services. Full catering kitchen and stage with audio-visual equipment.',
features: [
'Full catering kitchen',
'Stage',
'Audio-visual system',
'Wheelchair accessible',
'Large car park',
],
religions: ['All faiths welcome'],
address: '1 Redmyre Road, Strathfield NSW 2135',
},
{
id: 'lakeside',
name: 'Lakeside Pavilion',
imageUrl: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop',
location: 'Concord',
capacity: 60,
price: 650,
description:
'An open-air pavilion overlooking the lake. A peaceful setting for farewell services.',
features: ['Lake views', 'Natural setting', 'Covered pavilion'],
address: '8 Victoria Avenue, Concord NSW 2137',
},
];
const sampleServices: VenueService[] = [
{ id: 'photo', name: 'Photo presentation', price: 150 },
{ id: 'streaming', name: 'Livestream', price: 200 },
{ id: 'recording', name: 'Recording', price: 100 },
];
const defaultValues: VenueStepValues = {
search: '',
activeFilters: [],
selectedVenueId: null,
photoDisplay: false,
streaming: false,
recording: false,
};
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof VenueStep> = {
title: 'Pages/VenueStep',
component: VenueStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof VenueStep>;
// ─── Interactive (default) ──────────────────────────────────────────────────
/** Fully interactive — select a venue, see details + service toggles */
export const Default: Story = {
render: () => {
const [values, setValues] = useState<VenueStepValues>({ ...defaultValues });
const [errors, setErrors] = useState<VenueStepErrors>({});
const handleContinue = () => {
if (!values.selectedVenueId) {
setErrors({ selectedVenueId: 'Please choose a venue for the service.' });
return;
}
setErrors({});
alert(`Continue with venue: ${values.selectedVenueId}`);
};
return (
<VenueStep
values={values}
onChange={(v) => {
setValues(v);
setErrors({});
}}
onContinue={handleContinue}
onBack={() => alert('Back')}
onSaveAndExit={() => alert('Save and exit')}
errors={errors}
venues={sampleVenues}
services={sampleServices}
locationName="Strathfield"
navigation={nav}
/>
);
},
};
// ─── Venue selected ─────────────────────────────────────────────────────────
/** Venue already selected — detail panel and service toggles visible */
export const VenueSelected: Story = {
render: () => {
const [values, setValues] = useState<VenueStepValues>({
...defaultValues,
selectedVenueId: 'west-chapel',
photoDisplay: true,
});
return (
<VenueStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
onSaveAndExit={() => alert('Save')}
venues={sampleVenues}
services={sampleServices}
locationName="Strathfield"
navigation={nav}
/>
);
},
};
// ─── With streaming + recording ─────────────────────────────────────────────
/** Streaming enabled, revealing recording toggle */
export const WithStreaming: Story = {
render: () => {
const [values, setValues] = useState<VenueStepValues>({
...defaultValues,
selectedVenueId: 'memorial-hall',
streaming: true,
recording: true,
});
return (
<VenueStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
venues={sampleVenues}
services={sampleServices}
locationName="Strathfield"
navigation={nav}
/>
);
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning variant copy */
export const PrePlanning: Story = {
render: () => {
const [values, setValues] = useState<VenueStepValues>({ ...defaultValues });
return (
<VenueStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
venues={sampleVenues}
services={sampleServices}
isPrePlanning
navigation={nav}
/>
);
},
};
// ─── Validation error ───────────────────────────────────────────────────────
/** No venue selected with error */
export const WithError: Story = {
render: () => {
const [values, setValues] = useState<VenueStepValues>({ ...defaultValues });
return (
<VenueStep
values={values}
onChange={setValues}
onContinue={() => {}}
errors={{ selectedVenueId: 'Please choose a venue for the service.' }}
venues={sampleVenues}
services={sampleServices}
locationName="Strathfield"
navigation={nav}
/>
);
},
};
// ─── Empty state ────────────────────────────────────────────────────────────
/** No venues found */
export const NoVenues: Story = {
render: () => {
const [values, setValues] = useState<VenueStepValues>({
...defaultValues,
search: 'Wollongong',
});
return (
<VenueStep
values={values}
onChange={setValues}
onContinue={() => {}}
venues={[]}
locationName="Wollongong"
navigation={nav}
/>
);
},
};

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;

View File

@@ -0,0 +1,8 @@
export { VenueStep, default } from './VenueStep';
export type {
VenueStepProps,
VenueStepValues,
VenueStepErrors,
Venue,
VenueService,
} from './VenueStep';