- StepperBar: stepper centred at 700px max-width, cart hugs right edge - StepIndicator: bump desktop label fontSize to 0.875rem for readability - DateTimeStep story: demo progress bar + cart in context Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
486 lines
14 KiB
TypeScript
486 lines
14 KiB
TypeScript
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 wizard page templates */
|
|
export type WizardLayoutVariant =
|
|
| 'centered-form'
|
|
| 'wide-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',
|
|
gap: 3,
|
|
}}
|
|
>
|
|
<Box sx={{ flex: 1, maxWidth: 700, mx: 'auto' }}>{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>
|
|
);
|
|
|
|
/** Wide Form: single column maxWidth "lg", for card grids (coffins, etc.) */
|
|
const WideFormLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
<Container
|
|
maxWidth="lg"
|
|
sx={{
|
|
py: { xs: 4, md: 6 },
|
|
px: { xs: 2, md: 3 },
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{children}
|
|
</Container>
|
|
);
|
|
|
|
/** List + Map: 420px fixed scrollable list (left) / flex map (right) — D-B */
|
|
const ListMapLayout: React.FC<{
|
|
children: React.ReactNode;
|
|
secondaryPanel?: React.ReactNode;
|
|
backLink?: React.ReactNode;
|
|
}> = ({ children, secondaryPanel, backLink }) => (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Left panel — scrollable list with scrollbar visible on hover */}
|
|
<Box
|
|
sx={{
|
|
width: { xs: '100%', md: 420 },
|
|
flexShrink: 0,
|
|
overflowY: 'auto',
|
|
px: { xs: 2, md: 3 },
|
|
pt: 0,
|
|
pb: 3,
|
|
// Thin scrollbar, hidden until hover
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: 'transparent transparent',
|
|
'&:hover': {
|
|
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
|
|
},
|
|
'&::-webkit-scrollbar': { width: 6 },
|
|
'&::-webkit-scrollbar-thumb': {
|
|
background: 'transparent',
|
|
borderRadius: 3,
|
|
},
|
|
'&:hover::-webkit-scrollbar-thumb': {
|
|
background: 'rgba(0,0,0,0.25)',
|
|
},
|
|
}}
|
|
>
|
|
{backLink}
|
|
{children}
|
|
</Box>
|
|
{/* Right panel — map or placeholder, fills available space */}
|
|
<Box
|
|
sx={{
|
|
display: { xs: 'none', md: 'flex' },
|
|
flex: 1,
|
|
minHeight: 0,
|
|
}}
|
|
>
|
|
{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).
|
|
* Viewport-locked on desktop — both panels scroll independently.
|
|
* On mobile, stacks vertically and scrolls as a single page. */
|
|
const GridSidebarLayout: React.FC<{
|
|
children: React.ReactNode;
|
|
secondaryPanel?: React.ReactNode;
|
|
}> = ({ children, secondaryPanel }) => (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flex: 1,
|
|
overflow: { md: 'hidden' },
|
|
flexDirection: { xs: 'column', md: 'row' },
|
|
maxWidth: 1200,
|
|
mx: 'auto',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
{/* Left sidebar — scrollbar visible on hover */}
|
|
<Box
|
|
component="aside"
|
|
sx={{
|
|
width: { xs: '100%', md: '28%' },
|
|
flexShrink: 0,
|
|
overflowY: 'auto',
|
|
overflowX: 'hidden',
|
|
px: { xs: 2, md: 3 },
|
|
py: 3,
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: 'transparent transparent',
|
|
'&:hover': {
|
|
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
|
|
},
|
|
'&::-webkit-scrollbar': { width: 6 },
|
|
'&::-webkit-scrollbar-thumb': {
|
|
background: 'transparent',
|
|
borderRadius: 3,
|
|
},
|
|
'&:hover::-webkit-scrollbar-thumb': {
|
|
background: 'rgba(0,0,0,0.25)',
|
|
},
|
|
}}
|
|
>
|
|
{children}
|
|
</Box>
|
|
{/* Right panel — always scrollable */}
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
overflowY: { md: 'auto' },
|
|
px: { xs: 2, md: 3 },
|
|
py: 3,
|
|
scrollbarWidth: 'thin',
|
|
}}
|
|
>
|
|
{secondaryPanel}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
|
|
/** Detail + Toggles: scrollable left (image/desc) / sticky right (info/CTA) */
|
|
const DetailTogglesLayout: React.FC<{
|
|
children: React.ReactNode;
|
|
secondaryPanel?: React.ReactNode;
|
|
backLink?: React.ReactNode;
|
|
}> = ({ children, secondaryPanel, backLink }) => (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flex: 1,
|
|
overflow: 'hidden',
|
|
maxWidth: 1200,
|
|
mx: 'auto',
|
|
width: '100%',
|
|
}}
|
|
>
|
|
{/* Left panel — scrollable content */}
|
|
<Box
|
|
sx={{
|
|
width: { xs: '100%', md: '58%' },
|
|
flexShrink: 0,
|
|
overflowY: 'auto',
|
|
px: { xs: 2, md: 5 },
|
|
pt: 0,
|
|
pb: 3,
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: 'transparent transparent',
|
|
'&:hover': {
|
|
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
|
|
},
|
|
'&::-webkit-scrollbar': { width: 6 },
|
|
'&::-webkit-scrollbar-thumb': {
|
|
background: 'transparent',
|
|
borderRadius: 3,
|
|
},
|
|
'&:hover::-webkit-scrollbar-thumb': {
|
|
background: 'rgba(0,0,0,0.25)',
|
|
},
|
|
}}
|
|
>
|
|
{backLink && <Box sx={{ pt: 1.5 }}>{backLink}</Box>}
|
|
{children}
|
|
</Box>
|
|
{/* Right panel — sticky info */}
|
|
<Box
|
|
sx={{
|
|
display: { xs: 'none', md: 'block' },
|
|
width: { md: '42%' },
|
|
overflowY: 'auto',
|
|
px: 5,
|
|
py: 3,
|
|
borderLeft: '1px solid',
|
|
borderColor: 'divider',
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: 'transparent transparent',
|
|
'&:hover': {
|
|
scrollbarColor: 'rgba(0,0,0,0.25) transparent',
|
|
},
|
|
'&::-webkit-scrollbar': { width: 6 },
|
|
'&::-webkit-scrollbar-thumb': {
|
|
background: 'transparent',
|
|
borderRadius: 3,
|
|
},
|
|
'&:hover::-webkit-scrollbar-thumb': {
|
|
background: 'rgba(0,0,0,0.25)',
|
|
},
|
|
}}
|
|
>
|
|
{secondaryPanel}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
|
|
// ─── Variant map ─────────────────────────────────────────────────────────────
|
|
|
|
const LAYOUT_MAP: Record<
|
|
WizardLayoutVariant,
|
|
React.FC<{
|
|
children: React.ReactNode;
|
|
secondaryPanel?: React.ReactNode;
|
|
backLink?: React.ReactNode;
|
|
}>
|
|
> = {
|
|
'centered-form': CenteredFormLayout,
|
|
'wide-form': WideFormLayout,
|
|
'list-map': ListMapLayout,
|
|
'list-detail': ListDetailLayout,
|
|
'grid-sidebar': GridSidebarLayout,
|
|
'detail-toggles': DetailTogglesLayout,
|
|
};
|
|
|
|
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
|
|
|
|
// ─── 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,
|
|
* and optional progress stepper + running total bar (shown when props provided).
|
|
*/
|
|
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];
|
|
|
|
return (
|
|
<Box
|
|
ref={ref}
|
|
sx={[
|
|
{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
minHeight: '100vh',
|
|
bgcolor: 'background.default',
|
|
// list-map + detail-toggles: lock to viewport so panels scroll independently
|
|
...((variant === 'list-map' || variant === 'detail-toggles') && {
|
|
height: '100vh',
|
|
overflow: 'hidden',
|
|
}),
|
|
...(variant === 'grid-sidebar' && {
|
|
height: { xs: 'auto', md: '100vh' },
|
|
overflow: { xs: 'visible', md: 'hidden' },
|
|
}),
|
|
},
|
|
...(Array.isArray(sx) ? sx : [sx]),
|
|
]}
|
|
>
|
|
{/* Navigation */}
|
|
{navigation}
|
|
|
|
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
|
|
<StepperBar stepper={progressStepper} total={runningTotal} />
|
|
|
|
{/* Back link — inside left panel for list-map/detail-toggles, above content for others */}
|
|
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (
|
|
<Container
|
|
maxWidth={variant === 'centered-form' ? 'sm' : 'lg'}
|
|
sx={{ pt: 2, px: { xs: 4, md: 3 } }}
|
|
>
|
|
<BackLink label={backLabel} onClick={onBack} />
|
|
</Container>
|
|
)}
|
|
|
|
{/* Main content area */}
|
|
<Box
|
|
component="main"
|
|
sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}
|
|
>
|
|
<LayoutComponent
|
|
secondaryPanel={secondaryPanel}
|
|
backLink={
|
|
showBackLink && (variant === 'list-map' || variant === 'detail-toggles') ? (
|
|
<Box sx={{ pt: 1.5 }}>
|
|
<BackLink label={backLabel} onClick={onBack} />
|
|
</Box>
|
|
) : undefined
|
|
}
|
|
>
|
|
{children}
|
|
</LayoutComponent>
|
|
</Box>
|
|
|
|
{/* Sticky help bar */}
|
|
{!hideHelpBar && <HelpBar phone={helpPhone} />}
|
|
</Box>
|
|
);
|
|
},
|
|
);
|
|
|
|
WizardLayout.displayName = 'WizardLayout';
|
|
export default WizardLayout;
|