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

@@ -4,7 +4,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -55,9 +54,11 @@ export interface CoffinDetailsStepProps {
/**
* Step 11 — Coffin Details for the FA arrangement wizard.
*
* Shows the selected coffin's full profile: image, specs, description,
* and price. Customisation options (handles, lining, nameplate) have
* been deferred as a future enhancement (D-G).
* Detail-toggles layout: coffin image + description on the left,
* name, specs, price, and CTA on the right.
*
* Customisation options (handles, lining, nameplate) have been
* deferred as a future enhancement (D-G).
*
* Pure presentation component — props in, callbacks out.
*
@@ -78,7 +79,7 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
}) => {
return (
<WizardLayout
variant="centered-form"
variant="detail-toggles"
navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
@@ -87,45 +88,59 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
onBack={onBack}
hideHelpBar={hideHelpBar}
sx={sx}
>
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
Your selected coffin
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 5 }}>
{isPrePlanning
? 'Here are the details of the coffin you selected. You can change your selection at any time.'
: 'Review the details of your selected coffin below.'}
</Typography>
{/* Coffin image */}
<Box
role="img"
aria-label={`Photo of ${coffin.name}`}
sx={{
width: '100%',
height: { xs: 240, md: 320 },
borderRadius: 2,
backgroundImage: `url(${coffin.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
mb: 3,
}}
/>
{/* Coffin name + description */}
<Typography variant="h4" sx={{ mb: 1 }}>
secondaryPanel={
<Box sx={{ position: 'sticky', top: 24 }}>
{/* Coffin name */}
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
{coffin.name}
</Typography>
{coffin.description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{coffin.description}
{/* Price */}
<Typography variant="h5" color="primary" sx={{ mb: 1 }}>
${coffin.price.toLocaleString('en-AU')}
</Typography>
{coffin.priceNote && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{coffin.priceNote}
</Typography>
)}
{/* Specs grid */}
{/* Add coffin CTA */}
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
loading={loading}
sx={{ mb: 3 }}
>
{isPrePlanning ? 'Select this coffin' : 'Add coffin'}
</Button>
</Box>
{onSaveAndExit && (
<Button
variant="text"
color="secondary"
fullWidth
onClick={onSaveAndExit}
sx={{ mb: 3 }}
>
Save and continue later
</Button>
)}
{/* Specs */}
{coffin.specs && coffin.specs.length > 0 && (
<Box
sx={{
@@ -133,7 +148,6 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
gridTemplateColumns: 'auto 1fr',
gap: 0.5,
columnGap: 2,
mb: 3,
}}
>
{coffin.specs.map((spec) => (
@@ -146,47 +160,30 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
))}
</Box>
)}
{/* Price */}
<Typography variant="h5" color="primary" sx={{ mb: 1 }}>
${coffin.price.toLocaleString('en-AU')}
</Typography>
{coffin.priceNote && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{coffin.priceNote}
</Typography>
)}
<Divider sx={{ my: 3 }} />
{/* CTAs */}
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
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>
}
>
{/* Left panel: image + description */}
<Box
role="img"
aria-label={`Photo of ${coffin.name}`}
sx={{
width: '100%',
height: { xs: 280, md: 400 },
borderRadius: 2,
backgroundImage: `url(${coffin.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
mb: 3,
}}
/>
{coffin.description && (
<Typography variant="body1" color="text.secondary">
{coffin.description}
</Typography>
)}
</WizardLayout>
);
};

View File

