- Sort button: remove fontSize override so it inherits Button small size - Results count: mt 2 → 3 for more breathing room below controls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
580 lines
19 KiB
TypeScript
580 lines
19 KiB
TypeScript
import React from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import TextField from '@mui/material/TextField';
|
|
import InputAdornment from '@mui/material/InputAdornment';
|
|
import Autocomplete from '@mui/material/Autocomplete';
|
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
import MenuItem from '@mui/material/MenuItem';
|
|
import Menu from '@mui/material/Menu';
|
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
|
import ToggleButton from '@mui/material/ToggleButton';
|
|
import SwapVertIcon from '@mui/icons-material/SwapVert';
|
|
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
|
|
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
|
import type { SxProps, Theme } from '@mui/material/styles';
|
|
import { WizardLayout } from '../../templates/WizardLayout';
|
|
import { VenueCard } from '../../molecules/VenueCard';
|
|
import { FilterPanel } from '../../molecules/FilterPanel';
|
|
import { Button } from '../../atoms/Button';
|
|
import { Chip } from '../../atoms/Chip';
|
|
import { Switch } from '../../atoms/Switch';
|
|
import { Typography } from '../../atoms/Typography';
|
|
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;
|
|
}
|
|
|
|
/** A venue type option for the filter */
|
|
export interface VenueTypeOption {
|
|
/** Machine-readable value */
|
|
value: string;
|
|
/** Human-readable label */
|
|
label: string;
|
|
}
|
|
|
|
/** Structured filter state for the venue list */
|
|
export interface VenueFilterValues {
|
|
/** Selected venue type values (empty = all) */
|
|
venueTypes: string[];
|
|
/** Show only venues with video streaming */
|
|
videoStreaming: boolean;
|
|
/** Show only venues with photo display */
|
|
photoDisplay: boolean;
|
|
/** Selected service tradition (null = any) */
|
|
tradition: string | null;
|
|
}
|
|
|
|
/** Sort options for the venue list */
|
|
export type VenueSortBy = 'recommended' | 'nearest' | 'price_low' | 'price_high';
|
|
|
|
/** View mode for the listing */
|
|
export type ListViewMode = 'list' | 'map';
|
|
|
|
/** Props for the VenueStep page component */
|
|
export interface VenueStepProps {
|
|
/** List of venues to display */
|
|
venues: Venue[];
|
|
/** 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;
|
|
/** Current filter state */
|
|
filterValues: VenueFilterValues;
|
|
/** Callback when any filter changes */
|
|
onFilterChange: (values: VenueFilterValues) => void;
|
|
/** Available venue type options */
|
|
venueTypeOptions?: VenueTypeOption[];
|
|
/** Available service tradition options for the autocomplete */
|
|
traditionOptions?: string[];
|
|
/** Current sort order */
|
|
sortBy?: VenueSortBy;
|
|
/** Callback when sort order changes */
|
|
onSortChange?: (sort: VenueSortBy) => void;
|
|
/** Current view mode */
|
|
viewMode?: ListViewMode;
|
|
/** Callback when view mode changes */
|
|
onViewModeChange?: (mode: ListViewMode) => void;
|
|
/** Callback for back navigation */
|
|
onBack: () => void;
|
|
/** Location name for the results count */
|
|
locationName?: string;
|
|
/** Whether this is a pre-planning flow */
|
|
isPrePlanning?: boolean;
|
|
/** Map panel content — slot for map integration */
|
|
mapPanel?: React.ReactNode;
|
|
/** Navigation bar — passed through to WizardLayout */
|
|
navigation?: React.ReactNode;
|
|
/** Progress stepper */
|
|
progressStepper?: React.ReactNode;
|
|
/** Running total widget (e.g. CartButton) */
|
|
runningTotal?: React.ReactNode;
|
|
/** MUI sx prop for the root */
|
|
sx?: SxProps<Theme>;
|
|
}
|
|
|
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
|
|
const DEFAULT_VENUE_TYPES: VenueTypeOption[] = [
|
|
{ value: 'chapel', label: 'Chapel' },
|
|
{ value: 'church', label: 'Church' },
|
|
{ value: 'cathedral', label: 'Cathedral' },
|
|
{ value: 'outdoor', label: 'Outdoor Venue' },
|
|
{ value: 'hall', label: 'Hall / Room' },
|
|
{ value: 'mosque', label: 'Mosque' },
|
|
{ value: 'temple', label: 'Temple' },
|
|
];
|
|
|
|
const DEFAULT_TRADITIONS = [
|
|
'None',
|
|
'Anglican',
|
|
"Bahá'í",
|
|
'Baptist',
|
|
'Buddhist',
|
|
'Catholic',
|
|
'Eastern Orthodox',
|
|
'Hindu',
|
|
'Humanist',
|
|
'Indigenous Australian',
|
|
'Jewish',
|
|
'Lutheran',
|
|
'Methodist',
|
|
'Muslim',
|
|
'Non-religious',
|
|
'Pentecostal',
|
|
'Presbyterian',
|
|
'Salvation Army',
|
|
'Secular',
|
|
'Sikh',
|
|
'Uniting Church',
|
|
];
|
|
|
|
const SORT_OPTIONS: { value: VenueSortBy; label: string }[] = [
|
|
{ value: 'recommended', label: 'Recommended' },
|
|
{ value: 'nearest', label: 'Nearest' },
|
|
{ value: 'price_low', label: 'Price: Low to High' },
|
|
{ value: 'price_high', label: 'Price: High to Low' },
|
|
];
|
|
|
|
export const EMPTY_VENUE_FILTERS: VenueFilterValues = {
|
|
venueTypes: [],
|
|
videoStreaming: false,
|
|
photoDisplay: false,
|
|
tradition: null,
|
|
};
|
|
|
|
// ─── Shared styles ───────────────────────────────────────────────────────────
|
|
|
|
const sectionHeadingSx = {
|
|
mb: 1.5,
|
|
display: 'block',
|
|
fontWeight: 600,
|
|
color: 'text.primary',
|
|
} as const;
|
|
|
|
const chipWrapSx = {
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: 1,
|
|
} as const;
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Step 7 — Service Venue selection for the FA arrangement wizard.
|
|
*
|
|
* 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.
|
|
*
|
|
* Filters: location (chip-in-input), venue type (wrapping chips),
|
|
* additional services (switches), service tradition (autocomplete).
|
|
*
|
|
* 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> = ({
|
|
venues,
|
|
onSelectVenue,
|
|
searchQuery,
|
|
onSearchChange,
|
|
onSearch,
|
|
filterValues,
|
|
onFilterChange,
|
|
venueTypeOptions = DEFAULT_VENUE_TYPES,
|
|
traditionOptions = DEFAULT_TRADITIONS,
|
|
sortBy = 'recommended',
|
|
onSortChange,
|
|
viewMode = 'list',
|
|
onViewModeChange,
|
|
onBack,
|
|
locationName,
|
|
isPrePlanning = false,
|
|
mapPanel,
|
|
navigation,
|
|
progressStepper,
|
|
runningTotal,
|
|
sx,
|
|
}) => {
|
|
const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
|
|
|
|
const subheading = isPrePlanning
|
|
? 'Browse available venues. Your choice can be changed later.'
|
|
: 'Choose a venue for the funeral service. You can filter by type, services, and tradition.';
|
|
|
|
// ─── Active filter count ───
|
|
const activeCount =
|
|
(searchQuery.trim() ? 1 : 0) +
|
|
filterValues.venueTypes.length +
|
|
(filterValues.videoStreaming ? 1 : 0) +
|
|
(filterValues.photoDisplay ? 1 : 0) +
|
|
(filterValues.tradition ? 1 : 0);
|
|
|
|
const handleClear = () => {
|
|
onSearchChange('');
|
|
onFilterChange(EMPTY_VENUE_FILTERS);
|
|
};
|
|
|
|
const handleVenueTypeToggle = (value: string) => {
|
|
const current = filterValues.venueTypes;
|
|
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
|
|
onFilterChange({ ...filterValues, venueTypes: next });
|
|
};
|
|
|
|
return (
|
|
<WizardLayout
|
|
variant="list-map"
|
|
navigation={navigation}
|
|
progressStepper={progressStepper}
|
|
runningTotal={runningTotal}
|
|
showBackLink
|
|
backLabel="Back"
|
|
onBack={onBack}
|
|
sx={sx}
|
|
secondaryPanel={
|
|
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
|
|
{/* Floating view toggle */}
|
|
<ToggleButtonGroup
|
|
value={viewMode}
|
|
exclusive
|
|
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
|
size="small"
|
|
aria-label="View mode"
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 12,
|
|
left: 12,
|
|
zIndex: 1,
|
|
bgcolor: 'background.paper',
|
|
boxShadow: 'var(--fa-shadow-md)',
|
|
borderRadius: 1,
|
|
'& .MuiToggleButton-root': {
|
|
px: 1.5,
|
|
py: 0.5,
|
|
fontSize: '0.75rem',
|
|
fontWeight: 500,
|
|
gap: 0.5,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
textTransform: 'none',
|
|
'&.Mui-selected': {
|
|
bgcolor: 'var(--fa-color-brand-100)',
|
|
color: 'primary.main',
|
|
borderColor: 'primary.main',
|
|
'&:hover': { bgcolor: 'var(--fa-color-brand-200)' },
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<ToggleButton value="list" aria-label="List view">
|
|
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
|
|
List
|
|
</ToggleButton>
|
|
<ToggleButton value="map" aria-label="Map view">
|
|
<MapOutlinedIcon sx={{ fontSize: 16 }} />
|
|
Map
|
|
</ToggleButton>
|
|
</ToggleButtonGroup>
|
|
|
|
{/* Map content */}
|
|
{mapPanel || (
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: 'var(--fa-color-surface-cool)',
|
|
borderLeft: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<Typography variant="body1" color="text.secondary">
|
|
Map coming soon
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
}
|
|
>
|
|
{/* Heading — scrolls with listings */}
|
|
<Typography variant="h4" component="h1" sx={{ mb: 0.5, pt: 2 }} tabIndex={-1}>
|
|
Where would you like the service?
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
{subheading}
|
|
</Typography>
|
|
|
|
{/* Sticky controls — search + filters pinned while listings scroll */}
|
|
<Box
|
|
sx={{
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 1,
|
|
bgcolor: 'background.default',
|
|
pt: 3,
|
|
pb: 1.5,
|
|
mx: { xs: -2, md: -3 },
|
|
px: { xs: 2, md: 3 },
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
{/* 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 }}
|
|
/>
|
|
|
|
{/* Control bar — filters + sort */}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{/* Filters */}
|
|
<FilterPanel activeCount={activeCount} onClear={handleClear}>
|
|
{/* ── Location ── */}
|
|
<Box>
|
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
Location
|
|
</Typography>
|
|
<Autocomplete
|
|
multiple
|
|
freeSolo
|
|
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
|
onChange={(_, newValue) => {
|
|
const last = newValue[newValue.length - 1] ?? '';
|
|
onSearchChange(typeof last === 'string' ? last : '');
|
|
}}
|
|
options={[]}
|
|
renderInput={(params) => (
|
|
<TextField
|
|
{...params}
|
|
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
|
|
size="small"
|
|
InputProps={{
|
|
...params.InputProps,
|
|
startAdornment: (
|
|
<>
|
|
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
|
<LocationOnOutlinedIcon
|
|
sx={{ color: 'text.secondary', fontSize: 18 }}
|
|
/>
|
|
</InputAdornment>
|
|
{params.InputProps.startAdornment}
|
|
</>
|
|
),
|
|
}}
|
|
/>
|
|
)}
|
|
size="small"
|
|
/>
|
|
</Box>
|
|
|
|
<Divider />
|
|
|
|
{/* ── Venue type ── */}
|
|
<Box>
|
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
Venue type
|
|
</Typography>
|
|
<Box sx={chipWrapSx}>
|
|
{venueTypeOptions.map((option) => (
|
|
<Chip
|
|
key={option.value}
|
|
label={option.label}
|
|
selected={filterValues.venueTypes.includes(option.value)}
|
|
onClick={() => handleVenueTypeToggle(option.value)}
|
|
variant="outlined"
|
|
size="small"
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Divider />
|
|
|
|
{/* ── Additional services ── */}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
Additional services
|
|
</Typography>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={filterValues.videoStreaming}
|
|
onChange={(_, checked) =>
|
|
onFilterChange({ ...filterValues, videoStreaming: checked })
|
|
}
|
|
/>
|
|
}
|
|
label="Video streaming available"
|
|
sx={{ mx: 0 }}
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={filterValues.photoDisplay}
|
|
onChange={(_, checked) =>
|
|
onFilterChange({ ...filterValues, photoDisplay: checked })
|
|
}
|
|
/>
|
|
}
|
|
label="Photo display available"
|
|
sx={{ mx: 0 }}
|
|
/>
|
|
</Box>
|
|
|
|
<Divider />
|
|
|
|
{/* ── Service tradition ── */}
|
|
<Box>
|
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
Service tradition
|
|
</Typography>
|
|
<Autocomplete
|
|
value={filterValues.tradition}
|
|
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
|
|
options={traditionOptions}
|
|
renderInput={(params) => (
|
|
<TextField {...params} placeholder="Search traditions..." size="small" />
|
|
)}
|
|
clearOnEscape
|
|
size="small"
|
|
/>
|
|
</Box>
|
|
</FilterPanel>
|
|
|
|
{/* Sort — compact menu button, pushed right */}
|
|
<Box sx={{ ml: 'auto' }}>
|
|
<Button
|
|
variant="outlined"
|
|
color="secondary"
|
|
size="small"
|
|
startIcon={<SwapVertIcon sx={{ fontSize: 16 }} />}
|
|
onClick={(e) => setSortAnchor(e.currentTarget)}
|
|
aria-haspopup="listbox"
|
|
sx={{ textTransform: 'none' }}
|
|
>
|
|
{SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Sort'}
|
|
</Button>
|
|
<Menu
|
|
anchorEl={sortAnchor}
|
|
open={Boolean(sortAnchor)}
|
|
onClose={() => setSortAnchor(null)}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
>
|
|
{SORT_OPTIONS.map((opt) => (
|
|
<MenuItem
|
|
key={opt.value}
|
|
selected={opt.value === sortBy}
|
|
onClick={() => {
|
|
onSortChange?.(opt.value);
|
|
setSortAnchor(null);
|
|
}}
|
|
sx={{ fontSize: '0.813rem' }}
|
|
>
|
|
{opt.label}
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Results count — below controls */}
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ mt: 3, display: 'block' }}
|
|
aria-live="polite"
|
|
>
|
|
{venues.length} venue{venues.length !== 1 ? 's' : ''}
|
|
{locationName ? ` near ${locationName}` : ''} found
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Venue list — click-to-navigate */}
|
|
<Box
|
|
role="list"
|
|
aria-label="Available venues"
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 2,
|
|
pb: 3,
|
|
pt: 2,
|
|
px: { xs: 2, md: 3 },
|
|
mx: { xs: -2, md: -3 },
|
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
|
}}
|
|
>
|
|
{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}` : ''}`}
|
|
/>
|
|
))}
|
|
|
|
{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>
|
|
</WizardLayout>
|
|
);
|
|
};
|
|
|
|
VenueStep.displayName = 'VenueStep';
|
|
export default VenueStep;
|