diff --git a/src/components/molecules/VenueCard/VenueCard.tsx b/src/components/molecules/VenueCard/VenueCard.tsx index b57d50c..35e9142 100644 --- a/src/components/molecules/VenueCard/VenueCard.tsx +++ b/src/components/molecules/VenueCard/VenueCard.tsx @@ -20,8 +20,14 @@ export interface VenueCardProps { capacity?: number; /** Venue hire price in dollars */ price?: number; + /** Whether this card is the selected venue (brand border + warm bg) */ + selected?: boolean; /** Click handler — entire card is clickable */ onClick?: () => void; + /** HTML/ARIA passthrough for radiogroup patterns */ + role?: string; + 'aria-checked'?: boolean; + tabIndex?: number; /** MUI sx prop for style overrides */ sx?: SxProps; } @@ -57,13 +63,32 @@ const CONTENT_GAP = 'var(--fa-venue-card-content-gap)'; * ``` */ export const VenueCard = React.forwardRef( - ({ name, imageUrl, location, capacity, price, onClick, sx }, ref) => { + ( + { + name, + imageUrl, + location, + capacity, + price, + selected = false, + onClick, + role, + 'aria-checked': ariaChecked, + tabIndex, + sx, + }, + ref, + ) => { return ( ( + + + + +); + +const nav = ( + } + items={[ + { label: 'FAQ', href: '/faq' }, + { label: 'Contact Us', href: '/contact' }, + ]} + /> +); + +const sampleVenues: Venue[] = [ + { + id: 'west-chapel', + name: 'West Chapel', + imageUrl: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=400&h=300&fit=crop', + location: 'Strathfield', + capacity: 120, + price: 900, + description: + 'A modern chapel with natural light and flexible seating arrangements. Surrounded by established gardens.', + features: ['Air conditioning', 'Wheelchair accessible', 'On-site parking', 'Audio system'], + religions: ['No Religion', 'Civil Celebrant', 'Anglican', 'Catholic', 'Uniting Church'], + address: '42 The Boulevarde, Strathfield NSW 2135', + }, + { + id: 'rose-garden', + name: 'Rose Garden Chapel', + imageUrl: 'https://images.unsplash.com/photo-1464366400600-7168b8af9bc3?w=400&h=300&fit=crop', + location: 'Homebush', + capacity: 80, + price: 750, + description: + 'An intimate setting surrounded by heritage rose gardens. Ideal for smaller gatherings.', + features: ['Garden access', 'Wheelchair accessible', 'Audio system'], + religions: ['No Religion', 'Civil Celebrant', 'Buddhism', 'Hinduism'], + address: '15 Olympic Park Road, Homebush NSW 2140', + }, + { + id: 'memorial-hall', + name: 'Strathfield Memorial Hall', + imageUrl: 'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=400&h=300&fit=crop', + location: 'Strathfield', + capacity: 250, + price: 1200, + description: + 'A grand hall suited for large services. Full catering kitchen and stage with audio-visual equipment.', + features: [ + 'Full catering kitchen', + 'Stage', + 'Audio-visual system', + 'Wheelchair accessible', + 'Large car park', + ], + religions: ['All faiths welcome'], + address: '1 Redmyre Road, Strathfield NSW 2135', + }, + { + id: 'lakeside', + name: 'Lakeside Pavilion', + imageUrl: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop', + location: 'Concord', + capacity: 60, + price: 650, + description: + 'An open-air pavilion overlooking the lake. A peaceful setting for farewell services.', + features: ['Lake views', 'Natural setting', 'Covered pavilion'], + address: '8 Victoria Avenue, Concord NSW 2137', + }, +]; + +const sampleServices: VenueService[] = [ + { id: 'photo', name: 'Photo presentation', price: 150 }, + { id: 'streaming', name: 'Livestream', price: 200 }, + { id: 'recording', name: 'Recording', price: 100 }, +]; + +const defaultValues: VenueStepValues = { + search: '', + activeFilters: [], + selectedVenueId: null, + photoDisplay: false, + streaming: false, + recording: false, +}; + +// ─── Meta ──────────────────────────────────────────────────────────────────── + +const meta: Meta = { + title: 'Pages/VenueStep', + component: VenueStep, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── Interactive (default) ────────────────────────────────────────────────── + +/** Fully interactive — select a venue, see details + service toggles */ +export const Default: Story = { + render: () => { + const [values, setValues] = useState({ ...defaultValues }); + const [errors, setErrors] = useState({}); + + const handleContinue = () => { + if (!values.selectedVenueId) { + setErrors({ selectedVenueId: 'Please choose a venue for the service.' }); + return; + } + setErrors({}); + alert(`Continue with venue: ${values.selectedVenueId}`); + }; + + return ( + { + setValues(v); + setErrors({}); + }} + onContinue={handleContinue} + onBack={() => alert('Back')} + onSaveAndExit={() => alert('Save and exit')} + errors={errors} + venues={sampleVenues} + services={sampleServices} + locationName="Strathfield" + navigation={nav} + /> + ); + }, +}; + +// ─── Venue selected ───────────────────────────────────────────────────────── + +/** Venue already selected — detail panel and service toggles visible */ +export const VenueSelected: Story = { + render: () => { + const [values, setValues] = useState({ + ...defaultValues, + selectedVenueId: 'west-chapel', + photoDisplay: true, + }); + return ( + alert('Continue')} + onBack={() => alert('Back')} + onSaveAndExit={() => alert('Save')} + venues={sampleVenues} + services={sampleServices} + locationName="Strathfield" + navigation={nav} + /> + ); + }, +}; + +// ─── With streaming + recording ───────────────────────────────────────────── + +/** Streaming enabled, revealing recording toggle */ +export const WithStreaming: Story = { + render: () => { + const [values, setValues] = useState({ + ...defaultValues, + selectedVenueId: 'memorial-hall', + streaming: true, + recording: true, + }); + return ( + alert('Continue')} + onBack={() => alert('Back')} + venues={sampleVenues} + services={sampleServices} + locationName="Strathfield" + navigation={nav} + /> + ); + }, +}; + +// ─── Pre-planning ─────────────────────────────────────────────────────────── + +/** Pre-planning variant copy */ +export const PrePlanning: Story = { + render: () => { + const [values, setValues] = useState({ ...defaultValues }); + return ( + alert('Continue')} + onBack={() => alert('Back')} + venues={sampleVenues} + services={sampleServices} + isPrePlanning + navigation={nav} + /> + ); + }, +}; + +// ─── Validation error ─────────────────────────────────────────────────────── + +/** No venue selected with error */ +export const WithError: Story = { + render: () => { + const [values, setValues] = useState({ ...defaultValues }); + return ( + {}} + errors={{ selectedVenueId: 'Please choose a venue for the service.' }} + venues={sampleVenues} + services={sampleServices} + locationName="Strathfield" + navigation={nav} + /> + ); + }, +}; + +// ─── Empty state ──────────────────────────────────────────────────────────── + +/** No venues found */ +export const NoVenues: Story = { + render: () => { + const [values, setValues] = useState({ + ...defaultValues, + search: 'Wollongong', + }); + return ( + {}} + venues={[]} + locationName="Wollongong" + navigation={nav} + /> + ); + }, +}; diff --git a/src/components/pages/VenueStep/VenueStep.tsx b/src/components/pages/VenueStep/VenueStep.tsx new file mode 100644 index 0000000..4391444 --- /dev/null +++ b/src/components/pages/VenueStep/VenueStep.tsx @@ -0,0 +1,409 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import SearchIcon from '@mui/icons-material/Search'; +import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { WizardLayout } from '../../templates/WizardLayout'; +import { VenueCard } from '../../molecules/VenueCard'; +import { AddOnOption } from '../../molecules/AddOnOption'; +import { Collapse } from '../../atoms/Collapse'; +import { Chip } from '../../atoms/Chip'; +import { Typography } from '../../atoms/Typography'; +import { Button } from '../../atoms/Button'; +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; + description?: string; + features?: string[]; + religions?: string[]; + address?: string; +} + +/** Service add-on toggle */ +export interface VenueService { + id: string; + name: string; + description?: string; + price?: number; +} + +/** Form values for the venue step */ +export interface VenueStepValues { + /** Search query */ + search: string; + /** Active filter chip keys */ + activeFilters: string[]; + /** Selected venue ID */ + selectedVenueId: string | null; + /** Photo presentation enabled */ + photoDisplay: boolean; + /** Livestream enabled */ + streaming: boolean; + /** Recording enabled (depends on streaming) */ + recording: boolean; +} + +/** Field-level error messages */ +export interface VenueStepErrors { + selectedVenueId?: string; +} + +/** Props for the VenueStep page component */ +export interface VenueStepProps { + /** Current form values */ + values: VenueStepValues; + /** Callback when any field value changes */ + onChange: (values: VenueStepValues) => void; + /** Callback when the Continue button is clicked */ + onContinue: () => void; + /** Callback for back navigation */ + onBack?: () => void; + /** Callback for save-and-exit */ + onSaveAndExit?: () => void; + /** Field-level validation errors */ + errors?: VenueStepErrors; + /** Whether the Continue button is in a loading state */ + loading?: boolean; + /** Available venues */ + venues: Venue[]; + /** Available service add-ons shown after venue selection */ + services?: VenueService[]; + /** Filter chip options */ + filterOptions?: Array<{ key: string; label: string }>; + /** Location name for the results count */ + locationName?: string; + /** Whether this is a pre-planning flow */ + isPrePlanning?: boolean; + /** Navigation bar — passed through to WizardLayout */ + navigation?: React.ReactNode; + /** Progress stepper — passed through to WizardLayout */ + progressStepper?: React.ReactNode; + /** Running total — passed through to WizardLayout */ + runningTotal?: React.ReactNode; + /** Hide the help bar */ + hideHelpBar?: boolean; + /** MUI sx prop for the root */ + sx?: SxProps; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +/** + * Step 7 — Service Venue for the FA arrangement wizard. + * + * Consolidated from 3 baseline steps (venues + venue details + venue services) + * into a single step with progressive disclosure (Rec #5). + * + * Three phases: + * 1. Venue card grid with search/filters (grid-sidebar layout) + * 2. Selected venue detail (inline Collapse below grid) + * 3. Service toggles (photo presentation, streaming, recording) + * + * Pure presentation component — props in, callbacks out. + * + * Spec: documentation/steps/steps/07_venue_consolidated.yaml + */ +export const VenueStep: React.FC = ({ + values, + onChange, + onContinue, + onBack, + onSaveAndExit, + errors, + loading = false, + venues, + services = [], + filterOptions = [ + { key: 'features', label: 'Venue Features' }, + { key: 'religion', label: 'Religion' }, + ], + locationName, + isPrePlanning = false, + navigation, + progressStepper, + runningTotal, + hideHelpBar, + sx, +}) => { + const selectedVenue = venues.find((v) => v.id === values.selectedVenueId) ?? null; + const hasSelection = selectedVenue !== null; + + const handleVenueSelect = (venueId: string) => { + onChange({ ...values, selectedVenueId: venueId }); + }; + + const handleFilterToggle = (key: string) => { + const next = values.activeFilters.includes(key) + ? values.activeFilters.filter((f) => f !== key) + : [...values.activeFilters, key]; + onChange({ ...values, activeFilters: next }); + }; + + const handleToggle = (field: 'photoDisplay' | 'streaming' | 'recording', checked: boolean) => { + const next = { ...values, [field]: checked }; + // Disable recording when streaming is turned off + if (field === 'streaming' && !checked) { + next.recording = false; + } + onChange(next); + }; + + return ( + + {/* Page heading */} + + Where would you like the service? + + + + {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.'} + + + { + e.preventDefault(); + onContinue(); + }} + > + {/* ─── Search + Filters ─── */} + + onChange({ ...values, search: e.target.value })} + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ mb: 2 }} + /> + + + {filterOptions.map((filter) => ( + handleFilterToggle(filter.key)} + selected={values.activeFilters.includes(filter.key)} + /> + ))} + + + + {/* ─── Results count ─── */} + + Found {venues.length} venue{venues.length !== 1 ? 's' : ''} + {locationName ? ` near ${locationName}` : ''} + + + {/* ─── Venue card grid ─── */} + + {venues.map((venue, index) => ( + handleVenueSelect(venue.id)} + role="radio" + aria-checked={venue.id === values.selectedVenueId} + tabIndex={ + values.selectedVenueId === null + ? index === 0 + ? 0 + : -1 + : venue.id === values.selectedVenueId + ? 0 + : -1 + } + /> + ))} + + + {/* Validation error */} + {errors?.selectedVenueId && ( + + {errors.selectedVenueId} + + )} + + {/* ─── Selected venue detail (progressive disclosure) ─── */} + + {selectedVenue && ( + + + {selectedVenue.name} + + + {selectedVenue.address && ( + + + + {selectedVenue.address} + + + )} + + {selectedVenue.description && ( + + {selectedVenue.description} + + )} + + {/* Features */} + {selectedVenue.features && selectedVenue.features.length > 0 && ( + + + Features + + + {selectedVenue.features.map((feature) => ( + + + {feature} + + ))} + + + )} + + {/* Supported religions */} + {selectedVenue.religions && selectedVenue.religions.length > 0 && ( + + + Supported service styles + + + {selectedVenue.religions.map((r) => ( + + ))} + + + )} + + )} + + + {/* ─── Service toggles (after venue selection) ─── */} + + + + Venue services + + + s.id === 'photo')?.price} + checked={values.photoDisplay} + onChange={(c) => handleToggle('photoDisplay', c)} + /> + + s.id === 'streaming')?.price} + checked={values.streaming} + onChange={(c) => handleToggle('streaming', c)} + /> + + + s.id === 'recording')?.price} + checked={values.recording} + onChange={(c) => handleToggle('recording', c)} + /> + + + + + + + {/* CTAs */} + + {onSaveAndExit ? ( + + ) : ( + + )} + + + + + ); +}; + +VenueStep.displayName = 'VenueStep'; +export default VenueStep; diff --git a/src/components/pages/VenueStep/index.ts b/src/components/pages/VenueStep/index.ts new file mode 100644 index 0000000..a65a48a --- /dev/null +++ b/src/components/pages/VenueStep/index.ts @@ -0,0 +1,8 @@ +export { VenueStep, default } from './VenueStep'; +export type { + VenueStepProps, + VenueStepValues, + VenueStepErrors, + Venue, + VenueService, +} from './VenueStep';