CartButton molecule + progress bar on all wizard layouts

- New CartButton molecule: outlined pill trigger with receipt icon,
  "Your Plan" label + formatted total in brand colour. Click opens
  DialogShell with items grouped by section via LineItem, total row,
  empty state. Mobile collapses to icon + price.
- WizardLayout: remove STEPPER_VARIANTS whitelist — stepper bar now
  renders on any layout variant when progressStepper/runningTotal props
  are provided (StepperBar already returns null when both empty)
- Thread progressStepper + runningTotal props to DateTimeStep, VenueStep,
  SummaryStep, PaymentStep (joins 8 pages that already had them)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 15:19:20 +11:00
parent 7a06f89e84
commit e73ccf36dd
8 changed files with 302 additions and 6 deletions

View File

@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { CartButton } from './CartButton';
import { StepIndicator } from '../StepIndicator';
import type { CartItem } from './CartButton';
const sampleItems: CartItem[] = [
{ section: 'Funeral Provider', name: 'H. Parsons — Essential Package', price: 4950 },
{ section: 'Service Venue', name: 'West Chapel', price: 900 },
{ section: 'Service Venue', name: 'Photo presentation', price: 150 },
{ section: 'Crematorium', name: 'Warrill Park Crematorium', price: 850 },
{ section: 'Coffin', name: 'Richmond Rosewood', price: 1750 },
{ section: 'Optional Extras', name: 'Live musician — Vocalist', price: 450 },
{ section: 'Optional Extras', name: 'Catering', priceLabel: 'Price on application' },
];
const meta: Meta<typeof CartButton> = {
title: 'Molecules/CartButton',
component: CartButton,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof CartButton>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Full cart with multiple sections */
export const Default: Story = {
args: {
total: 9050,
items: sampleItems,
},
};
// ─── Empty ──────────────────────────────────────────────────────────────────
/** Empty plan — no items selected yet */
export const Empty: Story = {
args: {
total: 0,
items: [],
},
};
// ─── Single item ────────────────────────────────────────────────────────────
/** Just the package selected */
export const SingleItem: Story = {
args: {
total: 4950,
items: [{ section: 'Funeral Provider', name: 'H. Parsons — Essential Package', price: 4950 }],
},
};
// ─── In progress bar context ────────────────────────────────────────────────
/** How it looks inside the wizard progress bar */
export const InProgressBar: Story = {
render: () => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
width: '100%',
maxWidth: 900,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
px: 4,
py: 1.5,
}}
>
<Box sx={{ flex: 1 }}>
<StepIndicator
steps={[
{ label: 'Details' },
{ label: 'Venues' },
{ label: 'Coffins' },
{ label: 'Extras' },
{ label: 'Review' },
]}
currentStep={2}
/>
</Box>
<CartButton total={6700} items={sampleItems.slice(0, 4)} />
</Box>
),
parameters: {
layout: 'padded',
},
};

View File

