Iterate MiniCard and MapPin based on feedback

MiniCard:
- Verified badge → icon-only circle floating in image (top-right)
- Reorder content: title → meta → price → badges → chips
- Truncated titles show tooltip on hover with full text

MapPin: Rethink from price-only pill to two-line label:
- Line 1: Provider name (bold, truncated at 180px)
- Line 2: "From $X" (smaller, secondary colour) — optional
- Communicates who + starting price at a glance
- Verified/unverified palette distinction preserved
- Dot variant removed (name is always required now)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:11:13 +10:00
parent 5364c1a3fc
commit 2b9aeaf8ef
5 changed files with 257 additions and 205 deletions

View File

@@ -21,17 +21,19 @@ const meta: Meta<typeof MapPin> = {
export default meta; export default meta;
type Story = StoryObj<typeof MapPin>; type Story = StoryObj<typeof MapPin>;
/** Verified provider with price — warm brand pill */ /** Verified provider with name and price — warm brand label */
export const VerifiedWithPrice: Story = { export const VerifiedWithPrice: Story = {
args: { args: {
name: 'H.Parsons Funeral Directors',
price: 900, price: 900,
verified: true, verified: true,
}, },
}; };
/** Unverified provider with price — neutral grey pill */ /** Unverified provider — neutral grey label */
export const UnverifiedWithPrice: Story = { export const UnverifiedWithPrice: Story = {
args: { args: {
name: 'Smith & Sons Funerals',
price: 1200, price: 1200,
verified: false, verified: false,
}, },
@@ -40,6 +42,7 @@ export const UnverifiedWithPrice: Story = {
/** Active/selected state — inverted colours, slight scale-up */ /** Active/selected state — inverted colours, slight scale-up */
export const Active: Story = { export const Active: Story = {
args: { args: {
name: 'H.Parsons Funeral Directors',
price: 900, price: 900,
verified: true, verified: true,
active: true, active: true,
@@ -49,46 +52,42 @@ export const Active: Story = {
/** Active unverified */ /** Active unverified */
export const ActiveUnverified: Story = { export const ActiveUnverified: Story = {
args: { args: {
name: 'Smith & Sons Funerals',
price: 1200, price: 1200,
verified: false, verified: false,
active: true, active: true,
}, },
}; };
/** Dot marker — no price, verified */ /** Name only — no price line */
export const DotVerified: Story = { export const NameOnly: Story = {
args: { args: {
name: 'Lady Anne Funerals',
verified: true, verified: true,
}, },
}; };
/** Dot marker — no price, unverified */ /** Name only, unverified */
export const DotUnverified: Story = { export const NameOnlyUnverified: Story = {
args: { args: {
verified: false, name: 'Local Funeral Services',
},
};
/** Dot marker — active */
export const DotActive: Story = {
args: {
verified: true,
active: true,
}, },
}; };
/** Custom price label */ /** Custom price label */
export const CustomLabel: Story = { export const CustomPriceLabel: Story = {
args: { args: {
name: 'Premium Services',
priceLabel: 'POA', priceLabel: 'POA',
verified: true, verified: true,
}, },
}; };
/** High price — wider pill */ /** Long name — truncated with ellipsis at 180px max */
export const HighPrice: Story = { export const LongName: Story = {
args: { args: {
price: 12500, name: 'Botanical Funerals by Ian Allison',
price: 1200,
verified: true, verified: true,
}, },
}; };
@@ -100,8 +99,8 @@ export const MapSimulation: Story = {
<Box <Box
sx={{ sx={{
position: 'relative', position: 'relative',
width: 600, width: 700,
height: 400, height: 450,
bgcolor: '#E5E3DF', bgcolor: '#E5E3DF',
borderRadius: 2, borderRadius: 2,
overflow: 'hidden', overflow: 'hidden',
@@ -114,27 +113,27 @@ export const MapSimulation: Story = {
render: () => ( render: () => (
<> <>
{/* Verified providers */} {/* Verified providers */}
<Box sx={{ position: 'absolute', top: 80, left: 120 }}> <Box sx={{ position: 'absolute', top: 60, left: 80 }}>
<MapPin price={900} verified onClick={() => {}} /> <MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
</Box> </Box>
<Box sx={{ position: 'absolute', top: 160, left: 300 }}> <Box sx={{ position: 'absolute', top: 150, left: 280 }}>
<MapPin price={1450} verified active onClick={() => {}} /> <MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} />
</Box> </Box>
<Box sx={{ position: 'absolute', top: 240, left: 180 }}> <Box sx={{ position: 'absolute', top: 260, left: 140 }}>
<MapPin price={2200} verified onClick={() => {}} /> <MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
</Box> </Box>
{/* Unverified providers */} {/* Unverified providers */}
<Box sx={{ position: 'absolute', top: 120, left: 420 }}> <Box sx={{ position: 'absolute', top: 100, left: 450 }}>
<MapPin price={1100} onClick={() => {}} /> <MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
</Box> </Box>
<Box sx={{ position: 'absolute', top: 280, left: 380 }}> <Box sx={{ position: 'absolute', top: 300, left: 400 }}>
<MapPin onClick={() => {}} /> <MapPin name="Local Provider" onClick={() => {}} />
</Box> </Box>
{/* Dot markers */} {/* Name only verified */}
<Box sx={{ position: 'absolute', top: 60, left: 480 }}> <Box sx={{ position: 'absolute', top: 40, left: 500 }}>
<MapPin verified onClick={() => {}} /> <MapPin name="Kenneallys" verified onClick={() => {}} />
</Box> </Box>
</> </>
), ),

View File

@@ -6,7 +6,9 @@ import type { SxProps, Theme } from '@mui/material/styles';
/** Props for the FA MapPin atom */ /** Props for the FA MapPin atom */
export interface MapPinProps { export interface MapPinProps {
/** Price in dollars — renders a pill label. Omit for a dot marker. */ /** Provider or venue name — always shown on the pin */
name: string;
/** Starting package price in dollars — shown as "From $X" */
price?: number; price?: number;
/** Custom price label (e.g. "POA") — overrides formatted price */ /** Custom price label (e.g. "POA") — overrides formatted price */
priceLabel?: string; priceLabel?: string;
@@ -22,23 +24,21 @@ export interface MapPinProps {
// ─── Constants ────────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
const PIN_HEIGHT = 'var(--fa-map-pin-height)';
const PIN_PX = 'var(--fa-map-pin-padding-x)'; const PIN_PX = 'var(--fa-map-pin-padding-x)';
const PIN_FONT_SIZE = 'var(--fa-map-pin-font-size)';
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)'; const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
const DOT_SIZE = 'var(--fa-map-pin-dot-size)';
const NUB_SIZE = 'var(--fa-map-pin-nub-size)'; const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const MAX_WIDTH = 180;
// ─── Colour sets ──────────────────────────────────────────────────────────── // ─── Colour sets ────────────────────────────────────────────────────────────
const colours = { const colours = {
verified: { verified: {
bg: 'var(--fa-color-brand-100)', bg: 'var(--fa-color-brand-100)',
text: 'var(--fa-color-brand-700)', name: 'var(--fa-color-brand-900)',
price: 'var(--fa-color-brand-600)',
activeBg: 'var(--fa-color-brand-700)', activeBg: 'var(--fa-color-brand-700)',
activeText: 'var(--fa-color-white)', activeName: 'var(--fa-color-white)',
dot: 'var(--fa-color-brand-500)', activePrice: 'var(--fa-color-brand-200)',
activeDot: 'var(--fa-color-brand-700)',
nub: 'var(--fa-color-brand-100)', nub: 'var(--fa-color-brand-100)',
activeNub: 'var(--fa-color-brand-700)', activeNub: 'var(--fa-color-brand-700)',
border: 'var(--fa-color-brand-300)', border: 'var(--fa-color-brand-300)',
@@ -46,11 +46,11 @@ const colours = {
}, },
unverified: { unverified: {
bg: 'var(--fa-color-neutral-100)', bg: 'var(--fa-color-neutral-100)',
text: 'var(--fa-color-neutral-700)', name: 'var(--fa-color-neutral-800)',
price: 'var(--fa-color-neutral-500)',
activeBg: 'var(--fa-color-neutral-700)', activeBg: 'var(--fa-color-neutral-700)',
activeText: 'var(--fa-color-white)', activeName: 'var(--fa-color-white)',
dot: 'var(--fa-color-neutral-400)', activePrice: 'var(--fa-color-neutral-200)',
activeDot: 'var(--fa-color-neutral-700)',
nub: 'var(--fa-color-neutral-100)', nub: 'var(--fa-color-neutral-100)',
activeNub: 'var(--fa-color-neutral-700)', activeNub: 'var(--fa-color-neutral-700)',
border: 'var(--fa-color-neutral-300)', border: 'var(--fa-color-neutral-300)',
@@ -63,10 +63,12 @@ const colours = {
/** /**
* Map marker pin for the FA design system. * Map marker pin for the FA design system.
* *
* Airbnb-style price pill that sits on a map. When a price is provided, * Two-line label marker showing provider name and starting package
* renders a rounded pill with the price label and a small downward nub * price. Renders as a rounded pill with a downward nub pointing to
* pointing to the exact location. Without a price, renders a small * the exact map location.
* dot marker. *
* - **Line 1**: Provider name (bold, truncated)
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
* *
* Visual distinction: * Visual distinction:
* - **Verified** providers: warm brand palette (gold bg, copper text) * - **Verified** providers: warm brand palette (gold bg, copper text)
@@ -78,73 +80,34 @@ const colours = {
* *
* Usage: * Usage:
* ```tsx * ```tsx
* <MapPin price={900} verified onClick={() => selectProvider(id)} /> * <MapPin name="H.Parsons" price={900} verified onClick={...} />
* <MapPin verified={false} /> {/* Dot marker, no price *\/} * <MapPin name="Smith & Sons" /> {/* No price, unverified *\/}
* <MapPin price={900} verified active /> {/* Selected state *\/} * <MapPin name="H.Parsons" price={900} verified active />
* ``` * ```
*/ */
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>( export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
({ price, priceLabel, verified = false, active = false, onClick, sx }, ref) => { ({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
const palette = verified ? colours.verified : colours.unverified; const palette = verified ? colours.verified : colours.unverified;
const hasPrice = price != null || priceLabel != null; const hasPrice = price != null || priceLabel != null;
const label = priceLabel ?? (price != null ? `$${price.toLocaleString('en-AU')}` : undefined); const priceText =
priceLabel ?? (price != null ? `From $${price.toLocaleString('en-AU')}` : undefined);
// Dot variant — no price const handleKeyDown = (e: React.KeyboardEvent) => {
if (!hasPrice) { if ((e.key === 'Enter' || e.key === ' ') && onClick) {
return ( e.preventDefault();
<Box onClick(e as unknown as React.MouseEvent);
ref={ref} }
role="button" };
tabIndex={0}
aria-label={`${verified ? 'Verified' : 'Unverified'} provider${active ? ' (selected)' : ''}`}
onClick={onClick}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
}}
sx={[
{
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: '50%',
backgroundColor: active ? palette.activeDot : palette.dot,
border: '2px solid',
borderColor: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out, background-color 150ms ease-in-out',
transform: active ? 'scale(1.3)' : 'scale(1)',
'&:hover': {
transform: 'scale(1.3)',
},
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
/>
);
}
// Pill variant — price label + nub
return ( return (
<Box <Box
ref={ref} ref={ref}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={`${label}, ${verified ? 'verified' : 'unverified'} provider${active ? ' (selected)' : ''}`} aria-label={`${name}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
onClick={onClick} onClick={onClick}
onKeyDown={(e) => { onKeyDown={handleKeyDown}
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
}}
sx={[ sx={[
{ {
display: 'inline-flex', display: 'inline-flex',
@@ -152,13 +115,13 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
alignItems: 'center', alignItems: 'center',
cursor: 'pointer', cursor: 'pointer',
transition: 'transform 150ms ease-in-out', transition: 'transform 150ms ease-in-out',
transform: active ? 'scale(1.1)' : 'scale(1)', transform: active ? 'scale(1.08)' : 'scale(1)',
'&:hover': { '&:hover': {
transform: 'scale(1.1)', transform: 'scale(1.08)',
}, },
'&:focus-visible': { '&:focus-visible': {
outline: 'none', outline: 'none',
'& > .MapPin-pill': { '& > .MapPin-label': {
outline: '2px solid var(--fa-color-interactive-focus)', outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px', outlineOffset: '2px',
}, },
@@ -167,32 +130,61 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
...(Array.isArray(sx) ? sx : [sx]), ...(Array.isArray(sx) ? sx : [sx]),
]} ]}
> >
{/* Pill */} {/* Label pill */}
<Box <Box
className="MapPin-pill" className="MapPin-label"
sx={{ sx={{
display: 'inline-flex', display: 'flex',
alignItems: 'center', flexDirection: 'column',
justifyContent: 'center', alignItems: 'flex-start',
height: PIN_HEIGHT, maxWidth: MAX_WIDTH,
py: 0.5,
px: PIN_PX, px: PIN_PX,
borderRadius: PIN_RADIUS, borderRadius: PIN_RADIUS,
backgroundColor: active ? palette.activeBg : palette.bg, backgroundColor: active ? palette.activeBg : palette.bg,
color: active ? palette.activeText : palette.text,
border: '1px solid', border: '1px solid',
borderColor: active ? palette.activeBorder : palette.border, borderColor: active ? palette.activeBorder : palette.border,
fontSize: PIN_FONT_SIZE,
fontWeight: 700,
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1,
whiteSpace: 'nowrap',
userSelect: 'none',
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)', boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
transition: transition:
'background-color 150ms ease-in-out, color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out', 'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
}} }}
> >
{label} {/* Name */}
<Box
component="span"
sx={{
fontSize: 12,
fontWeight: 700,
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.3,
color: active ? palette.activeName : palette.name,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
transition: 'color 150ms ease-in-out',
}}
>
{name}
</Box>
{/* Price line */}
{hasPrice && (
<Box
component="span"
sx={{
fontSize: 11,
fontWeight: 500,
fontFamily: (t: Theme) => t.typography.fontFamily,
lineHeight: 1.2,
color: active ? palette.activePrice : palette.price,
whiteSpace: 'nowrap',
transition: 'color 150ms ease-in-out',
}}
>
{priceText}
</Box>
)}
</Box> </Box>
{/* Nub — downward pointer */} {/* Nub — downward pointer */}
@@ -205,7 +197,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
borderRight: `${NUB_SIZE} solid transparent`, borderRight: `${NUB_SIZE} solid transparent`,
borderTop: `${NUB_SIZE} solid`, borderTop: `${NUB_SIZE} solid`,
borderTopColor: active ? palette.activeNub : palette.nub, borderTopColor: active ? palette.activeNub : palette.nub,
mt: '-1px', // overlap the pill border mt: '-1px',
}} }}
/> />
</Box> </Box>

View File

@@ -124,7 +124,7 @@ export const WithPin: Story = {
verified verified
onViewDetails={() => {}} onViewDetails={() => {}}
/> />
<MapPin price={900} verified active /> <MapPin name="H.Parsons" price={900} verified active />
</> </>
), ),
}; };

View File

@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { MiniCard } from './MiniCard'; import { MiniCard } from './MiniCard';
// Placeholder images for stories // Placeholder images for stories
@@ -32,11 +31,12 @@ const meta: Meta<typeof MiniCard> = {
export default meta; export default meta;
type Story = StoryObj<typeof MiniCard>; type Story = StoryObj<typeof MiniCard>;
/** Default mini card with title, image, and price */ /** Default — verified provider with image, location, and price */
export const Default: Story = { export const Default: Story = {
args: { args: {
title: 'H.Parsons Funeral Directors', title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER, imageUrl: IMG_PROVIDER,
verified: true,
price: 900, price: 900,
location: 'Wollongong', location: 'Wollongong',
}, },
@@ -47,17 +47,25 @@ export const FullyLoaded: Story = {
args: { args: {
title: 'H.Parsons Funeral Directors', title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER, imageUrl: IMG_PROVIDER,
price: 900, verified: true,
location: 'Wollongong', location: 'Wollongong',
rating: 4.8, rating: 4.8,
badges: [ price: 900,
{ label: 'Verified', color: 'brand', variant: 'filled', icon: <VerifiedOutlinedIcon /> }, badges: [{ label: 'Online Arrangement', color: 'success' }],
{ label: 'Online Arrangement', color: 'success' },
],
chips: ['Burial', 'Cremation'], chips: ['Burial', 'Cremation'],
}, },
}; };
/** Unverified provider — no badge in image */
export const Unverified: Story = {
args: {
title: 'Smith & Sons Funeral Services',
imageUrl: IMG_VENUE,
price: 1200,
location: 'Sutherland',
},
};
/** Venue card usage — capacity instead of rating */ /** Venue card usage — capacity instead of rating */
export const Venue: Story = { export const Venue: Story = {
args: { args: {
@@ -92,24 +100,26 @@ export const Selected: Story = {
args: { args: {
title: 'H.Parsons Funeral Directors', title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER, imageUrl: IMG_PROVIDER,
verified: true,
price: 900, price: 900,
location: 'Wollongong', location: 'Wollongong',
selected: true, selected: true,
}, },
}; };
/** Long title truncated at 2 lines */ /** Long title truncated at 2 lines, hover tooltip shows full text */
export const LongTitle: Story = { export const LongTitle: Story = {
args: { args: {
title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services', title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_GARDEN, imageUrl: IMG_GARDEN,
price: 1200, verified: true,
location: 'Northern Beaches', location: 'Northern Beaches',
rating: 4.9, rating: 4.9,
price: 1200,
}, },
}; };
/** Multiple cards in a responsive grid */ /** Multiple cards in a responsive grid — mix of verified and unverified */
export const Grid: Story = { export const Grid: Story = {
decorators: [ decorators: [
(Story) => ( (Story) => (
@@ -130,20 +140,19 @@ export const Grid: Story = {
<MiniCard <MiniCard
title="H.Parsons Funeral Directors" title="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER} imageUrl={IMG_PROVIDER}
price={900} verified
location="Wollongong" location="Wollongong"
rating={4.8} rating={4.8}
badges={[ price={900}
{ label: 'Verified', color: 'brand', variant: 'filled', icon: <VerifiedOutlinedIcon /> }, chips={['Burial', 'Cremation']}
]}
onClick={() => {}} onClick={() => {}}
/> />
<MiniCard <MiniCard
title="Albany Creek Memorial Park" title="Albany Creek Memorial Park"
imageUrl={IMG_CHAPEL} imageUrl={IMG_CHAPEL}
price={450}
location="Albany Creek" location="Albany Creek"
capacity={120} capacity={120}
price={450}
onClick={() => {}} onClick={() => {}}
/> />
<MiniCard <MiniCard

View File

@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import type { SxProps, Theme } from '@mui/material/styles'; import type { SxProps, Theme } from '@mui/material/styles';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded'; import StarRoundedIcon from '@mui/icons-material/StarRounded';
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'; import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { Card } from '../../atoms/Card'; import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge'; import { Badge } from '../../atoms/Badge';
@@ -11,7 +13,7 @@ import type { BadgeProps } from '../../atoms/Badge/Badge';
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
/** A badge to render inside the MiniCard */ /** A badge to render inside the MiniCard content area */
export interface MiniCardBadge { export interface MiniCardBadge {
/** Label text */ /** Label text */
label: string; label: string;
@@ -31,6 +33,8 @@ export interface MiniCardProps {
imageUrl: string; imageUrl: string;
/** Alt text for the image — defaults to title */ /** Alt text for the image — defaults to title */
imageAlt?: string; imageAlt?: string;
/** Whether this provider/venue is verified — shows icon badge in image */
verified?: boolean;
/** Price in dollars — shown as "From $X" */ /** Price in dollars — shown as "From $X" */
price?: number; price?: number;
/** Custom price label (e.g. "POA", "Included") — overrides formatted price */ /** Custom price label (e.g. "POA", "Included") — overrides formatted price */
@@ -41,9 +45,9 @@ export interface MiniCardProps {
rating?: number; rating?: number;
/** Venue capacity (e.g. 120) */ /** Venue capacity (e.g. 120) */
capacity?: number; capacity?: number;
/** Badge items to render below the title */ /** Badge items rendered after the price row */
badges?: MiniCardBadge[]; badges?: MiniCardBadge[];
/** Chip labels rendered as small soft badges */ /** Chip labels rendered as small soft badges (after badges) */
chips?: string[]; chips?: string[];
/** Whether this card is currently selected */ /** Whether this card is currently selected */
selected?: boolean; selected?: boolean;
@@ -66,22 +70,25 @@ const CONTENT_GAP = 'var(--fa-mini-card-content-gap)';
* *
* A smaller, flexible card for displaying providers, venues, or packages * A smaller, flexible card for displaying providers, venues, or packages
* in grids, recommendation rows, and map popups. Shows an image with * in grids, recommendation rows, and map popups. Shows an image with
* a title and optional price, badges, chips, and meta row. * a title and optional meta, price, badges, and chips.
* *
* Lighter than ProviderCard — no verified/unverified tiers, no logo * Content hierarchy: **title → meta → price → chips/badges**.
* overlay, no capability tooltips. Just image + content.
* *
* Composes: Card + Typography + Badge. * Verified providers show a small icon-only badge floating in the
* image (top-right). Truncated titles show a tooltip on hover with
* the full text.
*
* Composes: Card + Typography + Badge + Tooltip.
* *
* Usage: * Usage:
* ```tsx * ```tsx
* <MiniCard * <MiniCard
* title="H.Parsons Funeral Directors" * title="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg" * imageUrl="/images/parsons.jpg"
* verified
* price={900} * price={900}
* location="Wollongong" * location="Wollongong"
* rating={4.8} * rating={4.8}
* badges={[{ label: 'Verified', color: 'brand', variant: 'filled' }]}
* onClick={() => navigate('/providers/parsons')} * onClick={() => navigate('/providers/parsons')}
* /> * />
* ``` * ```
@@ -92,6 +99,7 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
title, title,
imageUrl, imageUrl,
imageAlt, imageAlt,
verified = false,
price, price,
priceLabel, priceLabel,
location, location,
@@ -108,6 +116,17 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
const hasMeta = location != null || rating != null || capacity != null; const hasMeta = location != null || rating != null || capacity != null;
const hasPrice = price != null || priceLabel != null; const hasPrice = price != null || priceLabel != null;
// Detect title truncation for tooltip
const titleRef = React.useRef<HTMLElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
React.useEffect(() => {
const el = titleRef.current;
if (el) {
setIsTruncated(el.scrollHeight > el.clientHeight + 1);
}
}, [title]);
return ( return (
<Card <Card
ref={ref} ref={ref}
@@ -130,13 +149,38 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
role="img" role="img"
aria-label={imageAlt ?? title} aria-label={imageAlt ?? title}
sx={{ sx={{
position: 'relative',
height: IMAGE_HEIGHT, height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`, backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundColor: 'var(--fa-color-neutral-100)', backgroundColor: 'var(--fa-color-neutral-100)',
}} }}
/> >
{/* Verified icon badge — floating top-right */}
{verified && (
<Tooltip title="Verified provider" arrow placement="top">
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: 'var(--fa-color-brand-600)',
color: 'var(--fa-color-white)',
boxShadow: 'var(--fa-shadow-sm)',
}}
>
<VerifiedOutlinedIcon sx={{ fontSize: 16 }} />
</Box>
</Tooltip>
)}
</Box>
{/* ── Content ── */} {/* ── Content ── */}
<Box <Box
@@ -147,65 +191,20 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
p: CONTENT_PADDING, p: CONTENT_PADDING,
}} }}
> >
{/* Title */} {/* 1. Title — with tooltip when truncated */}
<Typography variant="h6" maxLines={2}> <Tooltip
{title} title={isTruncated ? title : ''}
</Typography> arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={titleRef} variant="h6" maxLines={2}>
{title}
</Typography>
</Tooltip>
{/* Price */} {/* 2. Meta row: location / rating / capacity */}
{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 && ( {hasMeta && (
<Box <Box
sx={{ sx={{
@@ -249,6 +248,59 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
)} )}
</Box> </Box>
)} )}
{/* 3. 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>
)}
{/* 4. 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>
)}
{/* 5. 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>
)}
</Box> </Box>
</Card> </Card>
); );