Add WizardLayout template with 5 layout variants

- centered-form: single column ~600px for form steps (intro, auth, etc.)
- list-map: 40/60 split for provider search (card list + map)
- list-detail: 40/60 master-detail for package selection
- grid-sidebar: 25/75 filter sidebar + card grid (coffins)
- detail-toggles: 50/50 hero image + product info (venue/coffin details)

Common elements: nav slot, sticky help bar, optional back link,
optional progress stepper + running total (grid-sidebar, detail-toggles).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 14:15:41 +11:00
parent ac17b12ad8
commit 43f0360252
3 changed files with 763 additions and 0 deletions

View File

@@ -0,0 +1,337 @@
import React from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PhoneIcon from '@mui/icons-material/Phone';
import type { SxProps, Theme } from '@mui/material/styles';
import { Link } from '../../atoms/Link';
import { Typography } from '../../atoms/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Layout variant matching the 5 wizard page templates */
export type WizardLayoutVariant =
| 'centered-form'
| 'list-map'
| 'list-detail'
| 'grid-sidebar'
| 'detail-toggles';
/** Props for the WizardLayout template */
export interface WizardLayoutProps {
/** Which layout variant to render */
variant: WizardLayoutVariant;
/** Navigation bar — rendered at the top of the page */
navigation?: React.ReactNode;
/** Optional progress stepper — shown below nav on grid-sidebar and detail-toggles variants */
progressStepper?: React.ReactNode;
/** Optional running total widget — shown in a bar below nav (grid-sidebar, detail-toggles) */
runningTotal?: React.ReactNode;
/** Show a back link above the content area */
showBackLink?: boolean;
/** Label for the back link */
backLabel?: string;
/** Click handler for the back link */
onBack?: () => void;
/** Help bar phone number */
helpPhone?: string;
/** Hide the sticky help bar */
hideHelpBar?: boolean;
// ─── Slot content ───
/** Main content — for centered-form this is the form; for split layouts this is the primary (left) panel */
children: React.ReactNode;
/** Secondary panel content — right panel for split layouts (map, detail, grid) */
secondaryPanel?: React.ReactNode;
/** MUI sx prop for the root container */
sx?: SxProps<Theme>;
}
// ─── Help bar ────────────────────────────────────────────────────────────────
const HelpBar: React.FC<{ phone: string }> = ({ phone }) => (
<Box
component="footer"
sx={{
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'background.paper',
borderTop: '1px solid',
borderColor: 'divider',
py: 1.5,
px: { xs: 2, md: 4 },
textAlign: 'center',
}}
>
<Typography variant="body2" color="text.secondary" component="span">
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
Need help? Call us on{' '}
<Link href={`tel:${phone.replace(/\s/g, '')}`} sx={{ fontWeight: 600 }}>
{phone}
</Link>
</Typography>
</Box>
);
// ─── Back link ───────────────────────────────────────────────────────────────
const BackLink: React.FC<{ label: string; onClick?: () => void }> = ({ label, onClick }) => (
<Link
component="button"
onClick={onClick}
underline="hover"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'text.secondary',
fontSize: '0.875rem',
fontWeight: 500,
mb: 2,
'&:hover': { color: 'text.primary' },
}}
>
<ArrowBackIcon sx={{ fontSize: 18 }} />
{label}
</Link>
);
// ─── Stepper + total bar ─────────────────────────────────────────────────────
const StepperBar: React.FC<{
stepper?: React.ReactNode;
total?: React.ReactNode;
}> = ({ stepper, total }) => {
if (!stepper && !total) return null;
return (
<Box
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
px: { xs: 2, md: 4 },
py: 1.5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box sx={{ flex: 1 }}>{stepper}</Box>
{total && <Box sx={{ flexShrink: 0 }}>{total}</Box>}
</Box>
);
};
// ─── Layout variants ─────────────────────────────────────────────────────────
/** Centered Form: single column ~600px, heading + fields + CTA */
const CenteredFormLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Container
maxWidth="sm"
sx={{
py: { xs: 6, md: 10 },
px: { xs: 4, md: 3 },
flex: 1,
}}
>
{children}
</Container>
);
/** List + Map: ~40% scrollable list (left) / ~60% map (right) */
const ListMapLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Box
sx={{
display: 'flex',
flex: 1,
overflow: 'hidden',
}}
>
<Box
sx={{
width: { xs: '100%', md: '40%' },
overflowY: 'auto',
px: { xs: 2, md: 3 },
py: 3,
}}
>
{children}
</Box>
<Box
sx={{
display: { xs: 'none', md: 'block' },
width: '60%',
position: 'relative',
}}
>
{secondaryPanel}
</Box>
</Box>
);
/** List + Detail: ~40% selection list (left) / ~60% detail panel (right) */
const ListDetailLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Container maxWidth="lg" sx={{ flex: 1, py: 3 }}>
<Box
sx={{
display: 'flex',
gap: { xs: 0, md: 4 },
flexDirection: { xs: 'column', md: 'row' },
}}
>
<Box sx={{ width: { xs: '100%', md: '40%' } }}>{children}</Box>
<Box sx={{ width: { xs: '100%', md: '60%' } }}>{secondaryPanel}</Box>
</Box>
</Container>
);
/** Grid + Sidebar: ~25% filter sidebar (left) / ~75% card grid (right) */
const GridSidebarLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Container maxWidth="lg" sx={{ flex: 1, py: 3 }}>
<Box
sx={{
display: 'flex',
gap: { xs: 0, md: 3 },
flexDirection: { xs: 'column', md: 'row' },
}}
>
<Box
component="aside"
sx={{
width: { xs: '100%', md: '25%' },
flexShrink: 0,
}}
>
{children}
</Box>
<Box sx={{ flex: 1 }}>{secondaryPanel}</Box>
</Box>
</Container>
);
/** Detail + Toggles: two-column hero (image left / info right), full-width section below */
const DetailTogglesLayout: React.FC<{
children: React.ReactNode;
secondaryPanel?: React.ReactNode;
}> = ({ children, secondaryPanel }) => (
<Container maxWidth="lg" sx={{ flex: 1, py: 3 }}>
<Box
sx={{
display: 'flex',
gap: { xs: 0, md: 4 },
flexDirection: { xs: 'column', md: 'row' },
mb: 4,
}}
>
<Box sx={{ width: { xs: '100%', md: '50%' } }}>{children}</Box>
<Box sx={{ width: { xs: '100%', md: '50%' } }}>{secondaryPanel}</Box>
</Box>
</Container>
);
// ─── Variant map ─────────────────────────────────────────────────────────────
const LAYOUT_MAP: Record<
WizardLayoutVariant,
React.FC<{ children: React.ReactNode; secondaryPanel?: React.ReactNode }>
> = {
'centered-form': CenteredFormLayout,
'list-map': ListMapLayout,
'list-detail': ListDetailLayout,
'grid-sidebar': GridSidebarLayout,
'detail-toggles': DetailTogglesLayout,
};
/** Variants that show the stepper/total bar */
const STEPPER_VARIANTS: WizardLayoutVariant[] = ['grid-sidebar', 'detail-toggles'];
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Page-level layout template for the FA arrangement wizard.
*
* Provides 5 layout variants matching the wizard page templates:
* - **centered-form**: Single centered column for form steps (intro, auth, date/time, etc.)
* - **list-map**: Split view with scrollable card list and map panel (providers)
* - **list-detail**: Master-detail split for selection + detail (packages, preview)
* - **grid-sidebar**: Filter sidebar + card grid (coffins)
* - **detail-toggles**: Hero image + info column (venue, coffin details)
*
* All variants share: navigation slot, optional back link, sticky help bar.
* Grid-sidebar and detail-toggles add: progress stepper, running total widget.
*/
export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
(
{
variant,
navigation,
progressStepper,
runningTotal,
showBackLink = false,
backLabel = 'Back',
onBack,
helpPhone = '1800 987 888',
hideHelpBar = false,
children,
secondaryPanel,
sx,
},
ref,
) => {
const LayoutComponent = LAYOUT_MAP[variant];
const showStepper = STEPPER_VARIANTS.includes(variant);
return (
<Box
ref={ref}
sx={[
{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
bgcolor: 'background.default',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Navigation */}
{navigation}
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
{showStepper && <StepperBar stepper={progressStepper} total={runningTotal} />}
{/* Back link — inside a container for consistent alignment */}
{showBackLink && (
<Container
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
>
<BackLink label={backLabel} onClick={onBack} />
</Container>
)}
{/* Main content area */}
<LayoutComponent secondaryPanel={secondaryPanel}>{children}</LayoutComponent>
{/* Sticky help bar */}
{!hideHelpBar && <HelpBar phone={helpPhone} />}
</Box>
);
},
);
WizardLayout.displayName = 'WizardLayout';
export default WizardLayout;