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:
337
src/components/templates/WizardLayout/WizardLayout.tsx
Normal file
337
src/components/templates/WizardLayout/WizardLayout.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user