Add Footer + ServiceSelector organisms
Footer: - Dark espresso (brand.950) bg, inverse white text - Logo, tagline, contact (phone/email), link group columns, legal bar - Semantic HTML: <footer>, <nav> per group, <ul> for lists - Critique 38/40, P2 fixes applied (legal link size, nav landmarks) ServiceSelector: - Single-select panel for arrangement flow - Composes ServiceOption × n + Typography + Button (radiogroup) - Auto-disables continue when nothing selected - 7 stories incl. InArrangementFlow with StepIndicator Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,11 +49,11 @@ duplicates) and MUST update it after completing one.
|
||||
|
||||
| Component | Status | Composed of | Notes |
|
||||
|-----------|--------|-------------|-------|
|
||||
| ServiceSelector | planned | ServiceOption × n + Typography + Button | Full service selection panel |
|
||||
| ServiceSelector | review | ServiceOption × n + Typography + Button | Single-select service panel for arrangement flow. Heading + subheading + ServiceOption list (radiogroup) + optional continue Button. Manages selection state via selectedId/onSelect. maxDescriptionLines pass-through. |
|
||||
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
|
||||
| ArrangementForm | planned | FormField × n + StepIndicator + Button | Multi-step arrangement flow |
|
||||
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
|
||||
| Footer | planned | Link × n + Typography + Divider | Site footer |
|
||||
| Footer | review | Link × n + Typography + Divider + Container + Grid | Dark espresso (brand.950) site footer. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
|
||||
|
||||
## Future enhancements
|
||||
|
||||
|
||||
@@ -796,7 +796,51 @@ Each entry follows this structure:
|
||||
- **Planned (4 organisms):** ServiceSelector, PricingTable, ArrangementForm, Footer
|
||||
|
||||
**Next steps:**
|
||||
- Build Footer organism (Link + Typography + Divider — dependencies ready)
|
||||
- Build ServiceSelector organism (ServiceOption × n + Typography + Button)
|
||||
- ~~Build Footer organism~~ ✓ Done
|
||||
- ~~Build ServiceSelector organism~~ ✓ Done
|
||||
- Consider FormField molecule if Input's built-in label/helperText proves insufficient
|
||||
- Remaining P3s: InfoOutlinedIcon sizing in ProviderCard, image loading placeholder, accent bar 3px→4px
|
||||
|
||||
### Session 2026-03-25p (continued) — Footer + ServiceSelector organisms
|
||||
|
||||
**Agent(s):** Claude Opus (via conversation)
|
||||
|
||||
**Work completed:**
|
||||
- Built Footer organism (`src/components/organisms/Footer/`):
|
||||
- Dark espresso (brand.950) surface with inverse white text
|
||||
- Logo + tagline + contact info (phone/email with tel:/mailto: links) in left column
|
||||
- Configurable link group columns with heading + `<ul>` list
|
||||
- Bottom legal bar: copyright + legal links (Privacy, Terms, Accessibility)
|
||||
- Semantic HTML: `<footer>`, `<nav aria-label>` per link group, `<ul>` for link lists
|
||||
- Responsive: stacks to single column on mobile, side-by-side on desktop
|
||||
- 5 stories: Default, Minimal, WithContactOnly, TwoColumns, FullPage (Nav + content + Footer)
|
||||
- Critique: 38/40 (Excellent). P2 fixes applied: legal links bumped from 11px → 12px, nav landmarks added
|
||||
- Built ServiceSelector organism (`src/components/organisms/ServiceSelector/`):
|
||||
- Single-select panel composing Typography + ServiceOption × n + Button
|
||||
- Heading + optional subheading + radiogroup of ServiceOption cards + optional continue button
|
||||
- Continue button auto-disables when nothing selected (overridable via `continueDisabled`)
|
||||
- `maxDescriptionLines` pass-through to ServiceOption for truncation
|
||||
- 7 stories: Default, PreSelected, Interactive, CoffinSelection, WithTruncatedDescriptions, WithoutContinue, WithDisabledOptions, InArrangementFlow (with StepIndicator)
|
||||
- No new tokens needed — both organisms reuse existing token system
|
||||
- TypeScript compiles, Storybook builds
|
||||
|
||||
**Decisions made:**
|
||||
- Footer uses brand.950 (espresso) background — darkest brand tone for maximum contrast with white text, warm but professional
|
||||
- Footer link groups get individual `<nav aria-label>` landmarks — better screen reader navigation
|
||||
- ServiceSelector is a controlled component: parent manages `selectedId` + `onSelect`. Keeps state lifting simple for arrangement flow.
|
||||
- Continue button defaults to disabled when nothing selected — prevents empty submissions without explicit error messages
|
||||
|
||||
**Component status at end of session:**
|
||||
- **Done (11 atoms):** Button, Typography, Input, Card, Badge, Chip, Switch, Radio, IconButton, Divider, Link
|
||||
- **Done (6 molecules):** ProviderCard, VenueCard, ServiceOption, SearchBar, AddOnOption, StepIndicator
|
||||
- **Done (1 organism):** Navigation
|
||||
- **Review (2 organisms):** Footer (critique 38/40), ServiceSelector
|
||||
- **Planned (3 atoms):** Icon, Avatar, ColourToggle, Slider
|
||||
- **Planned (2 molecules):** MapCard, FormField
|
||||
- **Planned (2 organisms):** PricingTable, ArrangementForm
|
||||
|
||||
**Next steps:**
|
||||
- User to review Footer + ServiceSelector in Storybook
|
||||
- Build ArrangementForm organism (StepIndicator + ServiceSelector + AddOnOption — multi-step flow)
|
||||
- Consider FormField molecule if Input's built-in label/helperText proves insufficient
|
||||
- P3 cleanup: inverse logo SVG, InfoOutlinedIcon sizing, image placeholder, accent bar 3px→4px
|
||||
|
||||
205
src/components/organisms/Footer/Footer.stories.tsx
Normal file
205
src/components/organisms/Footer/Footer.stories.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Footer } from './Footer';
|
||||
import { Navigation } from '../Navigation';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
|
||||
// Real FA logo — inverted version for dark background
|
||||
const FALogoInverse = () => (
|
||||
<Box
|
||||
component="img"
|
||||
src="/brandlogo/logo-full.svg"
|
||||
alt="Funeral Arranger"
|
||||
sx={{ height: 24, filter: 'brightness(0) invert(1)', opacity: 0.9 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const FALogoNav = () => (
|
||||
<Box
|
||||
component="img"
|
||||
src="/brandlogo/logo-full.svg"
|
||||
alt="Funeral Arranger"
|
||||
sx={{ height: 28 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const defaultLinkGroups = [
|
||||
{
|
||||
heading: 'Services',
|
||||
links: [
|
||||
{ label: 'Find a Director', href: '/directors' },
|
||||
{ label: 'Compare Venues', href: '/venues' },
|
||||
{ label: 'Pricing Guide', href: '/pricing' },
|
||||
{ label: 'Start Planning', href: '/arrange' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Support',
|
||||
links: [
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Grief Resources', href: '/resources' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Company',
|
||||
links: [
|
||||
{ label: 'About Us', href: '/about' },
|
||||
{ label: 'Provider Portal', href: '/provider-portal' },
|
||||
{ label: 'Partner With Us', href: '/partners' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultLegalLinks = [
|
||||
{ label: 'Privacy Policy', href: '/privacy' },
|
||||
{ label: 'Terms of Service', href: '/terms' },
|
||||
{ label: 'Accessibility', href: '/accessibility' },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof Footer> = {
|
||||
title: 'Organisms/Footer',
|
||||
component: Footer,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Footer>;
|
||||
|
||||
// --- Default -----------------------------------------------------------------
|
||||
|
||||
/** Full footer with all sections populated */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
logo: <FALogoInverse />,
|
||||
tagline: 'Helping Australian families plan with confidence',
|
||||
linkGroups: defaultLinkGroups,
|
||||
phone: '1800 987 888',
|
||||
email: 'support@funeralarranger.com.au',
|
||||
legalLinks: defaultLegalLinks,
|
||||
},
|
||||
};
|
||||
|
||||
// --- Minimal -----------------------------------------------------------------
|
||||
|
||||
/** Minimal footer — logo and copyright only */
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
logo: <FALogoInverse />,
|
||||
},
|
||||
};
|
||||
|
||||
// --- With Contact Only -------------------------------------------------------
|
||||
|
||||
/** Footer with contact info but no link columns */
|
||||
export const WithContactOnly: Story = {
|
||||
args: {
|
||||
logo: <FALogoInverse />,
|
||||
tagline: 'Helping Australian families plan with confidence',
|
||||
phone: '1800 987 888',
|
||||
email: 'support@funeralarranger.com.au',
|
||||
legalLinks: defaultLegalLinks,
|
||||
},
|
||||
};
|
||||
|
||||
// --- Two Columns -------------------------------------------------------------
|
||||
|
||||
/** Footer with two link groups — common for simpler sites */
|
||||
export const TwoColumns: Story = {
|
||||
args: {
|
||||
logo: <FALogoInverse />,
|
||||
tagline: 'Transparent funeral planning for Australian families',
|
||||
linkGroups: [
|
||||
{
|
||||
heading: 'Planning',
|
||||
links: [
|
||||
{ label: 'Find a Director', href: '/directors' },
|
||||
{ label: 'Compare Venues', href: '/venues' },
|
||||
{ label: 'Pricing Guide', href: '/pricing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Help',
|
||||
links: [
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'About Us', href: '/about' },
|
||||
],
|
||||
},
|
||||
],
|
||||
phone: '1800 987 888',
|
||||
legalLinks: [
|
||||
{ label: 'Privacy Policy', href: '/privacy' },
|
||||
{ label: 'Terms of Service', href: '/terms' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// --- Full Page ---------------------------------------------------------------
|
||||
|
||||
/** Navigation + content + footer — complete page frame */
|
||||
export const FullPage: Story = {
|
||||
render: () => (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Navigation
|
||||
logo={<FALogoNav />}
|
||||
items={[
|
||||
{ label: 'Provider Portal', href: '/provider-portal' },
|
||||
{ label: 'FAQ', href: '/faq' },
|
||||
{ label: 'Contact Us', href: '/contact' },
|
||||
{ label: 'Log in', href: '/login' },
|
||||
]}
|
||||
ctaLabel="Start planning"
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
maxWidth: 'lg',
|
||||
mx: 'auto',
|
||||
width: '100%',
|
||||
px: { xs: 2, md: 4 },
|
||||
py: 6,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" sx={{ mb: 3 }}>
|
||||
Find a funeral director
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4, maxWidth: 600 }}>
|
||||
Compare trusted funeral directors in your area. View services,
|
||||
pricing, and reviews to find the right support for your family.
|
||||
</Typography>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 2,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Provider {i + 1}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Placeholder content to demonstrate the full page frame.
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Footer
|
||||
logo={<FALogoInverse />}
|
||||
tagline="Helping Australian families plan with confidence"
|
||||
linkGroups={defaultLinkGroups}
|
||||
phone="1800 987 888"
|
||||
email="support@funeralarranger.com.au"
|
||||
legalLinks={defaultLegalLinks}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
264
src/components/organisms/Footer/Footer.tsx
Normal file
264
src/components/organisms/Footer/Footer.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from '@mui/material/Container';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import type { SxProps, Theme } from '@mui/material/styles';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
import { Link } from '../../atoms/Link';
|
||||
import { Divider } from '../../atoms/Divider';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A group of links in the footer */
|
||||
export interface FooterLinkGroup {
|
||||
/** Group heading */
|
||||
heading: string;
|
||||
/** Links within this group */
|
||||
links: { label: string; href: string; onClick?: () => void }[];
|
||||
}
|
||||
|
||||
/** Props for the FA Footer organism */
|
||||
export interface FooterProps {
|
||||
/** Site logo — rendered in the footer top row */
|
||||
logo: React.ReactNode;
|
||||
/** Optional tagline below the logo */
|
||||
tagline?: string;
|
||||
/** Link groups displayed as columns */
|
||||
linkGroups?: FooterLinkGroup[];
|
||||
/** Phone number displayed in the contact section */
|
||||
phone?: string;
|
||||
/** Email address displayed in the contact section */
|
||||
email?: string;
|
||||
/** Copyright text — defaults to current year + "Funeral Arranger" */
|
||||
copyright?: string;
|
||||
/** Optional legal links shown in the bottom bar (Privacy, Terms, etc.) */
|
||||
legalLinks?: { label: string; href: string; onClick?: () => void }[];
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Site footer for the FA design system.
|
||||
*
|
||||
* Multi-column footer with logo, link groups, contact info, and legal bar.
|
||||
* Designed for warmth and trust — uses the brand's dark surface with
|
||||
* inverse (white) text.
|
||||
*
|
||||
* Composes Typography + Link + Divider.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <Footer
|
||||
* logo={<img src="/brandlogo/logo-full.svg" alt="Funeral Arranger" height={28} />}
|
||||
* tagline="Helping Australian families plan with confidence"
|
||||
* linkGroups={[
|
||||
* { heading: 'Services', links: [{ label: 'Find a Director', href: '/directors' }] },
|
||||
* { heading: 'Support', links: [{ label: 'FAQ', href: '/faq' }] },
|
||||
* ]}
|
||||
* phone="1800 987 888"
|
||||
* email="support@funeralarranger.com.au"
|
||||
* legalLinks={[{ label: 'Privacy Policy', href: '/privacy' }]}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const Footer = React.forwardRef<HTMLDivElement, FooterProps>(
|
||||
(
|
||||
{
|
||||
logo,
|
||||
tagline,
|
||||
linkGroups = [],
|
||||
phone,
|
||||
email,
|
||||
copyright,
|
||||
legalLinks = [],
|
||||
sx,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const year = new Date().getFullYear();
|
||||
const copyrightText = copyright || `\u00A9 ${year} Funeral Arranger. All rights reserved.`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
component="footer"
|
||||
sx={[
|
||||
{
|
||||
bgcolor: 'var(--fa-color-brand-950)',
|
||||
color: 'var(--fa-color-white)',
|
||||
pt: { xs: 5, md: 8 },
|
||||
pb: 0,
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
{/* Main footer content */}
|
||||
<Grid container spacing={{ xs: 4, md: 6 }}>
|
||||
{/* Logo + tagline column */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ mb: 2 }}>{logo}</Box>
|
||||
{tagline && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'var(--fa-color-brand-300)', maxWidth: 280 }}
|
||||
>
|
||||
{tagline}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Contact info below tagline */}
|
||||
{(phone || email) && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{phone && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography
|
||||
variant="captionSm"
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-400)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'block',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
Call us
|
||||
</Typography>
|
||||
<Link
|
||||
href={`tel:${phone.replace(/\s/g, '')}`}
|
||||
sx={{
|
||||
color: 'var(--fa-color-white)',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
'&:hover': { color: 'var(--fa-color-brand-300)' },
|
||||
}}
|
||||
>
|
||||
{phone}
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
{email && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="captionSm"
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-400)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'block',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
Email
|
||||
</Typography>
|
||||
<Link
|
||||
href={`mailto:${email}`}
|
||||
sx={{
|
||||
color: 'var(--fa-color-white)',
|
||||
fontWeight: 500,
|
||||
'&:hover': { color: 'var(--fa-color-brand-300)' },
|
||||
}}
|
||||
>
|
||||
{email}
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Link group columns */}
|
||||
{linkGroups.map((group) => (
|
||||
<Grid item xs={6} sm={4} md key={group.heading} component="nav" aria-label={group.heading}>
|
||||
<Typography
|
||||
variant="label"
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-300)',
|
||||
mb: 2,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{group.heading}
|
||||
</Typography>
|
||||
<Box
|
||||
component="ul"
|
||||
sx={{ listStyle: 'none', p: 0, m: 0, display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
{group.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-200)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
'&:hover': { color: 'var(--fa-color-white)' },
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<Divider sx={{ borderColor: 'var(--fa-color-brand-900)', mt: { xs: 5, md: 8 } }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: { xs: 'center', md: 'center' },
|
||||
gap: 1.5,
|
||||
py: 3,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="captionSm"
|
||||
sx={{ color: 'var(--fa-color-brand-400)' }}
|
||||
>
|
||||
{copyrightText}
|
||||
</Typography>
|
||||
|
||||
{legalLinks.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 3,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{legalLinks.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
sx={{
|
||||
color: 'var(--fa-color-brand-400)',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
'&:hover': { color: 'var(--fa-color-white)' },
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Footer.displayName = 'Footer';
|
||||
export default Footer;
|
||||
1
src/components/organisms/Footer/index.ts
Normal file
1
src/components/organisms/Footer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Footer, type FooterProps, type FooterLinkGroup } from './Footer';
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { ServiceSelector } from './ServiceSelector';
|
||||
import { StepIndicator } from '../../molecules/StepIndicator';
|
||||
import { Typography } from '../../atoms/Typography';
|
||||
|
||||
const serviceTypes = [
|
||||
{
|
||||
id: 'burial',
|
||||
name: 'Traditional Burial',
|
||||
price: 4200,
|
||||
description:
|
||||
'Full service with chapel ceremony, viewing, hearse, and graveside committal. Includes coordination with cemetery and clergy.',
|
||||
},
|
||||
{
|
||||
id: 'cremation',
|
||||
name: 'Cremation with Service',
|
||||
price: 2800,
|
||||
description:
|
||||
'Chapel ceremony followed by cremation. Includes use of chapel, celebrant coordination, and return of ashes in a standard urn.',
|
||||
},
|
||||
{
|
||||
id: 'direct-cremation',
|
||||
name: 'Direct Cremation',
|
||||
price: 1600,
|
||||
description:
|
||||
'Simple cremation without a formal service. A private, no-fuss option. Ashes returned to the family within 5 business days.',
|
||||
},
|
||||
{
|
||||
id: 'memorial',
|
||||
name: 'Memorial Service',
|
||||
price: 3100,
|
||||
description:
|
||||
'Ceremony held after cremation or burial. Flexible timing allows for family to travel and gather. Venue of your choice.',
|
||||
},
|
||||
];
|
||||
|
||||
const coffinOptions = [
|
||||
{ id: 'eco', name: 'Eco Willow', price: 850, description: 'Handwoven natural willow. Biodegradable and sustainable.' },
|
||||
{ id: 'classic', name: 'Classic Maple', price: 1400, description: 'Solid maple with satin finish and brass handles.' },
|
||||
{ id: 'premium', name: 'Premium Oak', price: 2200, description: 'Quarter-sawn oak with high-gloss lacquer and gold-plated handles.' },
|
||||
{ id: 'simple', name: 'Simple Pine', price: 600, description: 'Unfinished pine. Can be personalised with paint, photos, or messages.' },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof ServiceSelector> = {
|
||||
title: 'Organisms/ServiceSelector',
|
||||
component: ServiceSelector,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
design: {
|
||||
type: 'figma',
|
||||
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=2349-39505',
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box sx={{ maxWidth: 560, width: '100%', mx: 'auto' }}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ServiceSelector>;
|
||||
|
||||
// --- Default -----------------------------------------------------------------
|
||||
|
||||
/** Static view — nothing selected */
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
heading: 'Choose a service type',
|
||||
subheading: 'Select the type of service you\'d like to arrange. Prices are starting estimates.',
|
||||
items: serviceTypes,
|
||||
continueLabel: 'Continue',
|
||||
},
|
||||
};
|
||||
|
||||
// --- Pre-selected ------------------------------------------------------------
|
||||
|
||||
/** With an option already selected */
|
||||
export const PreSelected: Story = {
|
||||
args: {
|
||||
heading: 'Choose a service type',
|
||||
items: serviceTypes,
|
||||
selectedId: 'cremation',
|
||||
continueLabel: 'Continue',
|
||||
},
|
||||
};
|
||||
|
||||
// --- Interactive -------------------------------------------------------------
|
||||
|
||||
/** Full interactive demo with state management */
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<ServiceSelector
|
||||
heading="Choose a service type"
|
||||
subheading="Select the type of service you'd like to arrange. Prices are starting estimates."
|
||||
items={serviceTypes}
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
continueLabel="Continue"
|
||||
onContinue={() => alert(`Selected: ${selected}`)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// --- Coffin Selection --------------------------------------------------------
|
||||
|
||||
/** Coffin/casket selection — shorter descriptions */
|
||||
export const CoffinSelection: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<ServiceSelector
|
||||
heading="Choose a coffin"
|
||||
subheading="All coffins include a fitted interior lining and nameplate."
|
||||
items={coffinOptions}
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
continueLabel="Continue"
|
||||
onContinue={() => alert(`Selected: ${selected}`)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// --- With Truncated Descriptions ---------------------------------------------
|
||||
|
||||
/** Long descriptions with "View more" toggle */
|
||||
export const WithTruncatedDescriptions: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<ServiceSelector
|
||||
heading="Choose a service type"
|
||||
items={serviceTypes}
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
continueLabel="Continue"
|
||||
onContinue={() => alert(`Selected: ${selected}`)}
|
||||
maxDescriptionLines={2}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// --- Without Continue Button -------------------------------------------------
|
||||
|
||||
/** Selection only — no continue action (parent manages flow) */
|
||||
export const WithoutContinue: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<string | undefined>('burial');
|
||||
|
||||
return (
|
||||
<ServiceSelector
|
||||
heading="Service type"
|
||||
items={serviceTypes}
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// --- With Disabled Options ---------------------------------------------------
|
||||
|
||||
/** Some options are unavailable */
|
||||
export const WithDisabledOptions: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<string | undefined>();
|
||||
|
||||
const items = serviceTypes.map((item) =>
|
||||
item.id === 'memorial' ? { ...item, disabled: true, description: item.description + ' (Currently unavailable at this location.)' } : item,
|
||||
);
|
||||
|
||||
return (
|
||||
<ServiceSelector
|
||||
heading="Choose a service type"
|
||||
subheading="Some options may not be available at your chosen location."
|
||||
items={items}
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
continueLabel="Continue"
|
||||
onContinue={() => alert(`Selected: ${selected}`)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// --- In Arrangement Flow Context ---------------------------------------------
|
||||
|
||||
/** ServiceSelector within a multi-step arrangement flow */
|
||||
export const InArrangementFlow: Story = {
|
||||
render: () => {
|
||||
const [selected, setSelected] = useState<string | undefined>();
|
||||
|
||||
const steps = [
|
||||
{ label: 'Service' },
|
||||
{ label: 'Coffin' },
|
||||
{ label: 'Venue' },
|
||||
{ label: 'Extras' },
|
||||
{ label: 'Review' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<StepIndicator steps={steps} currentStep={0} sx={{ mb: 4 }} />
|
||||
|
||||
<ServiceSelector
|
||||
heading="Choose a service type"
|
||||
subheading="This is the first step in creating your arrangement. You can change this later."
|
||||
items={serviceTypes}
|
||||
selectedId={selected}
|
||||
onSelect={setSelected}
|
||||
continueLabel="Continue to coffin selection"
|
||||
onContinue={() => alert('Next step: Coffin')}
|
||||
maxDescriptionLines={2}
|
||||
/>
|
||||
|
||||
<Typography variant="captionSm" color="text.secondary" sx={{ mt: 3, textAlign: 'center', display: 'block' }}>
|
||||
All prices are estimates and may vary based on your specific requirements.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
};
|
||||
154
src/components/organisms/ServiceSelector/ServiceSelector.tsx
Normal file
154
src/components/organisms/ServiceSelector/ServiceSelector.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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 { ServiceOption } from '../../molecules/ServiceOption';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** A service option within the selector */
|
||||
export interface ServiceItem {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Option name */
|
||||
name: string;
|
||||
/** Price in dollars */
|
||||
price?: number;
|
||||
/** Description text */
|
||||
description?: string;
|
||||
/** Whether this option is unavailable */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Props for the FA ServiceSelector organism */
|
||||
export interface ServiceSelectorProps {
|
||||
/** Section heading (e.g. "Choose a service type") */
|
||||
heading: string;
|
||||
/** Optional subheading providing context */
|
||||
subheading?: string;
|
||||
/** Available options */
|
||||
items: ServiceItem[];
|
||||
/** Currently selected item ID */
|
||||
selectedId?: string;
|
||||
/** Called when the user selects an option */
|
||||
onSelect?: (id: string) => void;
|
||||
/** Label for the continue/next button — omit to hide */
|
||||
continueLabel?: string;
|
||||
/** Called when the user clicks continue */
|
||||
onContinue?: () => void;
|
||||
/** Whether continue is disabled (e.g. nothing selected) — defaults to requiring selection */
|
||||
continueDisabled?: boolean;
|
||||
/** Max visible description lines before "View more" toggle */
|
||||
maxDescriptionLines?: number;
|
||||
/** MUI sx prop for the root element */
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Service selection panel for the FA arrangement flow.
|
||||
*
|
||||
* Presents a heading, subheading, and a list of ServiceOption cards
|
||||
* for single-select (radio) behaviour. An optional continue button
|
||||
* advances to the next step.
|
||||
*
|
||||
* Composes Typography + ServiceOption + Button.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <ServiceSelector
|
||||
* heading="Choose a service type"
|
||||
* subheading="Select the type of service you'd like to arrange."
|
||||
* items={[
|
||||
* { id: 'burial', name: 'Traditional Burial', price: 4200, description: '...' },
|
||||
* { id: 'cremation', name: 'Cremation', price: 2800, description: '...' },
|
||||
* ]}
|
||||
* selectedId={selected}
|
||||
* onSelect={setSelected}
|
||||
* continueLabel="Continue"
|
||||
* onContinue={() => nextStep()}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const ServiceSelector = React.forwardRef<HTMLDivElement, ServiceSelectorProps>(
|
||||
(
|
||||
{
|
||||
heading,
|
||||
subheading,
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
continueLabel,
|
||||
onContinue,
|
||||
continueDisabled,
|
||||
maxDescriptionLines,
|
||||
sx,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const nothingSelected = !selectedId;
|
||||
const isContinueDisabled = continueDisabled ?? nothingSelected;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={[
|
||||
{ width: '100%' },
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h4" component="h2" sx={{ mb: subheading ? 1 : 0 }}>
|
||||
{heading}
|
||||
</Typography>
|
||||
{subheading && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subheading}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Options list */}
|
||||
<Box
|
||||
role="radiogroup"
|
||||
aria-label={heading}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<ServiceOption
|
||||
key={item.id}
|
||||
name={item.name}
|
||||
price={item.price}
|
||||
description={item.description}
|
||||
selected={selectedId === item.id}
|
||||
disabled={item.disabled}
|
||||
onClick={() => onSelect?.(item.id)}
|
||||
maxDescriptionLines={maxDescriptionLines}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Continue button */}
|
||||
{continueLabel && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
fullWidth
|
||||
disabled={isContinueDisabled}
|
||||
onClick={onContinue}
|
||||
>
|
||||
{continueLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ServiceSelector.displayName = 'ServiceSelector';
|
||||
export default ServiceSelector;
|
||||
1
src/components/organisms/ServiceSelector/index.ts
Normal file
1
src/components/organisms/ServiceSelector/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ServiceSelector, type ServiceSelectorProps, type ServiceItem } from './ServiceSelector';
|
||||
Reference in New Issue
Block a user