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:
97
src/components/molecules/CartButton/CartButton.stories.tsx
Normal file
97
src/components/molecules/CartButton/CartButton.stories.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
175
src/components/molecules/CartButton/CartButton.tsx
Normal file
175
src/components/molecules/CartButton/CartButton.tsx
Normal 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;
|
||||||
2
src/components/molecules/CartButton/index.ts
Normal file
2
src/components/molecules/CartButton/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './CartButton';
|
||||||
|
export * from './CartButton';
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
Reference in New Issue
Block a user