Add ProviderCard molecule — first molecule in design system

First molecule component. Listing card for funeral providers on the
provider select screen (map + scrollable list layout).

- Verified providers: hero image, 48px logo overlay, "Trusted Partner"
  badge, name, location, reviews, capability badge, footer with price
- Unverified providers: text-only with same content alignment
- 7 component tokens (image height, logo size, footer/content spacing)
- Composes Card (interactive, padding="none") + Badge + Typography
- Capability badges accept arbitrary label + colour (categories may change)
- Footer bar with warm brand.100 bg, "Packages from $X >"
- 9 Storybook stories including mixed list layout demo
- Decisions D026-D030 logged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 17:39:03 +11:00
parent c10a5e4e1c
commit f31e37c837
10 changed files with 843 additions and 3 deletions

View File

@@ -0,0 +1,420 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ProviderCard } from './ProviderCard';
import Box from '@mui/material/Box';
// Placeholder images for stories (grey boxes via data URIs)
const HERO_PLACEHOLDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=600&h=300&fit=crop&auto=format';
const HERO_PLACEHOLDER_2 =
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=600&h=300&fit=crop&auto=format';
const HERO_PLACEHOLDER_3 =
'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=600&h=300&fit=crop&auto=format';
// Simple grey circle for logo placeholder
const LOGO_PLACEHOLDER =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><circle cx="48" cy="48" r="48" fill="%23E8E8E8"/><text x="48" y="54" text-anchor="middle" font-family="sans-serif" font-size="14" fill="%23737373">Logo</text></svg>',
);
const meta: Meta<typeof ProviderCard> = {
title: 'Molecules/ProviderCard',
component: ProviderCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
design: {
type: 'figma',
url: 'https://www.figma.com/design/XUDUrw4yMkEexBCCYHXUvT/Parsons?node-id=5369-140263',
},
},
argTypes: {
name: { control: 'text' },
location: { control: 'text' },
verified: { control: 'boolean' },
rating: { control: { type: 'number', min: 0, max: 5, step: 0.1 } },
reviewCount: { control: 'number' },
capabilityLabel: { control: 'text' },
capabilityColor: {
control: 'select',
options: ['default', 'success', 'warning', 'error', 'info'],
},
startingPrice: { control: 'number' },
onClick: { action: 'clicked' },
},
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ProviderCard>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default — verified provider with all fields */
export const Default: Story = {
args: {
name: 'H.Parsons Funeral Directors',
location: 'Wollongong',
verified: true,
imageUrl: HERO_PLACEHOLDER,
logoUrl: LOGO_PLACEHOLDER,
rating: 4.8,
reviewCount: 127,
capabilityLabel: 'Online Arrangement',
capabilityColor: 'success',
startingPrice: 900,
},
};
// ─── Verified Provider ──────────────────────────────────────────────────────
/** Full verified provider card with all elements */
export const VerifiedProvider: Story = {
name: 'Verified Provider',
render: () => (
<ProviderCard
name="Parsons Ladies Funeral Directors"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={900}
onClick={() => {}}
/>
),
};
// ─── Unverified Provider ────────────────────────────────────────────────────
/** Unverified provider — text only, no image/logo/trusted badge */
export const UnverifiedProvider: Story = {
name: 'Unverified Provider',
render: () => (
<ProviderCard
name="Rankins Funeral's Heathcote"
location="Heathcote"
rating={4.2}
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
startingPrice={1200}
onClick={() => {}}
/>
),
};
// ─── List Layout ────────────────────────────────────────────────────────────
/**
* Mixed verified and unverified providers in a scrollable list.
* This is the primary use case — text alignment should be consistent
* across both card types for scan readability.
*/
export const ListLayout: Story = {
name: 'List Layout — Mixed',
decorators: [
(Story) => (
<Box sx={{ width: 400, maxHeight: 700, overflow: 'auto' }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<ProviderCard
name="Parsons Ladies Funeral Directors"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={900}
onClick={() => {}}
/>
<ProviderCard
name="H.Parsons Wollongong"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER_2}
logoUrl={LOGO_PLACEHOLDER}
rating={4.6}
reviewCount={89}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={1100}
onClick={() => {}}
/>
<ProviderCard
name="Rankins Funeral's Heathcote"
location="Heathcote"
rating={4.2}
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
startingPrice={1200}
onClick={() => {}}
/>
<ProviderCard
name="Stella Armois Funerals"
location="Corrimal"
rating={3.9}
reviewCount={18}
capabilityLabel="Partial Arrangement"
capabilityColor="warning"
startingPrice={950}
onClick={() => {}}
/>
<ProviderCard
name="St. Angelo of God Funeral Directors"
location="Fairy Meadow"
verified
imageUrl={HERO_PLACEHOLDER_3}
logoUrl={LOGO_PLACEHOLDER}
rating={4.9}
reviewCount={203}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={1500}
onClick={() => {}}
/>
</Box>
),
};
// ─── Capability Variants ────────────────────────────────────────────────────
/** Three capability badge colours */
export const CapabilityVariants: Story = {
name: 'Capability Variants',
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<ProviderCard
name="Online Provider"
location="Sydney"
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={900}
onClick={() => {}}
/>
<ProviderCard
name="Partial Provider"
location="Melbourne"
capabilityLabel="Partial Arrangement"
capabilityColor="warning"
startingPrice={1100}
onClick={() => {}}
/>
<ProviderCard
name="Outsourced Provider"
location="Brisbane"
capabilityLabel="Outsourced"
capabilityColor="default"
startingPrice={800}
onClick={() => {}}
/>
</Box>
),
};
// ─── Edge Cases ─────────────────────────────────────────────────────────────
/** Edge cases: long name, missing fields, extremes */
export const EdgeCases: Story = {
name: 'Edge Cases',
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Long name — tests maxLines truncation */}
<ProviderCard
name="The Most Honourable and Distinguished Funeral Directors of the Greater Wollongong Metropolitan Area"
location="Wollongong"
rating={4.5}
reviewCount={67}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={2500}
onClick={() => {}}
/>
{/* No reviews */}
<ProviderCard
name="New Provider"
location="Perth"
capabilityLabel="Partial Arrangement"
capabilityColor="warning"
startingPrice={750}
onClick={() => {}}
/>
{/* No capability badge */}
<ProviderCard
name="Basic Listing"
location="Adelaide"
rating={3.2}
reviewCount={5}
startingPrice={600}
onClick={() => {}}
/>
{/* No price (footer hidden) */}
<ProviderCard
name="Price Unavailable"
location="Darwin"
rating={4.0}
reviewCount={12}
capabilityLabel="Outsourced"
capabilityColor="default"
onClick={() => {}}
/>
{/* Minimal — just name and location */}
<ProviderCard
name="Minimal Card"
location="Hobart"
onClick={() => {}}
/>
</Box>
),
};
// ─── Responsive ─────────────────────────────────────────────────────────────
/** Cards at different viewport widths */
export const Responsive: Story = {
decorators: [
(Story) => (
<Box sx={{ width: 'auto' }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', gap: 3, alignItems: 'start', flexWrap: 'wrap' }}>
{[280, 340, 420].map((width) => (
<Box key={width} sx={{ width }}>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>{width}px</Box>
<ProviderCard
name="H.Parsons Funeral Directors"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={900}
onClick={() => {}}
/>
</Box>
))}
</Box>
),
};
// ─── On Different Backgrounds ───────────────────────────────────────────────
/** Cards on white vs grey surfaces */
export const OnDifferentBackgrounds: Story = {
name: 'On Different Backgrounds',
decorators: [
(Story) => (
<Box sx={{ width: 'auto' }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ width: 360, p: 3, backgroundColor: 'background.default' }}>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>White surface</Box>
<ProviderCard
name="H.Parsons"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
startingPrice={900}
onClick={() => {}}
/>
</Box>
<Box sx={{ width: 360, p: 3, backgroundColor: 'var(--fa-color-surface-subtle)' }}>
<Box sx={{ mb: 1, fontSize: 12, color: 'text.secondary' }}>Grey surface (neutral.50)</Box>
<ProviderCard
name="Rankins Funerals"
location="Heathcote"
rating={4.2}
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
startingPrice={1200}
onClick={() => {}}
/>
</Box>
</Box>
),
};
// ─── Interactive Demo ───────────────────────────────────────────────────────
/** Click any card — fires onClick in Storybook actions panel */
export const InteractiveDemo: Story = {
name: 'Interactive — Click to Navigate',
decorators: [
(Story) => (
<Box sx={{ width: 380 }}>
<Story />
</Box>
),
],
render: () => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<ProviderCard
name="Parsons Ladies Funeral Directors"
location="Wollongong"
verified
imageUrl={HERO_PLACEHOLDER}
logoUrl={LOGO_PLACEHOLDER}
rating={4.8}
reviewCount={127}
capabilityLabel="Online Arrangement"
capabilityColor="success"
startingPrice={900}
onClick={() => alert('Navigate to Parsons Ladies packages')}
/>
<ProviderCard
name="Rankins Funeral's Heathcote"
location="Heathcote"
rating={4.2}
reviewCount={43}
capabilityLabel="Outsourced"
capabilityColor="default"
startingPrice={1200}
onClick={() => alert('Navigate to Rankins packages')}
/>
</Box>
),
};

View File

@@ -0,0 +1,260 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { Card } from '../../atoms/Card';
import { Badge } from '../../atoms/Badge';
import type { BadgeColor } from '../../atoms/Badge/Badge';
import { Typography } from '../../atoms/Typography';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA ProviderCard molecule */
export interface ProviderCardProps {
/** Provider display name */
name: string;
/** Location text (suburb, city) */
location: string;
/** Whether this provider is a verified/trusted partner */
verified?: boolean;
/** Hero image URL — only rendered when verified */
imageUrl?: string;
/** Provider logo URL — circular overlay on image, only rendered when verified */
logoUrl?: string;
/** Average rating (e.g. 4.8). Omit to hide reviews. */
rating?: number;
/** Number of reviews (e.g. 127). Omit to hide review count. */
reviewCount?: number;
/** Capability badge label (e.g. "Online Arrangement") */
capabilityLabel?: string;
/** Capability badge colour intent — maps to Badge colour */
capabilityColor?: BadgeColor;
/** Starting price in dollars (shown in footer as "Packages from $X") */
startingPrice?: number;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Constants ───────────────────────────────────────────────────────────────
const LOGO_SIZE = 'var(--fa-provider-card-logo-size)';
const LOGO_OVERLAP = 24; // half of 48px logo, in px
const IMAGE_HEIGHT = 'var(--fa-provider-card-image-height)';
const CONTENT_PADDING = 'var(--fa-provider-card-content-padding)';
const CONTENT_GAP = 'var(--fa-provider-card-content-gap)';
const FOOTER_BG = 'var(--fa-provider-card-footer-background)';
const FOOTER_PX = 'var(--fa-provider-card-footer-padding-x)';
const FOOTER_PY = 'var(--fa-provider-card-footer-padding-y)';
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Provider listing card for the FA design system.
*
* Displays a funeral provider in the provider select screen's scrollable
* list. Supports verified (paid partner) and unverified (scraped listing)
* providers with consistent text alignment for scan readability.
*
* **Verified providers** get a hero image, logo overlay, and "Trusted Partner"
* badge. **Unverified providers** show text content only — no image, logo,
* or verification badge.
*
* Composes: Card (interactive, padding="none"), Badge, Typography.
*
* Usage:
* ```tsx
* <ProviderCard
* name="H.Parsons Funeral Directors"
* location="Wollongong"
* verified
* imageUrl="/images/parsons-hero.jpg"
* logoUrl="/images/parsons-logo.png"
* rating={4.8}
* reviewCount={127}
* capabilityLabel="Online Arrangement"
* capabilityColor="success"
* startingPrice={900}
* onClick={() => navigate(`/providers/parsons`)}
* />
* ```
*/
export const ProviderCard = React.forwardRef<HTMLDivElement, ProviderCardProps>(
(
{
name,
location,
verified = false,
imageUrl,
logoUrl,
rating,
reviewCount,
capabilityLabel,
capabilityColor = 'default',
startingPrice,
onClick,
sx,
},
ref,
) => {
const showImage = verified && imageUrl;
const showLogo = verified && logoUrl;
return (
<Card
ref={ref}
interactive
padding="none"
onClick={onClick}
sx={[
{ overflow: 'hidden' },
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Image area (verified only) ── */}
{showImage && (
<Box
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'action.hover', // fallback if image fails
}}
>
{/* Trusted Partner badge */}
<Box sx={{ position: 'absolute', top: 12, right: 12 }}>
<Badge
variant="filled"
color="brand"
size="small"
icon={<VerifiedOutlinedIcon />}
>
Trusted Partner
</Badge>
</Box>
{/* Logo overlay */}
{showLogo && (
<Box
component="img"
src={logoUrl}
alt={`${name} logo`}
sx={{
position: 'absolute',
bottom: -LOGO_OVERLAP,
left: CONTENT_PADDING,
width: LOGO_SIZE,
height: LOGO_SIZE,
borderRadius: '50%',
objectFit: 'cover',
backgroundColor: 'background.paper',
boxShadow: 1,
border: '2px solid',
borderColor: 'background.paper',
}}
/>
)}
</Box>
)}
{/* ── Content area ── */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: CONTENT_GAP,
p: CONTENT_PADDING,
// Extra top padding when logo overlaps into content
...(showLogo && { pt: `calc(${CONTENT_PADDING} + ${LOGO_OVERLAP}px)` }),
}}
>
{/* Provider name */}
<Typography variant="h5" maxLines={1}>
{name}
</Typography>
{/* Meta row: location + reviews */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: 'wrap',
}}
>
{/* Location */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 16, color: 'text.secondary' }}
/>
<Typography variant="body2" color="text.secondary">
{location}
</Typography>
</Box>
{/* Reviews */}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
aria-label={`Rated ${rating} out of 5${reviewCount != null ? `, ${reviewCount} reviews` : ''}`}
>
<StarRoundedIcon
sx={{ fontSize: 16, color: 'warning.main' }}
aria-hidden
/>
<Typography variant="body2" color="text.secondary">
{rating}
{reviewCount != null && ` (${reviewCount.toLocaleString('en-AU')})`}
</Typography>
</Box>
)}
</Box>
{/* Capability badge */}
{capabilityLabel && (
<Box>
<Badge color={capabilityColor} size="small">
{capabilityLabel}
</Badge>
</Box>
)}
</Box>
{/* ── Footer bar ── */}
{startingPrice != null && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 0.5,
backgroundColor: FOOTER_BG,
px: FOOTER_PX,
py: FOOTER_PY,
}}
>
<Typography variant="label" color="text.secondary">
Packages from
</Typography>
<Typography variant="label" sx={{ fontWeight: 700 }}>
${startingPrice.toLocaleString('en-AU')}
</Typography>
<ChevronRightIcon
sx={{ fontSize: 20, color: 'text.secondary' }}
aria-hidden
/>
</Box>
)}
</Card>
);
},
);
ProviderCard.displayName = 'ProviderCard';
export default ProviderCard;

