Add MiniCard molecule and component tokens for MiniCard + MapPin

MiniCard: compact vertical card for grids, recommendations, and map
popups. Image + title + optional price/badges/chips/meta. Lighter
than ProviderCard — no verified tiers, no logo. Audit: 20/20.

MapPin tokens added (build next): price-pill markers for map views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:47:26 +10:00
parent 2843bf289f
commit f7efa7165c
8 changed files with 477 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { MiniCard } from './MiniCard';
// Placeholder images for stories
const IMG_PROVIDER =
'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=400&h=240&fit=crop&auto=format';
const IMG_VENUE =
'https://images.unsplash.com/photo-1497366216548-37526070297c?w=400&h=240&fit=crop&auto=format';
const IMG_CHAPEL =
'https://images.unsplash.com/photo-1548625149-fc4a29cf7092?w=400&h=240&fit=crop&auto=format';
const IMG_GARDEN =
'https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=400&h=240&fit=crop&auto=format';
const meta: Meta<typeof MiniCard> = {
title: 'Molecules/MiniCard',
component: MiniCard,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<Box sx={{ width: 240 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof MiniCard>;
/** Default mini card with title, image, and price */
export const Default: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
location: 'Wollongong',
},
};
/** With all optional fields populated */
export const FullyLoaded: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
location: 'Wollongong',
rating: 4.8,
badges: [
{ label: 'Verified', color: 'brand', variant: 'filled', icon: <VerifiedOutlinedIcon /> },
{ label: 'Online Arrangement', color: 'success' },
],
chips: ['Burial', 'Cremation'],
},
};
/** Venue card usage — capacity instead of rating */
export const Venue: Story = {
args: {
title: 'Albany Creek Memorial Park',
imageUrl: IMG_CHAPEL,
price: 450,
location: 'Albany Creek',
capacity: 120,
},
};
/** Package card usage — custom price label */
export const Package: Story = {
args: {
title: 'Essential Cremation Package',
imageUrl: IMG_GARDEN,
priceLabel: 'From $2,800',
badges: [{ label: 'Most Popular', color: 'brand' }],
},
};
/** Minimal — just title and image */
export const Minimal: Story = {
args: {
title: 'Lady Anne Funerals',
imageUrl: IMG_VENUE,
},
};
/** Selected state — brand border + warm background */
export const Selected: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
location: 'Wollongong',
selected: true,
},
};
/** Long title truncated at 2 lines */
export const LongTitle: Story = {
args: {
title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_GARDEN,
price: 1200,
location: 'Northern Beaches',
rating: 4.9,
},
};
/** Multiple cards in a responsive grid */
export const Grid: Story = {
decorators: [
(Story) => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 2,
width: 680,
}}
>
<Story />
</Box>
),
],
render: () => (
<>
<MiniCard
title="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
price={900}
location="Wollongong"
rating={4.8}
badges={[
{ label: 'Verified', color: 'brand', variant: 'filled', icon: <VerifiedOutlinedIcon /> },
]}
onClick={() => {}}
/>
<MiniCard
title="Albany Creek Memorial Park"
imageUrl={IMG_CHAPEL}
price={450}
location="Albany Creek"
capacity={120}
onClick={() => {}}
/>
<MiniCard
title="Lady Anne Funerals"
imageUrl={IMG_VENUE}
location="Sutherland Shire"
onClick={() => {}}
/>
</>
),
};

View File

