From a3069b2ee6612d62d4ffe5b82762b2ecced94eed Mon Sep 17 00:00:00 2001 From: Richie Date: Tue, 31 Mar 2026 16:29:30 +1100 Subject: [PATCH] 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) --- .../pages/VenueStep/VenueStep.stories.tsx | 55 +++- src/components/pages/VenueStep/VenueStep.tsx | 255 ++++++++++++++++-- 2 files changed, 272 insertions(+), 38 deletions(-) diff --git a/src/components/pages/VenueStep/VenueStep.stories.tsx b/src/components/pages/VenueStep/VenueStep.stories.tsx index cbf9f67..77fc435 100644 --- a/src/components/pages/VenueStep/VenueStep.stories.tsx +++ b/src/components/pages/VenueStep/VenueStep.stories.tsx @@ -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; // ─── 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([]); + const [filters, setFilters] = useState(EMPTY_VENUE_FILTERS); return ( 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({ + venueTypes: ['chapel'], + videoStreaming: true, + photoDisplay: false, + tradition: 'Catholic', + }); + + const filtered = sampleVenues.filter((v) => + v.location.toLowerCase().includes(search.toLowerCase()), + ); + + return ( + 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(EMPTY_VENUE_FILTERS); return ( 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(EMPTY_VENUE_FILTERS); return ( {}} searchQuery={search} onSearchChange={setSearch} + filterValues={filters} + onFilterChange={setFilters} onBack={() => alert('Back')} locationName="Wollongong" navigation={nav} diff --git a/src/components/pages/VenueStep/VenueStep.tsx b/src/components/pages/VenueStep/VenueStep.tsx index ff863eb..e55bd1d 100644 --- a/src/components/pages/VenueStep/VenueStep.tsx +++ b/src/components/pages/VenueStep/VenueStep.tsx @@ -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; } +// ─── 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 = ({ 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 = ({ }) => { 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 ( = ({ {/* Filters — right-aligned below search */} - - - {filterOptions.map((filter) => ( - onFilterToggle(filter.key) : undefined} - variant="outlined" - size="small" - /> - ))} + + {/* ── Location ── */} + + + Location + + { + const last = newValue[newValue.length - 1] ?? ''; + onSearchChange(typeof last === 'string' ? last : ''); + }} + options={[]} + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ), + }} + /> + )} + size="small" + /> + + + + + {/* ── Venue type ── */} + + + Venue type + + + {venueTypeOptions.map((option) => ( + handleVenueTypeToggle(option.value)} + variant="outlined" + size="small" + /> + ))} + + + + + + {/* ── Additional services ── */} + + + Additional services + + + onFilterChange({ ...filterValues, videoStreaming: checked }) + } + /> + } + label="Video streaming available" + sx={{ mx: 0 }} + /> + + onFilterChange({ ...filterValues, photoDisplay: checked }) + } + /> + } + label="Photo display available" + sx={{ mx: 0 }} + /> + + + + + {/* ── Service tradition ── */} + + + Service tradition + + onFilterChange({ ...filterValues, tradition: newValue })} + options={traditionOptions} + renderInput={(params) => ( + + )} + clearOnEscape + size="small" + />