Add Google Maps ProviderMap organism with clustering + popup flow

Introduces a full Google-Maps-backed provider map for the arrangement
wizard's ProvidersStep. Clicking a pin morphs it into a MapPopup at
the same coord; pins within 70px of each other collapse into a cluster
(ceiling at zoom 13) that opens a ClusterPopup list on click. Row
clicks pan + zoom the map to the provider and open their MapPopup.
Map-background click routes through an exit transition that fades the
popup out before reappearing the pin, via a matching fade-in keyframe
on the atom markers.

Key additions:
- @vis.gl/react-google-maps + @googlemaps/markerclusterer deps
- ClusterMarker atom (count badge; verified / unverified palettes)
- ClusterPopup molecule (image-free rows; verified icon aligned to
  name; right-aligned "From $X" column; verified-first sort)
- ProviderMap organism (APIProvider + Map + imperative AdvancedMarker
  layer via createRoot for clusterer compatibility)

Component changes:
- MapPin: promoted verified palette (brand-700); name now required;
  name-only and price-only variants dropped; active prop removed in
  favour of organism-level state; SVG nub with fill+stroke replaces
  the CSS border-triangle trick so the outline is continuous
- MapPopup: `exiting` prop drives close animation; click events stop
  propagation so the map's onClick can't clear state mid-interaction
- ProviderData type gains optional `coords`; demo fixtures populated
  with real NSW/QLD lat/lng for all 7 providers
- ProvidersStep demo route wires ProviderMap into the mapPanel slot

Memory:
- docs/memory/component-registry updated (ClusterMarker, ClusterPopup,
  ProviderMap added; MapPin + MapPopup refined; MapCard retired)
- docs/memory/session-log captures arc across 2026-04-21/22 and flags
  next-session work: ProvidersStep polish, mobile layout for list-map
  WizardLayout, and demo deploy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 09:29:37 +10:00
parent 626666e6f0
commit e78d88b2f3
20 changed files with 1720 additions and 171 deletions

View File

@@ -28,7 +28,8 @@ duplicates) and MUST update it after completing one.
| Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. | | Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. |
| Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. | | Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. |
| Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. | | Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. |
| MapPin | done | name+price (two-line), name-only, price-only × verified, unverified × default, active | mapPin.paddingX/borderRadius/nub.size, color.brand.100-900, color.neutral.100-800 | Two-line label map marker: name (bold, truncated 180px) + "From $X" (centred, semibold). Name optional for price-only variant. Verified = brand palette, unverified = grey. Active inverts + scale. Pure CSS. role="button" + keyboard + focus ring. | | MapPin | done | name+price × verified, unverified | mapPin.paddingX/borderRadius/nub.size, color.brand-200/700, color.neutral-100-800 | Two-line label map marker: name (bold, required, truncated 180px) + "From $X" (centred, semibold). Verified = promoted brand palette (brand-700 bg, white text, brand-200 price). Unverified = neutral grey pill (neutral-100 bg, neutral-800 text). No active state — selection handled at the organism level (ProviderMap swaps pin → MapPopup on click). Pure CSS. role="button" + keyboard + focus ring. Name-only / price-only variants dropped in D042 (production providers always have both). |
| ClusterMarker | done | count × verified (promoted), unverified (neutral) | color.brand-700, color.neutral-100-800, mapPin.nub.size | Circular 36px count badge for pin clusters. `hasVerified` flag drives the palette (if any provider in the cluster is verified, use the promoted brand-700 palette; else neutral grey). Same nub treatment + shadow tokens as MapPin for visual cohesion. Pure CSS + SVG. role="button" + keyboard + focus ring. Designed as the `render`-ed output of `@googlemaps/markerclusterer`. See D043. |
| ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. | | ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. |
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. | | Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. | | Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
@@ -45,7 +46,7 @@ duplicates) and MUST update it after completing one.
| FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation | | FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation |
| ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. | | ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. |
| VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. | | VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. |
| MapCard | planned | Card + Typography + Badge | Compact horizontal map popup card. Deferred until map integration. | | ~~MapCard~~ | superseded | — | Retired 2026-04-21. Role (compact map popup card) fully covered by `MapPopup`. See D041. |
| ServiceOption | done | Card (interactive, selected) + Typography | Selectable service option for arrangement flow. Heading + optional price (right-aligned) + optional description. role="radio" + aria-checked. Disabled state with opacity token. Maps to Figma ListItemPurchaseOption. | | ServiceOption | done | Card (interactive, selected) + Typography | Selectable service option for arrangement flow. Heading + optional price (right-aligned) + optional description. role="radio" + aria-checked. Disabled state with opacity token. Maps to Figma ListItemPurchaseOption. |
| SearchBar | done | Input + IconButton + Button | Search input with optional submit button. Enter-to-submit, progressive clear button, inline loading spinner. Guards empty submissions, refocuses after clear. role="search" landmark. Critique: 35/40. | | SearchBar | done | Input + IconButton + Button | Search input with optional submit button. Enter-to-submit, progressive clear button, inline loading spinner. Guards empty submissions, refocuses after clear. role="search" landmark. Critique: 35/40. |
| AddOnOption | done | Card (interactive, selected) + Typography + Switch | Toggleable add-on for arrangement flow extras. Heading + optional price + description + Switch. Click-anywhere toggle. Maps to Figma ListItemAddItem (2350:40658). | | AddOnOption | done | Card (interactive, selected) + Typography + Switch | Toggleable add-on for arrangement flow extras. Heading + optional price + description + Switch. Click-anywhere toggle. Maps to Figma ListItemAddItem (2350:40658). |
@@ -58,6 +59,7 @@ duplicates) and MUST update it after completing one.
| ComparisonColumnCard | done | Card + Badge + Button + Divider + Typography + Tooltip + Link + StarRoundedIcon + VerifiedOutlinedIcon | Desktop column header card for ComparisonTable. Floating badge: **medium** (26px) filled brand + StarRoundedIcon for recommended; soft brand + VerifiedOutlinedIcon for verified. Provider name **wraps to 2 lines** (`WebkitLineClamp: 2`) in a reserved 36px minHeight slot bottom-aligned so 1-line names anchor with location/rating/price at a consistent baseline. Recommended card: 2px brand-600 border + warm `selected` Card state + inline VerifiedOutlinedIcon left of name. `pt: 5` (40px breathing above name), uniform regardless of verified/recommended. Remove link always renders as the same Link element (visibility-hidden when not applicable) so CTA+footer align across all cards. Per-column wrapper in ComparisonTable is `display: flex` with `flex: 1` passed to the card root so all cards stretch to row height. Extracted from ComparisonTable (2026-04-12). | | ComparisonColumnCard | done | Card + Badge + Button + Divider + Typography + Tooltip + Link + StarRoundedIcon + VerifiedOutlinedIcon | Desktop column header card for ComparisonTable. Floating badge: **medium** (26px) filled brand + StarRoundedIcon for recommended; soft brand + VerifiedOutlinedIcon for verified. Provider name **wraps to 2 lines** (`WebkitLineClamp: 2`) in a reserved 36px minHeight slot bottom-aligned so 1-line names anchor with location/rating/price at a consistent baseline. Recommended card: 2px brand-600 border + warm `selected` Card state + inline VerifiedOutlinedIcon left of name. `pt: 5` (40px breathing above name), uniform regardless of verified/recommended. Remove link always renders as the same Link element (visibility-hidden when not applicable) so CTA+footer align across all cards. Per-column wrapper in ComparisonTable is `display: flex` with `flex: 1` passed to the card root so all cards stretch to row height. Extracted from ComparisonTable (2026-04-12). |
| ComparisonTabCard | done | Card + Badge + Typography + StarRoundedIcon | Mobile tab rail card for ComparisonPage. Provider name + package name + price. Recommended badge in normal flow with negative margin overlap — **filled brand + StarRoundedIcon** (matches desktop ComparisonColumnCard treatment, size="small" at 14px icon). **Fixed 235px width** (was 210). Border `brand-600` when recommended (consistent with primary). No glow — uses standard `shadow-sm` like other cards. `pt: 3.5` inside card. Shared by V1 and V2 (extracted 2026-04-12). | | ComparisonTabCard | done | Card + Badge + Typography + StarRoundedIcon | Mobile tab rail card for ComparisonPage. Provider name + package name + price. Recommended badge in normal flow with negative margin overlap — **filled brand + StarRoundedIcon** (matches desktop ComparisonColumnCard treatment, size="small" at 14px icon). **Fixed 235px width** (was 210). Border `brand-600` when recommended (consistent with primary). No glow — uses standard `shadow-sm` like other cards. `pt: 3.5` inside card. Shared by V1 and V2 (extracted 2026-04-12). |
| NearbyPackageCard | done | Card (outlined, interactive) + Typography + StarRoundedIcon + LocationOnOutlinedIcon | Compact card representing a package offered by a nearby verified provider — package name + price + provider + rating + location. Used in the "Similar packages from verified providers nearby" section of PackagesStep for unverified tiers. Click is a route change to that verified provider's PackagesStep with this package loaded. Extracted from UnverifiedPackageT2/T3 during 2026-04-17 consolidation. | | NearbyPackageCard | done | Card (outlined, interactive) + Typography + StarRoundedIcon + LocationOnOutlinedIcon | Compact card representing a package offered by a nearby verified provider — package name + price + provider + rating + location. Used in the "Similar packages from verified providers nearby" section of PackagesStep for unverified tiers. Click is a route change to that verified provider's PackagesStep with this package loaded. Extracted from UnverifiedPackageT2/T3 during 2026-04-17 consolidation. |
| ClusterPopup | done | Paper + Typography + IconButton + ButtonBase + inline provider-rows (fixed verified-icon slot + name + location + rating) | Cluster list popup — appears when a cluster marker is clicked. Header bar ("N providers in this area" + close X), scrollable stack of image-free provider rows (reserved verified-icon slot so titles align across tiers, name in copper for verified / neutral for unverified, location + rating meta). Verified-first sort order. 320px wide, matches MapPopup's card + nub + drop-shadow. Click on a row calls `onSelectProvider(id)`; in the ProviderMap flow that pans+zooms the map to the provider's coords (zoom 15) and opens their single-provider popup — there's no back-to-list. See D043, D044. |
## Organisms ## Organisms
@@ -74,6 +76,7 @@ duplicates) and MUST update it after completing one.
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. | | ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). | | Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
| Footer | done | Link × n + Typography + Divider + Container + Grid | Light grey (surface.subtle) site footer — matches header. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). | | Footer | done | Link × n + Typography + Divider + Container + Grid | Light grey (surface.subtle) site footer — matches header. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
| ProviderMap | done | MapPin + ClusterMarker + MapPopup + ClusterPopup + `@vis.gl/react-google-maps` + `@googlemaps/markerclusterer` | Google Maps provider map with clustering. **Individual pins**: `MapPin` per provider — click morphs it into a `MapPopup` at the same coord. **Clusters**: pins within 70px of each other collapse into a `ClusterMarker` (count badge) *only while zoomed out at level 13 or below* — past that, every pin shows individually. Click a cluster → `ClusterPopup` list at the cluster centroid, verified-first. **Click a row → map pans + zooms to that provider's coords (zoom 15) and opens their `MapPopup`**; cluster state cleared (no back-to-list). Map-background click reverts to pins. Markers are rendered imperatively (via `createRoot` into AdvancedMarker content elements) so `markerclusterer` can group them; popup layer remains declarative React. `selectedProviderId` force-opens a popup for external selection. Subtle empty state when no API key or no providers have coords (no throw). API key read from `VITE_GOOGLE_MAPS_API_KEY`. Desktop-only for now; mobile map sheet deferred. See D041, D042, D043, D044. |
## Templates ## Templates

