From 4fecb81853e2f1c5bbd8887a55232ae4b5ebc672 Mon Sep 17 00:00:00 2001 From: Richie Date: Mon, 6 Apr 2026 19:49:08 +1000 Subject: [PATCH] =?UTF-8?q?Add=20MapPin=20atom=20=E2=80=94=20price-pill=20?= =?UTF-8?q?map=20markers=20with=20verified/unverified=20distinction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Airbnb-style markers: pill variant (price label + nub) and dot variant (no price). Verified = brand palette, unverified = neutral grey. Active state inverts colours + scale-up. Pure CSS for map overlay use. Keyboard accessible with role="button" and focus ring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../atoms/MapPin/MapPin.stories.tsx | 141 ++++++++++++ src/components/atoms/MapPin/MapPin.tsx | 217 ++++++++++++++++++ src/components/atoms/MapPin/index.ts | 2 + 3 files changed, 360 insertions(+) create mode 100644 src/components/atoms/MapPin/MapPin.stories.tsx create mode 100644 src/components/atoms/MapPin/MapPin.tsx create mode 100644 src/components/atoms/MapPin/index.ts diff --git a/src/components/atoms/MapPin/MapPin.stories.tsx b/src/components/atoms/MapPin/MapPin.stories.tsx new file mode 100644 index 0000000..af78f31 --- /dev/null +++ b/src/components/atoms/MapPin/MapPin.stories.tsx @@ -0,0 +1,141 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import { MapPin } from './MapPin'; + +const meta: Meta = { + title: 'Atoms/MapPin', + component: MapPin, + tags: ['autodocs'], + parameters: { + layout: 'centered', + backgrounds: { + default: 'map', + values: [{ name: 'map', value: '#E5E3DF' }], + }, + }, + argTypes: { + onClick: { action: 'clicked' }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Verified provider with price — warm brand pill */ +export const VerifiedWithPrice: Story = { + args: { + price: 900, + verified: true, + }, +}; + +/** Unverified provider with price — neutral grey pill */ +export const UnverifiedWithPrice: Story = { + args: { + price: 1200, + verified: false, + }, +}; + +/** Active/selected state — inverted colours, slight scale-up */ +export const Active: Story = { + args: { + price: 900, + verified: true, + active: true, + }, +}; + +/** Active unverified */ +export const ActiveUnverified: Story = { + args: { + price: 1200, + verified: false, + active: true, + }, +}; + +/** Dot marker — no price, verified */ +export const DotVerified: Story = { + args: { + verified: true, + }, +}; + +/** Dot marker — no price, unverified */ +export const DotUnverified: Story = { + args: { + verified: false, + }, +}; + +/** Dot marker — active */ +export const DotActive: Story = { + args: { + verified: true, + active: true, + }, +}; + +/** Custom price label */ +export const CustomLabel: Story = { + args: { + priceLabel: 'POA', + verified: true, + }, +}; + +/** High price — wider pill */ +export const HighPrice: Story = { + args: { + price: 12500, + verified: true, + }, +}; + +/** Map simulation — multiple pins on a mock map background */ +export const MapSimulation: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + render: () => ( + <> + {/* Verified providers */} + + {}} /> + + + {}} /> + + + {}} /> + + + {/* Unverified providers */} + + {}} /> + + + {}} /> + + + {/* Dot markers */} + + {}} /> + + + ), +}; diff --git a/src/components/atoms/MapPin/MapPin.tsx b/src/components/atoms/MapPin/MapPin.tsx new file mode 100644 index 0000000..96f55d7 --- /dev/null +++ b/src/components/atoms/MapPin/MapPin.tsx @@ -0,0 +1,217 @@ +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 { + /** Price in dollars — renders a pill label. Omit for a dot marker. */ + 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; +} + +// ─── 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)'; + +// ─── Colour sets ──────────────────────────────────────────────────────────── + +const colours = { + verified: { + bg: 'var(--fa-color-brand-100)', + text: 'var(--fa-color-brand-700)', + activeBg: 'var(--fa-color-brand-700)', + activeText: 'var(--fa-color-white)', + dot: 'var(--fa-color-brand-500)', + activeDot: 'var(--fa-color-brand-700)', + 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)', + text: 'var(--fa-color-neutral-700)', + activeBg: 'var(--fa-color-neutral-700)', + activeText: 'var(--fa-color-white)', + dot: 'var(--fa-color-neutral-400)', + activeDot: 'var(--fa-color-neutral-700)', + 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. + * + * 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. + * + * 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 + * selectProvider(id)} /> + * {/* Dot marker, no price *\/} + * {/* Selected state *\/} + * ``` + */ +export const MapPin = React.forwardRef( + ({ 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); + + // 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]), + ]} + /> + ); + } + + // Pill variant — price label + nub + return ( + { + if ((e.key === 'Enter' || e.key === ' ') && onClick) { + e.preventDefault(); + onClick(e as unknown as React.MouseEvent); + } + }} + sx={[ + { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'center', + cursor: 'pointer', + transition: 'transform 150ms ease-in-out', + transform: active ? 'scale(1.1)' : 'scale(1)', + '&:hover': { + transform: 'scale(1.1)', + }, + '&:focus-visible': { + outline: 'none', + '& > .MapPin-pill': { + outline: '2px solid var(--fa-color-interactive-focus)', + outlineOffset: '2px', + }, + }, + }, + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + {/* 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', + }} + > + {label} + + + {/* Nub — downward pointer */} + + + ); + }, +); + +MapPin.displayName = 'MapPin'; +export default MapPin; diff --git a/src/components/atoms/MapPin/index.ts b/src/components/atoms/MapPin/index.ts new file mode 100644 index 0000000..a3de4fa --- /dev/null +++ b/src/components/atoms/MapPin/index.ts @@ -0,0 +1,2 @@ +export { MapPin, default } from './MapPin'; +export type { MapPinProps } from './MapPin';