View File

@@ -0,0 +1,2 @@
export { ProviderCard, default } from './ProviderCard';
export type { ProviderCardProps } from './ProviderCard';

View File

@@ -26,6 +26,8 @@
--fa-input-height-sm: 40px; /** Small — compact forms, admin layouts, matches Button medium height */
--fa-input-height-md: 48px; /** Medium (default) — standard forms, matches Button large for alignment */
--fa-input-icon-size-default: 20px; /** 20px — icon size inside input field, matches Figma trailing icon */
--fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */
--fa-provider-card-logo-size: 48px; /** Logo circle diameter — positioned bottom-left of image, overlapping content area */
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
--fa-radio-dot-size-default: 10px; /** Selected indicator dot — 50% of outer size */
--fa-switch-track-width: 44px; /** Track width — slightly narrower than Figma 52px for better proportion with 44px touch target */
@@ -265,6 +267,11 @@
--fa-input-font-size-default: var(--fa-font-size-base); /** 16px — prevents iOS auto-zoom on focus, matches Figma */
--fa-input-border-radius-default: var(--fa-border-radius-sm); /** 4px — subtle rounding, consistent with Figma design */
--fa-input-gap-default: var(--fa-spacing-2); /** 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability */
--fa-provider-card-footer-background: var(--fa-color-brand-100); /** Warm beige footer — brand.100 provides subtle brand warmth */
--fa-provider-card-footer-padding-x: var(--fa-spacing-4); /** 16px horizontal padding — matches compact card padding */
--fa-provider-card-footer-padding-y: var(--fa-spacing-3); /** 12px vertical padding — slightly tighter than content area */
--fa-provider-card-content-padding: var(--fa-spacing-4); /** 16px content padding — compact to maximise text area on listing cards */
--fa-provider-card-content-gap: var(--fa-spacing-2); /** 8px vertical gap between content rows (name, meta, capability) */
--fa-switch-track-border-radius: var(--fa-border-radius-full); /** Pill shape */
--fa-color-text-primary: var(--fa-color-neutral-800); /** Primary text — body content, headings. Cool charcoal (#2C2E35) for comfortable extended reading */
--fa-color-text-secondary: var(--fa-color-neutral-600); /** Secondary text — helper text, descriptions, metadata, less prominent content */