View File

@@ -26,6 +26,152 @@ Each entry follows this structure:
## Sessions ## Sessions
### Session 2026-04-22 — Session-end handoff
**Status at end of session:** Map work reached a stable checkpoint. User asked to resume in a new session rather than continue accumulating context. All work committed.
**What's next (for the next session to pick up):**
1. **ProvidersStep component polish** — user wants iteration on the step itself and some of its constituent components (likely SearchBar, filter chips, FilterPanel, ProviderCard layout, sort/view-mode controls). No specific issues flagged yet — a fresh design review pass will surface them.
2. **Mobile layout for ProvidersStep + ProviderMap.** The existing `list-map` WizardLayout hides the right (map) panel entirely on mobile via `display: { xs: 'none', md: 'flex' }`. The `viewMode: 'list' | 'map'` toggle is wired in state but doesn't yet control panel visibility on small screens. Two possible approaches to discuss: (a) wire `viewMode` to swap panels on mobile (list OR map, full-bleed), or (b) a bottom-sheet overlay pattern where the map is full-bleed and the list is a drawer. Worth a design conversation before building.
3. **Demo deploy** (`/preflight` + `npm run demo:publish`) — held until the above lands and the user is ready to share.
**Deferred** (previously flagged, still open): animated morph between pin and popup landed this session, so it's off the list; the only remaining map follow-up is mobile — folded into item 2 above.
---
### Session 2026-04-21c — ClusterMarker + ClusterPopup + clustering in ProviderMap
**Agent(s):** Claude Opus 4.7 (1M context)
**Context:** User iterated on clustering in Figma Make (reference: https://www.figma.com/make/704nCLj7uFqIQzBmAA21ql/Funeral-Provider-Finder-Map). Picked the "list-popup" approach over zoom-in: clicking a cluster morphs it into a scrollable list of providers rather than zooming the map. Directive: style pin + popup to match FA design system, use a version of existing cards in the list rows, impose rules on when clustering activates.
**Decisions made (D043):**
- **Library**: `@googlemaps/markerclusterer` (15KB gz, MIT, official Google team). `GridAlgorithm` for screen-pixel-based clustering — not geographic distance (pixel distance adapts to zoom automatically).
- **Rules**: `gridSize: 70` pixels, `maxZoom: 13` — past zoom 13 every pin shows individually no matter how close, so tight geographic clusters only form when the user's actually zoomed out.
- **UI model**: cluster click → `ClusterPopup` list at centroid (no zoom-in). Row click → drill into `MapPopup` with back chevron to return to list. Map click → revert all.
- **Row layout**: inlined 48px-thumbnail row inside `ClusterPopup`, *not* `ProviderCardCompact`. That molecule's 120160px thumbnail left only ~120px for the name column at 320px popup width; long names overlapped the image. The inlined 48px layout fits 5+ rows without scrolling.
- **Marker rendering change**: markers now rendered *imperatively* (React `createRoot` into `AdvancedMarkerElement.content` divs) because `markerclusterer` requires imperative marker instances. Popup layer (`MapPopup`, `ClusterPopup`) stays declarative via vis.gl's `<AdvancedMarker>` JSX.
**Work completed:**
- `npm install --legacy-peer-deps @googlemaps/markerclusterer`.
- New atom `src/components/atoms/ClusterMarker/` — 36px circular count badge, sibling palette to MapPin (`hasVerified` → brand-700 promoted, else neutral-100). Same nub + shadow. 5 stories.
- New molecule `src/components/molecules/ClusterPopup/` — scrollable list popup with header bar ("N providers in this area" + close X), verified-first sort, 320px wide, matches MapPopup's nub. Internal `ProviderRow` sub-component: 48px thumbnail (or location-icon fallback), name + location + rating + verified check. 5 stories.
- `src/components/molecules/MapPopup/MapPopup.tsx` — added `onBack?: () => void` prop. When provided, renders a small chevron IconButton in the top-left of the card (absolute-positioned on Paper which now has `position: relative`). Used by ProviderMap when a MapPopup was drilled into from a cluster list, so the user can return to the list.
- `src/components/organisms/ProviderMap/ProviderMap.tsx`**full refactor**:
- Imperative markers via `useMapsLibrary('marker')` + `createRoot` mounting `<MapPin />` into each marker's content div. Roots unmounted on cleanup.
- `MarkerClusterer` wraps the markers with `GridAlgorithm({ gridSize: 70, maxZoom: 13 })`. Custom `renderer` creates cluster markers with `<ClusterMarker />` content.
- State machine: `activeProviderId`, `activeCluster: { providers, position }`. When cluster is open (no drilled provider), `ClusterPopup` renders at `activeCluster.position`. When drilled (both set), `MapPopup` renders with `onBack` chevron. Map click clears both.
- `hiddenIds` set: providers whose popup is currently showing (or whose cluster's popup is showing) get filtered out of the marker layer. Rebuilds markers on change.
- Callbacks stashed in refs to avoid marker rebuild on parent re-render.
- Vis.gl `Map` import renamed to `GoogleMap` to avoid collision with JS `Map` constructor.
- Registry: MapPin row unchanged; new rows for `ClusterMarker` (atom) and `ClusterPopup` (molecule); `ProviderMap` row rewritten to describe clustering + drill-in state machine.
- Decisions log: D043 added.
**Preflight:** TS clean, ESLint clean. Verified in Storybook + via Playwright screenshot — cluster of 4 east-coast providers collapses on initial zoom (NSWQLD view), clicking opens `ClusterPopup` with verified providers sorted to top, layout clean at 320px.
**Open questions:**
- None blocking. Drill-in back navigation not explicitly tested via Playwright but code path is straightforward.
**Next steps:**
- User visual review of clustering in Storybook.
- Then `/preflight` + `npm run demo:publish` to push to `parsons.tensordesign.com.au`.
- Future: mobile map sheet (still deferred), possibly animated morph (framer-motion layoutId) if the simple swap feels abrupt in use.
**Post-review tweaks (same session):**
- **Font regression fix** — `(t: Theme) => t.typography.fontFamily` accessors in `MapPin` + `ClusterMarker` don't work when the component is rendered via `createRoot` into an imperative marker content div (no ThemeProvider in that disconnected tree → MUI default Roboto). Swapped to `fontFamily: 'var(--fa-font-family-body)'` — CSS vars are global and propagate regardless of React tree. Lesson: **any component that might be mounted via `createRoot` must avoid MUI theme callback accessors for fonts/colours; use CSS vars instead.**
- **Unverified avatar** (reverted — see D044) — briefly added 48×48 initials fallback for providers without photos; removed when the whole image column was dropped.
- **Verified icon position** — moved from right of the provider name to left (before the name in the flex row). Matches list-convention for "tier indicator then content."
- **Popup z-index** — active popup AdvancedMarkers bumped from `zIndex={100}` to `zIndex={1000}` to cleanly beat any lat-based stacking, so open popups always sit on top of other markers.
- **Image-free cluster rows + pan+zoom drill-in (D044)** — after review, the mixed thumbnail / initials-avatar treatment felt fragmented. Dropped the image column entirely in `ClusterPopup` rows; row layout is now a fixed verified-icon slot (so titles align across tiers) + name + location/rating. Drilling into a provider now **pans and zooms the map** to their coords at zoom 15 (past `CLUSTER_MAX_ZOOM`, so cluster members break apart into nearby pins around the selected one) and opens their `MapPopup`. Cluster state is cleared on drill-in — the back chevron and `onBack` prop on `MapPopup` are removed (zoom-out-to-reform-cluster is the natural backwards flow). New `MapRefCapture` internal component in ProviderMap uses `useMap()` to stash the map instance in a ref so `handleDrillIntoProvider` (outside the Map context) can call `panTo`/`setZoom`.
- **ClusterPopup polish (2026-04-22)** — three refinements: (a) verified icon now aligns with the provider name's top line (outer row switched to `alignItems: flex-start`, icon slot given `height: 1.25em` so it sits on the name's line-box instead of the row's vertical centre); (b) per-row price added — `startingPrice` and `priceLabel` extended onto the `ClusterPopupProvider` interface, right-aligned "From $X" column with copper colouring for verified and text.primary for unverified; (c) smooth open/close transitions — new optional `exiting` prop on `MapPopup` and `ClusterPopup` drives an opacity+scale CSS transition (180ms, `transformOrigin: bottom center` so the pin-point stays put). `ProviderMap` now routes map-clicks and cluster-close through a shared `closeWithExit` handler that sets `exiting=true`, waits 180ms, then actually clears state. Ref-backed timer + `cancelExit` guard handles rapid-click races. Matching 180ms opacity fade-in added to `MapPin` and `ClusterMarker` atoms via `@keyframes` so pins reappear smoothly instead of snapping in when a popup unmounts.
- **Click-leak bugs (late fix same day)** — two user-reported issues traced to the same root cause: DOM clicks inside popups + cluster marker clicks were bubbling to `Map.onClick`, which cleared our state the same frame we set it.
- Symptom 1: clicking a row in the cluster popup zoomed the map but didn't open the MapPopup; user had to click the pin again.
- Symptom 2: clicking a cluster while a provider popup was open didn't close the provider popup (the provider's state survived because `handleClusterClick` and `handleMapClick` ran in sequence with `handleMapClick` winning last).
- **Fixes applied**: (a) Switched cluster clicks to `MarkerClusterer`'s native `onClusterClick` option — this both overrides the library's default "zoom to fit cluster" behaviour and is the proper hook to stop the event. Defensively tries `event.stop()`, `event.stopPropagation()`, and `event.domEvent?.stopPropagation()` because the event shape passed by `markerclusterer` v2.6 is not quite the typed `google.maps.MapMouseEvent` (missing `.stop()`). (b) Added `marker.addListener('click', event => { event.stop(); ... })` on each pin marker to stop propagation at the Google Maps event level (in addition to the existing DOM `stopPropagation` from MapPin's onClick, which stays for keyboard users). (c) Added DOM-level `stopPropagation` on all interactive elements inside `ClusterPopup` (rows, close button, and a catch-all on the root Box for empty-area clicks) and wrapped `MapPopup`'s onClick handler so it stops propagation too — the molecule now assumes it's rendered inside a map context. **Lesson:** any React content rendered inside a Google Maps `AdvancedMarker.content` div that sits over a map with its own `onClick` must stop both the DOM click (for React-handled elements) *and* the Google Maps click event (for marker-attached listeners) — Google's 'click' event is separate from DOM bubbling.
---
### Session 2026-04-21b — MapPin simplification + ProviderMap morph behaviour
**Agent(s):** Claude Opus 4.7 (1M context)
**Context:** User reviewed the freshly-built ProviderMap in Storybook and requested interaction + visual changes.
**Decisions made (D042):**
- **Morph over overlay** — clicking a pin replaces it with a `MapPopup` at the same coord (single marker per location). Map-click reverts to pin. `POPUP_LIFT_PX` transform removed.
- **Verified pins promoted** to the former verified-active palette (`brand-700` bg, white text, `brand-200` price). Unverified unchanged.
- **Variants dropped** — MapPin name-only / price-only / active variants removed. `name` required; `active` prop gone. Selection handled at the organism level.
- **No consolidation** of MapPin + MapPopup into one component — organism-level swap preserves standalone reusability and keeps atoms lightweight.
**Work completed:**
- `MapPin.tsx` — rewritten palette constants (5 entries per tier, no active-*); `active` prop removed; `name` now required; JSDoc updated; a11y aria-label simplified.
- `MapPin.stories.tsx` — 9 stories → 5 (`Verified`, `Unverified`, `CustomPriceLabel`, `LongName`, `MapSimulation`).
- `MapPopup.stories.tsx` — one usage of `<MapPin ... active />` updated to drop the removed prop.
- `ProviderMap.tsx``PinMarker` + `PopupMarker` now mutually exclusive per provider via `activeId = activeMarkerId ?? selectedProviderId`. `PopupMarker` no longer transforms; it renders at the pin's coord. `POPUP_LIFT_PX` constant removed. `active` wiring to MapPin removed.
- Registry: MapPin and ProviderMap rows rewritten to match new behaviour.
- Decisions log: D042 added.
**Preflight:** TS clean, ESLint clean. Storybook HMR picked up the changes cleanly.
**Open questions:**
- User previewed in Storybook; next iteration will cover clustering/stacking behaviour when zoomed out.
**Next steps:**
- User visual review of morph + new palette.
- Then `/preflight` + `npm run demo:publish` to push to `parsons.tensordesign.com.au`.
- Future: clustering via `@googlemaps/markerclusterer`, and the deferred mobile map sheet.
---
### Session 2026-04-21 — ProviderMap organism (Google Maps) + wire into arrangement demo
**Agent(s):** Claude Opus 4.7 (1M context)
**Context:** The arrangement demo at parsons.tensordesign.com.au/arrangement had an empty `mapPanel` slot on ProvidersStep. Build the map organism and wire it into the demo. Prior session's registry had `MapPin` (atom, done), `MapPopup` (molecule, done), and `MapCard` (molecule, planned — "deferred until map integration").
**Decisions made:**
- **D041** — Google Maps via `@vis.gl/react-google-maps`; `MapCard` retired (superseded by `MapPopup`). Popup rendered as a second `AdvancedMarker` (not Google `InfoWindow`) to preserve `MapPopup`'s own nub and avoid competing chrome. Client already runs Google Maps in production, so the demo's stack matches — zero migration if this becomes prod.
- **API key**: demo-owned, created under Richie's Google Cloud account (`fa-demo-maps` project). Restricted to HTTP referrers: localhost:6006, localhost:5173, localhost:4173, and `https://parsons.tensordesign.com.au/*`. Scoped to Maps JavaScript API only. Key lives in `.env.local` (gitignored via `.env.*`), `VITE_GOOGLE_MAPS_API_KEY`. Vite bakes it into the bundle at build time — exposed to the browser, but the referrer restriction is the real security boundary.
- **Mobile deferred**: the existing `list-map` WizardLayout hides the right panel entirely on mobile (`display: { xs: 'none', md: 'flex' }`). Wiring the `viewMode: 'list' | 'map'` toggle to a panel swap, or building a mobile map sheet, is layout work that touches other wizard steps — out of scope for this task. Desktop-only for now.
**Work completed:**
- `npm install --legacy-peer-deps @vis.gl/react-google-maps` (pre-existing Storybook addon-a11y@8.6.14 ↔ react@8.6.18 peer conflict unrelated to our package).
- New organism `src/components/organisms/ProviderMap/{ProviderMap.tsx,ProviderMap.stories.tsx,index.ts}`:
- `APIProvider` + `Map` (mapId `fa-provider-map`, `disableDefaultUI` + `zoomControl`, `gestureHandling="greedy"`, `onClick` closes popup).
- Internal `FitBounds` component (uses `useMap` + `useEffect`) auto-fits bounds across all geocoded providers on mount/update; single-provider maps centre at zoom 13 with explicit `setCenter`/`setZoom`.
- Internal `PinMarker``AdvancedMarker` wrapping our `MapPin` atom. `active` = `selectedProviderId === p.id || activeMarkerId === p.id`. zIndex lifts when active.
- Internal `PopupMarker` — second `AdvancedMarker` at same coords with `transform: translateY(-50px)` on the wrapper, rendering `MapPopup` molecule. zIndex 100. Click → `onSelectProvider(id)`.
- Empty states: `!apiKey` → "Map unavailable — Google Maps API key not configured"; no geocoded providers → "Map unavailable — No provider locations to display". Never throws.
- Root Box: `role="application"`, `aria-label="Provider map"`, `display: flex; flex: 1; minHeight: 300; bgcolor: surface-cool`.
- 7 stories: `Default`, `WithSelectedProvider`, `InteractiveSelection`, `NoCoords`, `NoApiKey`, `SingleProvider`, `PartialCoords`.
- Extended `ProviderData` type with `coords?: { lat: number; lng: number }` (optional — legacy callers unaffected).
- Added real coords to the 7 demo providers in `src/demo/shared/fixtures/providers.ts`:
- parsons → Wentworth NSW (-34.1074, 141.9166)
- rankins → Warrawong NSW (-34.4870, 150.8970)
- wollongong-city → Wollongong NSW (-34.4278, 150.8931)
- killick → Kingaroy QLD (-26.5408, 151.8388)
- mackay → Ourimbah NSW (-33.3644, 151.3728)
- mannings → Bega NSW (-36.6742, 149.8417)
- botanical → Newtown NSW (-33.8988, 151.1794)
- Wired `<ProviderMap>` into `src/demo/apps/arrangement/routes/Providers.tsx` via the `mapPanel` prop. Same `onSelectProvider` navigation target as the list.
- Registry updated: ProviderMap row added to Organisms; MapCard row marked `superseded` with pointer to D041.
- Decisions log: D041 added.
**Preflight:** TS clean, ESLint clean on the new component (2 warnings for unused `// eslint-disable-next-line no-alert` directives were auto-removed). Full preflight + deploy still pending.
**Open questions:**
- None blocking. Awaiting user's visual review in Storybook before deploy.
**Next steps:**
- User reviews ProviderMap stories in Storybook (already running on :6006).
- Run `/preflight` and `npm run demo:publish` once approved.
- Mobile map sheet / `viewMode` panel swap is the tracked follow-up.
---
### Session 2026-04-20 — PackageDetail polish + PackagesStep spacing/drill-in + NearbyPackageCard elevation ### Session 2026-04-20 — PackageDetail polish + PackagesStep spacing/drill-in + NearbyPackageCard elevation
**Agent(s):** Claude Opus 4.7 (1M context) **Agent(s):** Claude Opus 4.7 (1M context)
@@ -1369,3 +1515,37 @@ Each entry follows this structure:
- If v2 chosen: add location autocomplete, write flow logic reference doc - If v2 chosen: add location autocomplete, write flow logic reference doc
--- ---
### Session 2026-04-20 — Demo slice hosting end-to-end
**Agent(s):** Claude Opus 4.7 (1M context) + workstation server agent
**Work completed:**
- Scaffolded `src/demo/` (shared fixtures + Zustand basket + URL sync) and `src/demo/apps/arrangement/` (Providers → Packages → Comparison flow) consuming existing page components with mocked data
- Per-slice Vite build via `vite.demo.config.ts` (`--mode <slice>` selects app folder + base prefix + outDir)
- Wrote `nginx/parsons-demos.conf` (server agent fixed regex ordering bug — asset cache regex must come before SPA fallback regex per nginx first-match rule)
- Wrote `scripts/deploy-demo.sh` (rsync wrapper, slice-aware, gitignored due to server-specific paths)
- Wrote `docs/reference/client-demo-deploy.md` (server runbook)
- Wired `assetUrl()` helper at `src/demo/shared/assets.ts` — wraps `import.meta.env.BASE_URL` so public-asset URLs resolve under `/<slice>/` in production but stay flat in dev
- Added `new-demo-slice` skill at `.claude/skills/new-demo-slice/SKILL.md`
- Added npm scripts: `demo:dev`, `demo:build`, `demo:publish`
- 7 demo providers across verified/tier3/tier2 with real venue photography from `brandassets/images/venues/`
- Verified-provider packages restructured to share canonical 9-item Essentials per FA convention (factory helpers in `packages.ts`); only Optionals + Extras vary per provider/package
- ProviderCard logo `objectFit: cover → contain` so wide logos don't crop
- Demo deployed and live at `https://parsons.tensordesign.com.au/arrangement/` behind basic auth (user: `client`, htpasswd at `/config/nginx/.htpasswd-parsons`)
**Decisions made:**
- Use TrueNAS `/mnt/config/docker/parsons-demos/` as document root (boot-pool `/srv/` not persistable on TrueNAS SCALE)
- Single htpasswd covering all slices (split per-slice if/when client-specific auth is needed)
- nginx auth covers root path too — change to `auth_basic off;` if landing page needs to be public
- Did NOT prune publicDir bloat (1.1GB build) — user waved off, storage isn't constrained
- Server-side: rsync target uses TrueNAS `truenas` SSH alias as user `truenas_admin` (UID 950 = swag's container user, `:ro` mount)
**Open questions:**
- None blocking — everything live and working
**Next steps:**
- ProviderMap on ProvidersStep — currently empty `mapPanel` slot. Build as new organism via `/build-organism ProviderMap` (needs Leaflet/MapLibre + lat/lng added to provider fixtures + verified-vs-unverified pin treatment), then wire into demo
- PackageDetail "Added ✓" state — Compare button silently dedupes when basket already contains current selection; should show stateful feedback. New session via `/build-molecule` or `/polish` on PackageDetail
- (Low priority) Asset prune — switch publicDir to allowlist if deploy size becomes annoying
---

83
package-lock.json generated
View File

@@ -10,9 +10,11 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.0", "@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@googlemaps/markerclusterer": "^2.6.2",
"@mui/icons-material": "^5.16.0", "@mui/icons-material": "^5.16.0",
"@mui/material": "^5.16.0", "@mui/material": "^5.16.0",
"@mui/system": "^5.16.0", "@mui/system": "^5.16.0",
"@vis.gl/react-google-maps": "^1.8.3",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",
@@ -1460,6 +1462,26 @@
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@googlemaps/js-api-loader": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz",
"integrity": "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q==",
"license": "Apache-2.0",
"dependencies": {
"@types/google.maps": "^3.53.1"
}
},
"node_modules/@googlemaps/markerclusterer": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.6.2.tgz",
"integrity": "sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/supercluster": "^7.1.3",
"fast-equals": "^5.2.2",
"supercluster": "^8.0.1"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -4108,6 +4130,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/google.maps": {
"version": "3.64.0",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
"integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4171,6 +4205,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4480,6 +4523,21 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@vis.gl/react-google-maps": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.8.3.tgz",
"integrity": "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==",
"license": "MIT",
"dependencies": {
"@googlemaps/js-api-loader": "^2.0.2",
"@types/google.maps": "^3.54.10",
"fast-deep-equal": "^3.1.3"
},
"peerDependencies": {
"react": ">=16.8.0 || ^19.0 || ^19.0.0-rc",
"react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -6473,9 +6531,17 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -7865,6 +7931,12 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -10563,6 +10635,15 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -27,9 +27,11 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.13.0", "@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@googlemaps/markerclusterer": "^2.6.2",
"@mui/icons-material": "^5.16.0", "@mui/icons-material": "^5.16.0",
"@mui/material": "^5.16.0", "@mui/material": "^5.16.0",
"@mui/system": "^5.16.0", "@mui/system": "^5.16.0",
"@vis.gl/react-google-maps": "^1.8.3",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",

View File

@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ClusterMarker } from './ClusterMarker';
const meta: Meta<typeof ClusterMarker> = {
title: 'Atoms/ClusterMarker',
component: ClusterMarker,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
argTypes: {
onClick: { action: 'clicked' },
},
};
export default meta;
type Story = StoryObj<typeof ClusterMarker>;
/** Cluster containing at least one verified provider — promoted palette */
export const MixedOrVerified: Story = {
args: {
count: 5,
hasVerified: true,
},
};
/** Cluster of all-unverified providers — neutral palette */
export const AllUnverified: Story = {
args: {
count: 3,
hasVerified: false,
},
};
/** Small cluster — pair of providers */
export const Pair: Story = {
args: {
count: 2,
hasVerified: true,
},
};
/** Large cluster — double-digit count */
export const LargeCluster: Story = {
args: {
count: 27,
hasVerified: true,
},
};
/** Side-by-side comparison — verified vs unverified at various counts */
export const PaletteGrid: Story = {
render: () => (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 6,
p: 4,
}}
>
<ClusterMarker count={2} hasVerified />
<ClusterMarker count={5} hasVerified />
<ClusterMarker count={12} hasVerified />
<ClusterMarker count={99} hasVerified />
<ClusterMarker count={2} />
<ClusterMarker count={5} />
<ClusterMarker count={12} />
<ClusterMarker count={99} />
</Box>
),
};

