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 |
|
| 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 |
|
| PricingTable | planned | PriceCard × n + Typography | Comparative pricing display |
|
||||||
| ArrangementForm | planned | FormField × n + StepIndicator + Button | Multi-step arrangement flow |
|
| 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). |
|
| 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
|
## Future enhancements
|
||||||
|
|
||||||
|
|||||||
@@ -796,7 +796,51 @@ Each entry follows this structure:
|
|||||||
- **Planned (4 organisms):** ServiceSelector, PricingTable, ArrangementForm, Footer
|
- **Planned (4 organisms):** ServiceSelector, PricingTable, ArrangementForm, Footer
|
||||||
|
|
||||||
**Next steps:**
|
**Next steps:**
|
||||||
- Build Footer organism (Link + Typography + Divider — dependencies ready)
|
- ~~Build Footer organism~~ ✓ Done
|
||||||
- Build ServiceSelector organism (ServiceOption × n + Typography + Button)
|
- ~~Build ServiceSelector organism~~ ✓ Done
|
||||||
- Consider FormField molecule if Input's built-in label/helperText proves insufficient
|
- 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
|
- 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