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:
157
src/components/molecules/MiniCard/MiniCard.stories.tsx
Normal file
157
src/components/molecules/MiniCard/MiniCard.stories.tsx
Normal 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={() => {}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
};
|
||||
259
src/components/molecules/MiniCard/MiniCard.tsx
Normal file
259
src/components/molecules/MiniCard/MiniCard.tsx
Normal 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;
|
||||
2
src/components/molecules/MiniCard/index.ts
Normal file
2
src/components/molecules/MiniCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MiniCard, default } from './MiniCard';
|
||||
export type { MiniCardProps, MiniCardBadge } from './MiniCard';
|
||||
Reference in New Issue
Block a user