@@ -0,0 +1,259 @@
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 PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
import type { BadgeProps } from '../../atoms/Badge/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A badge to render inside the MiniCard */
export interface MiniCardBadge {
/** Label text */
label: string;
/** Badge colour intent */
color?: BadgeProps['color'];
/** Badge variant */
variant?: BadgeProps['variant'];
/** Optional leading icon */
icon?: React.ReactNode;
}
/** Props for the FA MiniCard molecule */
export interface MiniCardProps {
/** Card title — provider name, venue name, package name, etc. */
title: string;
/** Hero image URL */
imageUrl: string;
/** Alt text for the image — defaults to title */
imageAlt?: string;
/** Price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA", "Included") — overrides formatted price */
priceLabel?: string;
/** Location text (suburb, city) */
location?: string;
/** Average rating (e.g. 4.8) */
rating?: number;
/** Venue capacity (e.g. 120) */
capacity?: number;
/** Badge items to render below the title */
badges?: MiniCardBadge[];
/** Chip labels rendered as small soft badges */
chips?: string[];
/** Whether this card is currently selected */
selected?: boolean;
/** Click handler — entire card is clickable */
onClick?: () => void;
/** MUI sx prop for style overrides */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const IMAGE_HEIGHT = 'var(--fa-mini-card-image-height)';
const CONTENT_PADDING = 'var(--fa-mini-card-content-padding)';
const CONTENT_GAP = 'var(--fa-mini-card-content-gap)';
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Compact vertical card for the FA design system.
*
* A smaller, flexible card for displaying providers, venues, or packages
* in grids, recommendation rows, and map popups. Shows an image with
* a title and optional price, badges, chips, and meta row.
*
* Lighter than ProviderCard — no verified/unverified tiers, no logo
* overlay, no capability tooltips. Just image + content.
*
* Composes: Card + Typography + Badge.
*
* Usage:
* ```tsx
* <MiniCard
* title="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* price={900}
* location="Wollongong"
* rating={4.8}
* badges={[{ label: 'Verified', color: 'brand', variant: 'filled' }]}
* onClick={() => navigate('/providers/parsons')}
* />
* ```
*/
export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
(
{
title,
imageUrl,
imageAlt,
price,
priceLabel,
location,
rating,
capacity,
badges,
chips,
selected = false,
onClick,
sx,
},
ref,
) => {
const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null;
return (
<Card
ref={ref}
interactive={!!onClick}
selected={selected}
padding="none"
onClick={onClick}
sx={[
{
overflow: 'hidden',
'&:hover': {
backgroundColor: 'background.paper',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* ── Image ── */}
<Box
role="img"
aria-label={imageAlt ?? title}
sx={{
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)',
}}
/>
{/* ── Content ── */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: CONTENT_GAP,
p: CONTENT_PADDING,
}}
>
{/* Title */}
<Typography variant="h6" maxLines={2}>
{title}
</Typography>
{/* Price */}
{hasPrice && (
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
{priceLabel ? (
<Typography variant="body2" color="primary" sx={{ fontStyle: 'italic' }}>
{priceLabel}
</Typography>
) : (
<>
<Typography variant="caption" color="text.secondary">
From
</Typography>
<Typography
variant="body2"
component="span"
color="primary"
sx={{ fontWeight: 600 }}
>
${price!.toLocaleString('en-AU')}
</Typography>
</>
)}
</Box>
)}
{/* Badges */}
{badges && badges.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{badges.map((badge) => (
<Badge
key={badge.label}
color={badge.color}
variant={badge.variant}
size="small"
icon={badge.icon}
>
{badge.label}
</Badge>
))}
</Box>
)}
{/* Chips */}
{chips && chips.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{chips.map((chip) => (
<Badge key={chip} color="default" variant="soft" size="small">
{chip}
</Badge>
))}
</Box>
)}
{/* Meta row: location / rating / capacity */}
{hasMeta && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
flexWrap: 'wrap',
}}
>
{location && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<LocationOnOutlinedIcon
sx={{ fontSize: 14, color: 'text.secondary' }}
aria-hidden
/>
<Typography variant="caption" color="text.secondary">
{location}
</Typography>
</Box>
)}
{rating != null && (
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
aria-label={`Rated ${rating} out of 5`}
>
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{rating}
</Typography>
</Box>
)}
{capacity != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PeopleOutlinedIcon sx={{ fontSize: 14, color: 'text.secondary' }} aria-hidden />
<Typography variant="caption" color="text.secondary">
{capacity} guests
</Typography>
</Box>
)}
</Box>
)}
</Box>
</Card>
);
},
);
MiniCard.displayName = 'MiniCard';
export default MiniCard;

View File

@@ -0,0 +1,2 @@
export { MiniCard, default } from './MiniCard';
export type { MiniCardProps, MiniCardBadge } from './MiniCard';

View File