View File

@@ -0,0 +1,161 @@
import React from 'react';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA ClusterMarker atom */
export interface ClusterMarkerProps {
/** Number of providers in this cluster */
count: number;
/** True if any provider in the cluster is verified — drives the promoted palette */
hasVerified?: boolean;
/** Click handler — opens the cluster popup */
onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
const BADGE_SIZE = 36;
// ─── Colour sets — matches MapPin ───────────────────────────────────────────
const colours = {
verified: {
bg: 'var(--fa-color-brand-700)',
text: 'var(--fa-color-white)',
border: 'var(--fa-color-brand-700)',
nub: 'var(--fa-color-brand-700)',
},
unverified: {
bg: 'var(--fa-color-neutral-100)',
text: 'var(--fa-color-neutral-800)',
border: 'var(--fa-color-neutral-300)',
nub: 'var(--fa-color-neutral-100)',
},
} as const;
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Cluster map marker for the FA design system.
*
* Circular pill with a count, representing N provider pins grouped at the
* same screen location. Sibling to `MapPin` — same palette language (verified
* promoted, unverified neutral), same nub treatment, same shadow.
*
* `hasVerified` drives the palette: if *any* provider in the cluster is
* verified, the cluster adopts the promoted (brand-700) palette. All-unverified
* clusters use the neutral palette.
*
* Designed for use as the `render`-ed output of `@googlemaps/markerclusterer`.
* Pure CSS + SVG — no canvas. role="button" + keyboard + focus ring.
*
* Usage:
* ```tsx
* <ClusterMarker count={5} hasVerified onClick={...} />
* <ClusterMarker count={12} />
* ```
*/
export const ClusterMarker = React.forwardRef<HTMLDivElement, ClusterMarkerProps>(
({ count, hasVerified = false, onClick, sx }, ref) => {
const palette = hasVerified ? colours.verified : colours.unverified;
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick(e as unknown as React.MouseEvent);
}
};
const label = `${count} providers in this area`;
return (
<Box
ref={ref}
role="button"
tabIndex={0}
aria-label={label}
onClick={onClick}
onKeyDown={handleKeyDown}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
transition: 'transform 150ms ease-in-out',
// Fade in on mount — matches MapPin and popups for a consistent
// entry timing across the map.
'@keyframes clusterMarkerIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'clusterMarkerIn 180ms ease-out',
'&:hover': { transform: 'scale(1.08)' },
'&:focus-visible': {
outline: 'none',
'& > .ClusterMarker-badge': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: '2px',
},
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
{/* Circular badge */}
<Box
className="ClusterMarker-badge"
sx={{
width: BADGE_SIZE,
height: BADGE_SIZE,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: palette.bg,
border: '1px solid',
borderColor: palette.border,
boxShadow: 'var(--fa-shadow-sm)',
color: palette.text,
fontFamily: 'var(--fa-font-family-body)',
fontSize: 14,
fontWeight: 700,
lineHeight: 1,
}}
>
{count}
</Box>
{/* Nub — same SVG pattern as MapPin for visual continuity */}
<svg
aria-hidden
viewBox="0 0 16 8"
style={{
display: 'block',
width: `calc(2 * ${NUB_SIZE})`,
height: NUB_SIZE,
marginTop: '-1px',
overflow: 'visible',
}}
>
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
<path
d="M 0 0 L 8 8 L 16 0"
fill="none"
stroke={palette.border}
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
</Box>
);
},
);
ClusterMarker.displayName = 'ClusterMarker';
export default ClusterMarker;

