VenueServicesStep page + AddOnOption price colour fix

- New VenueServicesStep (step 7c): venue-specific service toggles
  with compact venue card, availability notices, AddOnOption toggles
  with "View more" for long descriptions, conditional tally total
- AddOnOption: price colour changed from text.secondary to primary
  (copper) for consistency with all other price displays in the system
- 5 stories: Default, WithNotice, PrePlanning, WithSelections, Minimal
- Component registry updated with VenueDetailStep + VenueServicesStep

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 21:39:22 +11:00
parent 29a3cc0418
commit a8354cc8dd
5 changed files with 537 additions and 2 deletions

View File

@@ -80,7 +80,9 @@ duplicates) and MUST update it after completing one.
| ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. | | ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. |
| ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. | | ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. |
| DateTimeStep | done | WizardLayout (centered-form) + Input + TextField (date) + RadioGroup + Collapse + Divider + Button + Link | Wizard step 6 — details & scheduling. Deceased name (Input atom, external label) + preferred dates (up to 3, progressive disclosure) + time-of-day radios. Service tradition removed (flows from provider/package). Dividers between sections. Grief-sensitive labels. Save-and-exit CTA. | | DateTimeStep | done | WizardLayout (centered-form) + Input + TextField (date) + RadioGroup + Collapse + Divider + Button + Link | Wizard step 6 — details & scheduling. Deceased name (Input atom, external label) + preferred dates (up to 3, progressive disclosure) + time-of-day radios. Service tradition removed (flows from provider/package). Dividers between sections. Grief-sensitive labels. Save-and-exit CTA. |
| VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7 — venue selection. Consolidated from 3 baseline steps. Card grid with search/filters, inline detail, service toggles (photo, streaming, recording). | | VenueStep | done | WizardLayout (centered-form) + VenueCard + AddOnOption + Collapse + Chip + TextField + Divider + Button | Wizard step 7a — venue browsing. Click-to-navigate card grid with search/filters. Leads to VenueDetailStep. |
| VenueDetailStep | done | WizardLayout (detail-toggles) + ImageGallery + Card + Chip + Typography + Button + Divider | Wizard step 7b — venue detail. Two-panel: gallery/description/features/location (left), name/meta/price/CTA/religions (right). Informational service preview. |
| VenueServicesStep | done | WizardLayout (centered-form) + AddOnOption + Card + Typography + Button + Divider | Wizard step 7c — venue services. Compact venue card, availability notices, AddOnOption toggles with "View more" for long descriptions. Follows VenueDetailStep. |
| CrematoriumStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + TextField + Divider + Button | Wizard step 8 — crematorium. Single confirmation card or multi-card grid. Witness question personalised with deceased name. Special instructions textarea. | | CrematoriumStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + TextField + Divider + Button | Wizard step 8 — crematorium. Single confirmation card or multi-card grid. Witness question personalised with deceased name. Special instructions textarea. |
| CemeteryStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + Divider + Button | Wizard step 9 — cemetery. Triple progressive disclosure (have plot? → choose? → grid). Dependent field resets. | | CemeteryStep | done | WizardLayout (centered-form) + Card + RadioGroup + Collapse + Divider + Button | Wizard step 9 — cemetery. Triple progressive disclosure (have plot? → choose? → grid). Dependent field resets. |
| CoffinsStep | done | WizardLayout (centered-form) + Card + Badge + TextField + MenuItem + Pagination + Divider + Button | Wizard step 10 — coffin selection. 3-col card grid with category/price filters. "Most Popular" badge. Pagination. | | CoffinsStep | done | WizardLayout (centered-form) + Card + Badge + TextField + MenuItem + Pagination + Divider + Button | Wizard step 10 — coffin selection. 3-col card grid with category/price filters. "Most Popular" badge. Pagination. |

View File

