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:
@@ -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>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user