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:
@@ -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 handleContinue = () => {
|
||||
if (!values.selectedVenueId) {
|
||||
setErrors({ selectedVenueId: 'Please choose a venue for the service.' });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
alert(`Continue with venue: ${values.selectedVenueId}`);
|
||||
};
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState<string[]>([]);
|
||||
|
||||
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')}
|
||||
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],
|
||||
)
|
||||
}
|
||||
onFilterClear={() => setFilters([])}
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,2 @@
|
||||
export { VenueStep, default } from './VenueStep';
|
||||
export type {
|
||||
VenueStepProps,
|
||||
VenueStepValues,
|
||||
VenueStepErrors,
|
||||
Venue,
|
||||
VenueService,
|
||||
} from './VenueStep';
|
||||
export type { VenueStepProps, Venue } from './VenueStep';
|
||||
|
||||
Reference in New Issue
Block a user