@@ -142,7 +142,7 @@ export const AddOnOption = React.forwardRef<HTMLDivElement, AddOnOptionProps>(
{/* Price — tucks directly under heading */} {/* Price — tucks directly under heading */}
{price != null && ( {price != null && (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="primary">
${price.toLocaleString('en-AU')} ${price.toLocaleString('en-AU')}
</Typography> </Typography>
)} )}

View File

@@ -0,0 +1,219 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { VenueServicesStep } from './VenueServicesStep';
import type {
VenueServicesVenue,
VenueServiceOption,
VenueServiceNotice,
VenueServicesStepValues,
} from './VenueServicesStep';
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: VenueServicesVenue = {
name: 'Palmdale Memorial Park Hillside Chapel',
imageUrl: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&h=600&fit=crop',
location: 'Palmdale Road, Palmdale, NSW, 2258',
venueType: 'Chapel',
};
const sampleServices: VenueServiceOption[] = [
{
id: 'life-story-family',
name: 'Life Story Created By Family',
description:
'Some families have the capability and would feel more comfortable to prepare the Life Story Presentation themselves. To achieve a seamless funeral experience, we ask that you create the video with the following specifications:\n\n\u2022 Create the video in MP4 format.\n\u2022 Have a Title Page with text such as \u2018In Loving Memory Of...\u2019. Place the Title Page at the front and the back of the video so we can pause the video on this after the video has played in the funeral service.\n\u2022 Please synchronise your choice of music to the length of the video presentation.\n\u2022 Send the video to us via Dropbox at least 24 hours prior to the video service so we can test it on our equipment.',
},
{
id: 'essential-life-story',
name: 'Essential Life Story',
description:
'Photos evoke fond memories. Set them to music to create a moving tribute for your loved one that you and your family can cherish forever. Our team will work with you to select the perfect photos and music to celebrate their life.',
price: 290,
},
{
id: 'premium-life-story',
name: 'Premium Life Story Presentation',
description:
'An enhanced presentation with video clips, custom transitions, and professional narration. Includes up to 100 photos and 3 video clips, with a dedicated editor who will work closely with you to create something truly special.',
price: 590,
},
{
id: 'streaming',
name: 'Livestream of Funeral Service',
description:
'For family and friends unable to attend in person, we offer a secure livestream of the service. A private link will be shared with your guest list. The stream is available for 48 hours after the service.',
price: 200,
},
{
id: 'recording',
name: 'Recording of Funeral Service',
description:
'A professionally recorded copy of the funeral service, delivered digitally within 5 business days. Includes the full service from start to finish.',
price: 150,
},
];
const noStreamingNotice: VenueServiceNotice = {
title: 'No livestreaming or recording available.',
detail:
'The package you have selected does not offer live streaming or video recordings for your planned funeral. Please select another package or contact Mackay Family Funerals and they will try their best to accommodate your wishes.',
};
// ─── Stateful wrapper ───────────────────────────────────────────────────────
const StatefulVenueServices = (
props: React.ComponentProps<typeof VenueServicesStep> & {
initialValues?: VenueServicesStepValues;
},
) => {
const { initialValues = {}, ...rest } = props;
const [values, setValues] = useState<VenueServicesStepValues>(initialValues);
return <VenueServicesStep {...rest} values={values} onChange={setValues} />;
};
// ─── Meta ────────────────────────────────────────────────────────────────────
const meta: Meta<typeof VenueServicesStep> = {
title: 'Pages/VenueServicesStep',
component: VenueServicesStep,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
};
export default meta;
type Story = StoryObj<typeof VenueServicesStep>;
// ─── Default ─────────────────────────────────────────────────────────────────
/** Full services list with venue context */
export const Default: Story = {
render: () => (
<StatefulVenueServices
venue={sampleVenue}
services={sampleServices}
values={{}}
onChange={() => {}}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
onSaveAndExit={() => alert('Save and exit')}
navigation={nav}
/>
),
};
// ─── With Notice ─────────────────────────────────────────────────────────────
/** Shows an unavailability notice for livestreaming/recording */
export const WithNotice: Story = {
render: () => (
<StatefulVenueServices
venue={sampleVenue}
services={sampleServices.filter((s) => s.id !== 'streaming' && s.id !== 'recording')}
notices={[noStreamingNotice]}
values={{}}
onChange={() => {}}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
navigation={nav}
/>
),
};
// ─── Pre-planning ────────────────────────────────────────────────────────────
/** Pre-planning variant with softer copy */
export const PrePlanning: Story = {
render: () => (
<StatefulVenueServices
venue={sampleVenue}
services={sampleServices}
isPrePlanning
values={{}}
onChange={() => {}}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
navigation={nav}
/>
),
};
// ─── With Selections ─────────────────────────────────────────────────────────
/** Shows pre-selected services */
export const WithSelections: Story = {
render: () => (
<StatefulVenueServices
venue={sampleVenue}
services={sampleServices}
initialValues={{
'essential-life-story': true,
streaming: true,
}}
values={{}}
onChange={() => {}}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
onSaveAndExit={() => alert('Save and exit')}
navigation={nav}
/>
),
};
// ─── Minimal ─────────────────────────────────────────────────────────────────
/** Venue with no image and few services */
export const Minimal: Story = {
render: () => (
<StatefulVenueServices
venue={{
name: 'Community Hall',
location: 'Parramatta, NSW',
}}
services={[
{
id: 'photo',
name: 'Photo Presentation',
description: 'Display a photo slideshow during the service.',
price: 150,
},
]}
values={{}}
onChange={() => {}}
onContinue={() => alert('Continue')}
onBack={() => alert('Back')}
navigation={nav}
/>
),
};

View File

@@ -0,0 +1,306 @@
import React from 'react';
import Box from '@mui/material/Box';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import HomeWorkOutlinedIcon from '@mui/icons-material/HomeWorkOutlined';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { WizardLayout } from '../../templates/WizardLayout';
import { AddOnOption } from '../../molecules/AddOnOption';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Compact venue reference for the header card */
export interface VenueServicesVenue {
name: string;
imageUrl?: string;
location: string;
venueType?: string;
}
/** A venue service that can be toggled on/off */
export interface VenueServiceOption {
id: string;
name: string;
description?: string;
price?: number;
}
/** An informational notice about unavailable services */
export interface VenueServiceNotice {
title: string;
detail?: string;
}
/** Current toggle state — map of service ID to checked */
export interface VenueServicesStepValues {
[serviceId: string]: boolean;
}
/** Props for the VenueServicesStep page component */
export interface VenueServicesStepProps {
/** The selected venue (shown as compact summary card) */
venue: VenueServicesVenue;
/** Available services to toggle */
services: VenueServiceOption[];
/** Current toggle state for each service */
values: VenueServicesStepValues;
/** Callback when any service toggle changes */
onChange: (values: VenueServicesStepValues) => void;
/** Callback when Continue is clicked */
onContinue: () => void;
/** Callback for back navigation */
onBack?: () => void;
/** Callback for save-and-exit */
onSaveAndExit?: () => void;
/** Whether Continue is loading */
loading?: boolean;
/** Notices about unavailable services (e.g. no livestreaming) */
notices?: VenueServiceNotice[];
/** 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 Services page for the FA arrangement wizard.
*
* Shown after VenueDetailStep — the user has already selected a venue
* and now chooses which venue-specific services to include (photo
* presentation, livestreaming, recording, etc.).
*
* Compact venue card at top for context. Availability notices for
* services the selected package doesn't support. AddOnOption toggles
* with expandable descriptions for services that have detail.
*
* Pure presentation component — props in, callbacks out.
*
* Spec: documentation/steps/steps/07_venue.yaml (venue services section)
*/
export const VenueServicesStep: React.FC<VenueServicesStepProps> = ({
venue,
services,
values,
onChange,
onContinue,
onBack,
onSaveAndExit,
loading = false,
notices = [],
isPrePlanning = false,
navigation,
progressStepper,
runningTotal,
hideHelpBar,
sx,
}) => {
const handleToggle = (serviceId: string, checked: boolean) => {
onChange({ ...values, [serviceId]: checked });
};
// Compute tally of selected priced services
const selectedServices = services.filter((s) => values[s.id] && s.price != null);
const totalAdditional = selectedServices.reduce((sum, s) => sum + (s.price ?? 0), 0);
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}>
Funeral Service
</Typography>
{/* ─── Compact venue card ─── */}
<Card
variant="outlined"
padding="none"
sx={{
display: 'flex',
overflow: 'hidden',
minHeight: 100,
mb: 4,
}}
>
{venue.imageUrl && (
<Box
role="img"
aria-label={`${venue.name} photo`}
sx={{
width: { xs: 120, sm: 160 },
flexShrink: 0,
backgroundImage: `url(${venue.imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 0.5,
p: 2,
minWidth: 0,
}}
>
<Typography variant="h6" component="span" maxLines={1}>
{venue.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="body2" color="text.secondary">
{venue.location}
</Typography>
</Box>
{venue.venueType && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<HomeWorkOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="body2" color="text.secondary">
{venue.venueType}
</Typography>
</Box>
)}
</Box>
</Card>
{/* ─── Additional services heading ─── */}
<Typography variant="h5" component="h2" sx={{ mb: 0.5 }}>
Additional services
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{isPrePlanning
? 'These can be finalised later. Toggle on the ones you\u2019re interested in.'
: 'Select any additional services offered at this venue that you would like included in the funeral plan.'}
</Typography>
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
{/* ─── Availability notices ─── */}
{notices.map((notice, i) => (
<Card
key={i}
variant="outlined"
padding="compact"
sx={{
mb: 2,
bgcolor: 'var(--fa-color-surface-subtle)',
borderColor: 'var(--fa-color-border-default)',
}}
>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<InfoOutlinedIcon
sx={{ fontSize: 20, color: 'text.secondary', mt: 0.25, flexShrink: 0 }}
aria-hidden
/>
<Box>
<Typography variant="label" sx={{ display: 'block', mb: 0.5 }}>
{notice.title}
</Typography>
{notice.detail && (
<Typography variant="body2" color="text.secondary">
{notice.detail}
</Typography>
)}
</Box>
</Box>
</Card>
))}
{/* ─── Service toggles ─── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 4 }}>
{services.map((service) => (
<AddOnOption
key={service.id}
name={service.name}
description={service.description}
price={service.price}
checked={!!values[service.id]}
onChange={(checked) => handleToggle(service.id, checked)}
maxDescriptionLines={3}
/>
))}
</Box>
{/* ─── Tally ─── */}
{selectedServices.length > 0 && (
<>
<Divider sx={{ my: 3 }} />
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
aria-live="polite"
aria-atomic="true"
>
<Typography variant="h6">Additional services total</Typography>
<Typography variant="h6" color="primary">
${totalAdditional.toLocaleString('en-AU')}
</Typography>
</Box>
</>
)}
<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>
);
};
VenueServicesStep.displayName = 'VenueServicesStep';
export default VenueServicesStep;

View File

@@ -0,0 +1,8 @@
export { VenueServicesStep, default } from './VenueServicesStep';
export type {
VenueServicesStepProps,
VenueServicesVenue,
VenueServiceOption,
VenueServiceNotice,
VenueServicesStepValues,
} from './VenueServicesStep';