@@ -0,0 +1,175 @@
import React from 'react';
import Box from '@mui/material/Box';
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { DialogShell } from '../../atoms/DialogShell';
import { Divider } from '../../atoms/Divider';
import { LineItem } from '../LineItem';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A single item in the plan cart */
export interface CartItem {
/** Section heading (e.g. "Funeral Provider", "Venue") */
section: string;
/** Item name */
name: string;
/** Price in dollars — omit for included/complimentary items */
price?: number;
/** Custom price label (e.g. "Price on application", "Included") */
priceLabel?: string;
}
/** Props for the CartButton molecule */
export interface CartButtonProps {
/** Running total in dollars */
total: number;
/** Cart items grouped by section */
items?: CartItem[];
/** Override the structured dialog body with custom content */
children?: React.ReactNode;
/** MUI sx prop for the trigger button */
sx?: SxProps<Theme>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Group items by their section heading */
const groupBySection = (items: CartItem[]) => {
const groups: { section: string; items: CartItem[] }[] = [];
for (const item of items) {
const last = groups[groups.length - 1];
if (last && last.section === item.section) {
last.items.push(item);
} else {
groups.push({ section: item.section, items: [item] });
}
}
return groups;
};
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Cart button for the arrangement wizard progress bar.
*
* Shows the running plan total in a compact trigger button. Clicking opens
* a DialogShell with the plan contents — items grouped by section using
* LineItem molecules.
*
* Sits in the `runningTotal` slot of WizardLayout.
*
* Usage:
* ```tsx
* <CartButton
* total={6715}
* items={[
* { section: 'Funeral Provider', name: 'H. Parsons — Essential Package', price: 4950 },
* { section: 'Venue', name: 'West Chapel', price: 900 },
* { section: 'Extras', name: 'Catering', priceLabel: 'Price on application' },
* ]}
* />
* ```
*/
export const CartButton = React.forwardRef<HTMLButtonElement, CartButtonProps>(
({ total, items = [], children, sx }, ref) => {
const [open, setOpen] = React.useState(false);
const formattedTotal = `$${total.toLocaleString('en-AU')}`;
const groups = groupBySection(items);
return (
<>
{/* Trigger */}
<Button
ref={ref}
variant="outlined"
color="secondary"
size="small"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
startIcon={<ReceiptLongOutlinedIcon sx={{ fontSize: 18 }} />}
sx={[
{
borderRadius: '9999px',
textTransform: 'none',
gap: 1,
pl: 2,
pr: 2.5,
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' }, fontWeight: 500 }}>
Your Plan
</Box>
<Typography
component="span"
variant="label"
sx={{ color: 'primary.main', fontWeight: 700 }}
>
{formattedTotal}
</Typography>
</Button>
{/* Dialog */}
<DialogShell
open={open}
onClose={() => setOpen(false)}
title="Your plan so far"
maxWidth="xs"
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', px: 3, py: 2 }}>
<Button variant="text" color="secondary" onClick={() => setOpen(false)}>
Close
</Button>
</Box>
}
>
{children || (
<Box sx={{ px: 3, py: 2 }}>
{items.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', py: 4 }}
>
Your plan is empty. Selections will appear here as you build your arrangement.
</Typography>
) : (
<>
{groups.map((group, gi) => (
<Box key={group.section} sx={{ mb: gi < groups.length - 1 ? 2 : 0 }}>
<Typography
variant="labelSm"
color="text.secondary"
sx={{ mb: 1, textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
{group.section}
</Typography>
{group.items.map((item, ii) => (
<LineItem
key={`${group.section}-${ii}`}
name={item.name}
price={item.price}
priceLabel={item.priceLabel}
/>
))}
</Box>
))}
<Divider sx={{ my: 2 }} />
<LineItem name="Total" price={total} variant="total" />
</>
)}
</Box>
)}
</DialogShell>
</>
);
},
);
CartButton.displayName = 'CartButton';
export default CartButton;

View File

@@ -0,0 +1,2 @@
export { default } from './CartButton';
export * from './CartButton';

View File

@@ -70,6 +70,10 @@ export interface DateTimeStepProps {
showScheduling?: boolean; showScheduling?: boolean;
/** Navigation bar — passed through to WizardLayout */ /** Navigation bar — passed through to WizardLayout */
navigation?: React.ReactNode; navigation?: React.ReactNode;
/** Progress stepper */
progressStepper?: React.ReactNode;
/** Running total widget (e.g. CartButton) */
runningTotal?: React.ReactNode;
/** Hide the help bar */ /** Hide the help bar */
hideHelpBar?: boolean; hideHelpBar?: boolean;
/** MUI sx prop for the root */ /** MUI sx prop for the root */
@@ -115,6 +119,8 @@ export const DateTimeStep: React.FC<DateTimeStepProps> = ({
showNameFields = true, showNameFields = true,
showScheduling = true, showScheduling = true,
navigation, navigation,
progressStepper,
runningTotal,
hideHelpBar, hideHelpBar,
sx, sx,
}) => { }) => {
@@ -154,6 +160,8 @@ export const DateTimeStep: React.FC<DateTimeStepProps> = ({
<WizardLayout <WizardLayout
variant="centered-form" variant="centered-form"
navigation={navigation} navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink={!!onBack} showBackLink={!!onBack}
backLabel="Back" backLabel="Back"
onBack={onBack} onBack={onBack}

View File

@@ -70,6 +70,8 @@ export interface PaymentStepProps {
navigation?: React.ReactNode; navigation?: React.ReactNode;
/** Progress stepper */ /** Progress stepper */
progressStepper?: React.ReactNode; progressStepper?: React.ReactNode;
/** Running total widget (e.g. CartButton) */
runningTotal?: React.ReactNode;
/** Hide the help bar */ /** Hide the help bar */
hideHelpBar?: boolean; hideHelpBar?: boolean;
/** MUI sx prop */ /** MUI sx prop */
@@ -108,6 +110,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
cardFormSlot, cardFormSlot,
navigation, navigation,
progressStepper, progressStepper,
runningTotal,
hideHelpBar, hideHelpBar,
sx, sx,
}) => { }) => {
@@ -118,6 +121,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
variant="centered-form" variant="centered-form"
navigation={navigation} navigation={navigation}
progressStepper={progressStepper} progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink={!!onBack} showBackLink={!!onBack}
backLabel="Back" backLabel="Back"
onBack={onBack} onBack={onBack}

View File

@@ -101,6 +101,8 @@ export interface SummaryStepProps {
navigation?: React.ReactNode; navigation?: React.ReactNode;
/** Progress stepper */ /** Progress stepper */
progressStepper?: React.ReactNode; progressStepper?: React.ReactNode;
/** Running total widget (e.g. CartButton) */
runningTotal?: React.ReactNode;
/** Hide the help bar */ /** Hide the help bar */
hideHelpBar?: boolean; hideHelpBar?: boolean;
/** MUI sx prop */ /** MUI sx prop */
@@ -287,6 +289,7 @@ export const SummaryStep: React.FC<SummaryStepProps> = ({
isPrePlanning = false, isPrePlanning = false,
navigation, navigation,
progressStepper, progressStepper,
runningTotal,
hideHelpBar, hideHelpBar,
sx, sx,
}) => { }) => {
@@ -297,6 +300,7 @@ export const SummaryStep: React.FC<SummaryStepProps> = ({
variant="centered-form" variant="centered-form"
navigation={navigation} navigation={navigation}
progressStepper={progressStepper} progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink={!!onBack} showBackLink={!!onBack}
backLabel="Back" backLabel="Back"
onBack={onBack} onBack={onBack}

View File

@@ -52,6 +52,10 @@ export interface VenueStepProps {
mapPanel?: React.ReactNode; mapPanel?: React.ReactNode;
/** Navigation bar — passed through to WizardLayout */ /** Navigation bar — passed through to WizardLayout */
navigation?: React.ReactNode; navigation?: React.ReactNode;
/** Progress stepper */
progressStepper?: React.ReactNode;
/** Running total widget (e.g. CartButton) */
runningTotal?: React.ReactNode;
/** MUI sx prop for the root */ /** MUI sx prop for the root */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -90,6 +94,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
isPrePlanning = false, isPrePlanning = false,
mapPanel, mapPanel,
navigation, navigation,
progressStepper,
runningTotal,
sx, sx,
}) => { }) => {
const subheading = isPrePlanning const subheading = isPrePlanning
@@ -100,6 +106,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
<WizardLayout <WizardLayout
variant="list-map" variant="list-map"
navigation={navigation} navigation={navigation}
progressStepper={progressStepper}
runningTotal={runningTotal}
showBackLink showBackLink
backLabel="Back" backLabel="Back"
onBack={onBack} onBack={onBack}

View File

@@ -381,8 +381,7 @@ const LAYOUT_MAP: Record<
'detail-toggles': DetailTogglesLayout, 'detail-toggles': DetailTogglesLayout,
}; };
/** Variants that show the stepper/total bar */ /* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
const STEPPER_VARIANTS: WizardLayoutVariant[] = ['wide-form', 'grid-sidebar', 'detail-toggles'];
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
@@ -396,8 +395,8 @@ const STEPPER_VARIANTS: WizardLayoutVariant[] = ['wide-form', 'grid-sidebar', 'd
* - **grid-sidebar**: Filter sidebar + card grid (coffins) * - **grid-sidebar**: Filter sidebar + card grid (coffins)
* - **detail-toggles**: Hero image + info column (venue, coffin details) * - **detail-toggles**: Hero image + info column (venue, coffin details)
* *
* All variants share: navigation slot, optional back link, sticky help bar. * All variants share: navigation slot, optional back link, sticky help bar,
* Grid-sidebar and detail-toggles add: progress stepper, running total widget. * and optional progress stepper + running total bar (shown when props provided).
*/ */
export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>( export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
( (
@@ -418,7 +417,6 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
ref, ref,
) => { ) => {
const LayoutComponent = LAYOUT_MAP[variant]; const LayoutComponent = LAYOUT_MAP[variant];
const showStepper = STEPPER_VARIANTS.includes(variant);
return ( return (
<Box <Box
@@ -446,7 +444,7 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
{navigation} {navigation}
{/* Stepper + running total bar (grid-sidebar, detail-toggles only) */} {/* Stepper + running total bar (grid-sidebar, detail-toggles only) */}
{showStepper && <StepperBar stepper={progressStepper} total={runningTotal} />} <StepperBar stepper={progressStepper} total={runningTotal} />
{/* Back link — inside left panel for list-map/detail-toggles, above content for others */} {/* Back link — inside left panel for list-map/detail-toggles, above content for others */}
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && ( {showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (