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>
210 lines
7.4 KiB
TypeScript
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;
|