Add IntroStep page (wizard step 1) + audit fixes
IntroStep: urgency-sensitive segmentation entry point. ToggleButtonGroup for forWhom (Myself/Someone else) with progressive disclosure revealing hasPassedAway (Yes/No) via Collapse. Auto-sets hasPassedAway="no" when forWhom="myself". Grief-sensitive copy adapts subheading per selection. Pure presentation — props in, callbacks out. Audit fixes (18/20 → 20/20): - P1: Add <main> landmark wrapper in WizardLayout (all variants) - P2: Wrap IntroStep fields in <form> for landmark + Enter-to-submit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
214
src/components/pages/IntroStep/IntroStep.stories.tsx
Normal file
214
src/components/pages/IntroStep/IntroStep.stories.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { IntroStep } from './IntroStep';
|
||||||
|
import type { IntroStepValues, IntroStepErrors } from './IntroStep';
|
||||||
|
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' },
|
||||||
|
{ label: 'Log in', href: '/login' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Meta ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const meta: Meta<typeof IntroStep> = {
|
||||||
|
title: 'Pages/IntroStep',
|
||||||
|
component: IntroStep,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof IntroStep>;
|
||||||
|
|
||||||
|
// ─── Interactive (default) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Fully interactive — click through the progressive disclosure flow */
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<IntroStepValues>({
|
||||||
|
forWhom: null,
|
||||||
|
hasPassedAway: null,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<IntroStepErrors>({});
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
const newErrors: IntroStepErrors = {};
|
||||||
|
if (!values.forWhom) {
|
||||||
|
newErrors.forWhom = 'We need to know who the funeral is for to show you the right options.';
|
||||||
|
}
|
||||||
|
if (values.forWhom === 'someone' && !values.hasPassedAway) {
|
||||||
|
newErrors.hasPassedAway = 'This helps us tailor the process to your situation.';
|
||||||
|
}
|
||||||
|
setErrors(newErrors);
|
||||||
|
if (Object.keys(newErrors).length === 0) {
|
||||||
|
alert(`Continue with: ${JSON.stringify(values)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntroStep
|
||||||
|
values={values}
|
||||||
|
onChange={(v) => {
|
||||||
|
setValues(v);
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
onContinue={handleContinue}
|
||||||
|
errors={errors}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Pre-planning (myself) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** User has selected "Myself" — hasPassedAway auto-set to "no", no second question */
|
||||||
|
export const PrePlanningMyself: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<IntroStepValues>({
|
||||||
|
forWhom: 'myself',
|
||||||
|
hasPassedAway: 'no',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<IntroStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── At-need (someone, yes) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** User arranging for someone who has passed — at-need flow */
|
||||||
|
export const AtNeedSomeone: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<IntroStepValues>({
|
||||||
|
forWhom: 'someone',
|
||||||
|
hasPassedAway: 'yes',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<IntroStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Pre-planning (someone, no) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** User arranging for someone who is alive — pre-planning flow */
|
||||||
|
export const PrePlanningSomeone: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<IntroStepValues>({
|
||||||
|
forWhom: 'someone',
|
||||||
|
hasPassedAway: 'no',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<IntroStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Validation errors ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Both fields showing validation errors */
|
||||||
|
export const WithErrors: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<IntroStepValues>({
|
||||||
|
forWhom: null,
|
||||||
|
hasPassedAway: null,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<IntroStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => {}}
|
||||||
|
errors={{
|
||||||
|
forWhom: 'We need to know who the funeral is for to show you the right options.',
|
||||||
|
}}
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Loading state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Continue button in loading state */
|
||||||
|
export const Loading: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<IntroStepValues>({
|
||||||
|
forWhom: 'myself',
|
||||||
|
hasPassedAway: 'no',
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<IntroStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => {}}
|
||||||
|
loading
|
||||||
|
navigation={nav}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── No navigation (embedded) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Without navigation — for embedded/iframe use */
|
||||||
|
export const Embedded: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [values, setValues] = useState<IntroStepValues>({
|
||||||
|
forWhom: null,
|
||||||
|
hasPassedAway: null,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<IntroStep
|
||||||
|
values={values}
|
||||||
|
onChange={setValues}
|
||||||
|
onContinue={() => alert('Continue')}
|
||||||
|
hideHelpBar
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
191
src/components/pages/IntroStep/IntroStep.tsx
Normal file
191
src/components/pages/IntroStep/IntroStep.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
|
import { ToggleButtonGroup } from '../../atoms/ToggleButtonGroup';
|
||||||
|
import { Collapse } from '../../atoms/Collapse';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import { Button } from '../../atoms/Button';
|
||||||
|
import { Divider } from '../../atoms/Divider';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Form values for the intro step */
|
||||||
|
export interface IntroStepValues {
|
||||||
|
/** Who the funeral is being arranged for */
|
||||||
|
forWhom: 'myself' | 'someone' | null;
|
||||||
|
/** Whether the person has passed away (only relevant when forWhom="someone") */
|
||||||
|
hasPassedAway: 'yes' | 'no' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field-level error messages */
|
||||||
|
export interface IntroStepErrors {
|
||||||
|
/** Error for the forWhom field */
|
||||||
|
forWhom?: string;
|
||||||
|
/** Error for the hasPassedAway field */
|
||||||
|
hasPassedAway?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the IntroStep page component */
|
||||||
|
export interface IntroStepProps {
|
||||||
|
/** Current form values */
|
||||||
|
values: IntroStepValues;
|
||||||
|
/** Callback when any field value changes */
|
||||||
|
onChange: (values: IntroStepValues) => void;
|
||||||
|
/** Callback when the Continue button is clicked */
|
||||||
|
onContinue: () => void;
|
||||||
|
/** Field-level validation errors */
|
||||||
|
errors?: IntroStepErrors;
|
||||||
|
/** Whether the Continue button is in a loading state */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Navigation bar — passed through to WizardLayout */
|
||||||
|
navigation?: React.ReactNode;
|
||||||
|
/** Hide the help bar */
|
||||||
|
hideHelpBar?: boolean;
|
||||||
|
/** MUI sx prop for the root */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Copy helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getSubheading(values: IntroStepValues): string {
|
||||||
|
if (values.forWhom === 'someone' && values.hasPassedAway === 'yes') {
|
||||||
|
return "We'll guide you through each step. You can save your progress and come back anytime.";
|
||||||
|
}
|
||||||
|
if (values.forWhom === 'myself' || values.hasPassedAway === 'no') {
|
||||||
|
return "Explore your options and plan at your own pace. Nothing is locked in until you're ready.";
|
||||||
|
}
|
||||||
|
return "We'll guide you through arranging a funeral, step by step. You can save your progress and come back anytime.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 — Intro page for the FA arrangement wizard.
|
||||||
|
*
|
||||||
|
* Entry point with urgency-sensitive segmentation. User selects who
|
||||||
|
* the funeral is for, and (if arranging for someone else) whether
|
||||||
|
* that person has died.
|
||||||
|
*
|
||||||
|
* Uses the Centered Form layout variant. Progressive disclosure:
|
||||||
|
* selecting "Someone else" reveals the hasPassedAway question.
|
||||||
|
* Selecting "Myself" auto-sets hasPassedAway to "no" (pre-planning).
|
||||||
|
*
|
||||||
|
* Pure presentation component — props in, callbacks out.
|
||||||
|
* No routing, state management, or API calls.
|
||||||
|
*
|
||||||
|
* Spec: documentation/steps/steps/01_intro.yaml
|
||||||
|
*/
|
||||||
|
export const IntroStep: React.FC<IntroStepProps> = ({
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
onContinue,
|
||||||
|
errors,
|
||||||
|
loading = false,
|
||||||
|
navigation,
|
||||||
|
hideHelpBar,
|
||||||
|
sx,
|
||||||
|
}) => {
|
||||||
|
const handleForWhomChange = (newValue: string | null) => {
|
||||||
|
const forWhom = newValue as IntroStepValues['forWhom'];
|
||||||
|
if (forWhom === 'myself') {
|
||||||
|
// Auto-set hasPassedAway to 'no' — user is alive, pre-planning
|
||||||
|
onChange({ forWhom, hasPassedAway: 'no' });
|
||||||
|
} else {
|
||||||
|
// Reset hasPassedAway when switching to "someone"
|
||||||
|
onChange({ forWhom, hasPassedAway: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHasPassedAwayChange = (newValue: string | null) => {
|
||||||
|
onChange({ ...values, hasPassedAway: newValue as IntroStepValues['hasPassedAway'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const showHasPassedAway = values.forWhom === 'someone';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WizardLayout variant="centered-form" navigation={navigation} hideHelpBar={hideHelpBar} sx={sx}>
|
||||||
|
{/* Page heading — receives focus on entry for screen readers */}
|
||||||
|
<Typography variant="display3" component="h1" sx={{ mb: 1 }} tabIndex={-1}>
|
||||||
|
Let's get started
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 5 }} aria-live="polite">
|
||||||
|
{getSubheading(values)}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="form"
|
||||||
|
noValidate
|
||||||
|
onSubmit={(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onContinue();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* forWhom field */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
label="Who is this funeral being arranged for?"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'myself',
|
||||||
|
label: 'Myself',
|
||||||
|
description: 'I want to plan my own funeral',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'someone',
|
||||||
|
label: 'Someone else',
|
||||||
|
description: 'I am arranging for a family member or friend',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={values.forWhom}
|
||||||
|
onChange={handleForWhomChange}
|
||||||
|
error={!!errors?.forWhom}
|
||||||
|
helperText={errors?.forWhom}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* hasPassedAway field — revealed when forWhom="someone" */}
|
||||||
|
<Collapse in={showHasPassedAway}>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
label="Has the person died?"
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'yes',
|
||||||
|
label: 'Yes',
|
||||||
|
description: 'I need to arrange a funeral now',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'no',
|
||||||
|
label: 'No',
|
||||||
|
description: 'I am planning ahead',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={values.hasPassedAway}
|
||||||
|
onChange={handleHasPassedAwayChange}
|
||||||
|
error={!!errors?.hasPassedAway}
|
||||||
|
helperText={errors?.hasPassedAway}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button type="submit" variant="contained" size="large" loading={loading}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</WizardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
IntroStep.displayName = 'IntroStep';
|
||||||
|
export default IntroStep;
|
||||||
2
src/components/pages/IntroStep/index.ts
Normal file
2
src/components/pages/IntroStep/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './IntroStep';
|
||||||
|
export * from './IntroStep';
|
||||||
@@ -324,7 +324,9 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
|
<Box component="main" sx={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||||
<LayoutComponent secondaryPanel={secondaryPanel}>{children}</LayoutComponent>
|
<LayoutComponent secondaryPanel={secondaryPanel}>{children}</LayoutComponent>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Sticky help bar */}
|
{/* Sticky help bar */}
|
||||||
{!hideHelpBar && <HelpBar phone={helpPhone} />}
|
{!hideHelpBar && <HelpBar phone={helpPhone} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user