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:
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
import { VenueStep } from './VenueStep';
|
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 { Navigation } from '../../organisms/Navigation';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
@@ -85,11 +86,11 @@ type Story = StoryObj<typeof VenueStep>;
|
|||||||
|
|
||||||
// ─── Default ───────────────────────────────────────────────────────────────
|
// ─── Default ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Click-to-navigate — clicking a venue card triggers navigation */
|
/** Full filter panel — venue type, services, tradition */
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filters, setFilters] = useState<string[]>([]);
|
const [filters, setFilters] = useState<VenueFilterValues>(EMPTY_VENUE_FILTERS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VenueStep
|
<VenueStep
|
||||||
@@ -97,13 +98,41 @@ export const Default: Story = {
|
|||||||
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
|
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
|
||||||
searchQuery={search}
|
searchQuery={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
activeFilters={filters}
|
filterValues={filters}
|
||||||
onFilterToggle={(key) =>
|
onFilterChange={setFilters}
|
||||||
setFilters((prev) =>
|
onBack={() => alert('Back')}
|
||||||
prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key],
|
locationName="Strathfield"
|
||||||
)
|
navigation={nav}
|
||||||
}
|
/>
|
||||||
onFilterClear={() => setFilters([])}
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── 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')}
|
onBack={() => alert('Back')}
|
||||||
locationName="Strathfield"
|
locationName="Strathfield"
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
@@ -118,6 +147,7 @@ export const Default: Story = {
|
|||||||
export const PrePlanning: Story = {
|
export const PrePlanning: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [filters, setFilters] = useState<VenueFilterValues>(EMPTY_VENUE_FILTERS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VenueStep
|
<VenueStep
|
||||||
@@ -125,6 +155,8 @@ export const PrePlanning: Story = {
|
|||||||
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
|
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
|
||||||
searchQuery={search}
|
searchQuery={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
|
filterValues={filters}
|
||||||
|
onFilterChange={setFilters}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
isPrePlanning
|
isPrePlanning
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
@@ -139,6 +171,7 @@ export const PrePlanning: Story = {
|
|||||||
export const NoVenues: Story = {
|
export const NoVenues: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [search, setSearch] = useState('Wollongong');
|
const [search, setSearch] = useState('Wollongong');
|
||||||
|
const [filters, setFilters] = useState<VenueFilterValues>(EMPTY_VENUE_FILTERS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VenueStep
|
<VenueStep
|
||||||
@@ -146,6 +179,8 @@ export const NoVenues: Story = {
|
|||||||
onSelectVenue={() => {}}
|
onSelectVenue={() => {}}
|
||||||
searchQuery={search}
|
searchQuery={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
|
filterValues={filters}
|
||||||
|
onFilterChange={setFilters}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
locationName="Wollongong"
|
locationName="Wollongong"
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import React from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
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 LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { VenueCard } from '../../molecules/VenueCard';
|
import { VenueCard } from '../../molecules/VenueCard';
|
||||||
import { FilterPanel } from '../../molecules/FilterPanel';
|
import { FilterPanel } from '../../molecules/FilterPanel';
|
||||||
import { Chip } from '../../atoms/Chip';
|
import { Chip } from '../../atoms/Chip';
|
||||||
|
import { Switch } from '../../atoms/Switch';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import { Divider } from '../../atoms/Divider';
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,6 +26,26 @@ export interface Venue {
|
|||||||
price?: 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;
|
||||||
|
}
|
||||||
|
|
||||||
/** Props for the VenueStep page component */
|
/** Props for the VenueStep page component */
|
||||||
export interface VenueStepProps {
|
export interface VenueStepProps {
|
||||||
/** List of venues to display */
|
/** List of venues to display */
|
||||||
@@ -34,14 +58,14 @@ export interface VenueStepProps {
|
|||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
/** Callback when search is submitted */
|
/** Callback when search is submitted */
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
/** Filter chip options */
|
/** Current filter state */
|
||||||
filterOptions?: Array<{ key: string; label: string }>;
|
filterValues: VenueFilterValues;
|
||||||
/** Active filter keys */
|
/** Callback when any filter changes */
|
||||||
activeFilters?: string[];
|
onFilterChange: (values: VenueFilterValues) => void;
|
||||||
/** Callback when a filter chip is toggled */
|
/** Available venue type options */
|
||||||
onFilterToggle?: (key: string) => void;
|
venueTypeOptions?: VenueTypeOption[];
|
||||||
/** Callback to clear all filters */
|
/** Available service tradition options for the autocomplete */
|
||||||
onFilterClear?: () => void;
|
traditionOptions?: string[];
|
||||||
/** Callback for back navigation */
|
/** Callback for back navigation */
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
/** Location name for the results count */
|
/** Location name for the results count */
|
||||||
@@ -60,6 +84,64 @@ export interface VenueStepProps {
|
|||||||
sx?: SxProps<Theme>;
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +151,9 @@ export interface VenueStepProps {
|
|||||||
* venue cards with search and filter button. Right panel is a
|
* venue cards with search and filter button. Right panel is a
|
||||||
* slot for map integration.
|
* 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
|
* Click-to-navigate: clicking a venue card triggers navigation
|
||||||
* to VenueDetailStep — no selection state or Continue button.
|
* to VenueDetailStep — no selection state or Continue button.
|
||||||
*
|
*
|
||||||
@@ -82,13 +167,10 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSearch,
|
onSearch,
|
||||||
filterOptions = [
|
filterValues,
|
||||||
{ key: 'features', label: 'Venue Features' },
|
onFilterChange,
|
||||||
{ key: 'religion', label: 'Religion' },
|
venueTypeOptions = DEFAULT_VENUE_TYPES,
|
||||||
],
|
traditionOptions = DEFAULT_TRADITIONS,
|
||||||
activeFilters = [],
|
|
||||||
onFilterToggle,
|
|
||||||
onFilterClear,
|
|
||||||
onBack,
|
onBack,
|
||||||
locationName,
|
locationName,
|
||||||
isPrePlanning = false,
|
isPrePlanning = false,
|
||||||
@@ -100,7 +182,26 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const subheading = isPrePlanning
|
const subheading = isPrePlanning
|
||||||
? 'Browse available venues. Your choice can be changed later.'
|
? '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 (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
@@ -181,19 +282,117 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
|
|
||||||
{/* Filters — right-aligned below search */}
|
{/* Filters — right-aligned below search */}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||||
<FilterPanel activeCount={activeFilters.length} onClear={onFilterClear}>
|
<FilterPanel activeCount={activeCount} onClear={handleClear}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
{/* ── Location ── */}
|
||||||
{filterOptions.map((filter) => (
|
<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
|
<Chip
|
||||||
key={filter.key}
|
key={option.value}
|
||||||
label={filter.label}
|
label={option.label}
|
||||||
selected={activeFilters.includes(filter.key)}
|
selected={filterValues.venueTypes.includes(option.value)}
|
||||||
onClick={onFilterToggle ? () => onFilterToggle(filter.key) : undefined}
|
onClick={() => handleVenueTypeToggle(option.value)}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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>
|
</FilterPanel>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user