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

@@ -124,7 +124,7 @@ export const WithPin: Story = {
verified
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 Box from '@mui/material/Box';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { MiniCard } from './MiniCard';
// Placeholder images for stories
@@ -32,11 +31,12 @@ const meta: Meta<typeof MiniCard> = {
export default meta;
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 = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
price: 900,
location: 'Wollongong',
},
@@ -47,17 +47,25 @@ export const FullyLoaded: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
price: 900,
verified: true,
location: 'Wollongong',
rating: 4.8,
badges: [
{ label: 'Verified', color: 'brand', variant: 'filled', icon: <VerifiedOutlinedIcon /> },
{ label: 'Online Arrangement', color: 'success' },
],
price: 900,
badges: [{ label: 'Online Arrangement', color: 'success' }],
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 */
export const Venue: Story = {
args: {
@@ -92,24 +100,26 @@ export const Selected: Story = {
args: {
title: 'H.Parsons Funeral Directors',
imageUrl: IMG_PROVIDER,
verified: true,
price: 900,
location: 'Wollongong',
selected: true,
},
};
/** Long title truncated at 2 lines */
/** Long title truncated at 2 lines, hover tooltip shows full text */
export const LongTitle: Story = {
args: {
title: 'Botanical Funerals by Ian Allison — Sustainable & Eco-Friendly Services',
imageUrl: IMG_GARDEN,
price: 1200,
verified: true,
location: 'Northern Beaches',
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 = {
decorators: [
(Story) => (
@@ -130,20 +140,19 @@ export const Grid: Story = {
<MiniCard
title="H.Parsons Funeral Directors"
imageUrl={IMG_PROVIDER}
price={900}
verified
location="Wollongong"
rating={4.8}
badges={[
{ label: 'Verified', color: 'brand', variant: 'filled', icon: <VerifiedOutlinedIcon /> },
]}
price={900}
chips={['Burial', 'Cremation']}
onClick={() => {}}
/>
<MiniCard
title="Albany Creek Memorial Park"
imageUrl={IMG_CHAPEL}
price={450}
location="Albany Creek"
capacity={120}
price={450}
onClick={() => {}}
/>
<MiniCard

View File

@@ -1,9 +1,11 @@
import React from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
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 VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import { Card } from '../../atoms/Card';
import { Typography } from '../../atoms/Typography';
import { Badge } from '../../atoms/Badge';
@@ -11,7 +13,7 @@ import type { BadgeProps } from '../../atoms/Badge/Badge';
// ─── Types ───────────────────────────────────────────────────────────────────
/** A badge to render inside the MiniCard */
/** A badge to render inside the MiniCard content area */
export interface MiniCardBadge {
/** Label text */
label: string;
@@ -31,6 +33,8 @@ export interface MiniCardProps {
imageUrl: string;
/** Alt text for the image — defaults to title */
imageAlt?: string;
/** Whether this provider/venue is verified — shows icon badge in image */
verified?: boolean;
/** Price in dollars — shown as "From $X" */
price?: number;
/** Custom price label (e.g. "POA", "Included") — overrides formatted price */
@@ -41,9 +45,9 @@ export interface MiniCardProps {
rating?: number;
/** Venue capacity (e.g. 120) */
capacity?: number;
/** Badge items to render below the title */
/** Badge items rendered after the price row */
badges?: MiniCardBadge[];
/** Chip labels rendered as small soft badges */
/** Chip labels rendered as small soft badges (after badges) */
chips?: string[];
/** Whether this card is currently selected */
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
* 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
* overlay, no capability tooltips. Just image + content.
* Content hierarchy: **title → meta → price → chips/badges**.
*
* 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:
* ```tsx
* <MiniCard
* title="H.Parsons Funeral Directors"
* imageUrl="/images/parsons.jpg"
* verified
* price={900}
* location="Wollongong"
* rating={4.8}
* badges={[{ label: 'Verified', color: 'brand', variant: 'filled' }]}
* onClick={() => navigate('/providers/parsons')}
* />
* ```
@@ -92,6 +99,7 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
title,
imageUrl,
imageAlt,
verified = false,
price,
priceLabel,
location,
@@ -108,6 +116,17 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
const hasMeta = location != null || rating != null || capacity != 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 (
<Card
ref={ref}
@@ -130,13 +149,38 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
role="img"
aria-label={imageAlt ?? title}
sx={{
position: 'relative',
height: IMAGE_HEIGHT,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
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 ── */}
<Box
@@ -147,65 +191,20 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
p: CONTENT_PADDING,
}}
>
{/* Title */}
<Typography variant="h6" maxLines={2}>
{title}
</Typography>
{/* 1. Title — with tooltip when truncated */}
<Tooltip
title={isTruncated ? title : ''}
arrow
placement="top"
enterDelay={300}
disableHoverListener={!isTruncated}
>
<Typography ref={titleRef} variant="h6" maxLines={2}>
{title}
</Typography>
</Tooltip>
{/* 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 */}
{/* 2. Meta row: location / rating / capacity */}
{hasMeta && (
<Box
sx={{
@@ -249,6 +248,59 @@ export const MiniCard = React.forwardRef<HTMLDivElement, MiniCardProps>(
)}
</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>
</Card>
);