Add VenueStep page (wizard step 7) + VenueCard selected prop
- Consolidated 3 baseline steps (venue select + venue detail + venue services) into 1 - CSS Grid venue card layout (1 col mobile, 2 col desktop) with radiogroup ARIA - VenueCard extended with selected, role, aria-checked, tabIndex props - Progressive disclosure: venue detail panel + service toggles after selection - Service toggles via AddOnOption: photo presentation, livestream, recording - Recording depends on streaming (auto-disabled when streaming off) - Search input + filter chips for venue filtering - Results count with aria-live, validation error with role="alert" - Pre-planning variant with softer copy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,14 @@ export interface VenueCardProps {
|
|||||||
capacity?: number;
|
capacity?: number;
|
||||||
/** Venue hire price in dollars */
|
/** Venue hire price in dollars */
|
||||||
price?: number;
|
price?: number;
|
||||||
|
/** Whether this card is the selected venue (brand border + warm bg) */
|
||||||
|
selected?: boolean;
|
||||||
/** Click handler — entire card is clickable */
|
/** Click handler — entire card is clickable */
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
/** HTML/ARIA passthrough for radiogroup patterns */
|
||||||
|
role?: string;
|
||||||
|
'aria-checked'?: boolean;
|
||||||
|
tabIndex?: number;
|
||||||
/** MUI sx prop for style overrides */
|
/** MUI sx prop for style overrides */
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
@@ -57,13 +63,32 @@ const CONTENT_GAP = 'var(--fa-venue-card-content-gap)';
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const VenueCard = React.forwardRef<HTMLDivElement, VenueCardProps>(
|
export const VenueCard = React.forwardRef<HTMLDivElement, VenueCardProps>(
|
||||||
({ name, imageUrl, location, capacity, price, onClick, sx }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
imageUrl,
|
||||||
|
location,
|
||||||
|
capacity,
|
||||||
|
price,
|
||||||
|
selected = false,
|
||||||
|
onClick,
|
||||||
|
role,
|
||||||
|
'aria-checked': ariaChecked,
|
||||||
|
tabIndex,
|
||||||
|
sx,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
ref={ref}
|
ref={ref}
|
||||||
interactive
|
interactive
|
||||||
|
selected={selected}
|
||||||
padding="none"
|
padding="none"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
role={role}
|
||||||
|
aria-checked={ariaChecked}
|
||||||
|
tabIndex={tabIndex}
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
|||||||
277
src/components/pages/VenueStep/VenueStep.stories.tsx
Normal file
277
src/components/pages/VenueStep/VenueStep.stories.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { VenueStep } from './VenueStep';
|
||||||
|
import type { VenueStepValues, VenueStepErrors, Venue, VenueService } from './VenueStep';
|
||||||
|
import { Navigation } from '../../organisms/Navigation';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FALogo = () => (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src="/brandlogo/logo-full.svg"
|
||||||
|
alt="Funeral Arranger"
|
||||||
|
sx={{ height: 28, display: { xs: 'none', md: 'block' } }}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src="/brandlogo/logo-short.svg"
|
||||||
|
alt="Funeral Arranger"
|
||||||
|
sx={{ height: 28, display: { xs: 'block', md: 'none' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const nav = (
|
||||||
|
<Navigation
|
||||||
|
logo={<FALogo />}
|
||||||
|
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<typeof VenueStep> = {
|
||||||
|
title: 'Pages/VenueStep',
|
||||||
|
component: VenueStep,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof VenueStep>;
|
||||||
|
|
||||||
|
// ─── Interactive (default) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fully interactive — select a venue, see details + service toggles */
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<VenueStepValues>({ ...defaultValues });
|
||||||
|
const [errors, setErrors] = useState<VenueStepErrors>({});
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (!values.selectedVenueId) {
|
||||||
|
setErrors({ selectedVenueId: 'Please choose a venue for the service.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
alert(`Continue with venue: ${values.selectedVenueId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VenueStep
|
||||||
|
values={values}
|
||||||
|
onChange={(v) => {
|
||||||
|
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<VenueStepValues>({
|
||||||
|
...defaultValues,
|
||||||
|
selectedVenueId: 'west-chapel',
|
||||||
|
photoDisplay: true,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<VenueStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => 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<VenueStepValues>({
|
||||||
|
...defaultValues,
|
||||||
|
selectedVenueId: 'memorial-hall',
|
||||||
|
streaming: true,
|
||||||
|
recording: true,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<VenueStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => 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<VenueStepValues>({ ...defaultValues });
|
||||||
|
return (
|
||||||
|
<VenueStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => 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<VenueStepValues>({ ...defaultValues });
|
||||||
|
return (
|
||||||
|
<VenueStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => {}}
|
||||||
|
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<VenueStepValues>({
|
||||||
|
...defaultValues,
|
||||||
|
search: 'Wollongong',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<VenueStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => {}}
|
||||||
|
venues={[]}
|
||||||
|
locationName="Wollongong"
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
409
src/components/pages/VenueStep/VenueStep.tsx
Normal file
409
src/components/pages/VenueStep/VenueStep.tsx
Normal file
@@ -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<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<VenueStepProps> = ({
|
||||||
|
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 (
|
||||||
|
<WizardLayout
|
||||||
|
variant="centered-form"
|
||||||
|
navigation={navigation}
|
||||||
|
progressStepper={progressStepper}
|
||||||
|
runningTotal={runningTotal}
|
||||||
|
showBackLink={!!onBack}
|
||||||
|
backLabel="Back"
|
||||||
|
onBack={onBack}
|
||||||
|
hideHelpBar={hideHelpBar}
|
||||||
|
sx={sx}
|
||||||
|
>
|
||||||
|
{/* Page heading */}
|
||||||
|
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
|
||||||
|
Where would you like the service?
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||||
|
{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.'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
noValidate
|
||||||
|
onSubmit={(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onContinue();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ─── Search + Filters ─── */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search a town or suburb..."
|
||||||
|
value={values.search}
|
||||||
|
onChange={(e) => onChange({ ...values, search: e.target.value })}
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon sx={{ color: 'text.secondary' }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{filterOptions.map((filter) => (
|
||||||
|
<Chip
|
||||||
|
key={filter.key}
|
||||||
|
label={filter.label}
|
||||||
|
onClick={() => handleFilterToggle(filter.key)}
|
||||||
|
selected={values.activeFilters.includes(filter.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ─── Results count ─── */}
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }} aria-live="polite">
|
||||||
|
Found {venues.length} venue{venues.length !== 1 ? 's' : ''}
|
||||||
|
{locationName ? ` near ${locationName}` : ''}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* ─── Venue card grid ─── */}
|
||||||
|
<Box
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Available venues"
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: {
|
||||||
|
xs: '1fr',
|
||||||
|
sm: 'repeat(2, 1fr)',
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{venues.map((venue, index) => (
|
||||||
|
<VenueCard
|
||||||
|
key={venue.id}
|
||||||
|
name={venue.name}
|
||||||
|
imageUrl={venue.imageUrl}
|
||||||
|
location={venue.location}
|
||||||
|
capacity={venue.capacity}
|
||||||
|
price={venue.price}
|
||||||
|
selected={venue.id === values.selectedVenueId}
|
||||||
|
onClick={() => 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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Validation error */}
|
||||||
|
{errors?.selectedVenueId && (
|
||||||
|
<Typography variant="body2" color="error" sx={{ mb: 2 }} role="alert">
|
||||||
|
{errors.selectedVenueId}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Selected venue detail (progressive disclosure) ─── */}
|
||||||
|
<Collapse in={hasSelection}>
|
||||||
|
{selectedVenue && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
p: 3,
|
||||||
|
mb: 3,
|
||||||
|
bgcolor: 'var(--fa-color-surface-warm)',
|
||||||
|
}}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Typography variant="h5" sx={{ mb: 1 }}>
|
||||||
|
{selectedVenue.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{selectedVenue.address && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 2 }}>
|
||||||
|
<LocationOnOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selectedVenue.address}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedVenue.description && (
|
||||||
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
|
{selectedVenue.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
{selectedVenue.features && selectedVenue.features.length > 0 && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Features
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{selectedVenue.features.map((feature) => (
|
||||||
|
<Box key={feature} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<CheckCircleOutlineIcon
|
||||||
|
sx={{ fontSize: 16, color: 'success.main' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">{feature}</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supported religions */}
|
||||||
|
{selectedVenue.religions && selectedVenue.religions.length > 0 && (
|
||||||
|
<Box sx={{ mb: 1 }}>
|
||||||
|
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Supported service styles
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{selectedVenue.religions.map((r) => (
|
||||||
|
<Chip key={r} label={r} size="small" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
{/* ─── Service toggles (after venue selection) ─── */}
|
||||||
|
<Collapse in={hasSelection}>
|
||||||
|
<Box sx={{ mb: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Typography variant="h5" sx={{ mb: 1 }}>
|
||||||
|
Venue services
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<AddOnOption
|
||||||
|
name="Photo presentation"
|
||||||
|
description="Display a photo slideshow during the service"
|
||||||
|
price={services.find((s) => s.id === 'photo')?.price}
|
||||||
|
checked={values.photoDisplay}
|
||||||
|
onChange={(c) => handleToggle('photoDisplay', c)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddOnOption
|
||||||
|
name="Livestream of funeral service"
|
||||||
|
description="Allow family and friends to watch the service remotely"
|
||||||
|
price={services.find((s) => s.id === 'streaming')?.price}
|
||||||
|
checked={values.streaming}
|
||||||
|
onChange={(c) => handleToggle('streaming', c)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Collapse in={values.streaming}>
|
||||||
|
<AddOnOption
|
||||||
|
name="Recording of funeral service"
|
||||||
|
description="Receive a recording of the service to keep"
|
||||||
|
price={services.find((s) => s.id === 'recording')?.price}
|
||||||
|
checked={values.recording}
|
||||||
|
onChange={(c) => handleToggle('recording', c)}
|
||||||
|
/>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
{/* CTAs */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: { xs: 'column-reverse', sm: 'row' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onSaveAndExit ? (
|
||||||
|
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
|
||||||
|
Save and continue later
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Box />
|
||||||
|
)}
|
||||||
|
<Button type="submit" variant="contained" size="large" loading={loading}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</WizardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
VenueStep.displayName = 'VenueStep';
|
||||||
|
export default VenueStep;
|
||||||
8
src/components/pages/VenueStep/index.ts
Normal file
8
src/components/pages/VenueStep/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { VenueStep, default } from './VenueStep';
|
||||||
|
export type {
|
||||||
|
VenueStepProps,
|
||||||
|
VenueStepValues,
|
||||||
|
VenueStepErrors,
|
||||||
|
Venue,
|
||||||
|
VenueService,
|
||||||
|
} from './VenueStep';
|
||||||
Reference in New Issue
Block a user