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;
|
||||
/** Navigation bar — passed through to WizardLayout */
|
||||
navigation?: React.ReactNode;
|
||||
/** Progress stepper */
|
||||
progressStepper?: React.ReactNode;
|
||||
/** Running total widget (e.g. CartButton) */
|
||||
runningTotal?: React.ReactNode;
|
||||
/** Hide the help bar */
|
||||
hideHelpBar?: boolean;
|
||||
/** MUI sx prop for the root */
|
||||
@@ -115,6 +119,8 @@ export const DateTimeStep: React.FC<DateTimeStepProps> = ({
|
||||
showNameFields = true,
|
||||
showScheduling = true,
|
||||
navigation,
|
||||
progressStepper,
|
||||
runningTotal,
|
||||
hideHelpBar,
|
||||
sx,
|
||||
}) => {
|
||||
@@ -154,6 +160,8 @@ export const DateTimeStep: React.FC<DateTimeStepProps> = ({
|
||||
<WizardLayout
|
||||
variant="centered-form"
|
||||
navigation={navigation}
|
||||
progressStepper={progressStepper}
|
||||
runningTotal={runningTotal}
|
||||
showBackLink={!!onBack}
|
||||
backLabel="Back"
|
||||
onBack={onBack}
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface PaymentStepProps {
|
||||
navigation?: React.ReactNode;
|
||||
/** Progress stepper */
|
||||
progressStepper?: React.ReactNode;
|
||||
/** Running total widget (e.g. CartButton) */
|
||||
runningTotal?: React.ReactNode;
|
||||
/** Hide the help bar */
|
||||
hideHelpBar?: boolean;
|
||||
/** MUI sx prop */
|
||||
@@ -108,6 +110,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
|
||||
cardFormSlot,
|
||||
navigation,
|
||||
progressStepper,
|
||||
runningTotal,
|
||||
hideHelpBar,
|
||||
sx,
|
||||
}) => {
|
||||
@@ -118,6 +121,7 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
|
||||
variant="centered-form"
|
||||
navigation={navigation}
|
||||
progressStepper={progressStepper}
|
||||
runningTotal={runningTotal}
|
||||
showBackLink={!!onBack}
|
||||
backLabel="Back"
|
||||
onBack={onBack}
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface SummaryStepProps {
|
||||
navigation?: React.ReactNode;
|
||||
/** Progress stepper */
|
||||
progressStepper?: React.ReactNode;
|
||||
/** Running total widget (e.g. CartButton) */
|
||||
runningTotal?: React.ReactNode;
|
||||
/** Hide the help bar */
|
||||
hideHelpBar?: boolean;
|
||||
/** MUI sx prop */
|
||||
@@ -287,6 +289,7 @@ export const SummaryStep: React.FC<SummaryStepProps> = ({
|
||||
isPrePlanning = false,
|
||||
navigation,
|
||||
progressStepper,
|
||||
runningTotal,
|
||||
hideHelpBar,
|
||||
sx,
|
||||
}) => {
|
||||
@@ -297,6 +300,7 @@ export const SummaryStep: React.FC<SummaryStepProps> = ({
|
||||
variant="centered-form"
|
||||
navigation={navigation}
|
||||
progressStepper={progressStepper}
|
||||
runningTotal={runningTotal}
|
||||
showBackLink={!!onBack}
|
||||
backLabel="Back"
|
||||
onBack={onBack}
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface VenueStepProps {
|
||||
mapPanel?: React.ReactNode;
|
||||
/** Navigation bar — passed through to WizardLayout */
|
||||
navigation?: React.ReactNode;
|
||||
/** Progress stepper */
|
||||
progressStepper?: React.ReactNode;
|
||||
/** Running total widget (e.g. CartButton) */
|
||||
runningTotal?: React.ReactNode;
|
||||
/** MUI sx prop for the root */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
@@ -90,6 +94,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
||||
isPrePlanning = false,
|
||||
mapPanel,
|
||||
navigation,
|
||||
progressStepper,
|
||||
runningTotal,
|
||||
sx,
|
||||
}) => {
|
||||
const subheading = isPrePlanning
|
||||
@@ -100,6 +106,8 @@ export const VenueStep: React.FC<VenueStepProps> = ({
|
||||
<WizardLayout
|
||||
variant="list-map"
|
||||
navigation={navigation}
|
||||
progressStepper={progressStepper}
|
||||
runningTotal={runningTotal}
|
||||
showBackLink
|
||||
backLabel="Back"
|
||||
onBack={onBack}
|
||||
|
||||
@@ -381,8 +381,7 @@ const LAYOUT_MAP: Record<
|
||||
'detail-toggles': DetailTogglesLayout,
|
||||
};
|
||||
|
||||
/** Variants that show the stepper/total bar */
|
||||
const STEPPER_VARIANTS: WizardLayoutVariant[] = ['wide-form', 'grid-sidebar', 'detail-toggles'];
|
||||
/* Stepper bar renders on any variant when progressStepper or runningTotal is provided */
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -396,8 +395,8 @@ const STEPPER_VARIANTS: WizardLayoutVariant[] = ['wide-form', 'grid-sidebar', 'd
|
||||
* - **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.
|
||||
* 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>(
|
||||
(
|
||||
@@ -418,7 +417,6 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
ref,
|
||||
) => {
|
||||
const LayoutComponent = LAYOUT_MAP[variant];
|
||||
const showStepper = STEPPER_VARIANTS.includes(variant);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -446,7 +444,7 @@ export const WizardLayout = React.forwardRef<HTMLDivElement, WizardLayoutProps>(
|
||||
{navigation}
|
||||
|
||||
{/* 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 */}
|
||||
{showBackLink && variant !== 'list-map' && variant !== 'detail-toggles' && (
|
||||
|
||||
Reference in New Issue
Block a user