View File

@@ -0,0 +1 @@
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';

View File

@@ -21,8 +21,8 @@ const meta: Meta<typeof MapPin> = {
export default meta; export default meta;
type Story = StoryObj<typeof MapPin>; type Story = StoryObj<typeof MapPin>;
/** Verified provider with name and price — warm brand label */ /** Verified provider — promoted brand palette (dark copper bg, white text) */
export const VerifiedWithPrice: Story = { export const Verified: Story = {
args: { args: {
name: 'H.Parsons Funeral Directors', name: 'H.Parsons Funeral Directors',
price: 900, price: 900,
@@ -31,7 +31,7 @@ export const VerifiedWithPrice: Story = {
}; };
/** Unverified provider — neutral grey label */ /** Unverified provider — neutral grey label */
export const UnverifiedWithPrice: Story = { export const Unverified: Story = {
args: { args: {
name: 'Smith & Sons Funerals', name: 'Smith & Sons Funerals',
price: 1200, price: 1200,
@@ -39,66 +39,7 @@ export const UnverifiedWithPrice: Story = {
}, },
}; };
/** Active/selected state — inverted colours, slight scale-up */ /** Custom price label (e.g. "POA" for providers without a fixed starting price) */
export const Active: Story = {
args: {
name: 'H.Parsons Funeral Directors',
price: 900,
verified: true,
active: true,
},
};
/** Active unverified */
export const ActiveUnverified: Story = {
args: {
name: 'Smith & Sons Funerals',
price: 1200,
verified: false,
active: true,
},
};
/** Name only — no price line */
export const NameOnly: Story = {
args: {
name: 'Lady Anne Funerals',
verified: true,
},
};
/** Name only, unverified */
export const NameOnlyUnverified: Story = {
args: {
name: 'Local Funeral Services',
},
};
/** Price-only pill — no name, verified */
export const PriceOnly: Story = {
args: {
price: 900,
verified: true,
},
};
/** Price-only pill — unverified */
export const PriceOnlyUnverified: Story = {
args: {
price: 1200,
},
};
/** Price-only pill — active */
export const PriceOnlyActive: Story = {
args: {
price: 900,
verified: true,
active: true,
},
};
/** Custom price label */
export const CustomPriceLabel: Story = { export const CustomPriceLabel: Story = {
args: { args: {
name: 'Premium Services', name: 'Premium Services',
@@ -141,7 +82,7 @@ export const MapSimulation: Story = {
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} /> <MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
</Box> </Box>
<Box sx={{ position: 'absolute', top: 150, left: 280 }}> <Box sx={{ position: 'absolute', top: 150, left: 280 }}>
<MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} /> <MapPin name="Lady Anne Funerals" price={1450} verified onClick={() => {}} />
</Box> </Box>
<Box sx={{ position: 'absolute', top: 260, left: 140 }}> <Box sx={{ position: 'absolute', top: 260, left: 140 }}>
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} /> <MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
@@ -152,12 +93,7 @@ export const MapSimulation: Story = {
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} /> <MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
</Box> </Box>
<Box sx={{ position: 'absolute', top: 300, left: 400 }}> <Box sx={{ position: 'absolute', top: 300, left: 400 }}>
<MapPin name="Local Provider" onClick={() => {}} /> <MapPin name="Local Provider" price={1600} onClick={() => {}} />
</Box>
{/* Name only verified */}
<Box sx={{ position: 'absolute', top: 40, left: 500 }}>
<MapPin name="Kenneallys" verified onClick={() => {}} />
</Box> </Box>
</> </>
), ),

View File

@@ -6,16 +6,14 @@ 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 {
/** Provider or venue name — omit for a price-only pill */ /** Provider or venue name (required — shown as line 1) */
name?: string; name: string;
/** Starting package price in dollars — shown as "From $X" */ /** Starting package price in dollars — shown as "From $X" on line 2 */
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;
/** Whether this provider/venue is verified (brand colour vs neutral) */ /** Whether this provider/venue is verified (brand palette vs neutral palette) */
verified?: boolean; verified?: boolean;
/** Whether this pin is currently active/selected */
active?: boolean;
/** Click handler */ /** Click handler */
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
/** MUI sx prop for the root element */ /** MUI sx prop for the root element */
@@ -33,28 +31,18 @@ const MAX_WIDTH = 180;
const colours = { const colours = {
verified: { verified: {
bg: 'var(--fa-color-brand-100)', bg: 'var(--fa-color-brand-700)',
name: 'var(--fa-color-brand-900)', name: 'var(--fa-color-white)',
price: 'var(--fa-color-brand-600)', price: 'var(--fa-color-brand-200)',
activeBg: 'var(--fa-color-brand-700)', nub: 'var(--fa-color-brand-700)',
activeName: 'var(--fa-color-white)', border: 'var(--fa-color-brand-700)',
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: { unverified: {
bg: 'var(--fa-color-neutral-100)', bg: 'var(--fa-color-neutral-100)',
name: 'var(--fa-color-neutral-800)', name: 'var(--fa-color-neutral-800)',
price: 'var(--fa-color-neutral-500)', 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)', nub: 'var(--fa-color-neutral-100)',
activeNub: 'var(--fa-color-neutral-700)',
border: 'var(--fa-color-neutral-300)', border: 'var(--fa-color-neutral-300)',
activeBorder: 'var(--fa-color-neutral-700)',
}, },
} as const; } as const;
@@ -68,26 +56,25 @@ const colours = {
* the exact map location. * the exact map location.
* *
* - **Line 1**: Provider name (bold, truncated) * - **Line 1**: Provider name (bold, truncated)
* - **Line 2**: "From $X" (smaller, secondary colour) — optional * - **Line 2**: "From $X" (smaller, secondary colour)
* *
* Visual distinction: * Visual distinction:
* - **Verified** providers: warm brand palette (gold bg, copper text) * - **Verified** providers: warm brand palette (dark copper bg, white text)
* - **Unverified** providers: neutral grey palette * - **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. * Designed for use as custom HTML markers in Google Maps. Pure CSS — no
* Pure CSS — no canvas, no SVG dependency. * canvas, no SVG dependency. Selection/popup behaviour is handled at the
* organism level (ProviderMap swaps pin → popup on click).
* *
* Usage: * Usage:
* ```tsx * ```tsx
* <MapPin name="H.Parsons" price={900} verified onClick={...} /> * <MapPin name="H.Parsons" price={900} verified onClick={...} />
* <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/} * <MapPin name="Smith & Sons" price={1200} />
* <MapPin price={900} verified /> {/* Price-only pill, no name *\/} * <MapPin name="Botanical" priceLabel="POA" verified />
* <MapPin name="H.Parsons" price={900} verified active />
* ``` * ```
*/ */
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>( export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => { ({ name, price, priceLabel, verified = 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;
@@ -106,7 +93,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
ref={ref} ref={ref}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`} aria-label={`${name}${hasPrice ? `, packages from ${priceLabel ?? `$${price?.toLocaleString('en-AU')}`}` : ''}${verified ? ', verified' : ''}`}
onClick={onClick} onClick={onClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
sx={[ sx={[
@@ -116,7 +103,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.08)' : 'scale(1)', // Fade in on mount — matches the popup's exit timing so the pin
// reappears smoothly when a popup closes.
'@keyframes mapPinIn': {
from: { opacity: 0 },
to: { opacity: 1 },
},
animation: 'mapPinIn 180ms ease-out',
'&:hover': { '&:hover': {
transform: 'scale(1.08)', transform: 'scale(1.08)',
}, },
@@ -142,53 +135,41 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
py: 0.5, py: 0.5,
px: PIN_PX, px: PIN_PX,
borderRadius: PIN_RADIUS, borderRadius: PIN_RADIUS,
backgroundColor: active ? palette.activeBg : palette.bg, backgroundColor: palette.bg,
border: '1px solid', border: '1px solid',
borderColor: active ? palette.activeBorder : palette.border, borderColor: palette.border,
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)', boxShadow: 'var(--fa-shadow-sm)',
transition:
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
}} }}
> >
{/* Name */} {/* Name */}
{name && (
<Box <Box
component="span" component="span"
sx={{ sx={{
fontSize: 12, fontSize: 12,
fontWeight: 700, fontWeight: 700,
fontFamily: (t: Theme) => t.typography.fontFamily, fontFamily: 'var(--fa-font-family-body)',
lineHeight: 1.3, lineHeight: 1.3,
color: active ? palette.activeName : palette.name, color: palette.name,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
maxWidth: '100%', maxWidth: '100%',
transition: 'color 150ms ease-in-out',
}} }}
> >
{name} {name}
</Box> </Box>
)}
{/* Price line */} {/* Price line */}
{hasPrice && ( {hasPrice && (
<Box <Box
component="span" component="span"
sx={{ sx={{
fontSize: !name ? 12 : 11, fontSize: 11,
fontWeight: !name ? 700 : 600, fontWeight: 600,
fontFamily: (t: Theme) => t.typography.fontFamily, fontFamily: 'var(--fa-font-family-body)',
lineHeight: 1.2, lineHeight: 1.2,
color: !name color: palette.price,
? active
? palette.activeName
: palette.name
: active
? palette.activePrice
: palette.price,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
transition: 'color 150ms ease-in-out',
}} }}
> >
{priceText} {priceText}
@@ -196,19 +177,33 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
)} )}
</Box> </Box>
{/* Nub — downward pointer */} {/* Nub — downward pointer. Two SVG paths:
<Box • fill is an extended pentagon that overhangs 3 units *into* the
pill's bg so sub-pixel scaling artifacts (hover transform) can't
expose the pill's bottom border through the seam;
• stroke is a separate open path on the two slanted sides only,
so the nub outline is continuous with the pill's border.
overflow: visible lets the fill render above the viewBox. */}
<svg
aria-hidden aria-hidden
sx={{ viewBox="0 0 16 8"
width: 0, style={{
height: 0, display: 'block',
borderLeft: `${NUB_SIZE} solid transparent`, width: `calc(2 * ${NUB_SIZE})`,
borderRight: `${NUB_SIZE} solid transparent`, height: NUB_SIZE,
borderTop: `${NUB_SIZE} solid`, marginTop: '-1px',
borderTopColor: active ? palette.activeNub : palette.nub, overflow: 'visible',
mt: '-1px',
}} }}
>
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
<path
d="M 0 0 L 8 8 L 16 0"
fill="none"
stroke={palette.border}
strokeWidth={1}
strokeLinejoin="round"
/> />
</svg>
</Box> </Box>
); );
}, },

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ClusterPopup } from './ClusterPopup';
const meta: Meta<typeof ClusterPopup> = {
title: 'Molecules/ClusterPopup',
component: ClusterPopup,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'map',
values: [{ name: 'map', value: '#E5E3DF' }],
},
},
decorators: [
(Story) => (
<Box sx={{ p: 4 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ClusterPopup>;
// Fixture data — mirrors the shape used in the demo
const mixedCluster = [
{
id: 'parsons',
name: 'H.Parsons Funeral Directors',
location: 'Wentworth, NSW',
verified: true,
rating: 4.6,
startingPrice: 1800,
},
{
id: 'rankins',
name: 'Rankins Funeral Services',
location: 'Warrawong, NSW',
verified: true,
rating: 4.8,
startingPrice: 2450,
},
{
id: 'wollongong-city',
name: 'Wollongong City Funerals',
location: 'Wollongong, NSW',
verified: false,
rating: 4.2,
startingPrice: 3400,
},
{
id: 'botanical',
name: 'Botanical Funerals',
location: 'Newtown, NSW',
verified: false,
rating: 4.9,
startingPrice: 5200,
},
];
/** Mixed-tier cluster — verified providers sorted to top */
export const Mixed: Story = {
args: {
providers: mixedCluster,
onSelectProvider: (id) => {
alert(`Drill into ${id}`);
},
onClose: () => {
alert('Close cluster');
},
},
};
/** Small pair — two providers at the same location */
export const Pair: Story = {
args: {
providers: mixedCluster.slice(0, 2),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** All verified — every provider in the cluster is a partner */
export const AllVerified: Story = {
args: {
providers: mixedCluster.filter((p) => p.verified),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** All unverified — no partners in this cluster */
export const AllUnverified: Story = {
args: {
providers: mixedCluster.filter((p) => !p.verified),
onSelectProvider: () => {},
onClose: () => {},
},
};
/** Tall cluster — scrolls when providers exceed visible area */
export const TallCluster: Story = {
args: {
providers: [
...mixedCluster,
...mixedCluster.map((p) => ({ ...p, id: `${p.id}-2`, name: `${p.name} (Branch 2)` })),
],
onSelectProvider: () => {},
onClose: () => {},
},
};

View File

@@ -0,0 +1,360 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import ButtonBase from '@mui/material/ButtonBase';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
import { Typography } from '../../atoms/Typography';
// ─── Types ──────────────────────────────────────────────────────────────────
/** A provider summary used in the cluster list */
export interface ClusterPopupProvider {
/** Unique provider ID */
id: string;
/** Provider display name */
name: string;
/** Location text (suburb, city) */
location: string;
/** Whether this is a verified/partner provider — drives sort order + colour accents */
verified?: boolean;
/** Average rating */
rating?: number;
/** Starting package price in dollars — shown as "From $X" on the right */
startingPrice?: number;
/** Custom price label (e.g. "POA") — overrides the formatted price */
priceLabel?: string;
}
/** Props for the FA ClusterPopup molecule */
export interface ClusterPopupProps {
/** Providers in this cluster */
providers: ClusterPopupProvider[];
/** Click handler — fires when a provider row is clicked */
onSelectProvider: (id: string) => void;
/** Close handler — fires when the close button is clicked */
onClose?: () => void;
/** When true, animates the popup out (opacity + scale) without unmounting.
* Callers should unmount after the transition completes (180ms). */
exiting?: boolean;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const POPUP_WIDTH = 320;
const MAX_CONTENT_HEIGHT = 360;
const NUB_SIZE = 8;
/** Fixed width reserved for the verified-icon slot so all row titles share
* the same x-origin regardless of whether the row is verified. */
const VERIFIED_SLOT_WIDTH = 18;
// ─── Row sub-component ──────────────────────────────────────────────────────
interface ProviderRowProps {
provider: ClusterPopupProvider;
onClick: () => void;
}
/**
* Single provider row inside the cluster list. Image-free layout:
* verified-icon slot (fixed width so titles align across rows) + name +
* location/rating meta. Full-width clickable surface. Clicking triggers
* `onClick` — in `ProviderMap` that pans+zooms the map to the provider's
* location and opens their single-provider popup.
*/
const ProviderRow: React.FC<ProviderRowProps> = ({ provider, onClick }) => {
const hasPrice = provider.startingPrice != null || provider.priceLabel != null;
const priceText =
provider.priceLabel ??
(provider.startingPrice != null ? `$${provider.startingPrice.toLocaleString('en-AU')}` : null);
return (
<ButtonBase
onClick={(e) => {
// stopPropagation so the DOM click doesn't bubble to Map.onClick
// (which would clear state the same frame we're trying to drill in).
e.stopPropagation();
onClick();
}}
sx={{
width: '100%',
display: 'flex',
// flex-start so the verified-icon slot aligns with the name's top line,
// not the vertical centre of the row.
alignItems: 'flex-start',
gap: 1,
p: 1.25,
borderRadius: 1,
textAlign: 'left',
transition: 'background-color 120ms ease-in-out',
'&:hover': {
bgcolor: provider.verified
? 'var(--fa-color-brand-50)'
: 'var(--fa-color-surface-subtle)',
},
'&:focus-visible': {
outline: '2px solid var(--fa-color-interactive-focus)',
outlineOffset: 2,
},
}}
>
{/* Verified-icon slot — reserved width + fixed line-height so the icon
sits vertically on the name's line-box regardless of whether the
row has location/rating/price content below. */}
<Box
sx={{
width: VERIFIED_SLOT_WIDTH,
flexShrink: 0,
height: '1.25em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{provider.verified && (
<VerifiedOutlinedIcon
sx={{ fontSize: 16, color: 'var(--fa-color-brand-600)' }}
aria-label="Verified provider"
/>
)}
</Box>
{/* Text column — name + location/rating meta */}
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
color: provider.verified ? 'var(--fa-color-brand-700)' : 'text.primary',
minWidth: 0,
lineHeight: 1.25,
}}
maxLines={1}
>
{provider.name}
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: 'text.secondary',
flexWrap: 'wrap',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<LocationOnOutlinedIcon sx={{ fontSize: 12 }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{provider.location}
</Typography>
</Box>
{provider.rating != null && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
{provider.rating}
</Typography>
</Box>
)}
</Box>
</Box>
{/* Price column — right-aligned, matches MapPopup's "From $X" typography.
Verified providers get the brand-600 copper price; unverified get
text.primary. "From" label uses caption/secondary for hierarchy. */}
{hasPrice && (
<Box
sx={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
pt: '1px',
}}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 10 }}>
From
</Typography>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: 13,
color: provider.verified ? 'var(--fa-color-brand-600)' : 'text.primary',
lineHeight: 1.2,
}}
>
{priceText}
</Typography>
</Box>
)}
</ButtonBase>
);
};
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Cluster popup card for the FA design system.
*
* Appears when a cluster marker is clicked. Shows the providers grouped at
* that map location as a scrollable stack of image-free rows — each row: a
* fixed-width verified-icon slot (so titles align across mixed-tier lists) +
* provider name (copper for verified, neutral for unverified) + location and
* rating meta. Clicking a row calls `onSelectProvider(id)`. In the
* ProviderMap flow, that pans and zooms the map to the provider's location
* before opening their single-provider popup — restoring spatial context
* that a list-only popup otherwise loses.
*
* Verified providers are sorted to the top of the list (business outcome:
* promote partner providers in any crowded cluster).
*
* Sibling to `MapPopup` — same card + nub treatment, same drop-shadow, same
* 320px width, same `surface-subtle` header bar convention. Designed to
* render inside a Google Maps `AdvancedMarker`.
*
* Composes: Paper + Typography + IconButton + ButtonBase + icons.
*
* Usage:
* ```tsx
* <ClusterPopup
* providers={[
* { id: 'p1', name: 'H.Parsons', location: 'Wentworth', verified: true, rating: 4.6 },
* { id: 'p2', name: 'Smith & Sons', location: 'Cronulla', verified: false, rating: 4.2 },
* ]}
* onSelectProvider={(id) => drillIntoProvider(id)}
* onClose={() => closePopup()}
* />
* ```
*/
export const ClusterPopup = React.forwardRef<HTMLDivElement, ClusterPopupProps>(
({ providers, onSelectProvider, onClose, exiting = false, sx }, ref) => {
// Verified-first sort (stable within each tier)
const sorted = React.useMemo(
() =>
[...providers].sort((a, b) => Number(b.verified ?? false) - Number(a.verified ?? false)),
[providers],
);
return (
<Box
ref={ref}
// Swallow clicks on any empty space inside the popup (header, scroll
// gutter, etc.) so they don't bubble to Map.onClick and close us.
onClick={(e) => e.stopPropagation()}
sx={[
{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
transformOrigin: 'bottom center',
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
opacity: exiting ? 0 : 1,
transform: exiting ? 'scale(0.9)' : 'scale(1)',
'@keyframes clusterPopupIn': {
from: { opacity: 0, transform: 'scale(0.9)' },
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'clusterPopupIn 180ms ease-out',
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Paper
elevation={0}
sx={{
width: POPUP_WIDTH,
borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
maxHeight: MAX_CONTENT_HEIGHT,
}}
>
{/* Header bar */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1.25,
bgcolor: 'var(--fa-color-surface-subtle)',
borderBottom: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<MapOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} aria-hidden />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', flex: 1 }}>
{providers.length} providers in this area
</Typography>
{onClose && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
aria-label="Close cluster popup"
sx={{ mr: -0.5 }}
>
<CloseRoundedIcon sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
{/* Provider list — scrollable */}
<Box
sx={{
overflowY: 'auto',
p: 1,
display: 'flex',
flexDirection: 'column',
gap: 1,
// Thin scrollbar styling
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-thumb': {
background: 'rgba(0,0,0,0.2)',
borderRadius: 3,
},
}}
>
{sorted.map((p) => (
<ProviderRow key={p.id} provider={p} onClick={() => onSelectProvider(p.id)} />
))}
</Box>
</Paper>
{/* Nub — matches MapPopup (fill-only, soft shadow carries the depth) */}
<svg
aria-hidden
width={NUB_SIZE * 2}
height={NUB_SIZE}
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
>
<path
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
fill="var(--fa-color-white)"
/>
</svg>
</Box>
);
},
);
ClusterPopup.displayName = 'ClusterPopup';
export default ClusterPopup;

View File

@@ -0,0 +1 @@
export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup';

View File

@@ -132,7 +132,7 @@ export const WithPin: Story = {
verified verified
onClick={() => {}} onClick={() => {}}
/> />
<MapPin name="H.Parsons" price={900} verified active /> <MapPin name="H.Parsons" price={900} verified />
</> </>
), ),
}; };

View File

@@ -31,6 +31,9 @@ export interface MapPopupProps {
verified?: boolean; verified?: boolean;
/** Click handler — entire card is clickable */ /** Click handler — entire card is clickable */
onClick?: () => void; onClick?: () => void;
/** When true, animates the popup out (opacity + scale) without unmounting.
* Callers should unmount after the transition completes (180ms). */
exiting?: boolean;
/** MUI sx prop for the root element */ /** MUI sx prop for the root element */
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
} }
@@ -85,6 +88,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
capacity, capacity,
verified = false, verified = false,
onClick, onClick,
exiting = false,
sx, sx,
}, },
ref, ref,
@@ -103,12 +107,21 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
} }
}, [name]); }, [name]);
// Swallow clicks on the popup so they don't bubble to an enclosing
// Map.onClick (which would close the popup mid-click). Always applied,
// even when onClick is unset, because callers consistently render this
// molecule inside a map context where ambient clicks should not escape.
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
return ( return (
<Box <Box
ref={ref} ref={ref}
role={onClick ? 'button' : undefined} role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined} tabIndex={onClick ? 0 : undefined}
onClick={onClick} onClick={handleClick}
onKeyDown={ onKeyDown={
onClick onClick
? (e: React.KeyboardEvent) => { ? (e: React.KeyboardEvent) => {
@@ -127,8 +140,17 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
alignItems: 'center', alignItems: 'center',
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))', filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
cursor: onClick ? 'pointer' : 'default', cursor: onClick ? 'pointer' : 'default',
transition: 'transform 150ms ease-in-out', transformOrigin: 'bottom center',
'&:hover': onClick transition: 'opacity 180ms ease-out, transform 180ms ease-out',
opacity: exiting ? 0 : 1,
transform: exiting ? 'scale(0.9)' : 'scale(1)',
'@keyframes mapPopupIn': {
from: { opacity: 0, transform: 'scale(0.9)' },
to: { opacity: 1, transform: 'scale(1)' },
},
animation: exiting ? undefined : 'mapPopupIn 180ms ease-out',
'&:hover':
onClick && !exiting
? { ? {
transform: 'scale(1.02)', transform: 'scale(1.02)',
} }
@@ -149,6 +171,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
borderRadius: 'var(--fa-card-border-radius-default)', borderRadius: 'var(--fa-card-border-radius-default)',
overflow: 'hidden', overflow: 'hidden',
bgcolor: 'background.paper', bgcolor: 'background.paper',
position: 'relative',
}} }}
> >
{/* ── Image ── */} {/* ── Image ── */}
@@ -279,19 +302,20 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
</Box> </Box>
</Paper> </Paper>
{/* Nub — downward pointer connecting to pin */} {/* Nub — downward pointer. SVG (fill-only; MapPopup uses a drop-shadow
<Box for depth instead of a hard border, so no stroke needed) */}
<svg
aria-hidden aria-hidden
sx={{ width={NUB_SIZE * 2}
width: 0, height={NUB_SIZE}
height: 0, viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
borderLeft: `${NUB_SIZE}px solid transparent`, style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
borderRight: `${NUB_SIZE}px solid transparent`, >
borderTop: `${NUB_SIZE}px solid`, <path
borderTopColor: 'background.paper', d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
mt: '-1px', fill="var(--fa-color-white)"
}}
/> />
</svg>
</Box> </Box>
); );
}, },

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Box from '@mui/material/Box';
import { ProviderMap } from './ProviderMap';
import { providers as demoProviders } from '../../../demo/shared/fixtures/providers';
import type { ProviderData } from '../../pages/ProvidersStep';
const meta: Meta<typeof ProviderMap> = {
title: 'Organisms/ProviderMap',
component: ProviderMap,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'Google Map showing provider pins with click-to-open popup. Uses the MapPin atom for markers and the MapPopup molecule for the popup card. Auto-fits the viewport to all providers with coords. Clicking a popup triggers `onSelectProvider`.',
},
},
},
decorators: [
(Story) => (
<Box sx={{ width: '100vw', height: '100vh', display: 'flex' }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof ProviderMap>;
// Cast: DemoProvider adds `tier` over ProviderData, structural subset for the map
const providers = demoProviders as ProviderData[];
// ────────────────────────────────────────────────────────────────────────────
/** All 7 demo providers with real NSW/QLD coordinates. Map fits bounds across them. */
export const Default: Story = {
args: {
providers,
onSelectProvider: (id) => {
alert(`Navigate to provider ${id}`);
},
},
};
/** One provider pre-selected — its pin renders in the active (inverted) state. */
export const WithSelectedProvider: Story = {
args: {
providers,
selectedProviderId: 'parsons',
onSelectProvider: (id) => {
alert(`Navigate to provider ${id}`);
},
},
};
/** Interactive demo — clicking a popup clears/re-selects as if navigating. */
export const InteractiveSelection: Story = {
render: (args) => {
const StoryWrapper = () => {
const [selected, setSelected] = useState<string | null>(null);
return (
<ProviderMap
{...args}
selectedProviderId={selected}
onSelectProvider={(id) => setSelected((prev) => (prev === id ? null : id))}
/>
);
};
return <StoryWrapper />;
},
args: {
providers,
onSelectProvider: () => {},
},
};
/** Providers without coords — falls back to the "Map unavailable" empty state. */
export const NoCoords: Story = {
args: {
providers: providers.map(({ coords: _omit, ...p }) => p),
onSelectProvider: () => {},
},
};
/** No API key supplied — renders the empty state without attempting to load Google Maps. */
export const NoApiKey: Story = {
args: {
providers,
apiKey: '',
onSelectProvider: () => {},
},
};
/** Single provider — map centres on that coord with zoom 13. */
export const SingleProvider: Story = {
args: {
providers: [providers[0]],
onSelectProvider: () => {},
},
};
/** Mixed — some providers with coords, some without. Only those with coords render. */
export const PartialCoords: Story = {
args: {
providers: providers.map((p, i) => (i % 2 === 0 ? p : { ...p, coords: undefined })),
onSelectProvider: () => {},
},
};

View File

@@ -0,0 +1,487 @@
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import Box from '@mui/material/Box';
import type { SxProps, Theme } from '@mui/material/styles';
import {
APIProvider,
Map as GoogleMap,
AdvancedMarker,
useMap,
useMapsLibrary,
} from '@vis.gl/react-google-maps';
import { MarkerClusterer, GridAlgorithm } from '@googlemaps/markerclusterer';
import { MapPin } from '../../atoms/MapPin';
import { ClusterMarker } from '../../atoms/ClusterMarker';
import { MapPopup } from '../../molecules/MapPopup';
import { ClusterPopup } from '../../molecules/ClusterPopup';
import { Typography } from '../../atoms/Typography';
import type { ProviderData } from '../../pages/ProvidersStep';
// ─── Constants ──────────────────────────────────────────────────────────────
/** Sydney — fallback centre when no providers have coords and no default supplied */
const FALLBACK_CENTER = { lat: -33.8688, lng: 151.2093 };
const FALLBACK_ZOOM = 5;
/** Google Maps requires a mapId for AdvancedMarker support */
const MAP_ID = 'fa-provider-map';
/** fitBounds padding (applied as google.maps.Padding) */
const BOUNDS_PADDING = { top: 64, right: 48, bottom: 64, left: 48 };
/** Screen-pixel radius at which nearby pins collapse into a cluster */
const CLUSTER_GRID_SIZE = 70;
/** Zoom level above which clustering is disabled (pins show individually) */
const CLUSTER_MAX_ZOOM = 13;
/** Zoom level the map animates to on cluster drill-in (street-level, past
* CLUSTER_MAX_ZOOM so nearby cluster members break apart into their own pins) */
const DRILL_IN_ZOOM = 15;
/** Exit-animation duration for popups on close — keep in sync with the
* transition values set on MapPopup/ClusterPopup. */
const POPUP_EXIT_MS = 180;
// ─── Types ──────────────────────────────────────────────────────────────────
/** Props for the FA ProviderMap organism */
export interface ProviderMapProps {
/** Providers to render as pins. Providers without coords are filtered out silently. */
providers: ProviderData[];
/** ID of the provider whose popup should open (external selection, e.g. list hover) */
selectedProviderId?: string | null;
/** Called when the user clicks through a popup — usually triggers navigation */
onSelectProvider: (id: string) => void;
/** Initial map centre — used only when no providers have coords */
defaultCenter?: { lat: number; lng: number };
/** Initial zoom — used only when no providers have coords */
defaultZoom?: number;
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
apiKey?: string;
/** MUI sx prop for the root element */
sx?: SxProps<Theme>;
}
interface ActiveCluster {
providers: ProviderData[];
position: google.maps.LatLngLiteral;
}
// ─── Internal components ────────────────────────────────────────────────────
/**
* Fits the map to the bounds of all providers with coords. Runs whenever the
* provider list changes. Sited inside APIProvider so `useMap()` resolves.
*/
const FitBounds: React.FC<{ providers: ProviderData[] }> = ({ providers }) => {
const map = useMap();
React.useEffect(() => {
if (!map) return;
const withCoords = providers.filter((p) => p.coords);
if (withCoords.length === 0) return;
if (withCoords.length === 1) {
map.setCenter(withCoords[0].coords!);
map.setZoom(13);
return;
}
const bounds = new window.google.maps.LatLngBounds();
withCoords.forEach((p) => bounds.extend(p.coords!));
map.fitBounds(bounds, BOUNDS_PADDING);
}, [map, providers]);
return null;
};
/**
* Captures the Google Map instance into a parent ref so imperative
* actions (panTo, setZoom) can be triggered from outside the Map context.
*/
const MapRefCapture: React.FC<{
mapRef: React.MutableRefObject<google.maps.Map | null>;
}> = ({ mapRef }) => {
const map = useMap();
React.useEffect(() => {
mapRef.current = map;
}, [map, mapRef]);
return null;
};
/**
* Imperative marker layer — builds AdvancedMarker instances with React
* content, groups them via MarkerClusterer, and rebuilds whenever the
* visible provider set changes.
*
* Providers listed in `hiddenIds` are excluded from the map (their popup is
* currently showing instead).
*/
const MarkerLayer: React.FC<{
providers: ProviderData[];
hiddenIds: Set<string>;
onPinClick: (id: string) => void;
onClusterClick: (providers: ProviderData[], position: google.maps.LatLngLiteral) => void;
}> = ({ providers, hiddenIds, onPinClick, onClusterClick }) => {
const map = useMap();
const markerLibrary = useMapsLibrary('marker');
// Stash callbacks in a ref so the effect below doesn't re-run (and rebuild
// every marker) when the parent passes fresh arrow-function references.
const onPinClickRef = React.useRef(onPinClick);
const onClusterClickRef = React.useRef(onClusterClick);
React.useEffect(() => {
onPinClickRef.current = onPinClick;
onClusterClickRef.current = onClusterClick;
}, [onPinClick, onClusterClick]);
React.useEffect(() => {
if (!map || !markerLibrary) return;
const roots: Root[] = [];
const markerToProvider = new Map<google.maps.marker.AdvancedMarkerElement, ProviderData>();
const markers = providers
.filter((p) => p.coords && !hiddenIds.has(p.id))
.map((p) => {
const el = document.createElement('div');
const root = createRoot(el);
// MapPin's own onClick stays for keyboard a11y (Enter/Space via its
// onKeyDown). stopPropagation guards against the DOM click bubbling
// to the Map's onClick and closing the popup the same frame it opens.
root.render(
<MapPin
name={p.name}
price={p.startingPrice}
verified={p.verified}
onClick={(e) => {
e.stopPropagation();
onPinClickRef.current(p.id);
}}
/>,
);
roots.push(root);
const marker = new markerLibrary.AdvancedMarkerElement({
position: p.coords,
content: el,
gmpClickable: true,
});
// Also listen at the Google Maps level + stop the GMaps event so
// Map's onClick can't fire when a pin is clicked via mouse. Safe to
// fire twice with keyboard — handlePinClick is idempotent.
marker.addListener('click', (event: google.maps.MapMouseEvent) => {
event.stop();
onPinClickRef.current(p.id);
});
markerToProvider.set(marker, p);
return marker;
});
const clusterer = new MarkerClusterer({
map,
markers,
algorithm: new GridAlgorithm({
maxZoom: CLUSTER_MAX_ZOOM,
gridSize: CLUSTER_GRID_SIZE,
}),
// Override the library's default "zoom to fit cluster" on click —
// we open the cluster popup instead. The event shape the library
// passes varies: sometimes a google.maps.MapMouseEvent (has .stop),
// sometimes a plain DOM MouseEvent. Stop whichever we got so the
// click doesn't also fire Map.onClick and clear our state.
onClusterClick: (event, cluster) => {
const anyEvent = event as unknown as {
stop?: () => void;
stopPropagation?: () => void;
domEvent?: { stopPropagation?: () => void };
};
anyEvent.stop?.();
anyEvent.stopPropagation?.();
anyEvent.domEvent?.stopPropagation?.();
const providersInCluster = cluster.markers
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
.filter((p): p is ProviderData => !!p);
const clusterPosition =
cluster.position instanceof window.google.maps.LatLng
? cluster.position.toJSON()
: (cluster.position as google.maps.LatLngLiteral);
onClusterClickRef.current(providersInCluster, clusterPosition);
},
renderer: {
render: ({ count, position, markers: clusterMarkers }) => {
const providersInCluster = clusterMarkers
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
.filter((p): p is ProviderData => !!p);
const hasVerified = providersInCluster.some((p) => p.verified);
const el = document.createElement('div');
const root = createRoot(el);
// Visual only — click is handled at the MarkerClusterer level above.
root.render(<ClusterMarker count={count} hasVerified={hasVerified} />);
roots.push(root);
return new markerLibrary.AdvancedMarkerElement({
position,
content: el,
gmpClickable: true,
});
},
},
});
return () => {
clusterer.clearMarkers();
// Defer unmount so React doesn't warn about unmounting during render.
setTimeout(() => {
roots.forEach((r) => r.unmount());
}, 0);
};
}, [map, markerLibrary, providers, hiddenIds]);
return null;
};
/** Empty-state shown when no API key is configured or no providers have coords. */
const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason }) => (
<Box sx={{ m: 'auto', textAlign: 'center', px: 3 }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>
Map unavailable
</Typography>
<Typography variant="caption" color="text.secondary">
{reason === 'no-key'
? 'Google Maps API key not configured.'
: 'No provider locations to display.'}
</Typography>
</Box>
);
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Google Map showing provider pins with clustering + click-to-open popups.
*
* **Interaction model:**
* - Clicking an individual pin **morphs** it into a `MapPopup` at the same
* coord. Clicking the map background reverts.
* - Pins within `CLUSTER_GRID_SIZE` (70px) of each other collapse into a
* `ClusterMarker` — but only while zoomed out at level `CLUSTER_MAX_ZOOM`
* (13) or below. Zoom in past that and every pin shows individually.
* - Clicking a cluster opens a `ClusterPopup` listing its providers
* (verified-first). Clicking a row **pans and zooms the map to that
* provider's location** (zoom 15 = past the clustering ceiling, so the
* other cluster members separate into their own pins around the selected
* one) and opens that provider's `MapPopup`. The cluster state is cleared
* — there's no back-to-list; the user's path forward is clear rather than
* hierarchical.
*
* **Viewport:** auto-fits to include every provider with coords on load and
* when the list changes. Single-provider maps centre with zoom 13.
*
* **Empty states:** if no API key is set or no providers have coords, a
* subtle empty state renders in place (no throw).
*
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
* (molecules). Clustering via `@googlemaps/markerclusterer`.
*/
export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
(
{
providers,
selectedProviderId,
onSelectProvider,
defaultCenter = FALLBACK_CENTER,
defaultZoom = FALLBACK_ZOOM,
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
sx,
},
ref,
) => {
const [activeProviderId, setActiveProviderId] = React.useState<string | null>(null);
const [activeCluster, setActiveCluster] = React.useState<ActiveCluster | null>(null);
const [exiting, setExiting] = React.useState(false);
const mapRef = React.useRef<google.maps.Map | null>(null);
const exitTimerRef = React.useRef<number | null>(null);
// Helper: cancel any pending exit timer so rapid clicks don't clobber
// newly-opened popups with a leftover clear from a previous close.
const cancelExit = React.useCallback(() => {
if (exitTimerRef.current) {
window.clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
setExiting(false);
}, []);
React.useEffect(
() => () => {
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
},
[],
);
const withCoords = React.useMemo(() => providers.filter((p) => p.coords), [providers]);
// External selection (e.g. list hover) force-opens a popup. Internal click wins.
const effectiveProviderId = activeProviderId ?? selectedProviderId ?? null;
const activeProvider = React.useMemo(
() =>
effectiveProviderId ? (withCoords.find((p) => p.id === effectiveProviderId) ?? null) : null,
[withCoords, effectiveProviderId],
);
// Pins hidden from the map (because their popup is showing instead).
const hiddenIds = React.useMemo(() => {
const s = new Set<string>();
if (effectiveProviderId) s.add(effectiveProviderId);
if (activeCluster) {
activeCluster.providers.forEach((p) => s.add(p.id));
}
return s;
}, [effectiveProviderId, activeCluster]);
const handlePinClick = React.useCallback(
(id: string) => {
cancelExit();
setActiveProviderId(id);
setActiveCluster(null);
},
[cancelExit],
);
const handleClusterClick = React.useCallback(
(clusterProviders: ProviderData[], position: google.maps.LatLngLiteral) => {
cancelExit();
setActiveProviderId(null);
setActiveCluster({ providers: clusterProviders, position });
},
[cancelExit],
);
/** Shared close path — animate the popup out (exiting=true triggers the
* CSS transition in MapPopup / ClusterPopup), then actually clear state
* after the transition completes so the pin can fade back in. */
const closeWithExit = React.useCallback(() => {
if (!activeProviderId && !activeCluster) return;
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
setExiting(true);
exitTimerRef.current = window.setTimeout(() => {
setActiveProviderId(null);
setActiveCluster(null);
setExiting(false);
exitTimerRef.current = null;
}, POPUP_EXIT_MS);
}, [activeProviderId, activeCluster]);
const handleMapClick = closeWithExit;
const handleCloseCluster = closeWithExit;
/** Cluster list → single-provider drill-in.
* Pans + zooms the map to the provider's coords (zoom 15 = past
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
* pins around the selected one), then clears the cluster state and
* opens the single-provider popup. */
const handleDrillIntoProvider = React.useCallback(
(id: string) => {
cancelExit();
const provider = withCoords.find((p) => p.id === id);
if (provider?.coords && mapRef.current) {
mapRef.current.panTo(provider.coords);
mapRef.current.setZoom(DRILL_IN_ZOOM);
}
setActiveProviderId(id);
setActiveCluster(null);
},
[withCoords, cancelExit],
);
const rootSx = [
{
position: 'relative' as const,
display: 'flex',
flex: 1,
minHeight: 300,
width: '100%',
overflow: 'hidden',
bgcolor: 'var(--fa-color-surface-cool)',
},
...(Array.isArray(sx) ? sx : [sx]),
];
// Empty states
if (!apiKey) {
return (
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
<MapEmptyState reason="no-key" />
</Box>
);
}
if (withCoords.length === 0) {
return (
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
<MapEmptyState reason="no-coords" />
</Box>
);
}
return (
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
<APIProvider apiKey={apiKey}>
<GoogleMap
defaultCenter={defaultCenter}
defaultZoom={defaultZoom}
mapId={MAP_ID}
disableDefaultUI
zoomControl
gestureHandling="greedy"
onClick={handleMapClick}
style={{ width: '100%', height: '100%' }}
>
<FitBounds providers={withCoords} />
<MapRefCapture mapRef={mapRef} />
<MarkerLayer
providers={withCoords}
hiddenIds={hiddenIds}
onPinClick={handlePinClick}
onClusterClick={handleClusterClick}
/>
{/* Single-provider popup (pin click OR post-zoom cluster drill-in) */}
{activeProvider && (
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
<MapPopup
name={activeProvider.name}
imageUrl={activeProvider.imageUrl}
price={activeProvider.startingPrice}
location={activeProvider.location}
rating={activeProvider.rating}
verified={activeProvider.verified}
exiting={exiting}
onClick={() => onSelectProvider(activeProvider.id)}
/>
</AdvancedMarker>
)}
{/* Cluster list popup — shown while a cluster is active and no
provider has been drilled into. Drilling clears activeCluster,
which swaps this for the single-provider popup above. */}
{activeCluster && !activeProviderId && (
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
<ClusterPopup
providers={activeCluster.providers.map((p) => ({
id: p.id,
name: p.name,
location: p.location,
verified: p.verified,
rating: p.rating,
startingPrice: p.startingPrice,
}))}
exiting={exiting}
onSelectProvider={handleDrillIntoProvider}
onClose={handleCloseCluster}
/>
</AdvancedMarker>
)}
</GoogleMap>
</APIProvider>
</Box>
);
},
);
ProviderMap.displayName = 'ProviderMap';
export default ProviderMap;

View File

@@ -0,0 +1 @@
export { ProviderMap, type ProviderMapProps } from './ProviderMap';

View File

@@ -49,6 +49,8 @@ export interface ProviderData {
distanceKm?: number; distanceKm?: number;
/** Brief description */ /** Brief description */
description?: string; description?: string;
/** Geographic coordinates for map display */
coords?: { lat: number; lng: number };
} }
/** A funeral type option for the filter */ /** A funeral type option for the filter */

View File

@@ -7,6 +7,7 @@ import {
type ProviderSortBy, type ProviderSortBy,
type ListViewMode, type ListViewMode,
} from '../../../../components/pages/ProvidersStep'; } from '../../../../components/pages/ProvidersStep';
import { ProviderMap } from '../../../../components/organisms/ProviderMap';
import { providers } from '../../../shared/fixtures/providers'; import { providers } from '../../../shared/fixtures/providers';
import { demoNav } from '../DemoNav'; import { demoNav } from '../DemoNav';
@@ -33,6 +34,12 @@ export function ProvidersRoute() {
onViewModeChange={setView} onViewModeChange={setView}
onBack={() => window.history.back()} onBack={() => window.history.back()}
navigation={demoNav} navigation={demoNav}
mapPanel={
<ProviderMap
providers={filtered}
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
/>
}
/> />
); );
} }

View File

@@ -20,6 +20,7 @@ export const providers: DemoProvider[] = [
reviewCount: 7, reviewCount: 7,
startingPrice: 1800, startingPrice: 1800,
distanceKm: 2.3, distanceKm: 2.3,
coords: { lat: -34.1074, lng: 141.9166 },
description: description:
'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.', 'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.',
}, },
@@ -35,6 +36,7 @@ export const providers: DemoProvider[] = [
reviewCount: 23, reviewCount: 23,
startingPrice: 2450, startingPrice: 2450,
distanceKm: 5.1, distanceKm: 5.1,
coords: { lat: -34.487, lng: 150.897 },
}, },
{ {
id: 'wollongong-city', id: 'wollongong-city',
@@ -46,6 +48,7 @@ export const providers: DemoProvider[] = [
reviewCount: 15, reviewCount: 15,
startingPrice: 3400, startingPrice: 3400,
distanceKm: 6.8, distanceKm: 6.8,
coords: { lat: -34.4278, lng: 150.8931 },
}, },
{ {
id: 'killick', id: 'killick',
@@ -59,6 +62,7 @@ export const providers: DemoProvider[] = [
reviewCount: 15, reviewCount: 15,
startingPrice: 3100, startingPrice: 3100,
distanceKm: 8.4, distanceKm: 8.4,
coords: { lat: -26.5408, lng: 151.8388 },
}, },
{ {
id: 'mackay', id: 'mackay',
@@ -72,6 +76,7 @@ export const providers: DemoProvider[] = [
reviewCount: 87, reviewCount: 87,
startingPrice: 2800, startingPrice: 2800,
distanceKm: 18.2, distanceKm: 18.2,
coords: { lat: -33.3644, lng: 151.3728 },
}, },
{ {
id: 'mannings', id: 'mannings',
@@ -85,6 +90,7 @@ export const providers: DemoProvider[] = [
reviewCount: 31, reviewCount: 31,
startingPrice: 2600, startingPrice: 2600,
distanceKm: 22.0, distanceKm: 22.0,
coords: { lat: -36.6742, lng: 149.8417 },
}, },
{ {
id: 'botanical', id: 'botanical',
@@ -96,6 +102,7 @@ export const providers: DemoProvider[] = [
reviewCount: 8, reviewCount: 8,
startingPrice: 5200, startingPrice: 5200,
distanceKm: 15.0, distanceKm: 15.0,
coords: { lat: -33.8988, lng: 151.1794 },
}, },
]; ];