From e78d88b2f36b8e24987dd680759fd4ee00f85973 Mon Sep 17 00:00:00 2001 From: Richie Date: Wed, 22 Apr 2026 09:29:37 +1000 Subject: [PATCH] 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) --- docs/memory/component-registry.md | 7 +- docs/memory/session-log.md | 180 +++++++ package-lock.json | 83 ++- package.json | 2 + .../ClusterMarker/ClusterMarker.stories.tsx | 77 +++ .../atoms/ClusterMarker/ClusterMarker.tsx | 161 ++++++ src/components/atoms/ClusterMarker/index.ts | 1 + .../atoms/MapPin/MapPin.stories.tsx | 76 +-- src/components/atoms/MapPin/MapPin.tsx | 151 +++--- .../ClusterPopup/ClusterPopup.stories.tsx | 114 ++++ .../molecules/ClusterPopup/ClusterPopup.tsx | 360 +++++++++++++ .../molecules/ClusterPopup/index.ts | 1 + .../molecules/MapPopup/MapPopup.stories.tsx | 2 +- .../molecules/MapPopup/MapPopup.tsx | 62 ++- .../ProviderMap/ProviderMap.stories.tsx | 110 ++++ .../organisms/ProviderMap/ProviderMap.tsx | 487 ++++++++++++++++++ src/components/organisms/ProviderMap/index.ts | 1 + .../pages/ProvidersStep/ProvidersStep.tsx | 2 + .../apps/arrangement/routes/Providers.tsx | 7 + src/demo/shared/fixtures/providers.ts | 7 + 20 files changed, 1720 insertions(+), 171 deletions(-) create mode 100644 src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx create mode 100644 src/components/atoms/ClusterMarker/ClusterMarker.tsx create mode 100644 src/components/atoms/ClusterMarker/index.ts create mode 100644 src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx create mode 100644 src/components/molecules/ClusterPopup/ClusterPopup.tsx create mode 100644 src/components/molecules/ClusterPopup/index.ts create mode 100644 src/components/organisms/ProviderMap/ProviderMap.stories.tsx create mode 100644 src/components/organisms/ProviderMap/ProviderMap.tsx create mode 100644 src/components/organisms/ProviderMap/index.ts diff --git a/docs/memory/component-registry.md b/docs/memory/component-registry.md index 751dd0a..50027f7 100644 --- a/docs/memory/component-registry.md +++ b/docs/memory/component-registry.md @@ -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. | | 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. | -| 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. | | 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. | @@ -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 | | 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. | -| 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. | | 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). | @@ -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). | | 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. | +| 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 @@ -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. | | 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). | +| 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 diff --git a/docs/memory/session-log.md b/docs/memory/session-log.md index 876a1cf..f2523e4 100644 --- a/docs/memory/session-log.md +++ b/docs/memory/session-log.md @@ -26,6 +26,152 @@ Each entry follows this structure: ## 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 120–160px 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 `` 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 `` 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 `` 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 (NSW–QLD 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 `` 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 `` 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 **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 --- +### 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 ` 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 `//` 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 + +--- diff --git a/package-lock.json b/package-lock.json index 4229368..fc8891d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", + "@googlemaps/markerclusterer": "^2.6.2", "@mui/icons-material": "^5.16.0", "@mui/material": "^5.16.0", "@mui/system": "^5.16.0", + "@vis.gl/react-google-maps": "^1.8.3", "react": "^18.3.0", "react-dom": "^18.3.0", "react-router-dom": "^7.14.1", @@ -1460,6 +1462,26 @@ "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": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4108,6 +4130,18 @@ "dev": true, "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": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4171,6 +4205,15 @@ "dev": true, "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4480,6 +4523,21 @@ "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": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -6473,9 +6531,17 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "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": { "version": "2.1.0", "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_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": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10563,6 +10635,15 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "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": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 847b0bd..a94aac2 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,11 @@ "dependencies": { "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", + "@googlemaps/markerclusterer": "^2.6.2", "@mui/icons-material": "^5.16.0", "@mui/material": "^5.16.0", "@mui/system": "^5.16.0", + "@vis.gl/react-google-maps": "^1.8.3", "react": "^18.3.0", "react-dom": "^18.3.0", "react-router-dom": "^7.14.1", diff --git a/src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx b/src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx new file mode 100644 index 0000000..e882f08 --- /dev/null +++ b/src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import { ClusterMarker } from './ClusterMarker'; + +const meta: Meta = { + 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; + +/** 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: () => ( + + + + + + + + + + + ), +}; diff --git a/src/components/atoms/ClusterMarker/ClusterMarker.tsx b/src/components/atoms/ClusterMarker/ClusterMarker.tsx new file mode 100644 index 0000000..5f0d8c2 --- /dev/null +++ b/src/components/atoms/ClusterMarker/ClusterMarker.tsx @@ -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; +} + +// ─── 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 + * + * + * ``` + */ +export const ClusterMarker = React.forwardRef( + ({ 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 ( + .ClusterMarker-badge': { + outline: '2px solid var(--fa-color-interactive-focus)', + outlineOffset: '2px', + }, + }, + }, + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + {/* Circular badge */} + + {count} + + + {/* Nub — same SVG pattern as MapPin for visual continuity */} + + + + + + ); + }, +); + +ClusterMarker.displayName = 'ClusterMarker'; +export default ClusterMarker; diff --git a/src/components/atoms/ClusterMarker/index.ts b/src/components/atoms/ClusterMarker/index.ts new file mode 100644 index 0000000..0444718 --- /dev/null +++ b/src/components/atoms/ClusterMarker/index.ts @@ -0,0 +1 @@ +export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker'; diff --git a/src/components/atoms/MapPin/MapPin.stories.tsx b/src/components/atoms/MapPin/MapPin.stories.tsx index 0f6a28a..52df930 100644 --- a/src/components/atoms/MapPin/MapPin.stories.tsx +++ b/src/components/atoms/MapPin/MapPin.stories.tsx @@ -21,8 +21,8 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/** Verified provider with name and price — warm brand label */ -export const VerifiedWithPrice: Story = { +/** Verified provider — promoted brand palette (dark copper bg, white text) */ +export const Verified: Story = { args: { name: 'H.Parsons Funeral Directors', price: 900, @@ -31,7 +31,7 @@ export const VerifiedWithPrice: Story = { }; /** Unverified provider — neutral grey label */ -export const UnverifiedWithPrice: Story = { +export const Unverified: Story = { args: { name: 'Smith & Sons Funerals', price: 1200, @@ -39,66 +39,7 @@ export const UnverifiedWithPrice: Story = { }, }; -/** Active/selected state — inverted colours, slight scale-up */ -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 */ +/** Custom price label (e.g. "POA" for providers without a fixed starting price) */ export const CustomPriceLabel: Story = { args: { name: 'Premium Services', @@ -141,7 +82,7 @@ export const MapSimulation: Story = { {}} /> - {}} /> + {}} /> {}} /> @@ -152,12 +93,7 @@ export const MapSimulation: Story = { {}} /> - {}} /> - - - {/* Name only verified */} - - {}} /> + {}} /> ), diff --git a/src/components/atoms/MapPin/MapPin.tsx b/src/components/atoms/MapPin/MapPin.tsx index a7f6e08..06c511d 100644 --- a/src/components/atoms/MapPin/MapPin.tsx +++ b/src/components/atoms/MapPin/MapPin.tsx @@ -6,16 +6,14 @@ import type { SxProps, Theme } from '@mui/material/styles'; /** Props for the FA MapPin atom */ export interface MapPinProps { - /** Provider or venue name — omit for a price-only pill */ - name?: string; - /** Starting package price in dollars — shown as "From $X" */ + /** Provider or venue name (required — shown as line 1) */ + name: string; + /** Starting package price in dollars — shown as "From $X" on line 2 */ price?: number; /** Custom price label (e.g. "POA") — overrides formatted price */ 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; - /** Whether this pin is currently active/selected */ - active?: boolean; /** Click handler */ onClick?: (e: React.MouseEvent) => void; /** MUI sx prop for the root element */ @@ -33,28 +31,18 @@ const MAX_WIDTH = 180; const colours = { verified: { - bg: 'var(--fa-color-brand-100)', - name: 'var(--fa-color-brand-900)', - price: 'var(--fa-color-brand-600)', - activeBg: 'var(--fa-color-brand-700)', - activeName: 'var(--fa-color-white)', - activePrice: 'var(--fa-color-brand-200)', - nub: 'var(--fa-color-brand-100)', - activeNub: 'var(--fa-color-brand-700)', - border: 'var(--fa-color-brand-300)', - activeBorder: 'var(--fa-color-brand-700)', + bg: 'var(--fa-color-brand-700)', + name: 'var(--fa-color-white)', + price: 'var(--fa-color-brand-200)', + nub: 'var(--fa-color-brand-700)', + border: 'var(--fa-color-brand-700)', }, unverified: { bg: 'var(--fa-color-neutral-100)', name: 'var(--fa-color-neutral-800)', price: 'var(--fa-color-neutral-500)', - activeBg: 'var(--fa-color-neutral-700)', - activeName: 'var(--fa-color-white)', - activePrice: 'var(--fa-color-neutral-200)', nub: 'var(--fa-color-neutral-100)', - activeNub: 'var(--fa-color-neutral-700)', border: 'var(--fa-color-neutral-300)', - activeBorder: 'var(--fa-color-neutral-700)', }, } as const; @@ -68,26 +56,25 @@ const colours = { * the exact map location. * * - **Line 1**: Provider name (bold, truncated) - * - **Line 2**: "From $X" (smaller, secondary colour) — optional + * - **Line 2**: "From $X" (smaller, secondary colour) * * 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 - * - **Active/selected**: inverted colours (dark bg, white text) + scale-up * - * Designed for use as custom HTML markers in Mapbox GL / Google Maps. - * Pure CSS — no canvas, no SVG dependency. + * Designed for use as custom HTML markers in Google Maps. Pure CSS — no + * canvas, no SVG dependency. Selection/popup behaviour is handled at the + * organism level (ProviderMap swaps pin → popup on click). * * Usage: * ```tsx * - * {/* Name only, unverified *\/} - * {/* Price-only pill, no name *\/} - * + * + * * ``` */ export const MapPin = React.forwardRef( - ({ 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 hasPrice = price != null || priceLabel != null; @@ -106,7 +93,7 @@ export const MapPin = React.forwardRef( ref={ref} role="button" 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} onKeyDown={handleKeyDown} sx={[ @@ -116,7 +103,13 @@ export const MapPin = React.forwardRef( alignItems: 'center', cursor: 'pointer', 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': { transform: 'scale(1.08)', }, @@ -142,53 +135,41 @@ export const MapPin = React.forwardRef( py: 0.5, px: PIN_PX, borderRadius: PIN_RADIUS, - backgroundColor: active ? palette.activeBg : palette.bg, + backgroundColor: palette.bg, border: '1px solid', - borderColor: active ? palette.activeBorder : palette.border, - boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)', - transition: - 'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out', + borderColor: palette.border, + boxShadow: 'var(--fa-shadow-sm)', }} > {/* Name */} - {name && ( - t.typography.fontFamily, - lineHeight: 1.3, - color: active ? palette.activeName : palette.name, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - maxWidth: '100%', - transition: 'color 150ms ease-in-out', - }} - > - {name} - - )} + + {name} + {/* Price line */} {hasPrice && ( t.typography.fontFamily, + fontSize: 11, + fontWeight: 600, + fontFamily: 'var(--fa-font-family-body)', lineHeight: 1.2, - color: !name - ? active - ? palette.activeName - : palette.name - : active - ? palette.activePrice - : palette.price, + color: palette.price, whiteSpace: 'nowrap', - transition: 'color 150ms ease-in-out', }} > {priceText} @@ -196,19 +177,33 @@ export const MapPin = React.forwardRef( )} - {/* Nub — downward pointer */} - + > + + + ); }, diff --git a/src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx b/src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx new file mode 100644 index 0000000..83db3dc --- /dev/null +++ b/src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import { ClusterPopup } from './ClusterPopup'; + +const meta: Meta = { + title: 'Molecules/ClusterPopup', + component: ClusterPopup, + tags: ['autodocs'], + parameters: { + layout: 'centered', + backgrounds: { + default: 'map', + values: [{ name: 'map', value: '#E5E3DF' }], + }, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// 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: () => {}, + }, +}; diff --git a/src/components/molecules/ClusterPopup/ClusterPopup.tsx b/src/components/molecules/ClusterPopup/ClusterPopup.tsx new file mode 100644 index 0000000..6b83495 --- /dev/null +++ b/src/components/molecules/ClusterPopup/ClusterPopup.tsx @@ -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; +} + +// ─── 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 = ({ provider, onClick }) => { + const hasPrice = provider.startingPrice != null || provider.priceLabel != null; + const priceText = + provider.priceLabel ?? + (provider.startingPrice != null ? `$${provider.startingPrice.toLocaleString('en-AU')}` : null); + + return ( + { + // 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. */} + + {provider.verified && ( + + )} + + + {/* Text column — name + location/rating meta */} + + + {provider.name} + + + + + + + {provider.location} + + + + {provider.rating != null && ( + + + + {provider.rating} + + + )} + + + + {/* 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 && ( + + + From + + + {priceText} + + + )} + + ); +}; + +// ─── 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 + * drillIntoProvider(id)} + * onClose={() => closePopup()} + * /> + * ``` + */ +export const ClusterPopup = React.forwardRef( + ({ 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 ( + 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]), + ]} + > + + {/* Header bar */} + + + + {providers.length} providers in this area + + {onClose && ( + { + e.stopPropagation(); + onClose(); + }} + aria-label="Close cluster popup" + sx={{ mr: -0.5 }} + > + + + )} + + + {/* Provider list — scrollable */} + + {sorted.map((p) => ( + onSelectProvider(p.id)} /> + ))} + + + + {/* Nub — matches MapPopup (fill-only, soft shadow carries the depth) */} + + + + + ); + }, +); + +ClusterPopup.displayName = 'ClusterPopup'; +export default ClusterPopup; diff --git a/src/components/molecules/ClusterPopup/index.ts b/src/components/molecules/ClusterPopup/index.ts new file mode 100644 index 0000000..e752505 --- /dev/null +++ b/src/components/molecules/ClusterPopup/index.ts @@ -0,0 +1 @@ +export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup'; diff --git a/src/components/molecules/MapPopup/MapPopup.stories.tsx b/src/components/molecules/MapPopup/MapPopup.stories.tsx index 2dfd3af..67572e1 100644 --- a/src/components/molecules/MapPopup/MapPopup.stories.tsx +++ b/src/components/molecules/MapPopup/MapPopup.stories.tsx @@ -132,7 +132,7 @@ export const WithPin: Story = { verified onClick={() => {}} /> - + ), }; diff --git a/src/components/molecules/MapPopup/MapPopup.tsx b/src/components/molecules/MapPopup/MapPopup.tsx index 208d72c..f80ca1b 100644 --- a/src/components/molecules/MapPopup/MapPopup.tsx +++ b/src/components/molecules/MapPopup/MapPopup.tsx @@ -31,6 +31,9 @@ export interface MapPopupProps { verified?: boolean; /** Click handler — entire card is clickable */ 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 */ sx?: SxProps; } @@ -85,6 +88,7 @@ export const MapPopup = React.forwardRef( capacity, verified = false, onClick, + exiting = false, sx, }, ref, @@ -103,12 +107,21 @@ export const MapPopup = React.forwardRef( } }, [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 ( { @@ -127,12 +140,21 @@ export const MapPopup = React.forwardRef( alignItems: 'center', filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))', cursor: onClick ? 'pointer' : 'default', - transition: 'transform 150ms ease-in-out', - '&:hover': onClick - ? { - transform: 'scale(1.02)', - } - : undefined, + transformOrigin: 'bottom center', + 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)', + } + : undefined, '&:focus-visible': { outline: '2px solid var(--fa-color-interactive-focus)', outlineOffset: '2px', @@ -149,6 +171,7 @@ export const MapPopup = React.forwardRef( borderRadius: 'var(--fa-card-border-radius-default)', overflow: 'hidden', bgcolor: 'background.paper', + position: 'relative', }} > {/* ── Image ── */} @@ -279,19 +302,20 @@ export const MapPopup = React.forwardRef( - {/* Nub — downward pointer connecting to pin */} - + width={NUB_SIZE * 2} + height={NUB_SIZE} + viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`} + style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }} + > + + ); }, diff --git a/src/components/organisms/ProviderMap/ProviderMap.stories.tsx b/src/components/organisms/ProviderMap/ProviderMap.stories.tsx new file mode 100644 index 0000000..90d8df5 --- /dev/null +++ b/src/components/organisms/ProviderMap/ProviderMap.stories.tsx @@ -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 = { + 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) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// 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(null); + return ( + setSelected((prev) => (prev === id ? null : id))} + /> + ); + }; + return ; + }, + 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: () => {}, + }, +}; diff --git a/src/components/organisms/ProviderMap/ProviderMap.tsx b/src/components/organisms/ProviderMap/ProviderMap.tsx new file mode 100644 index 0000000..94ba294 --- /dev/null +++ b/src/components/organisms/ProviderMap/ProviderMap.tsx @@ -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; +} + +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; +}> = ({ 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; + 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(); + + 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( + { + 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(); + 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 }) => ( + + + Map unavailable + + + {reason === 'no-key' + ? 'Google Maps API key not configured.' + : 'No provider locations to display.'} + + +); + +// ─── 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( + ( + { + 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(null); + const [activeCluster, setActiveCluster] = React.useState(null); + const [exiting, setExiting] = React.useState(false); + const mapRef = React.useRef(null); + const exitTimerRef = React.useRef(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(); + 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 ( + + + + ); + } + if (withCoords.length === 0) { + return ( + + + + ); + } + + return ( + + + + + + + + + {/* Single-provider popup (pin click OR post-zoom cluster drill-in) */} + {activeProvider && ( + + onSelectProvider(activeProvider.id)} + /> + + )} + + {/* 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 && ( + + ({ + id: p.id, + name: p.name, + location: p.location, + verified: p.verified, + rating: p.rating, + startingPrice: p.startingPrice, + }))} + exiting={exiting} + onSelectProvider={handleDrillIntoProvider} + onClose={handleCloseCluster} + /> + + )} + + + + ); + }, +); + +ProviderMap.displayName = 'ProviderMap'; +export default ProviderMap; diff --git a/src/components/organisms/ProviderMap/index.ts b/src/components/organisms/ProviderMap/index.ts new file mode 100644 index 0000000..167cc8f --- /dev/null +++ b/src/components/organisms/ProviderMap/index.ts @@ -0,0 +1 @@ +export { ProviderMap, type ProviderMapProps } from './ProviderMap'; diff --git a/src/components/pages/ProvidersStep/ProvidersStep.tsx b/src/components/pages/ProvidersStep/ProvidersStep.tsx index 499fe54..527bfdb 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.tsx @@ -49,6 +49,8 @@ export interface ProviderData { distanceKm?: number; /** Brief description */ description?: string; + /** Geographic coordinates for map display */ + coords?: { lat: number; lng: number }; } /** A funeral type option for the filter */ diff --git a/src/demo/apps/arrangement/routes/Providers.tsx b/src/demo/apps/arrangement/routes/Providers.tsx index 7a83e93..c4cc552 100644 --- a/src/demo/apps/arrangement/routes/Providers.tsx +++ b/src/demo/apps/arrangement/routes/Providers.tsx @@ -7,6 +7,7 @@ import { type ProviderSortBy, type ListViewMode, } from '../../../../components/pages/ProvidersStep'; +import { ProviderMap } from '../../../../components/organisms/ProviderMap'; import { providers } from '../../../shared/fixtures/providers'; import { demoNav } from '../DemoNav'; @@ -33,6 +34,12 @@ export function ProvidersRoute() { onViewModeChange={setView} onBack={() => window.history.back()} navigation={demoNav} + mapPanel={ + navigate(`/providers/${id}/packages`)} + /> + } /> ); } diff --git a/src/demo/shared/fixtures/providers.ts b/src/demo/shared/fixtures/providers.ts index 0040e22..9450413 100644 --- a/src/demo/shared/fixtures/providers.ts +++ b/src/demo/shared/fixtures/providers.ts @@ -20,6 +20,7 @@ export const providers: DemoProvider[] = [ reviewCount: 7, startingPrice: 1800, distanceKm: 2.3, + coords: { lat: -34.1074, lng: 141.9166 }, description: '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, startingPrice: 2450, distanceKm: 5.1, + coords: { lat: -34.487, lng: 150.897 }, }, { id: 'wollongong-city', @@ -46,6 +48,7 @@ export const providers: DemoProvider[] = [ reviewCount: 15, startingPrice: 3400, distanceKm: 6.8, + coords: { lat: -34.4278, lng: 150.8931 }, }, { id: 'killick', @@ -59,6 +62,7 @@ export const providers: DemoProvider[] = [ reviewCount: 15, startingPrice: 3100, distanceKm: 8.4, + coords: { lat: -26.5408, lng: 151.8388 }, }, { id: 'mackay', @@ -72,6 +76,7 @@ export const providers: DemoProvider[] = [ reviewCount: 87, startingPrice: 2800, distanceKm: 18.2, + coords: { lat: -33.3644, lng: 151.3728 }, }, { id: 'mannings', @@ -85,6 +90,7 @@ export const providers: DemoProvider[] = [ reviewCount: 31, startingPrice: 2600, distanceKm: 22.0, + coords: { lat: -36.6742, lng: 149.8417 }, }, { id: 'botanical', @@ -96,6 +102,7 @@ export const providers: DemoProvider[] = [ reviewCount: 8, startingPrice: 5200, distanceKm: 15.0, + coords: { lat: -33.8988, lng: 151.1794 }, }, ];