From 2b9aeaf8efe2275863cec0f1cc9aeb157eb590ce Mon Sep 17 00:00:00 2001 From: Richie Date: Mon, 6 Apr 2026 20:11:13 +1000 Subject: [PATCH] Iterate MiniCard and MapPin based on feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../atoms/MapPin/MapPin.stories.tsx | 67 ++++--- src/components/atoms/MapPin/MapPin.tsx | 168 ++++++++-------- .../molecules/MapPopup/MapPopup.stories.tsx | 2 +- .../molecules/MiniCard/MiniCard.stories.tsx | 39 ++-- .../molecules/MiniCard/MiniCard.tsx | 186 +++++++++++------- 5 files changed, 257 insertions(+), 205 deletions(-) diff --git a/src/components/atoms/MapPin/MapPin.stories.tsx b/src/components/atoms/MapPin/MapPin.stories.tsx index af78f31..bb7736b 100644 --- a/src/components/atoms/MapPin/MapPin.stories.tsx +++ b/src/components/atoms/MapPin/MapPin.stories.tsx @@ -21,17 +21,19 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/** Verified provider with price — warm brand pill */ +/** Verified provider with name and price — warm brand label */ export const VerifiedWithPrice: Story = { args: { + name: 'H.Parsons Funeral Directors', price: 900, verified: true, }, }; -/** Unverified provider with price — neutral grey pill */ +/** Unverified provider — neutral grey label */ export const UnverifiedWithPrice: Story = { args: { + name: 'Smith & Sons Funerals', price: 1200, verified: false, }, @@ -40,6 +42,7 @@ export const UnverifiedWithPrice: Story = { /** Active/selected state — inverted colours, slight scale-up */ export const Active: Story = { args: { + name: 'H.Parsons Funeral Directors', price: 900, verified: true, active: true, @@ -49,46 +52,42 @@ export const Active: Story = { /** Active unverified */ export const ActiveUnverified: Story = { args: { + name: 'Smith & Sons Funerals', price: 1200, verified: false, active: true, }, }; -/** Dot marker — no price, verified */ -export const DotVerified: Story = { +/** Name only — no price line */ +export const NameOnly: Story = { args: { + name: 'Lady Anne Funerals', verified: true, }, }; -/** Dot marker — no price, unverified */ -export const DotUnverified: Story = { +/** Name only, unverified */ +export const NameOnlyUnverified: Story = { args: { - verified: false, - }, -}; - -/** Dot marker — active */ -export const DotActive: Story = { - args: { - verified: true, - active: true, + name: 'Local Funeral Services', }, }; /** Custom price label */ -export const CustomLabel: Story = { +export const CustomPriceLabel: Story = { args: { + name: 'Premium Services', priceLabel: 'POA', verified: true, }, }; -/** High price — wider pill */ -export const HighPrice: Story = { +/** Long name — truncated with ellipsis at 180px max */ +export const LongName: Story = { args: { - price: 12500, + name: 'Botanical Funerals by Ian Allison', + price: 1200, verified: true, }, }; @@ -100,8 +99,8 @@ export const MapSimulation: Story = { ( <> {/* Verified providers */} - - {}} /> + + {}} /> - - {}} /> + + {}} /> - - {}} /> + + {}} /> {/* Unverified providers */} - - {}} /> + + {}} /> - - {}} /> + + {}} /> - {/* Dot markers */} - - {}} /> + {/* Name only verified */} + + {}} /> ), diff --git a/src/components/atoms/MapPin/MapPin.tsx b/src/components/atoms/MapPin/MapPin.tsx index 96f55d7..e0671f0 100644 --- a/src/components/atoms/MapPin/MapPin.tsx +++ b/src/components/atoms/MapPin/MapPin.tsx @@ -6,7 +6,9 @@ import type { SxProps, Theme } from '@mui/material/styles'; /** Props for the FA MapPin atom */ 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; /** Custom price label (e.g. "POA") — overrides formatted price */ priceLabel?: string; @@ -22,23 +24,21 @@ export interface MapPinProps { // ─── Constants ────────────────────────────────────────────────────────────── -const PIN_HEIGHT = 'var(--fa-map-pin-height)'; 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 DOT_SIZE = 'var(--fa-map-pin-dot-size)'; const NUB_SIZE = 'var(--fa-map-pin-nub-size)'; +const MAX_WIDTH = 180; // ─── Colour sets ──────────────────────────────────────────────────────────── const colours = { verified: { 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)', - activeText: 'var(--fa-color-white)', - dot: 'var(--fa-color-brand-500)', - activeDot: 'var(--fa-color-brand-700)', + activeName: 'var(--fa-color-white)', + activePrice: 'var(--fa-color-brand-200)', nub: 'var(--fa-color-brand-100)', activeNub: 'var(--fa-color-brand-700)', border: 'var(--fa-color-brand-300)', @@ -46,11 +46,11 @@ const colours = { }, unverified: { 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)', - activeText: 'var(--fa-color-white)', - dot: 'var(--fa-color-neutral-400)', - activeDot: 'var(--fa-color-neutral-700)', + activeName: 'var(--fa-color-white)', + activePrice: 'var(--fa-color-neutral-200)', nub: 'var(--fa-color-neutral-100)', activeNub: 'var(--fa-color-neutral-700)', border: 'var(--fa-color-neutral-300)', @@ -63,10 +63,12 @@ const colours = { /** * Map marker pin for the FA design system. * - * Airbnb-style price pill that sits on a map. When a price is provided, - * renders a rounded pill with the price label and a small downward nub - * pointing to the exact location. Without a price, renders a small - * dot marker. + * Two-line label marker showing provider name and starting package + * price. Renders as a rounded pill with a downward nub pointing to + * the exact map location. + * + * - **Line 1**: Provider name (bold, truncated) + * - **Line 2**: "From $X" (smaller, secondary colour) — optional * * Visual distinction: * - **Verified** providers: warm brand palette (gold bg, copper text) @@ -78,73 +80,34 @@ const colours = { * * Usage: * ```tsx - * selectProvider(id)} /> - * {/* Dot marker, no price *\/} - * {/* Selected state *\/} + * + * {/* No price, unverified *\/} + * * ``` */ export const MapPin = React.forwardRef( - ({ 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 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 - if (!hasPrice) { - return ( - { - 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]), - ]} - /> - ); - } + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.key === 'Enter' || e.key === ' ') && onClick) { + e.preventDefault(); + onClick(e as unknown as React.MouseEvent); + } + }; - // Pill variant — price label + nub return ( { - if ((e.key === 'Enter' || e.key === ' ') && onClick) { - e.preventDefault(); - onClick(e as unknown as React.MouseEvent); - } - }} + onKeyDown={handleKeyDown} sx={[ { display: 'inline-flex', @@ -152,13 +115,13 @@ export const MapPin = React.forwardRef( alignItems: 'center', cursor: 'pointer', transition: 'transform 150ms ease-in-out', - transform: active ? 'scale(1.1)' : 'scale(1)', + transform: active ? 'scale(1.08)' : 'scale(1)', '&:hover': { - transform: 'scale(1.1)', + transform: 'scale(1.08)', }, '&:focus-visible': { outline: 'none', - '& > .MapPin-pill': { + '& > .MapPin-label': { outline: '2px solid var(--fa-color-interactive-focus)', outlineOffset: '2px', }, @@ -167,32 +130,61 @@ export const MapPin = React.forwardRef( ...(Array.isArray(sx) ? sx : [sx]), ]} > - {/* Pill */} + {/* Label pill */} t.typography.fontFamily, - lineHeight: 1, - whiteSpace: 'nowrap', - userSelect: 'none', boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)', 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 */} + 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} + + + {/* Price line */} + {hasPrice && ( + t.typography.fontFamily, + lineHeight: 1.2, + color: active ? palette.activePrice : palette.price, + whiteSpace: 'nowrap', + transition: 'color 150ms ease-in-out', + }} + > + {priceText} + + )} {/* Nub — downward pointer */} @@ -205,7 +197,7 @@ export const MapPin = React.forwardRef( borderRight: `${NUB_SIZE} solid transparent`, borderTop: `${NUB_SIZE} solid`, borderTopColor: active ? palette.activeNub : palette.nub, - mt: '-1px', // overlap the pill border + mt: '-1px', }} /> diff --git a/src/components/molecules/MapPopup/MapPopup.stories.tsx b/src/components/molecules/MapPopup/MapPopup.stories.tsx index b8a63b1..9bf5631 100644 --- a/src/components/molecules/MapPopup/MapPopup.stories.tsx +++ b/src/components/molecules/MapPopup/MapPopup.stories.tsx @@ -124,7 +124,7 @@ export const WithPin: Story = { verified onViewDetails={() => {}} /> - + ), }; diff --git a/src/components/molecules/MiniCard/MiniCard.stories.tsx b/src/components/molecules/MiniCard/MiniCard.stories.tsx index c95bb82..0c9d999 100644 --- a/src/components/molecules/MiniCard/MiniCard.stories.tsx +++ b/src/components/molecules/MiniCard/MiniCard.stories.tsx @@ -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 = { export default meta; type Story = StoryObj; -/** 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: }, - { 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 = { }, - ]} + price={900} + chips={['Burial', 'Cremation']} onClick={() => {}} /> {}} /> navigate('/providers/parsons')} * /> * ``` @@ -92,6 +99,7 @@ export const MiniCard = React.forwardRef( title, imageUrl, imageAlt, + verified = false, price, priceLabel, location, @@ -108,6 +116,17 @@ export const MiniCard = React.forwardRef( const hasMeta = location != null || rating != null || capacity != null; const hasPrice = price != null || priceLabel != null; + // Detect title truncation for tooltip + const titleRef = React.useRef(null); + const [isTruncated, setIsTruncated] = React.useState(false); + + React.useEffect(() => { + const el = titleRef.current; + if (el) { + setIsTruncated(el.scrollHeight > el.clientHeight + 1); + } + }, [title]); + return ( ( 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 && ( + + + + + + )} + {/* ── Content ── */} ( p: CONTENT_PADDING, }} > - {/* Title */} - - {title} - + {/* 1. Title — with tooltip when truncated */} + + + {title} + + - {/* Price */} - {hasPrice && ( - - {priceLabel ? ( - - {priceLabel} - - ) : ( - <> - - From - - - ${price!.toLocaleString('en-AU')} - - - )} - - )} - - {/* Badges */} - {badges && badges.length > 0 && ( - - {badges.map((badge) => ( - - {badge.label} - - ))} - - )} - - {/* Chips */} - {chips && chips.length > 0 && ( - - {chips.map((chip) => ( - - {chip} - - ))} - - )} - - {/* Meta row: location / rating / capacity */} + {/* 2. Meta row: location / rating / capacity */} {hasMeta && ( ( )} )} + + {/* 3. Price */} + {hasPrice && ( + + {priceLabel ? ( + + {priceLabel} + + ) : ( + <> + + From + + + ${price!.toLocaleString('en-AU')} + + + )} + + )} + + {/* 4. Badges */} + {badges && badges.length > 0 && ( + + {badges.map((badge) => ( + + {badge.label} + + ))} + + )} + + {/* 5. Chips */} + {chips && chips.length > 0 && ( + + {chips.map((chip) => ( + + {chip} + + ))} + + )} );