View File

@@ -72,6 +72,13 @@ export const InputFontSizeDefault = "1rem"; // 16px — prevents iOS auto-zoom o
export const InputBorderRadiusDefault = "4px"; // 4px — subtle rounding, consistent with Figma design
export const InputGapDefault = "8px"; // 8px — vertical rhythm between label/input/helper, slightly more generous than Figma's 6px for readability
export const InputIconSizeDefault = "20px"; // 20px — icon size inside input field, matches Figma trailing icon
export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts
export const ProviderCardLogoSize = "48px"; // Logo circle diameter — positioned bottom-left of image, overlapping content area
export const ProviderCardFooterBackground = "#f7ecdf"; // Warm beige footer — brand.100 provides subtle brand warmth
export const ProviderCardFooterPaddingX = "16px"; // 16px horizontal padding — matches compact card padding
export const ProviderCardFooterPaddingY = "12px"; // 12px vertical padding — slightly tighter than content area
export const ProviderCardContentPadding = "16px"; // 16px content padding — compact to maximise text area on listing cards
export const ProviderCardContentGap = "8px"; // 8px vertical gap between content rows (name, meta, capability)
export const RadioSizeDefault = "20px"; // Default radio size — matches Figma 16px + padding for 44px touch target area
export const RadioDotSizeDefault = "10px"; // Selected indicator dot — 50% of outer size
export const SwitchTrackWidth = "44px"; // Track width — slightly narrower than Figma 52px for better proportion with 44px touch target