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:
409
src/components/pages/VenueStep/VenueStep.tsx
Normal file
409
src/components/pages/VenueStep/VenueStep.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user