VenueStep click-to-navigate, new VenueDetailStep, CoffinDetailsStep detail-toggles
- VenueStep: simplified to click-to-navigate (like ProvidersStep) - Removed selection state, Continue button, inline detail, service toggles - Clicking a venue card triggers onSelectVenue navigation - VenueDetailStep: new page with detail-toggles layout - Left: venue image, description, features - Right: name, location, type, price, Add Venue CTA, address, religions, service toggles - CoffinDetailsStep: switched from centered-form to detail-toggles layout - Left: coffin image, description - Right: name, price, Add Coffin CTA, specs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
|||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Button } from '../../atoms/Button';
|
import { Button } from '../../atoms/Button';
|
||||||
import { Divider } from '../../atoms/Divider';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -55,9 +54,11 @@ export interface CoffinDetailsStepProps {
|
|||||||
/**
|
/**
|
||||||
* Step 11 — Coffin Details for the FA arrangement wizard.
|
* Step 11 — Coffin Details for the FA arrangement wizard.
|
||||||
*
|
*
|
||||||
* Shows the selected coffin's full profile: image, specs, description,
|
* Detail-toggles layout: coffin image + description on the left,
|
||||||
* and price. Customisation options (handles, lining, nameplate) have
|
* name, specs, price, and CTA on the right.
|
||||||
* been deferred as a future enhancement (D-G).
|
*
|
||||||
|
* Customisation options (handles, lining, nameplate) have been
|
||||||
|
* deferred as a future enhancement (D-G).
|
||||||
*
|
*
|
||||||
* Pure presentation component — props in, callbacks out.
|
* Pure presentation component — props in, callbacks out.
|
||||||
*
|
*
|
||||||
@@ -78,7 +79,7 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
variant="centered-form"
|
variant="detail-toggles"
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
progressStepper={progressStepper}
|
progressStepper={progressStepper}
|
||||||
runningTotal={runningTotal}
|
runningTotal={runningTotal}
|
||||||
@@ -87,45 +88,59 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
|
|||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
hideHelpBar={hideHelpBar}
|
hideHelpBar={hideHelpBar}
|
||||||
sx={sx}
|
sx={sx}
|
||||||
>
|
secondaryPanel={
|
||||||
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
|
<Box sx={{ position: 'sticky', top: 24 }}>
|
||||||
Your selected coffin
|
{/* Coffin name */}
|
||||||
</Typography>
|
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
|
||||||
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 5 }}>
|
|
||||||
{isPrePlanning
|
|
||||||
? 'Here are the details of the coffin you selected. You can change your selection at any time.'
|
|
||||||
: 'Review the details of your selected coffin below.'}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
{/* Coffin image */}
|
|
||||||
<Box
|
|
||||||
role="img"
|
|
||||||
aria-label={`Photo of ${coffin.name}`}
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
height: { xs: 240, md: 320 },
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundImage: `url(${coffin.imageUrl})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundColor: 'var(--fa-color-surface-subtle)',
|
|
||||||
mb: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Coffin name + description */}
|
|
||||||
<Typography variant="h4" sx={{ mb: 1 }}>
|
|
||||||
{coffin.name}
|
{coffin.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{coffin.description && (
|
{/* Price */}
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
<Typography variant="h5" color="primary" sx={{ mb: 1 }}>
|
||||||
{coffin.description}
|
${coffin.price.toLocaleString('en-AU')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{coffin.priceNote && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{coffin.priceNote}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Specs grid */}
|
{/* Add coffin CTA */}
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
noValidate
|
||||||
|
aria-busy={loading}
|
||||||
|
onSubmit={(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loading) onContinue();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
{isPrePlanning ? 'Select this coffin' : 'Add coffin'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{onSaveAndExit && (
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="secondary"
|
||||||
|
fullWidth
|
||||||
|
onClick={onSaveAndExit}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
Save and continue later
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Specs */}
|
||||||
{coffin.specs && coffin.specs.length > 0 && (
|
{coffin.specs && coffin.specs.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -133,7 +148,6 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
|
|||||||
gridTemplateColumns: 'auto 1fr',
|
gridTemplateColumns: 'auto 1fr',
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
columnGap: 2,
|
columnGap: 2,
|
||||||
mb: 3,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{coffin.specs.map((spec) => (
|
{coffin.specs.map((spec) => (
|
||||||
@@ -146,47 +160,30 @@ export const CoffinDetailsStep: React.FC<CoffinDetailsStepProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<Typography variant="h5" color="primary" sx={{ mb: 1 }}>
|
|
||||||
${coffin.price.toLocaleString('en-AU')}
|
|
||||||
</Typography>
|
|
||||||
{coffin.priceNote && (
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
{coffin.priceNote}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
|
||||||
|
|
||||||
{/* CTAs */}
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
noValidate
|
|
||||||
aria-busy={loading}
|
|
||||||
onSubmit={(e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!loading) onContinue();
|
|
||||||
}}
|
|
||||||
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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Left panel: image + description */}
|
||||||
|
<Box
|
||||||
|
role="img"
|
||||||
|
aria-label={`Photo of ${coffin.name}`}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: { xs: 280, md: 400 },
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundImage: `url(${coffin.imageUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundColor: 'var(--fa-color-surface-subtle)',
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{coffin.description && (
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
{coffin.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</WizardLayout>
|
</WizardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
180
src/components/pages/VenueDetailStep/VenueDetailStep.stories.tsx
Normal file
180
src/components/pages/VenueDetailStep/VenueDetailStep.stories.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { VenueDetailStep } from './VenueDetailStep';
|
||||||
|
import type { VenueDetailStepValues, VenueDetail, VenueService } from './VenueDetailStep';
|
||||||
|
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 sampleVenue: VenueDetail = {
|
||||||
|
id: 'west-chapel',
|
||||||
|
name: 'West Chapel',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&h=600&fit=crop',
|
||||||
|
location: 'Strathfield',
|
||||||
|
venueType: 'Indoor Chapel',
|
||||||
|
capacity: 120,
|
||||||
|
price: 900,
|
||||||
|
priceNote: 'Selecting this venue increases your plan total by $900.00',
|
||||||
|
description:
|
||||||
|
'A modern chapel with natural light and flexible seating arrangements. Surrounded by established gardens with ample parking for guests. The venue features a covered portico entrance, audio-visual equipment, and climate control for year-round comfort.',
|
||||||
|
features: [
|
||||||
|
'Air conditioning',
|
||||||
|
'Wheelchair accessible',
|
||||||
|
'On-site parking',
|
||||||
|
'Audio system',
|
||||||
|
'Garden access',
|
||||||
|
],
|
||||||
|
religions: ['No Religion', 'Civil Celebrant', 'Anglican', 'Catholic', 'Uniting Church'],
|
||||||
|
address: '42 The Boulevarde, Strathfield NSW 2135',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleServices: VenueService[] = [
|
||||||
|
{ id: 'photo', name: 'Photo presentation', price: 150 },
|
||||||
|
{ id: 'streaming', name: 'Livestream', price: 200 },
|
||||||
|
{ id: 'recording', name: 'Recording', price: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultValues: VenueDetailStepValues = {
|
||||||
|
photoDisplay: false,
|
||||||
|
streaming: false,
|
||||||
|
recording: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const meta: Meta<typeof VenueDetailStep> = {
|
||||||
|
title: 'Pages/VenueDetailStep',
|
||||||
|
component: VenueDetailStep,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof VenueDetailStep>;
|
||||||
|
|
||||||
|
// ─── Default ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Full venue detail with service toggles */
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<VenueDetailStepValues>({ ...defaultValues });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VenueDetailStep
|
||||||
|
venue={sampleVenue}
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onAddVenue={() => alert('Venue added!')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
onSaveAndExit={() => alert('Save and exit')}
|
||||||
|
services={sampleServices}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── With services selected ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Services already toggled on */
|
||||||
|
export const WithServices: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<VenueDetailStepValues>({
|
||||||
|
photoDisplay: true,
|
||||||
|
streaming: true,
|
||||||
|
recording: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VenueDetailStep
|
||||||
|
venue={sampleVenue}
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onAddVenue={() => alert('Venue added!')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
services={sampleServices}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Pre-planning ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Pre-planning variant */
|
||||||
|
export const PrePlanning: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<VenueDetailStepValues>({ ...defaultValues });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VenueDetailStep
|
||||||
|
venue={sampleVenue}
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onAddVenue={() => alert('Venue selected!')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
services={sampleServices}
|
||||||
|
isPrePlanning
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Minimal venue ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Venue with minimal data (no features, religions, services) */
|
||||||
|
export const Minimal: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<VenueDetailStepValues>({ ...defaultValues });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VenueDetailStep
|
||||||
|
venue={{
|
||||||
|
id: 'basic',
|
||||||
|
name: 'Community Hall',
|
||||||
|
imageUrl:
|
||||||
|
'https://images.unsplash.com/photo-1519167758481-83f550bb49b3?w=800&h=600&fit=crop',
|
||||||
|
location: 'Parramatta',
|
||||||
|
price: 500,
|
||||||
|
address: '10 Church Street, Parramatta NSW 2150',
|
||||||
|
}}
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onAddVenue={() => alert('Venue added!')}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
303
src/components/pages/VenueDetailStep/VenueDetailStep.tsx
Normal file
303
src/components/pages/VenueDetailStep/VenueDetailStep.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
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 { AddOnOption } from '../../molecules/AddOnOption';
|
||||||
|
import { Chip } from '../../atoms/Chip';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import { Button } from '../../atoms/Button';
|
||||||
|
import { Divider } from '../../atoms/Divider';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Full venue detail data */
|
||||||
|
export interface VenueDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
location: string;
|
||||||
|
venueType?: string;
|
||||||
|
capacity?: number;
|
||||||
|
price?: number;
|
||||||
|
priceNote?: string;
|
||||||
|
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 service toggles */
|
||||||
|
export interface VenueDetailStepValues {
|
||||||
|
photoDisplay: boolean;
|
||||||
|
streaming: boolean;
|
||||||
|
recording: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the VenueDetailStep page component */
|
||||||
|
export interface VenueDetailStepProps {
|
||||||
|
/** The venue to display */
|
||||||
|
venue: VenueDetail;
|
||||||
|
/** Current service toggle values */
|
||||||
|
values: VenueDetailStepValues;
|
||||||
|
/** Callback when service toggles change */
|
||||||
|
onChange: (values: VenueDetailStepValues) => void;
|
||||||
|
/** Callback when "Add Venue" is clicked */
|
||||||
|
onAddVenue: () => void;
|
||||||
|
/** Callback for back navigation */
|
||||||
|
onBack?: () => void;
|
||||||
|
/** Callback for save-and-exit */
|
||||||
|
onSaveAndExit?: () => void;
|
||||||
|
/** Whether the button is in a loading state */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Available venue services */
|
||||||
|
services?: VenueService[];
|
||||||
|
/** Whether this is a pre-planning flow */
|
||||||
|
isPrePlanning?: boolean;
|
||||||
|
/** Navigation bar */
|
||||||
|
navigation?: React.ReactNode;
|
||||||
|
/** Progress stepper */
|
||||||
|
progressStepper?: React.ReactNode;
|
||||||
|
/** Running total */
|
||||||
|
runningTotal?: React.ReactNode;
|
||||||
|
/** Hide the help bar */
|
||||||
|
hideHelpBar?: boolean;
|
||||||
|
/** MUI sx prop */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Venue Detail page for the FA arrangement wizard.
|
||||||
|
*
|
||||||
|
* Detail-toggles layout: venue image + description on the left,
|
||||||
|
* venue info, price, CTA, and service toggles on the right.
|
||||||
|
*
|
||||||
|
* Reached by clicking a venue card on VenueStep. User reviews
|
||||||
|
* the venue and clicks "Add Venue" to select it.
|
||||||
|
*
|
||||||
|
* Pure presentation component — props in, callbacks out.
|
||||||
|
*
|
||||||
|
* Spec: documentation/steps/steps/07_venue_consolidated.yaml
|
||||||
|
*/
|
||||||
|
export const VenueDetailStep: React.FC<VenueDetailStepProps> = ({
|
||||||
|
venue,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
onAddVenue,
|
||||||
|
onBack,
|
||||||
|
onSaveAndExit,
|
||||||
|
loading = false,
|
||||||
|
services = [],
|
||||||
|
isPrePlanning = false,
|
||||||
|
navigation,
|
||||||
|
progressStepper,
|
||||||
|
runningTotal,
|
||||||
|
hideHelpBar,
|
||||||
|
sx,
|
||||||
|
}) => {
|
||||||
|
const handleToggle = (field: keyof VenueDetailStepValues, checked: boolean) => {
|
||||||
|
const next = { ...values, [field]: checked };
|
||||||
|
if (field === 'streaming' && !checked) {
|
||||||
|
next.recording = false;
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WizardLayout
|
||||||
|
variant="detail-toggles"
|
||||||
|
navigation={navigation}
|
||||||
|
progressStepper={progressStepper}
|
||||||
|
runningTotal={runningTotal}
|
||||||
|
showBackLink={!!onBack}
|
||||||
|
backLabel="Back"
|
||||||
|
onBack={onBack}
|
||||||
|
hideHelpBar={hideHelpBar}
|
||||||
|
sx={sx}
|
||||||
|
secondaryPanel={
|
||||||
|
<Box sx={{ position: 'sticky', top: 24 }}>
|
||||||
|
{/* Venue name */}
|
||||||
|
<Typography variant="h4" component="h1" sx={{ mb: 1 }}>
|
||||||
|
{venue.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
|
||||||
|
<LocationOnOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} aria-hidden />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{venue.location}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Venue type */}
|
||||||
|
{venue.venueType && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
{venue.venueType}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
{venue.price != null && (
|
||||||
|
<Typography variant="h5" color="primary" sx={{ mb: 0.5 }}>
|
||||||
|
${venue.price.toLocaleString('en-AU')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{venue.priceNote && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 2, display: 'block' }}>
|
||||||
|
{venue.priceNote}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Venue CTA */}
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
noValidate
|
||||||
|
aria-busy={loading}
|
||||||
|
onSubmit={(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!loading) onAddVenue();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
{isPrePlanning ? 'Select venue' : 'Add venue'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{onSaveAndExit && (
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="secondary"
|
||||||
|
fullWidth
|
||||||
|
onClick={onSaveAndExit}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
Save and continue later
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
{venue.address && (
|
||||||
|
<>
|
||||||
|
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||||
|
Address
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
|
{venue.address}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supported religions */}
|
||||||
|
{venue.religions && venue.religions.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Supported service styles
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 3 }}>
|
||||||
|
{venue.religions.map((r) => (
|
||||||
|
<Chip key={r} label={r} size="small" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Service toggles */}
|
||||||
|
{services.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="h5" sx={{ mb: 2 }}>
|
||||||
|
Venue services
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<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"
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
{values.streaming && (
|
||||||
|
<AddOnOption
|
||||||
|
name="Recording"
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Left panel: image + description + features */}
|
||||||
|
<Box
|
||||||
|
role="img"
|
||||||
|
aria-label={`Photo of ${venue.name}`}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: { xs: 280, md: 400 },
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundImage: `url(${venue.imageUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundColor: 'var(--fa-color-surface-subtle)',
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{venue.description && (
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
{venue.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
{venue.features && venue.features.length > 0 && (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
Venue features
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{venue.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>
|
||||||
|
)}
|
||||||
|
</WizardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
VenueDetailStep.displayName = 'VenueDetailStep';
|
||||||
|
export default VenueDetailStep;
|
||||||
7
src/components/pages/VenueDetailStep/index.ts
Normal file
7
src/components/pages/VenueDetailStep/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { VenueDetailStep, default } from './VenueDetailStep';
|
||||||
|
export type {
|
||||||
|
VenueDetailStepProps,
|
||||||
|
VenueDetailStepValues,
|
||||||
|
VenueDetail,
|
||||||
|
VenueService,
|
||||||
|
} from './VenueDetailStep';
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { VenueStepValues, VenueStepErrors, Venue, VenueService } from './VenueStep';
|
import type { Venue } from './VenueStep';
|
||||||
import { Navigation } from '../../organisms/Navigation';
|
import { Navigation } from '../../organisms/Navigation';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
@@ -42,11 +42,6 @@ const sampleVenues: Venue[] = [
|
|||||||
location: 'Strathfield',
|
location: 'Strathfield',
|
||||||
capacity: 120,
|
capacity: 120,
|
||||||
price: 900,
|
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',
|
id: 'rose-garden',
|
||||||
@@ -55,11 +50,6 @@ const sampleVenues: Venue[] = [
|
|||||||
location: 'Homebush',
|
location: 'Homebush',
|
||||||
capacity: 80,
|
capacity: 80,
|
||||||
price: 750,
|
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',
|
id: 'memorial-hall',
|
||||||
@@ -68,17 +58,6 @@ const sampleVenues: Venue[] = [
|
|||||||
location: 'Strathfield',
|
location: 'Strathfield',
|
||||||
capacity: 250,
|
capacity: 250,
|
||||||
price: 1200,
|
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',
|
id: 'lakeside',
|
||||||
@@ -87,28 +66,9 @@ const sampleVenues: Venue[] = [
|
|||||||
location: 'Concord',
|
location: 'Concord',
|
||||||
capacity: 60,
|
capacity: 60,
|
||||||
price: 650,
|
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 ────────────────────────────────────────────────────────────────────
|
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const meta: Meta<typeof VenueStep> = {
|
const meta: Meta<typeof VenueStep> = {
|
||||||
@@ -123,88 +83,28 @@ const meta: Meta<typeof VenueStep> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof VenueStep>;
|
type Story = StoryObj<typeof VenueStep>;
|
||||||
|
|
||||||
// ─── Interactive (default) ──────────────────────────────────────────────────
|
// ─── Default ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Fully interactive — select a venue, see details + service toggles */
|
/** Click-to-navigate — clicking a venue card triggers navigation */
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [values, setValues] = useState<VenueStepValues>({ ...defaultValues });
|
const [search, setSearch] = useState('');
|
||||||
const [errors, setErrors] = useState<VenueStepErrors>({});
|
const [filters, setFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
const handleContinue = () => {
|
return (
|
||||||
if (!values.selectedVenueId) {
|
<VenueStep
|
||||||
setErrors({ selectedVenueId: 'Please choose a venue for the service.' });
|
venues={sampleVenues}
|
||||||
return;
|
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],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
setErrors({});
|
onFilterClear={() => setFilters([])}
|
||||||
alert(`Continue with venue: ${values.selectedVenueId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VenueStep
|
|
||||||
values={values}
|
|
||||||
onChange={(v) => {
|
|
||||||
setValues(v);
|
|
||||||
setErrors({});
|
|
||||||
}}
|
|
||||||
onContinue={handleContinue}
|
|
||||||
onBack={() => alert('Back')}
|
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"
|
locationName="Strathfield"
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
@@ -217,15 +117,15 @@ export const WithStreaming: Story = {
|
|||||||
/** Pre-planning variant copy */
|
/** Pre-planning variant copy */
|
||||||
export const PrePlanning: Story = {
|
export const PrePlanning: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [values, setValues] = useState<VenueStepValues>({ ...defaultValues });
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VenueStep
|
<VenueStep
|
||||||
values={values}
|
venues={sampleVenues}
|
||||||
onChange={setValues}
|
onSelectVenue={(id) => alert(`Navigate to venue: ${id}`)}
|
||||||
onContinue={() => alert('Continue')}
|
searchQuery={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
onBack={() => alert('Back')}
|
onBack={() => alert('Back')}
|
||||||
venues={sampleVenues}
|
|
||||||
services={sampleServices}
|
|
||||||
isPrePlanning
|
isPrePlanning
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
@@ -233,42 +133,20 @@ export const PrePlanning: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 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 ────────────────────────────────────────────────────────────
|
// ─── Empty state ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** No venues found */
|
/** No venues found */
|
||||||
export const NoVenues: Story = {
|
export const NoVenues: Story = {
|
||||||
render: () => {
|
render: () => {
|
||||||
const [values, setValues] = useState<VenueStepValues>({
|
const [search, setSearch] = useState('Wollongong');
|
||||||
...defaultValues,
|
|
||||||
search: 'Wollongong',
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<VenueStep
|
<VenueStep
|
||||||
values={values}
|
|
||||||
onChange={setValues}
|
|
||||||
onContinue={() => {}}
|
|
||||||
venues={[]}
|
venues={[]}
|
||||||
|
onSelectVenue={() => {}}
|
||||||
|
searchQuery={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
onBack={() => alert('Back')}
|
||||||
locationName="Wollongong"
|
locationName="Wollongong"
|
||||||
navigation={nav}
|
navigation={nav}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,16 +3,12 @@ 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 LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
|
||||||
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 { AddOnOption } from '../../molecules/AddOnOption';
|
|
||||||
import { FilterPanel } from '../../molecules/FilterPanel';
|
import { FilterPanel } from '../../molecules/FilterPanel';
|
||||||
import { Collapse } from '../../atoms/Collapse';
|
|
||||||
import { Chip } from '../../atoms/Chip';
|
import { Chip } from '../../atoms/Chip';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Button } from '../../atoms/Button';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -24,65 +20,30 @@ export interface Venue {
|
|||||||
location: string;
|
location: string;
|
||||||
capacity?: number;
|
capacity?: number;
|
||||||
price?: 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 */
|
/** Props for the VenueStep page component */
|
||||||
export interface VenueStepProps {
|
export interface VenueStepProps {
|
||||||
/** Current form values */
|
/** List of venues to display */
|
||||||
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[];
|
venues: Venue[];
|
||||||
/** Available service add-ons shown after venue selection */
|
/** Callback when a venue card is clicked — triggers navigation to VenueDetailStep */
|
||||||
services?: VenueService[];
|
onSelectVenue: (id: string) => void;
|
||||||
|
/** Search query value */
|
||||||
|
searchQuery: string;
|
||||||
|
/** Callback when search query changes */
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
/** Callback when search is submitted */
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
/** Filter chip options */
|
/** Filter chip options */
|
||||||
filterOptions?: Array<{ key: string; label: string }>;
|
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 */
|
/** Callback to clear all filters */
|
||||||
onFilterClear?: () => void;
|
onFilterClear?: () => void;
|
||||||
|
/** Callback for back navigation */
|
||||||
|
onBack: () => void;
|
||||||
/** Location name for the results count */
|
/** Location name for the results count */
|
||||||
locationName?: string;
|
locationName?: string;
|
||||||
/** Whether this is a pre-planning flow */
|
/** Whether this is a pre-planning flow */
|
||||||
@@ -91,12 +52,6 @@ export interface VenueStepProps {
|
|||||||
mapPanel?: React.ReactNode;
|
mapPanel?: React.ReactNode;
|
||||||
/** Navigation bar — passed through to WizardLayout */
|
/** Navigation bar — passed through to WizardLayout */
|
||||||
navigation?: React.ReactNode;
|
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 */
|
/** MUI sx prop for the root */
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
@@ -104,77 +59,50 @@ export interface VenueStepProps {
|
|||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 7 — Service Venue for the FA arrangement wizard.
|
* Step 7 — Service Venue selection for the FA arrangement wizard.
|
||||||
*
|
*
|
||||||
* Consolidated from 3 baseline steps (venues + venue details + venue services)
|
* List + Map split layout. Left panel shows a scrollable list of
|
||||||
* into a single step with progressive disclosure (Rec #5).
|
* venue cards with search and filter button. Right panel is a
|
||||||
|
* slot for map integration.
|
||||||
*
|
*
|
||||||
* Three phases:
|
* Click-to-navigate: clicking a venue card triggers navigation
|
||||||
* 1. Venue card grid with search/filters (grid-sidebar layout)
|
* to VenueDetailStep — no selection state or Continue button.
|
||||||
* 2. Selected venue detail (inline Collapse below grid)
|
|
||||||
* 3. Service toggles (photo presentation, streaming, recording)
|
|
||||||
*
|
*
|
||||||
* Pure presentation component — props in, callbacks out.
|
* Pure presentation component — props in, callbacks out.
|
||||||
*
|
*
|
||||||
* Spec: documentation/steps/steps/07_venue_consolidated.yaml
|
* Spec: documentation/steps/steps/07_venue_consolidated.yaml
|
||||||
*/
|
*/
|
||||||
export const VenueStep: React.FC<VenueStepProps> = ({
|
export const VenueStep: React.FC<VenueStepProps> = ({
|
||||||
values,
|
|
||||||
onChange,
|
|
||||||
onContinue,
|
|
||||||
onBack,
|
|
||||||
onSaveAndExit,
|
|
||||||
errors,
|
|
||||||
loading = false,
|
|
||||||
venues,
|
venues,
|
||||||
services = [],
|
onSelectVenue,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
onSearch,
|
||||||
filterOptions = [
|
filterOptions = [
|
||||||
{ key: 'features', label: 'Venue Features' },
|
{ key: 'features', label: 'Venue Features' },
|
||||||
{ key: 'religion', label: 'Religion' },
|
{ key: 'religion', label: 'Religion' },
|
||||||
],
|
],
|
||||||
|
activeFilters = [],
|
||||||
|
onFilterToggle,
|
||||||
onFilterClear,
|
onFilterClear,
|
||||||
|
onBack,
|
||||||
locationName,
|
locationName,
|
||||||
isPrePlanning = false,
|
isPrePlanning = false,
|
||||||
mapPanel,
|
mapPanel,
|
||||||
navigation,
|
navigation,
|
||||||
progressStepper,
|
|
||||||
runningTotal,
|
|
||||||
hideHelpBar,
|
|
||||||
sx,
|
sx,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedVenue = venues.find((v) => v.id === values.selectedVenueId) ?? null;
|
const subheading = isPrePlanning
|
||||||
const hasSelection = selectedVenue !== null;
|
? 'Browse available venues. Your choice can be changed later.'
|
||||||
|
: 'Choose a venue for the funeral service. You can filter by location, features, and religion.';
|
||||||
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 (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
variant="list-map"
|
variant="list-map"
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
progressStepper={progressStepper}
|
showBackLink
|
||||||
runningTotal={runningTotal}
|
|
||||||
showBackLink={!!onBack}
|
|
||||||
backLabel="Back"
|
backLabel="Back"
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
hideHelpBar={hideHelpBar}
|
|
||||||
sx={sx}
|
sx={sx}
|
||||||
secondaryPanel={
|
secondaryPanel={
|
||||||
mapPanel || (
|
mapPanel || (
|
||||||
@@ -195,15 +123,6 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
noValidate
|
|
||||||
aria-busy={loading}
|
|
||||||
onSubmit={(e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!loading) onContinue();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Sticky header — stays pinned while card list scrolls */}
|
{/* Sticky header — stays pinned while card list scrolls */}
|
||||||
<Box
|
<Box
|
||||||
@@ -221,19 +140,22 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
<Typography variant="h4" component="h1" sx={{ mb: 0.5 }} tabIndex={-1}>
|
||||||
Where would you like the service?
|
Where would you like the service?
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
{isPrePlanning
|
{subheading}
|
||||||
? '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>
|
</Typography>
|
||||||
|
|
||||||
{/* ─── Location search ─── */}
|
{/* Location search */}
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Search a town or suburb..."
|
placeholder="Search a town or suburb..."
|
||||||
aria-label="Search venues by town or suburb"
|
aria-label="Search venues by town or suburb"
|
||||||
value={values.search}
|
value={searchQuery}
|
||||||
onChange={(e) => onChange({ ...values, search: e.target.value })}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && onSearch) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch(searchQuery);
|
||||||
|
}
|
||||||
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@@ -246,23 +168,25 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
sx={{ mb: 1.5 }}
|
sx={{ mb: 1.5 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ─── 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={values.activeFilters.length} onClear={onFilterClear}>
|
<FilterPanel activeCount={activeFilters.length} onClear={onFilterClear}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{filterOptions.map((filter) => (
|
{filterOptions.map((filter) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={filter.key}
|
key={filter.key}
|
||||||
label={filter.label}
|
label={filter.label}
|
||||||
onClick={() => handleFilterToggle(filter.key)}
|
selected={activeFilters.includes(filter.key)}
|
||||||
selected={values.activeFilters.includes(filter.key)}
|
onClick={onFilterToggle ? () => onFilterToggle(filter.key) : undefined}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ─── Results count ─── */}
|
{/* Results count */}
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
@@ -274,13 +198,13 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* ─── Venue card grid ─── */}
|
{/* Venue list — click-to-navigate */}
|
||||||
<Box
|
<Box
|
||||||
role="radiogroup"
|
role="list"
|
||||||
aria-label="Available venues"
|
aria-label="Available venues"
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, pb: 3 }}
|
||||||
>
|
>
|
||||||
{venues.map((venue, index) => (
|
{venues.map((venue) => (
|
||||||
<VenueCard
|
<VenueCard
|
||||||
key={venue.id}
|
key={venue.id}
|
||||||
name={venue.name}
|
name={venue.name}
|
||||||
@@ -288,19 +212,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
location={venue.location}
|
location={venue.location}
|
||||||
capacity={venue.capacity}
|
capacity={venue.capacity}
|
||||||
price={venue.price}
|
price={venue.price}
|
||||||
selected={venue.id === values.selectedVenueId}
|
onClick={() => onSelectVenue(venue.id)}
|
||||||
onClick={() => handleVenueSelect(venue.id)}
|
aria-label={`${venue.name}, ${venue.location}${venue.price ? `, $${venue.price}` : ''}`}
|
||||||
role="radio"
|
|
||||||
aria-checked={venue.id === values.selectedVenueId}
|
|
||||||
tabIndex={
|
|
||||||
values.selectedVenueId === null
|
|
||||||
? index === 0
|
|
||||||
? 0
|
|
||||||
: -1
|
|
||||||
: venue.id === values.selectedVenueId
|
|
||||||
? 0
|
|
||||||
: -1
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -315,135 +228,6 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Validation error */}
|
|
||||||
{errors?.selectedVenueId && (
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{ mb: 2, color: 'var(--fa-color-text-brand)' }}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* CTAs */}
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, pb: 2 }}>
|
|
||||||
{onSaveAndExit && (
|
|
||||||
<Button variant="text" color="secondary" onClick={onSaveAndExit} type="button">
|
|
||||||
Save and exit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" variant="contained" size="large" loading={loading}>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</WizardLayout>
|
</WizardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,2 @@
|
|||||||
export { VenueStep, default } from './VenueStep';
|
export { VenueStep, default } from './VenueStep';
|
||||||
export type {
|
export type { VenueStepProps, Venue } from './VenueStep';
|
||||||
VenueStepProps,
|
|
||||||
VenueStepValues,
|
|
||||||
VenueStepErrors,
|
|
||||||
Venue,
|
|
||||||
VenueService,
|
|
||||||
} from './VenueStep';
|
|
||||||
|
|||||||
Reference in New Issue
Block a user