Files
Parsons/src/components/pages/VenueStep/VenueStep.tsx
Richie 7b8cb65a63 Move List/Map toggle to floating overlay on map panel
- View toggle: floating in top-left of map panel with shadow + paper bg
- Control bar: just Filters + Sort (consistent heights, no overflow)
- Results count: bumped mt from 1 to 2 for more breathing room
- Both ProvidersStep and VenueStep updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:21:06 +11:00

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={{ fontSize: '0.75rem', 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: 2, 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;