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:
@@ -124,7 +124,7 @@ export const WithPin: Story = {
|
||||
verified
|
||||
onViewDetails={() => {}}
|
||||
/>
|
||||
<MapPin price={900} verified active />
|
||||
<MapPin name="H.Parsons" price={900} verified active />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user