Files
Parsons/src/components/atoms/MapPin/MapPin.tsx
Richie 2b9aeaf8ef 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>
2026-04-06 20:11:13 +10:00

210 lines
7.4 KiB
TypeScript

import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA MapPin atom */
export interface MapPinProps {
/** 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;
/** Whether this provider/venue is verified (brand colour vs neutral) */
verified?: boolean;
/** Whether this pin is currently active/selected */
active?: boolean;
/** Click handler */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const PIN_PX = 'var(--fa-map-pin-padding-x)';
const PIN_RADIUS = 'var(--fa-map-pin-border-radius)';
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const MAX_WIDTH = 180;
// ─── Colour sets ────────────────────────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-100)',
name: 'var(--fa-color-brand-900)',
price: 'var(--fa-color-brand-600)',
activeBg: '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)',
activeBorder: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
name: 'var(--fa-color-neutral-800)',
price: 'var(--fa-color-neutral-500)',
activeBg: '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)',
activeBorder: 'var(--fa-color-neutral-700)',
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Map marker pin for the FA design system.
*
* 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)
* - **Unverified** providers: neutral grey palette
* - **Active/selected**: inverted colours (dark bg, white text) + scale-up
*
* Designed for use as custom HTML markers in Mapbox GL / Google Maps.
* Pure CSS — no canvas, no SVG dependency.
*
* Usage:
* ```tsx
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
* <MapPin name="Smith & Sons" /> {/* No price, unverified *\/}
* <MapPin name="H.Parsons" price={900} verified active />
* ```
*/
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
const palette = verified ? colours.verified : colours.unverified;
const hasPrice = price != null || priceLabel != null;
const priceText =
priceLabel ?? (price != null ? `From $${price.toLocaleString('en-AU')}` : undefined);
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
};
return (
<Box
ref={ref}
role="button"
tabIndex={0}
aria-label={`${name}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
transform: active ? 'scale(1.08)' : 'scale(1)',
'&:hover': {
transform: 'scale(1.08)',
},
'&:focus-visible': {
outline: 'none',
'& > .MapPin-label': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Label pill */}
<Box
className="MapPin-label"
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
maxWidth: MAX_WIDTH,
py: 0.5,
px: PIN_PX,
borderRadius: PIN_RADIUS,
backgroundColor: active ? palette.activeBg : palette.bg,
border: '1px solid',
borderColor: active ? palette.activeBorder : palette.border,
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
transition:
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
}}
>
{/* 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>
{/* Nub — downward pointer */}
<Box
aria-hidden
sx={{
width: 0,
height: 0,
borderLeft: `${NUB_SIZE} solid transparent`,
borderRight: `${NUB_SIZE} solid transparent`,
borderTop: `${NUB_SIZE} solid`,
borderTopColor: active ? palette.activeNub : palette.nub,
mt: '-1px',
}}
/>
</Box>
);
},
);
MapPin.displayName = 'MapPin';
export default MapPin;