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:
@@ -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 `<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 (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 `<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
|
||||
|
||||
**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 <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
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user