New components for side-by-side funeral package comparison: - CompareBar molecule: floating bottom pill with fraction badge (1/3, 2/3, 3/3), contextual copy, Compare CTA. For ProvidersStep and PackagesStep. - ComparisonTable organism: CSS Grid comparison with info card, floating verified badges, separate section tables (Essentials/Optionals/Extras) with left accent borders, row hover, horizontal scroll on narrow desktops, font hierarchy. - ComparisonPage: WizardLayout wide-form with Share/Print actions. Desktop shows ComparisonTable, mobile shows mini-card tab rail + single package card view. Recommended package as separate prop (D038). Also fixes PackageDetail: adds priceLabel pass-through (D039), updates stories to Essentials/Optionals/Extras section naming (D035). Decisions: D035-D039 logged. Audits: CompareBar 18/20, ComparisonTable 17/20. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import React from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
|
import type { SxProps, Theme } from '@mui/material/styles';
|
|
import { Typography } from '../../atoms/Typography';
|
|
import { Button } from '../../atoms/Button';
|
|
import { Divider } from '../../atoms/Divider';
|
|
import { LineItem } from '../../molecules/LineItem';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
/** A single item within a package section */
|
|
export interface PackageLineItem {
|
|
/** Item name */
|
|
name: string;
|
|
/** Tooltip description — clients have laboured over these, display them all */
|
|
info?: string;
|
|
/** Price in dollars — omit for complimentary items */
|
|
price?: number;
|
|
/** Whether this is an allowance (shows asterisk) */
|
|
isAllowance?: boolean;
|
|
/** Custom price display — overrides formatted price (e.g. "Complimentary", "Price On Application") */
|
|
priceLabel?: string;
|
|
}
|
|
|
|
/** A section of items within a package (e.g. "Essentials", "Complimentary Items") */
|
|
export interface PackageSection {
|
|
/** Section heading */
|
|
heading: string;
|
|
/** Items in this section */
|
|
items: PackageLineItem[];
|
|
}
|
|
|
|
/** Props for the FA PackageDetail organism */
|
|
export interface PackageDetailProps {
|
|
/** Package name */
|
|
name: string;
|
|
/** Package price in dollars */
|
|
price: number;
|
|
/** Main package sections shown BEFORE the total (Essentials, Complimentary Items) */
|
|
sections: PackageSection[];
|
|
/** Package total — shown between main sections and extras */
|
|
total?: number;
|
|
/** Additional-cost extras shown AFTER the total — these can be added at extra cost */
|
|
extras?: PackageSection;
|
|
/** Terms and conditions text — required by providers */
|
|
terms?: string;
|
|
/** Called when user clicks "Make Arrangement" */
|
|
onArrange?: () => void;
|
|
/** Called when user clicks "Compare" */
|
|
onCompare?: () => void;
|
|
/** Whether the arrange button is disabled */
|
|
arrangeDisabled?: boolean;
|
|
/** Whether the compare button is in loading state */
|
|
compareLoading?: boolean;
|
|
/** Custom label for the arrange CTA button (default: "Make Arrangement") */
|
|
arrangeLabel?: string;
|
|
/** Disclaimer shown below the price (e.g. for unverified/estimated pricing) */
|
|
priceDisclaimer?: string;
|
|
/** When true, replaces the itemised breakdown with an "Itemised Pricing Unavailable" notice */
|
|
itemizedUnavailable?: boolean;
|
|
/** MUI sx prop for the root element */
|
|
sx?: SxProps<Theme>;
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Renders a section heading + list of LineItems */
|
|
function SectionBlock({ section, subtext }: { section: PackageSection; subtext?: string }) {
|
|
return (
|
|
<Box>
|
|
<Typography variant="h6" component="h3" sx={{ mb: subtext ? 0.5 : 2 }}>
|
|
{section.heading}
|
|
</Typography>
|
|
{subtext && (
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
|
{subtext}
|
|
</Typography>
|
|
)}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
{section.items.map((item) => (
|
|
<LineItem
|
|
key={item.name}
|
|
name={item.name}
|
|
info={item.info}
|
|
price={item.price}
|
|
isAllowance={item.isAllowance}
|
|
priceLabel={item.priceLabel}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// ─── Component ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Package detail panel for the FA design system.
|
|
*
|
|
* Displays the full contents of a funeral package — name, price, CTA buttons,
|
|
* grouped line items, total, optional extras, and T&Cs.
|
|
*
|
|
* Structure:
|
|
* - **Header** (warm bg): Package name, price, and CTA buttons
|
|
* - **sections** (before total): What's included in the package price
|
|
* (Essentials, Complimentary Items)
|
|
* - **total**: The package price
|
|
* - **extras** (after total): Additional items that can be added at extra cost
|
|
* - **terms**: Provider T&Cs (grey footer)
|
|
*
|
|
* "Make Arrangement" is the FA term for selecting/committing to a package.
|
|
*
|
|
* Composes Typography + Button + Divider + LineItem.
|
|
*/
|
|
export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps>(
|
|
(
|
|
{
|
|
name,
|
|
price,
|
|
sections,
|
|
total,
|
|
extras,
|
|
terms,
|
|
onArrange,
|
|
onCompare,
|
|
arrangeDisabled = false,
|
|
compareLoading = false,
|
|
arrangeLabel = 'Make Arrangement',
|
|
priceDisclaimer,
|
|
itemizedUnavailable = false,
|
|
sx,
|
|
},
|
|
ref,
|
|
) => {
|
|
return (
|
|
<Box
|
|
ref={ref}
|
|
sx={[
|
|
{
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
borderRadius: 'var(--fa-card-border-radius-default)',
|
|
overflow: 'hidden',
|
|
},
|
|
...(Array.isArray(sx) ? sx : [sx]),
|
|
]}
|
|
>
|
|
{/* Header band — warm bg to separate from content */}
|
|
<Box
|
|
sx={{
|
|
bgcolor: 'var(--fa-color-surface-warm)',
|
|
px: { xs: 2, sm: 3 },
|
|
pt: 3,
|
|
pb: 2.5,
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="overlineSm"
|
|
sx={{
|
|
color: 'text.secondary',
|
|
display: 'block',
|
|
mb: 1,
|
|
}}
|
|
>
|
|
Package
|
|
</Typography>
|
|
<Typography variant="h3" component="h2">
|
|
{name}
|
|
</Typography>
|
|
<Typography variant="h5" sx={{ mt: 0.5, color: 'primary.main', fontWeight: 600 }}>
|
|
${price.toLocaleString('en-AU')}
|
|
</Typography>
|
|
|
|
{/* Price disclaimer */}
|
|
{priceDisclaimer && (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: 1,
|
|
mt: 1.5,
|
|
px: 1.5,
|
|
py: 1,
|
|
bgcolor: 'var(--fa-color-surface-cool, #F5F7FA)',
|
|
borderRadius: 'var(--fa-border-radius-sm, 6px)',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<InfoOutlinedIcon
|
|
sx={{ fontSize: 16, color: 'text.secondary', mt: '1px', flexShrink: 0 }}
|
|
aria-hidden
|
|
/>
|
|
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.4 }}>
|
|
{priceDisclaimer}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{/* CTA buttons */}
|
|
<Box
|
|
sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 1.5, mt: 2.5 }}
|
|
>
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
fullWidth
|
|
disabled={arrangeDisabled}
|
|
onClick={onArrange}
|
|
>
|
|
{arrangeLabel}
|
|
</Button>
|
|
{onCompare && (
|
|
<Button
|
|
variant="soft"
|
|
color="secondary"
|
|
size="large"
|
|
loading={compareLoading}
|
|
onClick={onCompare}
|
|
sx={{ flexShrink: 0 }}
|
|
>
|
|
Compare
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Package contents */}
|
|
<Box sx={{ px: { xs: 2, sm: 3 }, py: 3 }}>
|
|
{itemizedUnavailable ? (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
textAlign: 'center',
|
|
bgcolor: 'var(--fa-color-surface-warm, #FEF9F5)',
|
|
borderRadius: 'var(--fa-border-radius-md, 8px)',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
px: 3,
|
|
py: 4,
|
|
}}
|
|
>
|
|
<InfoOutlinedIcon
|
|
sx={{ fontSize: 28, color: 'var(--fa-color-brand-500)', mb: 1.5 }}
|
|
aria-hidden
|
|
/>
|
|
<Typography variant="h6" component="p" sx={{ mb: 1 }}>
|
|
Itemised Pricing Unavailable
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360 }}>
|
|
This provider has shared their overall package price, but has not provided a
|
|
detailed breakdown of what is included.
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<>
|
|
{/* Main sections — included in the package price */}
|
|
{sections.map((section, idx) => (
|
|
<Box key={section.heading} sx={{ mb: idx < sections.length - 1 ? 3 : 0 }}>
|
|
<SectionBlock section={section} />
|
|
</Box>
|
|
))}
|
|
|
|
{/* Total — separates included content from extras */}
|
|
{total != null && <LineItem name="Total" price={total} variant="total" />}
|
|
|
|
{/* Extras — additional cost items after the total */}
|
|
{extras && extras.items.length > 0 && (
|
|
<>
|
|
<Divider sx={{ my: 3 }} />
|
|
<SectionBlock
|
|
section={extras}
|
|
subtext="These items can be added to your package at additional cost."
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Terms & Conditions footer */}
|
|
{terms && (
|
|
<Box
|
|
sx={{
|
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
|
px: { xs: 2, sm: 3 },
|
|
py: 2,
|
|
}}
|
|
>
|
|
<Typography variant="captionSm" color="text.secondary" sx={{ lineHeight: 1.3 }}>
|
|
{terms}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
},
|
|
);
|
|
|
|
PackageDetail.displayName = 'PackageDetail';
|
|
export default PackageDetail;
|