- React 18 → 19, MUI v5 → v7, Storybook 8 → 9 - Fix Grid v2 API in Footer (remove item prop, use size prop) - Inline provider fixtures (was importing from excluded demo dir) - Remove consolidated SB addons (essentials, storysource, blocks) - Update addon-designs to SB9-compatible version - Add autodocs via tags in preview config - Add Tailwind v3, PostCSS, autoprefixer dev deps (config next) - Zero TypeScript errors, Storybook starts clean Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
265 lines
8.7 KiB
TypeScript
265 lines
8.7 KiB
TypeScript
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.`;
|
|
|
|
const overlineSx = {
|
|
color: 'text.secondary',
|
|
textTransform: 'uppercase' as const,
|
|
letterSpacing: '0.08em',
|
|
display: 'block',
|
|
mb: 0.5,
|
|
};
|
|
|
|
const contactLinkSx = {
|
|
color: 'text.primary',
|
|
'&:hover': { color: 'var(--fa-color-brand-600)' },
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
ref={ref}
|
|
component="footer"
|
|
sx={[
|
|
{
|
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
|
color: 'text.primary',
|
|
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 size={{ xs: 12, md: 4 }}>
|
|
<Box sx={{ mb: 2 }}>{logo}</Box>
|
|
{tagline && (
|
|
<Typography
|
|
variant="body2"
|
|
sx={{ color: 'text.secondary', maxWidth: { xs: '100%', md: 280 } }}
|
|
>
|
|
{tagline}
|
|
</Typography>
|
|
)}
|
|
|
|
{/* Contact info below tagline */}
|
|
{(phone || email) && (
|
|
<Box sx={{ mt: 3 }}>
|
|
{phone && (
|
|
<Box sx={{ mb: 1 }}>
|
|
<Typography variant="overlineSm" sx={overlineSx}>
|
|
Call us
|
|
</Typography>
|
|
<Link
|
|
href={`tel:${phone.replace(/\s/g, '')}`}
|
|
sx={{
|
|
...contactLinkSx,
|
|
fontWeight: 600,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
minHeight: 44,
|
|
}}
|
|
>
|
|
{phone}
|
|
</Link>
|
|
</Box>
|
|
)}
|
|
{email && (
|
|
<Box>
|
|
<Typography variant="overlineSm" sx={overlineSx}>
|
|
Email
|
|
</Typography>
|
|
<Link
|
|
href={`mailto:${email}`}
|
|
sx={{
|
|
...contactLinkSx,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
minHeight: 44,
|
|
}}
|
|
>
|
|
{email}
|
|
</Link>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Grid>
|
|
|
|
{/* Link group columns */}
|
|
{linkGroups.map((group) => (
|
|
<Grid
|
|
size={{ xs: 6, sm: 4, md: 'grow' }}
|
|
key={group.heading}
|
|
component="nav"
|
|
aria-label={group.heading}
|
|
>
|
|
<Typography
|
|
variant="label"
|
|
sx={{
|
|
color: 'text.secondary',
|
|
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: 'text.primary',
|
|
fontSize: '0.875rem',
|
|
fontWeight: 500,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
minHeight: 44,
|
|
'&:hover': { color: 'var(--fa-color-brand-600)' },
|
|
}}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</Box>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
|
|
{/* Bottom bar */}
|
|
<Divider sx={{ 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: 'text.secondary' }}>
|
|
{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: 'text.secondary',
|
|
fontSize: '0.75rem',
|
|
fontWeight: 500,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
minHeight: 44,
|
|
'&:hover': { color: 'var(--fa-color-brand-600)' },
|
|
}}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</Container>
|
|
</Box>
|
|
);
|
|
},
|
|
);
|
|
|
|
Footer.displayName = 'Footer';
|
|
export default Footer;
|