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';
|
||||
Reference in New Issue
Block a user