Add LineItem, ProviderCardCompact, PackageDetail for Package Select page
LineItem (molecule): - Name + optional info tooltip + optional price - Allowance asterisk, total variant (bold + top border) - Reusable for package contents, order summaries, invoices ProviderCardCompact (molecule): - Horizontal layout: image left, name + location + rating right - Used at top of Package Select page to show selected provider PackageDetail (organism): - Right-side detail panel for Package Select page - Name/price header, Make Arrangement + Compare CTAs - Grouped LineItem sections, total row, T&C footer - PackageSelectPage story: full page with filter chips, package list (ServiceOption), sticky detail panel, and Navigation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
src/components/molecules/LineItem/LineItem.stories.tsx
Normal file
129
src/components/molecules/LineItem/LineItem.stories.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { LineItem } from './LineItem';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
|
||||
const meta: Meta<typeof LineItem> = {
|
||||
title: 'Molecules/LineItem',
|
||||
component: LineItem,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 500, width: '100%' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LineItem>;
|
||||
|
||||
// --- Default -----------------------------------------------------------------
|
||||
|
||||
/** Basic line item with name and price */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
name: 'Professional Service Fee',
|
||||
price: 1500,
|
||||
info: 'Covers coordination of the entire funeral service, liaising with clergy, cemetery, and crematorium.',
|
||||
},
|
||||
};
|
||||
|
||||
// --- Allowance ---------------------------------------------------------------
|
||||
|
||||
/** Price marked with asterisk — indicates an allowance that can be customised */
|
||||
export const Allowance: Story = {
|
||||
args: {
|
||||
name: 'Allowance for Coffin',
|
||||
price: 1500,
|
||||
isAllowance: true,
|
||||
info: 'This is an allowance amount. You may upgrade or change the coffin selection during your arrangement.',
|
||||
},
|
||||
};
|
||||
|
||||
// --- No Price ----------------------------------------------------------------
|
||||
|
||||
/** Complimentary/included item — no price shown */
|
||||
export const Complimentary: Story = {
|
||||
args: {
|
||||
name: 'Dressing Fee',
|
||||
info: 'Included at no additional charge with this package.',
|
||||
},
|
||||
};
|
||||
|
||||
// --- Custom Price Label ------------------------------------------------------
|
||||
|
||||
/** Custom text instead of dollar amount */
|
||||
export const CustomPriceLabel: Story = {
|
||||
args: {
|
||||
name: 'Transfer of Deceased',
|
||||
priceLabel: 'Included',
|
||||
info: 'Transfer within 50km of the funeral home.',
|
||||
},
|
||||
};
|
||||
|
||||
// --- Total Row ---------------------------------------------------------------
|
||||
|
||||
/** Summary total — bold with top border */
|
||||
export const Total: Story = {
|
||||
args: {
|
||||
name: 'Total',
|
||||
price: 2700,
|
||||
variant: 'total',
|
||||
},
|
||||
};
|
||||
|
||||
// --- Package Contents --------------------------------------------------------
|
||||
|
||||
/** Realistic package breakdown as seen on the Package Select page */
|
||||
export const PackageContents: Story = {
|
||||
render: () => (
|
||||
<Box>
|
||||
<Typography variant="label" sx={{ mb: 1.5, display: 'block' }}>
|
||||
Essentials
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<LineItem name="Accommodation" price={1500} info="Refrigerated holding of the deceased prior to the funeral service." />
|
||||
<LineItem name="Death Registration Certificate" price={1500} info="Lodgement of death registration with NSW Registry of Births, Deaths & Marriages." />
|
||||
<LineItem name="Doctor Fee for Cremation" price={1500} info="Statutory medical referee fee required for all cremations in NSW." />
|
||||
<LineItem name="NSW Government Levy — Cremation" price={1500} info="NSW Government cremation levy as set by the Department of Health." />
|
||||
<LineItem name="Professional Mortuary Care" price={1500} info="Preparation and care of the deceased." />
|
||||
<LineItem name="Professional Service Fee" price={1500} info="Coordination of all funeral arrangements and services." />
|
||||
<LineItem name="Allowance for Coffin" price={1500} isAllowance info="Allowance amount — upgrade options available during arrangement." />
|
||||
<LineItem name="Allowance for Crematorium" price={1500} isAllowance info="Allowance for crematorium fees — varies by location." />
|
||||
<LineItem name="Allowance for Hearse" price={1500} isAllowance info="Allowance for hearse transfer — distance surcharges may apply." />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="label" sx={{ mb: 1.5, display: 'block' }}>
|
||||
Complimentary Items
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<LineItem name="Dressing Fee" info="Dressing and preparation of the deceased — included at no charge." />
|
||||
<LineItem name="Viewing Fee" info="One private family viewing — included at no charge." />
|
||||
</Box>
|
||||
|
||||
<LineItem name="Total" price={13500} variant="total" />
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="label" sx={{ mb: 1.5, display: 'block' }}>
|
||||
Extras
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<LineItem name="Allowance for Flowers" price={1500} isAllowance info="Seasonal floral arrangements for the service." />
|
||||
<LineItem name="Allowance for Master of Ceremonies" price={1500} isAllowance info="Professional celebrant or MC for the funeral service." />
|
||||
<LineItem name="After Business Hours Service Surcharge" price={1500} info="Additional fee for services held outside standard business hours." />
|
||||
<LineItem name="After Hours Prayers" price={1500} info="Evening prayer service at the funeral home." />
|
||||
<LineItem name="Coffin Bearing by Funeral Directors" price={1500} info="Professional pallbearing by funeral directors." />
|
||||
<LineItem name="Digital Recording" price={1500} info="Professional video recording of the funeral service." />
|
||||
</Box>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
121
src/components/molecules/LineItem/LineItem.tsx
Normal file
121
src/components/molecules/LineItem/LineItem.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Props for the FA LineItem molecule */
|
||||
export interface LineItemProps {
|
||||
/** Item name/label */
|
||||
name: string;
|
||||
/** Optional tooltip text explaining the item (shown via info icon) */
|
||||
info?: string;
|
||||
/** Price in dollars — omit for complimentary/included items */
|
||||
price?: number;
|
||||
/** Whether the price is an allowance (shows asterisk) */
|
||||
isAllowance?: boolean;
|
||||
/** Custom price display — overrides `price` formatting (e.g. "Included", "TBC") */
|
||||
priceLabel?: string;
|
||||
/** Visual weight — "default" for regular items, "total" for summary rows */
|
||||
variant?: 'default' | 'total';
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single line item showing a name, optional info tooltip, and optional price.
|
||||
*
|
||||
* Used in package contents, order summaries, and invoices. The `info` prop
|
||||
* renders a small info icon with a tooltip — used by providers to explain
|
||||
* what each inclusion covers.
|
||||
*
|
||||
* Composes Typography + Tooltip.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <LineItem name="Professional Service Fee" info="Covers all coordination..." price={1500} />
|
||||
* <LineItem name="Allowance for Coffin" price={1500} isAllowance info="Can be upgraded..." />
|
||||
* <LineItem name="Dressing Fee" info="Included in this package" />
|
||||
* <LineItem name="Total" price={2700} variant="total" />
|
||||
* ```
|
||||
*/
|
||||
export const LineItem = React.forwardRef<HTMLDivElement, LineItemProps>(
|
||||
({ name, info, price, isAllowance = false, priceLabel, variant = 'default', sx }, ref) => {
|
||||
const isTotal = variant === 'total';
|
||||
|
||||
const formattedPrice = priceLabel
|
||||
?? (price != null ? `$${price.toLocaleString('en-AU')}${isAllowance ? '*' : ''}` : undefined);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={[
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
...(isTotal && {
|
||||
pt: 1.5,
|
||||
mt: 1.5,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}),
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Name + optional info icon */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant={isTotal ? 'h6' : 'body2'}
|
||||
sx={{
|
||||
fontWeight: isTotal ? 600 : 400,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
{info && (
|
||||
<Tooltip title={info} arrow placement="top">
|
||||
<InfoOutlinedIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: 'text.secondary',
|
||||
cursor: 'help',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Price */}
|
||||
{formattedPrice && (
|
||||
<Typography
|
||||
variant={isTotal ? 'h6' : 'body2'}
|
||||
sx={{
|
||||
fontWeight: isTotal ? 600 : 400,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
...(isTotal && { color: 'primary.main' }),
|
||||
}}
|
||||
>
|
||||
{formattedPrice}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LineItem.displayName = 'LineItem';
|
||||
export default LineItem;
|
||||
1
src/components/molecules/LineItem/index.ts
Normal file
1
src/components/molecules/LineItem/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LineItem, type LineItemProps } from './LineItem';
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ProviderCardCompact } from './ProviderCardCompact';
|
||||
|
||||
const DEMO_IMAGE = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
|
||||
|
||||
const meta: Meta<typeof ProviderCardCompact> = {
|
||||
title: 'Molecules/ProviderCardCompact',
|
||||
component: ProviderCardCompact,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 480, width: '100%' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ProviderCardCompact>;
|
||||
|
||||
// --- Default -----------------------------------------------------------------
|
||||
|
||||
/** Compact provider card with image, name, location, and rating */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
name: 'H.Parsons',
|
||||
location: 'Wentworth',
|
||||
imageUrl: DEMO_IMAGE,
|
||||
rating: 4.5,
|
||||
reviewCount: 11,
|
||||
},
|
||||
};
|
||||
|
||||
// --- Without Image -----------------------------------------------------------
|
||||
|
||||
/** Provider without a photo — text only */
|
||||
export const WithoutImage: Story = {
|
||||
args: {
|
||||
name: 'Smith & Sons Funerals',
|
||||
location: 'Parramatta',
|
||||
rating: 4.2,
|
||||
reviewCount: 38,
|
||||
},
|
||||
};
|
||||
|
||||
// --- Without Rating ----------------------------------------------------------
|
||||
|
||||
/** Provider without reviews */
|
||||
export const WithoutRating: Story = {
|
||||
args: {
|
||||
name: 'Peaceful Rest Funerals',
|
||||
location: 'Mildura',
|
||||
imageUrl: DEMO_IMAGE,
|
||||
},
|
||||
};
|
||||
|
||||
// --- Interactive -------------------------------------------------------------
|
||||
|
||||
/** Clickable — navigates back to provider details */
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
name: 'H.Parsons',
|
||||
location: 'Wentworth',
|
||||
imageUrl: DEMO_IMAGE,
|
||||
rating: 4.5,
|
||||
reviewCount: 11,
|
||||
onClick: () => alert('Navigate to provider details'),
|
||||
},
|
||||
};
|
||||
|
||||
// --- In Context --------------------------------------------------------------
|
||||
|
||||
/** As it appears at the top of the Package Select page */
|
||||
export const InContext: Story = {
|
||||
render: () => (
|
||||
<Box>
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => {}}
|
||||
sx={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
mb: 3,
|
||||
p: 0,
|
||||
color: 'text.secondary',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '1rem',
|
||||
'&:hover': { color: 'text.primary' },
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</Box>
|
||||
<Box sx={{ typography: 'h3', mb: 3 }}>Select a package</Box>
|
||||
<ProviderCardCompact
|
||||
name="H.Parsons"
|
||||
location="Wentworth"
|
||||
imageUrl={DEMO_IMAGE}
|
||||
rating={4.5}
|
||||
reviewCount={11}
|
||||
onClick={() => alert('View provider')}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import { Card } from '../../atoms/Card';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Props for the FA ProviderCardCompact molecule */
|
||||
export interface ProviderCardCompactProps {
|
||||
/** Provider display name */
|
||||
name: string;
|
||||
/** Location text (suburb, city) */
|
||||
location: string;
|
||||
/** Hero image URL — shown on the left */
|
||||
imageUrl?: string;
|
||||
/** Average rating (e.g. 4.5). Omit to hide. */
|
||||
rating?: number;
|
||||
/** Number of reviews. Omit to hide review count. */
|
||||
reviewCount?: number;
|
||||
/** Click handler — makes the card interactive */
|
||||
onClick?: () => void;
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compact horizontal provider card for the FA design system.
|
||||
*
|
||||
* Used at the top of the Package Select page to show which provider
|
||||
* the user has selected. Horizontal layout with image on the left,
|
||||
* name + meta on the right.
|
||||
*
|
||||
* For the full vertical listing card, use ProviderCard instead.
|
||||
*
|
||||
* Composes Card + Typography.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <ProviderCardCompact
|
||||
* name="H.Parsons"
|
||||
* location="Wentworth"
|
||||
* imageUrl="/images/parsons.jpg"
|
||||
* rating={4.5}
|
||||
* reviewCount={11}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ProviderCardCompact = React.forwardRef<HTMLDivElement, ProviderCardCompactProps>(
|
||||
({ name, location, imageUrl, rating, reviewCount, onClick, sx }, ref) => {
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
variant="outlined"
|
||||
interactive={!!onClick}
|
||||
padding="none"
|
||||
onClick={onClick}
|
||||
sx={[
|
||||
{
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
minHeight: 110,
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Image */}
|
||||
{imageUrl && (
|
||||
<Box
|
||||
role="img"
|
||||
aria-label={`${name} photo`}
|
||||
sx={{
|
||||
width: { xs: 120, sm: 160 },
|
||||
flexShrink: 0,
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: 0.5,
|
||||
p: 2,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" component="span">
|
||||
{name}
|
||||
</Typography>
|
||||
|
||||
{/* Location */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<LocationOnOutlinedIcon
|
||||
sx={{ fontSize: 16, color: 'text.secondary' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{location}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Rating */}
|
||||
{rating != null && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<StarRoundedIcon
|
||||
sx={{ fontSize: 16, color: 'var(--fa-color-brand-500)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{rating} Rating{reviewCount != null ? ` (${reviewCount} ${reviewCount === 1 ? 'Review' : 'Reviews'})` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ProviderCardCompact.displayName = 'ProviderCardCompact';
|
||||
export default ProviderCardCompact;
|
||||
1
src/components/molecules/ProviderCardCompact/index.ts
Normal file
1
src/components/molecules/ProviderCardCompact/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProviderCardCompact, type ProviderCardCompactProps } from './ProviderCardCompact';
|
||||
Reference in New Issue
Block a user