@@ -0,0 +1,180 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { VenueDetailStep } from './VenueDetailStep';
import type { VenueDetailStepValues, VenueDetail, VenueService } from './VenueDetailStep';
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 sampleVenue: VenueDetail = {
id: 'west-chapel',
name: 'West Chapel',
imageUrl: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&h=600&fit=crop',
location: 'Strathfield',
venueType: 'Indoor Chapel',
capacity: 120,
price: 900,
priceNote: 'Selecting this venue increases your plan total by $900.00',
description:
'A modern chapel with natural light and flexible seating arrangements. Surrounded by established gardens with ample parking for guests. The venue features a covered portico entrance, audio-visual equipment, and climate control for year-round comfort.',
features: [
'Air conditioning',
'Wheelchair accessible',
'On-site parking',
'Audio system',
'Garden access',
],
religions: ['No Religion', 'Civil Celebrant', 'Anglican', 'Catholic', 'Uniting Church'],
address: '42 The Boulevarde, Strathfield NSW 2135',
};
const sampleServices: VenueService[] = [
{ id: 'photo', name: 'Photo presentation', price: 150 },
{ id: 'streaming', name: 'Livestream', price: 200 },
{ id: 'recording', name: 'Recording', price: 100 },
];
const defaultValues: VenueDetailStepValues = {
photoDisplay: false,
streaming: false,
recording: false,
};
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof VenueDetailStep> = {
title: 'Pages/VenueDetailStep',
component: VenueDetailStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof VenueDetailStep>;
// ─── Default ───────────────────────────────────────────────────────────────
/** Full venue detail with service toggles */
export const Default: Story = {
render: () => {
const [values, setValues] = useState<VenueDetailStepValues>({ ...defaultValues });
return (
<VenueDetailStep
venue={sampleVenue}
values={values}
onChange={setValues}
onAddVenue={() => alert('Venue added!')}
onBack={() => alert('Back')}
onSaveAndExit={() => alert('Save and exit')}
services={sampleServices}
navigation={nav}
/>
);
},
};
// ─── With services selected ────────────────────────────────────────────────
/** Services already toggled on */
export const WithServices: Story = {
render: () => {
const [values, setValues] = useState<VenueDetailStepValues>({
photoDisplay: true,
streaming: true,
recording: false,
});
return (
<VenueDetailStep
venue={sampleVenue}
values={values}
onChange={setValues}
onAddVenue={() => alert('Venue added!')}
onBack={() => alert('Back')}
services={sampleServices}
navigation={nav}
/>
);
},
};
// ─── Pre-planning ───────────────────────────────────────────────────────────
/** Pre-planning variant */
export const PrePlanning: Story = {
render: () => {
const [values, setValues] = useState<VenueDetailStepValues>({ ...defaultValues });
return (
<VenueDetailStep
venue={sampleVenue}
values={values}
onChange={setValues}
onAddVenue={() => alert('Venue selected!')}
onBack={() => alert('Back')}
services={sampleServices}
isPrePlanning
navigation={nav}
/>
);
},
};
// ─── Minimal venue ─────────────────────────────────────────────────────────
/** Venue with minimal data (no features, religions, services) */
export const Minimal: Story = {
render: () => {
const [values, setValues] = useState<VenueDetailStepValues>({ ...defaultValues });
return (
<VenueDetailStep
venue={{
id: 'basic',
name: 'Community Hall',
imageUrl:
'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=800&h=600&fit=crop',
location: 'Parramatta',
price: 500,
address: '10 Church Street, Parramatta NSW 2150',
}}
values={values}
onChange={setValues}
onAddVenue={() => alert('Venue added!')}
onBack={() => alert('Back')}
navigation={nav}
/>
);
},
};

View File

@@ -0,0 +1,303 @@
import React from 'react';
import Box from '@mui/material/Box';
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 { AddOnOption } from '../../molecules/AddOnOption';
import { Chip } from '../../atoms/Chip';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Full venue detail data */
export interface VenueDetail {
id: string;
name: string;
imageUrl: string;
location: string;
venueType?: string;
capacity?: number;
price?: number;
priceNote?: string;
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 service toggles */
export interface VenueDetailStepValues {
photoDisplay: boolean;
streaming: boolean;
recording: boolean;
}
/** Props for the VenueDetailStep page component */
export interface VenueDetailStepProps {
/** The venue to display */
venue: VenueDetail;
/** Current service toggle values */
values: VenueDetailStepValues;
/** Callback when service toggles change */
onChange: (values: VenueDetailStepValues) => void;
/** Callback when "Add Venue" is clicked */
onAddVenue: () => void;
/** Callback for back navigation */
onBack?: () => void;
/** Callback for save-and-exit */
onSaveAndExit?: () => void;
/** Whether the button is in a loading state */
loading?: boolean;
/** Available venue services */
services?: VenueService[];
/** 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>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Venue Detail page for the FA arrangement wizard.
*
* Detail-toggles layout: venue image + description on the left,
* venue info, price, CTA, and service toggles on the right.
*
* Reached by clicking a venue card on VenueStep. User reviews
* the venue and clicks "Add Venue" to select it.
*
* Pure presentation component — props in, callbacks out.
*
* Spec: documentation/steps/steps/07_venue_consolidated.yaml
*/
export const VenueDetailStep: React.FC<VenueDetailStepProps> = ({
venue,
values,
onChange,
onAddVenue,
onBack,
onSaveAndExit,
loading = false,
services = [],
isPrePlanning = false,
navigation,
progressStepper,
runningTotal,
hideHelpBar,
sx,
}) => {
const handleToggle = (field: keyof VenueDetailStepValues, checked: boolean) => {
const next = { ...values, [field]: checked };
if (field === 'streaming' && !checked) {
next.recording = false;
}
onChange(next);
};
return (
<WizardLayout
variant="detail-toggles"
navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink={!!onBack}
backLabel="Back"
onBack={onBack}
hideHelpBar={hideHelpBar}
sx={sx}
secondaryPanel={
<Box sx={{ position: 'sticky', top: 24 }}>
{/* Venue name */}
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
{venue.name}
</Typography>
{/* Location */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} aria-hidden />
<Typography variant="body2" color="text.secondary">
{venue.location}
</Typography>
</Box>
{/* Venue type */}
{venue.venueType && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{venue.venueType}
</Typography>
)}
{/* Price */}
{venue.price != null && (
<Typography variant="h5" color="primary" sx={{ mb: 0.5 }}>
${venue.price.toLocaleString('en-AU')}
</Typography>
)}
{venue.priceNote && (
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
{venue.priceNote}
</Typography>
)}
{/* Add Venue CTA */}
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onAddVenue();
}}
>
<Button
type="submit"
variant="contained"
size="large"
fullWidth
loading={loading}
sx={{ mb: 2 }}
>
{isPrePlanning ? 'Select venue' : 'Add venue'}
</Button>
</Box>
{onSaveAndExit && (
<Button
variant="text"
color="secondary"
fullWidth
onClick={onSaveAndExit}
sx={{ mb: 3 }}
>
Save and continue later
</Button>
)}
{/* Address */}
{venue.address && (
<>
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 0.5 }}>
Address
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
{venue.address}
</Typography>
</>
)}
{/* Supported religions */}
{venue.religions && venue.religions.length > 0 && (
<>
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 1 }}>
Supported service styles
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 3 }}>
{venue.religions.map((r) => (
<Chip key={r} label={r} size="small" />
))}
</Box>
</>
)}
{/* Service toggles */}
{services.length > 0 && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="h5" sx={{ mb: 2 }}>
Venue services
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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"
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)}
/>
{values.streaming && (
<AddOnOption
name="Recording"
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)}
/>
)}
</Box>
</>
)}
</Box>
}
>
{/* Left panel: image + description + features */}
<Box
role="img"
aria-label={`Photo of ${venue.name}`}
sx={{
width: '100%',
height: { xs: 280, md: 400 },
borderRadius: 2,
backgroundImage: `url(${venue.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-surface-subtle)',
mb: 3,
}}
/>
{venue.description && (
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{venue.description}
</Typography>
)}
{/* Features */}
{venue.features && venue.features.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 1 }}>
Venue features
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{venue.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>
)}
</WizardLayout>
);
};
VenueDetailStep.displayName = 'VenueDetailStep';
export default VenueDetailStep;

View File

@@ -0,0 +1,7 @@
export { VenueDetailStep, default } from './VenueDetailStep';
export type {
VenueDetailStepProps,
VenueDetailStepValues,
VenueDetail,
VenueService,
} from './VenueDetailStep';

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { VenueStep } from './VenueStep';
import type { VenueStepValues, VenueStepErrors, Venue, VenueService } from './VenueStep';
import type { Venue } from './VenueStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
@@ -42,11 +42,6 @@ const sampleVenues: Venue[] = [
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',
@@ -55,11 +50,6 @@ const sampleVenues: Venue[] = [
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',
@@ -68,17 +58,6 @@ const sampleVenues: Venue[] = [
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',
@@ -87,28 +66,9 @@ const sampleVenues: Venue[] = [
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> = {
@@ -123,88 +83,28 @@ const meta: Meta<typeof VenueStep> = {
export default meta;
type Story = StoryObj<typeof VenueStep>;
// ─── Interactive (default) ──────────────────────────────────────────────────
// ─── Default ───────────────────────────────────────────────────────────────
/** Fully interactive — select a venue, see details + service toggles */
/** Click-to-navigate — clicking a venue card triggers navigation */
export const Default: Story = {
render: () => {
const [values, setValues] = useState<VenueStepValues>({ ...defaultValues });
const [errors, setErrors] = useState<VenueStepErrors>({});
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<string[]>([]);
const handleContinue = () => {
if (!values.selectedVenueId) {
setErrors({ selectedVenueId: 'Please choose a venue for the service.' });
return;
return (
<VenueStep
venues={sampleVenues}
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
searchQuery={search}
onSearchChange={setSearch}
activeFilters={filters}
onFilterToggle={(key) =>
setFilters((prev) =>
prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key],
)
}
setErrors({});
alert(`Continue with venue: ${values.selectedVenueId}`);
};
return (
<VenueStep
values={values}
onChange={(v) => {
setValues(v);
setErrors({});
}}
onContinue={handleContinue}
onFilterClear={() => setFilters([])}
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}
/>
@@ -217,15 +117,15 @@ export const WithStreaming: Story = {
/** Pre-planning variant copy */
export const PrePlanning: Story = {
render: () => {
const [values, setValues] = useState<VenueStepValues>({ ...defaultValues });
const [search, setSearch] = useState('');
return (
<VenueStep
values={values}
onChange={setValues}
onContinue={() => alert('Continue')}
venues={sampleVenues}
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
searchQuery={search}
onSearchChange={setSearch}
onBack={() => alert('Back')}
venues={sampleVenues}
services={sampleServices}
isPrePlanning
navigation={nav}
/>
@@ -233,42 +133,20 @@ export const PrePlanning: Story = {
},
};
// ─── 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',
});
const [search, setSearch] = useState('Wollongong');
return (
<VenueStep
values={values}
onChange={setValues}
onContinue={() => {}}
venues={[]}
onSelectVenue={() => {}}
searchQuery={search}
onSearchChange={setSearch}
onBack={() => alert('Back')}
locationName="Wollongong"
navigation={nav}
/>

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 || (
@@ -195,15 +123,6 @@ export const VenueStep: React.FC<VenueStepProps> = ({
</Box>
)
}
>
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
{/* Sticky header — stays pinned while card list scrolls */}
<Box
@@ -221,19 +140,22 @@ export const VenueStep: React.FC<VenueStepProps> = ({
<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 }}>
{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.'}
{subheading}
</Typography>
{/* ─── Location search ─── */}
{/* 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 })}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && onSearch) {
e.preventDefault();
onSearch(searchQuery);
}
}}
fullWidth
size="small"
InputProps={{
@@ -246,23 +168,25 @@ export const VenueStep: React.FC<VenueStepProps> = ({
sx={{ mb: 1.5 }}
/>
{/* ─── Filters — right-aligned below search ─── */}
{/* Filters — right-aligned below search */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<FilterPanel activeCount={values.activeFilters.length} onClear={onFilterClear}>
<FilterPanel activeCount={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)}
selected={activeFilters.includes(filter.key)}
onClick={onFilterToggle ? () => onFilterToggle(filter.key) : undefined}
variant="outlined"
size="small"
/>
))}
</Box>
</FilterPanel>
</Box>
{/* ─── Results count ─── */}
{/* Results count */}
<Typography
variant="caption"
color="text.secondary"
@@ -274,13 +198,13 @@ export const VenueStep: React.FC<VenueStepProps> = ({
</Typography>
</Box>
{/* ─── Venue card grid ─── */}
{/* Venue list — click-to-navigate */}
<Box
role="radiogroup"
role="list"
aria-label="Available venues"
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, pb: 3 }}
>
{venues.map((venue, index) => (
{venues.map((venue) => (
<VenueCard
key={venue.id}
name={venue.name}
@@ -288,19 +212,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
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
}
onClick={() => onSelectVenue(venue.id)}
aria-label={`${venue.name}, ${venue.location}${venue.price ? `, $${venue.price}` : ''}`}
/>
))}
@@ -315,135 +228,6 @@ export const VenueStep: React.FC<VenueStepProps> = ({
</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
</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>
);
};

View File

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