VenueStep structured filters: venue type, services, tradition

Replace generic chip filters with structured VenueFilterValues:
- Location: chip-in-input (same pattern as ProvidersStep)
- Venue type: wrapping chip multi-select (Chapel, Church, Cathedral,
  Outdoor Venue, Hall/Room, Mosque, Temple)
- Additional services: switches (Video Streaming, Photo Display)
- Service tradition: autocomplete with type-to-search (21 options)

New VenueFilterValues interface, EMPTY_VENUE_FILTERS export.
New story: WithActiveFilters (Chapel + video + Catholic).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 16:29:30 +11:00
parent f541926cb9
commit a3069b2ee6
2 changed files with 272 additions and 38 deletions

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { VenueStep } from './VenueStep';
import type { Venue } from './VenueStep';
import type { Venue, VenueFilterValues } from './VenueStep';
import { EMPTY_VENUE_FILTERS } from './VenueStep';
import { Navigation } from '../../organisms/Navigation';
import Box from '@mui/material/Box';
@@ -85,11 +86,11 @@ type Story = StoryObj<typeof VenueStep>;
// ─── Default ───────────────────────────────────────────────────────────────
/** Click-to-navigate — clicking a venue card triggers navigation */
/** Full filter panel — venue type, services, tradition */
export const Default: Story = {
render: () => {
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<string[]>([]);
const [filters, setFilters] = useState<VenueFilterValues>(EMPTY_VENUE_FILTERS);
return (
<VenueStep
@@ -97,13 +98,41 @@ export const Default: Story = {
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([])}
filterValues={filters}
onFilterChange={setFilters}
onBack={() => alert('Back')}
locationName="Strathfield"
navigation={nav}
/>
);
},
};
// ─── With active filters ────────────────────────────────────────────────────
/** Filters pre-applied — chapel + video streaming + Catholic */
export const WithActiveFilters: Story = {
render: () => {
const [search, setSearch] = useState('Strathfield');
const [filters, setFilters] = useState<VenueFilterValues>({
venueTypes: ['chapel'],
videoStreaming: true,
photoDisplay: false,
tradition: 'Catholic',
});
const filtered = sampleVenues.filter((v) =>
v.location.toLowerCase().includes(search.toLowerCase()),
);
return (
<VenueStep
venues={filtered}
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
searchQuery={search}
onSearchChange={setSearch}
filterValues={filters}
onFilterChange={setFilters}
onBack={() => alert('Back')}
locationName="Strathfield"
navigation={nav}
@@ -118,6 +147,7 @@ export const Default: Story = {
export const PrePlanning: Story = {
render: () => {
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<VenueFilterValues>(EMPTY_VENUE_FILTERS);
return (
<VenueStep
@@ -125,6 +155,8 @@ export const PrePlanning: Story = {
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
searchQuery={search}
onSearchChange={setSearch}
filterValues={filters}
onFilterChange={setFilters}
onBack={() => alert('Back')}
isPrePlanning
navigation={nav}
@@ -139,6 +171,7 @@ export const PrePlanning: Story = {
export const NoVenues: Story = {
render: () => {
const [search, setSearch] = useState('Wollongong');
const [filters, setFilters] = useState<VenueFilterValues>(EMPTY_VENUE_FILTERS);
return (
<VenueStep
@@ -146,6 +179,8 @@ export const NoVenues: Story = {
onSelectVenue={() => {}}
searchQuery={search}
onSearchChange={setSearch}
filterValues={filters}
onFilterChange={setFilters}
onBack={() => alert('Back')}
locationName="Wollongong"
navigation={nav}

View File

@@ -2,13 +2,17 @@ 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 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 { Chip } from '../../atoms/Chip';
import { Switch } from '../../atoms/Switch';
import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -22,6 +26,26 @@ export interface Venue {
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;
}
/** Props for the VenueStep page component */
export interface VenueStepProps {
/** List of venues to display */
@@ -34,14 +58,14 @@ export interface VenueStepProps {
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;
/** 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[];
/** Callback for back navigation */
onBack: () => void;
/** Location name for the results count */
@@ -60,6 +84,64 @@ export interface VenueStepProps {
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',
];
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 ───────────────────────────────────────────────────────────────
/**
@@ -69,6 +151,9 @@ export interface VenueStepProps {
* 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.
*
@@ -82,13 +167,10 @@ export const VenueStep: React.FC<VenueStepProps> = ({
searchQuery,
onSearchChange,
onSearch,
filterOptions = [
{ key: 'features', label: 'Venue Features' },
{ key: 'religion', label: 'Religion' },
],
activeFilters = [],
onFilterToggle,
onFilterClear,
filterValues,
onFilterChange,
venueTypeOptions = DEFAULT_VENUE_TYPES,
traditionOptions = DEFAULT_TRADITIONS,
onBack,
locationName,
isPrePlanning = false,
@@ -100,7 +182,26 @@ export const VenueStep: React.FC<VenueStepProps> = ({
}) => {
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.';
: '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
@@ -181,18 +282,116 @@ export const VenueStep: React.FC<VenueStepProps> = ({
{/* 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"
/>
))}
<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>
</Box>