@@ -26,6 +26,11 @@
--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-map-pin-height: 28px; /** Pill height — compact for map density */
--fa-map-pin-font-size: 12px; /** Small but legible price text */
--fa-map-pin-dot-size: 12px; /** Small circle marker */
--fa-map-pin-nub-size: 6px; /** Nub triangle size */
--fa-mini-card-image-height: 120px; /** Shorter image than full listing cards (180px) for compact grids */
--fa-provider-card-image-height: 180px; /** Fixed image height for consistent card sizing in list layouts */
--fa-provider-card-logo-size: 64px; /** Logo width/height — rounded rectangle, overlapping image bottom into content row */
--fa-radio-size-default: 20px; /** Default radio size — matches Figma 16px + padding for 44px touch target area */
@@ -268,6 +273,10 @@
--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-map-pin-padding-x: var(--fa-spacing-2); /** 8px horizontal padding inside pill */
--fa-map-pin-border-radius: var(--fa-border-radius-full); /** Fully rounded pill shape */
--fa-mini-card-content-padding: var(--fa-spacing-3); /** 12px — matches ProviderCard/VenueCard content padding */
--fa-mini-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows */
--fa-provider-card-logo-border-radius: var(--fa-border-radius-md); /** 8px rounded rectangle — softer than circle, matches card border radius */
--fa-provider-card-content-padding: var(--fa-spacing-3); /** 12px content padding — tight to keep card compact in listing layout */
--fa-provider-card-content-gap: var(--fa-spacing-1); /** 4px vertical gap between content rows — tight for compact listing cards */

View File

@@ -71,6 +71,15 @@ export declare const InputFontSizeDefault: string;
export declare const InputBorderRadiusDefault: string;
export declare const InputGapDefault: string;
export declare const InputIconSizeDefault: string;
export declare const MapPinHeight: string;
export declare const MapPinPaddingX: string;
export declare const MapPinFontSize: string;
export declare const MapPinBorderRadius: string;
export declare const MapPinDotSize: string;
export declare const MapPinNubSize: string;
export declare const MiniCardImageHeight: string;
export declare const MiniCardContentPadding: string;
export declare const MiniCardContentGap: string;
export declare const ProviderCardImageHeight: string;
export declare const ProviderCardLogoSize: string;
export declare const ProviderCardLogoBorderRadius: string;

View File

@@ -72,6 +72,15 @@ 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 MapPinHeight = "28px"; // Pill height — compact for map density
export const MapPinPaddingX = "8px"; // 8px horizontal padding inside pill
export const MapPinFontSize = "12px"; // Small but legible price text
export const MapPinBorderRadius = "9999px"; // Fully rounded pill shape
export const MapPinDotSize = "12px"; // Small circle marker
export const MapPinNubSize = "6px"; // Nub triangle size
export const MiniCardImageHeight = "120px"; // Shorter image than full listing cards (180px) for compact grids
export const MiniCardContentPadding = "12px"; // 12px — matches ProviderCard/VenueCard content padding
export const MiniCardContentGap = "4px"; // 4px vertical gap between content rows
export const ProviderCardImageHeight = "180px"; // Fixed image height for consistent card sizing in list layouts
export const ProviderCardLogoSize = "64px"; // Logo width/height — rounded rectangle, overlapping image bottom into content row
export const ProviderCardLogoBorderRadius = "8px"; // 8px rounded rectangle — softer than circle, matches card border radius

View File

@@ -0,0 +1,17 @@
{
"mapPin": {
"$description": "MapPin atom tokens — price-pill map markers for provider/venue map views. Verified (brand) vs unverified (neutral) visual distinction.",
"height": { "$type": "dimension", "$value": "28px", "$description": "Pill height — compact for map density" },
"paddingX": { "$type": "dimension", "$value": "{spacing.2}", "$description": "8px horizontal padding inside pill" },
"fontSize": { "$type": "dimension", "$value": "12px", "$description": "Small but legible price text" },
"borderRadius": { "$type": "dimension", "$value": "{borderRadius.full}", "$description": "Fully rounded pill shape" },
"dot": {
"$description": "Dot variant for pins without a price label.",
"size": { "$type": "dimension", "$value": "12px", "$description": "Small circle marker" }
},
"nub": {
"$description": "Downward-pointing nub anchoring the pill to the map location.",
"size": { "$type": "dimension", "$value": "6px", "$description": "Nub triangle size" }
}
}
}

View File

@@ -0,0 +1,15 @@
{
"miniCard": {
"$description": "MiniCard molecule tokens — compact vertical card for providers, venues, packages in grids, recommendations, and map popups.",
"image": {
"$type": "dimension",
"$description": "Hero image area dimensions.",
"height": { "$value": "120px", "$description": "Shorter image than full listing cards (180px) for compact grids" }
},
"content": {
"$description": "Content area spacing.",
"padding": { "$type": "dimension", "$value": "{spacing.3}", "$description": "12px — matches ProviderCard/VenueCard content padding" },
"gap": { "$type": "dimension", "$value": "{spacing.1}", "$description": "4px vertical gap between content rows" }
}
}
}