Files
Parsons/src/components/organisms/PackageDetail/PackageDetail.tsx
Richie 52fd0f199a Add package comparison feature: CompareBar, ComparisonTable, ComparisonPage
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>
2026-04-07 01:17:34 +10:00

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;