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:
228
src/components/organisms/PackageDetail/PackageDetail.stories.tsx
Normal file
228
src/components/organisms/PackageDetail/PackageDetail.stories.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { PackageDetail } from './PackageDetail';
|
||||
import { ServiceOption } from '../../molecules/ServiceOption';
|
||||
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
|
||||
import { Chip } from '../../atoms/Chip';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Button } from '../../atoms/Button';
|
||||
import { Navigation } from '../Navigation';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
const DEMO_IMAGE = 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=300&fit=crop';
|
||||
|
||||
const essentials = [
|
||||
{ name: 'Accommodation', price: 1500, info: 'Refrigerated holding of the deceased prior to the funeral service.' },
|
||||
{ name: 'Death Registration Certificate', price: 1500, info: 'Lodgement of death registration with NSW Registry of Births, Deaths & Marriages.' },
|
||||
{ name: 'Doctor Fee for Cremation', price: 1500, info: 'Statutory medical referee fee required for all cremations in NSW.' },
|
||||
{ name: 'NSW Government Levy — Cremation', price: 1500, info: 'NSW Government cremation levy as set by the Department of Health.' },
|
||||
{ name: 'Professional Mortuary Care', price: 1500, info: 'Preparation and care of the deceased.' },
|
||||
{ name: 'Professional Service Fee', price: 1500, info: 'Coordination of all funeral arrangements and services.' },
|
||||
{ name: 'Allowance for Coffin', price: 1500, isAllowance: true, info: 'Allowance amount — upgrade options available during arrangement.' },
|
||||
{ name: 'Allowance for Crematorium', price: 1500, isAllowance: true, info: 'Allowance for crematorium fees — varies by location.' },
|
||||
{ name: 'Allowance for Hearse', price: 1500, isAllowance: true, info: 'Allowance for hearse transfer — distance surcharges may apply.' },
|
||||
];
|
||||
|
||||
const complimentary = [
|
||||
{ name: 'Dressing Fee', info: 'Dressing and preparation of the deceased — included at no charge.' },
|
||||
{ name: 'Viewing Fee', info: 'One private family viewing — included at no charge.' },
|
||||
];
|
||||
|
||||
const extras = [
|
||||
{ name: 'Allowance for Flowers', price: 1500, isAllowance: true, info: 'Seasonal floral arrangements for the service.' },
|
||||
{ name: 'Allowance for Master of Ceremonies', price: 1500, isAllowance: true, info: 'Professional celebrant or MC for the funeral service.' },
|
||||
{ name: 'After Business Hours Service Surcharge', price: 1500, info: 'Additional fee for services held outside standard business hours.' },
|
||||
{ name: 'After Hours Prayers', price: 1500, info: 'Evening prayer service at the funeral home.' },
|
||||
{ name: 'Coffin Bearing by Funeral Directors', price: 1500, info: 'Professional pallbearing by funeral directors.' },
|
||||
{ name: 'Digital Recording', price: 1500, info: 'Professional video recording of the funeral service.' },
|
||||
];
|
||||
|
||||
const termsText = '* This package includes a funeral service at a chapel or a church with a funeral procession following to the crematorium. It includes many of the most commonly selected funeral options preselected for you. Many people choose this package for the extended funeral rituals — of course, you can tailor the funeral service to meet your needs and budget as you go through the selections.';
|
||||
|
||||
const packages = [
|
||||
{ id: 'everyday', name: 'Everyday Funeral Package', price: 900, description: 'Our most popular package with all essential services included. Suitable for a traditional chapel or church service.' },
|
||||
{ id: 'deluxe', name: 'Deluxe Funeral Package', price: 1200, description: 'An enhanced package with premium coffin and additional floral arrangements.' },
|
||||
{ id: 'essential', name: 'Essential Funeral Package', price: 600, description: 'A simple, dignified service covering all necessary arrangements.' },
|
||||
{ id: 'catholic', name: 'Catholic Service', price: 950, description: 'A service tailored for Catholic traditions including prayers and church ceremony.' },
|
||||
];
|
||||
|
||||
const funeralTypes = ['All', 'Cremation', 'Burial', 'Memorial', 'Catholic', 'Direct Cremation'];
|
||||
|
||||
const FALogoNav = () => (
|
||||
<Box component="img" src="/brandlogo/logo-full.svg" alt="Funeral Arranger" sx={{ height: 28 }} />
|
||||
);
|
||||
|
||||
const meta: Meta<typeof PackageDetail> = {
|
||||
title: 'Organisms/PackageDetail',
|
||||
component: PackageDetail,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 600, width: '100%' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PackageDetail>;
|
||||
|
||||
// --- Default -----------------------------------------------------------------
|
||||
|
||||
/** Full package detail panel with all sections */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
name: 'Everyday Funeral Package',
|
||||
price: 900,
|
||||
sections: [
|
||||
{ heading: 'Essentials', items: essentials },
|
||||
{ heading: 'Complimentary Items', items: complimentary },
|
||||
{ heading: 'Extras', items: extras },
|
||||
],
|
||||
total: 2700,
|
||||
terms: termsText,
|
||||
},
|
||||
};
|
||||
|
||||
// --- Without Extras ----------------------------------------------------------
|
||||
|
||||
/** Simpler package with essentials only */
|
||||
export const WithoutExtras: Story = {
|
||||
args: {
|
||||
name: 'Essential Funeral Package',
|
||||
price: 600,
|
||||
sections: [
|
||||
{ heading: 'Essentials', items: essentials.slice(0, 6) },
|
||||
{ heading: 'Complimentary Items', items: complimentary },
|
||||
],
|
||||
total: 9000,
|
||||
terms: termsText,
|
||||
},
|
||||
};
|
||||
|
||||
// --- Package Select Page Layout ----------------------------------------------
|
||||
|
||||
/** Full page layout — left: package list, right: detail panel */
|
||||
export const PackageSelectPage: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 'none', width: '100%' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
render: () => {
|
||||
const [selectedPkg, setSelectedPkg] = useState('everyday');
|
||||
const [activeFilter, setActiveFilter] = useState('Cremation');
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Navigation
|
||||
logo={<FALogoNav />}
|
||||
items={[
|
||||
{ label: 'Provider Portal', href: '/provider-portal' },
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
|
||||
gap: { xs: 3, md: 4 },
|
||||
maxWidth: 'lg',
|
||||
mx: 'auto',
|
||||
px: { xs: 2, md: 4 },
|
||||
py: { xs: 2, md: 4 },
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
{/* Left column: back, heading, provider, filter, packages */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="text"
|
||||
color="secondary"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ mb: 2, ml: -1 }}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Typography variant="h2" sx={{ mb: 3 }}>
|
||||
Select a package
|
||||
</Typography>
|
||||
|
||||
<ProviderCardCompact
|
||||
name="H.Parsons"
|
||||
location="Wentworth"
|
||||
imageUrl={DEMO_IMAGE}
|
||||
rating={4.5}
|
||||
reviewCount={11}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{/* Funeral type filter */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{funeralTypes.map((type) => (
|
||||
<Chip
|
||||
key={type}
|
||||
label={type}
|
||||
variant={activeFilter === type ? 'filled' : 'outlined'}
|
||||
selected={activeFilter === type}
|
||||
onClick={() => setActiveFilter(type)}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||
Packages
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-label="Available packages"
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
{packages.map((pkg) => (
|
||||
<ServiceOption
|
||||
key={pkg.id}
|
||||
name={pkg.name}
|
||||
price={pkg.price}
|
||||
description={pkg.description}
|
||||
selected={selectedPkg === pkg.id}
|
||||
onClick={() => setSelectedPkg(pkg.id)}
|
||||
maxDescriptionLines={2}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right column: package detail */}
|
||||
<Box sx={{ position: { md: 'sticky' }, top: { md: 96 } }}>
|
||||
<PackageDetail
|
||||
name={packages.find((p) => p.id === selectedPkg)?.name ?? ''}
|
||||
price={packages.find((p) => p.id === selectedPkg)?.price ?? 0}
|
||||
sections={[
|
||||
{ heading: 'Essentials', items: essentials },
|
||||
{ heading: 'Complimentary Items', items: complimentary },
|
||||
{ heading: 'Extras', items: extras },
|
||||
]}
|
||||
total={2700}
|
||||
terms={termsText}
|
||||
onArrange={() => alert(`Making arrangement for: ${selectedPkg}`)}
|
||||
onCompare={() => alert(`Added ${selectedPkg} to compare`)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
};
|
||||
196
src/components/organisms/PackageDetail/PackageDetail.tsx
Normal file
196
src/components/organisms/PackageDetail/PackageDetail.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
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;
|
||||
}
|
||||
|
||||
/** A section of items within a package (e.g. "Essentials", "Extras") */
|
||||
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;
|
||||
/** Grouped sections of package contents */
|
||||
sections: PackageSection[];
|
||||
/** Package total — usually the sum of priced essentials */
|
||||
total?: number;
|
||||
/** 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;
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Package detail panel for the FA design system.
|
||||
*
|
||||
* Displays the full contents of a funeral package — name, price, CTA buttons,
|
||||
* grouped line items (Essentials, Complimentary, Extras), total, and T&Cs.
|
||||
*
|
||||
* Used as the right-side panel on the Package Select page. The contents and
|
||||
* T&Cs are provider-authored and must be displayed in full.
|
||||
*
|
||||
* "Make Arrangement" is the FA term for selecting/committing to a package.
|
||||
*
|
||||
* Composes Typography + Button + Divider + LineItem.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <PackageDetail
|
||||
* name="Everyday Funeral Package"
|
||||
* price={2700}
|
||||
* sections={[
|
||||
* { heading: 'Essentials', items: [...] },
|
||||
* { heading: 'Complimentary Items', items: [...] },
|
||||
* ]}
|
||||
* total={2700}
|
||||
* terms="* This package includes a funeral service at a chapel..."
|
||||
* onArrange={() => startArrangement(pkg.id)}
|
||||
* onCompare={() => addToCompare(pkg.id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const PackageDetail = React.forwardRef<HTMLDivElement, PackageDetailProps>(
|
||||
(
|
||||
{
|
||||
name,
|
||||
price,
|
||||
sections,
|
||||
total,
|
||||
terms,
|
||||
onArrange,
|
||||
onCompare,
|
||||
arrangeDisabled = 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]),
|
||||
]}
|
||||
>
|
||||
{/* Main content area */}
|
||||
<Box sx={{ px: { xs: 2, sm: 3 }, py: 2 }}>
|
||||
{/* Header: name + price */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="h4" component="h2">
|
||||
{name}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
${price.toLocaleString('en-AU')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
disabled={arrangeDisabled}
|
||||
onClick={onArrange}
|
||||
>
|
||||
Make Arrangement
|
||||
</Button>
|
||||
{onCompare && (
|
||||
<Button
|
||||
variant="soft"
|
||||
color="secondary"
|
||||
size="large"
|
||||
onClick={onCompare}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Compare
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
{/* Sections */}
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<Box key={section.heading} sx={{ mb: sectionIdx < sections.length - 1 ? 2 : 0 }}>
|
||||
<Typography variant="label" sx={{ display: 'block', mb: 1.5 }}>
|
||||
{section.heading}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{section.items.map((item) => (
|
||||
<LineItem
|
||||
key={item.name}
|
||||
name={item.name}
|
||||
info={item.info}
|
||||
price={item.price}
|
||||
isAllowance={item.isAllowance}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Total */}
|
||||
{total != null && (
|
||||
<LineItem name="Total" price={total} variant="total" />
|
||||
)}
|
||||
</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.5 }}>
|
||||
{terms}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PackageDetail.displayName = 'PackageDetail';
|
||||
export default PackageDetail;
|
||||
1
src/components/organisms/PackageDetail/index.ts
Normal file
1
src/components/organisms/PackageDetail/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PackageDetail, type PackageDetailProps, type PackageSection, type PackageLineItem } from './PackageDetail';
|
||||
Reference in New Issue
Block a user