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:
@@ -28,7 +28,8 @@ duplicates) and MUST update it after completing one.
|
|||||||
| Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. |
|
| Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. |
|
||||||
| Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. |
|
| Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. |
|
||||||
| Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. |
|
| Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. |
|
||||||
| MapPin | done | name+price (two-line), name-only, price-only × verified, unverified × default, active | mapPin.paddingX/borderRadius/nub.size, color.brand.100-900, color.neutral.100-800 | Two-line label map marker: name (bold, truncated 180px) + "From $X" (centred, semibold). Name optional for price-only variant. Verified = brand palette, unverified = grey. Active inverts + scale. Pure CSS. role="button" + keyboard + focus ring. |
|
| MapPin | done | name+price × verified, unverified | mapPin.paddingX/borderRadius/nub.size, color.brand-200/700, color.neutral-100-800 | Two-line label map marker: name (bold, required, truncated 180px) + "From $X" (centred, semibold). Verified = promoted brand palette (brand-700 bg, white text, brand-200 price). Unverified = neutral grey pill (neutral-100 bg, neutral-800 text). No active state — selection handled at the organism level (ProviderMap swaps pin → MapPopup on click). Pure CSS. role="button" + keyboard + focus ring. Name-only / price-only variants dropped in D042 (production providers always have both). |
|
||||||
|
| ClusterMarker | done | count × verified (promoted), unverified (neutral) | color.brand-700, color.neutral-100-800, mapPin.nub.size | Circular 36px count badge for pin clusters. `hasVerified` flag drives the palette (if any provider in the cluster is verified, use the promoted brand-700 palette; else neutral grey). Same nub treatment + shadow tokens as MapPin for visual cohesion. Pure CSS + SVG. role="button" + keyboard + focus ring. Designed as the `render`-ed output of `@googlemaps/markerclusterer`. See D043. |
|
||||||
| ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. |
|
| ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. |
|
||||||
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
|
| Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. |
|
||||||
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
|
| Link | done | underline: hover/always/none × any MUI colour | color.text.brand (copper brand.600, 4.8:1), color.interactive.active | Navigation text link. Wraps MUI Link. Copper default, underline on hover, focus ring. |
|
||||||
@@ -45,7 +46,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
| FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation |
|
| FormField | planned | Input + Typography (label) + Typography (helper) | Standard form field with label and validation |
|
||||||
| ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. |
|
| ProviderCard | done | Card + Typography + Badge + Tooltip | Provider listing card. Verified: image + logo (64px rounded rect) + "Verified" badge. Unverified: text-only with top accent bar. Capability badges with info icon + tooltip. Price split typography. No footer. 4 component tokens. |
|
||||||
| VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. |
|
| VenueCard | done | Card + Typography | Venue listing card. Always has photo + location + capacity ("X guests") + price ("From $X"). No verification tiers, no logo, no badges. 3 component tokens. Critique: 33/40. |
|
||||||
| MapCard | planned | Card + Typography + Badge | Compact horizontal map popup card. Deferred until map integration. |
|
| ~~MapCard~~ | superseded | — | Retired 2026-04-21. Role (compact map popup card) fully covered by `MapPopup`. See D041. |
|
||||||
| ServiceOption | done | Card (interactive, selected) + Typography | Selectable service option for arrangement flow. Heading + optional price (right-aligned) + optional description. role="radio" + aria-checked. Disabled state with opacity token. Maps to Figma ListItemPurchaseOption. |
|
| ServiceOption | done | Card (interactive, selected) + Typography | Selectable service option for arrangement flow. Heading + optional price (right-aligned) + optional description. role="radio" + aria-checked. Disabled state with opacity token. Maps to Figma ListItemPurchaseOption. |
|
||||||
| SearchBar | done | Input + IconButton + Button | Search input with optional submit button. Enter-to-submit, progressive clear button, inline loading spinner. Guards empty submissions, refocuses after clear. role="search" landmark. Critique: 35/40. |
|
| SearchBar | done | Input + IconButton + Button | Search input with optional submit button. Enter-to-submit, progressive clear button, inline loading spinner. Guards empty submissions, refocuses after clear. role="search" landmark. Critique: 35/40. |
|
||||||
| AddOnOption | done | Card (interactive, selected) + Typography + Switch | Toggleable add-on for arrangement flow extras. Heading + optional price + description + Switch. Click-anywhere toggle. Maps to Figma ListItemAddItem (2350:40658). |
|
| AddOnOption | done | Card (interactive, selected) + Typography + Switch | Toggleable add-on for arrangement flow extras. Heading + optional price + description + Switch. Click-anywhere toggle. Maps to Figma ListItemAddItem (2350:40658). |
|
||||||
@@ -58,6 +59,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
| ComparisonColumnCard | done | Card + Badge + Button + Divider + Typography + Tooltip + Link + StarRoundedIcon + VerifiedOutlinedIcon | Desktop column header card for ComparisonTable. Floating badge: **medium** (26px) filled brand + StarRoundedIcon for recommended; soft brand + VerifiedOutlinedIcon for verified. Provider name **wraps to 2 lines** (`WebkitLineClamp: 2`) in a reserved 36px minHeight slot bottom-aligned so 1-line names anchor with location/rating/price at a consistent baseline. Recommended card: 2px brand-600 border + warm `selected` Card state + inline VerifiedOutlinedIcon left of name. `pt: 5` (40px breathing above name), uniform regardless of verified/recommended. Remove link always renders as the same Link element (visibility-hidden when not applicable) so CTA+footer align across all cards. Per-column wrapper in ComparisonTable is `display: flex` with `flex: 1` passed to the card root so all cards stretch to row height. Extracted from ComparisonTable (2026-04-12). |
|
| ComparisonColumnCard | done | Card + Badge + Button + Divider + Typography + Tooltip + Link + StarRoundedIcon + VerifiedOutlinedIcon | Desktop column header card for ComparisonTable. Floating badge: **medium** (26px) filled brand + StarRoundedIcon for recommended; soft brand + VerifiedOutlinedIcon for verified. Provider name **wraps to 2 lines** (`WebkitLineClamp: 2`) in a reserved 36px minHeight slot bottom-aligned so 1-line names anchor with location/rating/price at a consistent baseline. Recommended card: 2px brand-600 border + warm `selected` Card state + inline VerifiedOutlinedIcon left of name. `pt: 5` (40px breathing above name), uniform regardless of verified/recommended. Remove link always renders as the same Link element (visibility-hidden when not applicable) so CTA+footer align across all cards. Per-column wrapper in ComparisonTable is `display: flex` with `flex: 1` passed to the card root so all cards stretch to row height. Extracted from ComparisonTable (2026-04-12). |
|
||||||
| ComparisonTabCard | done | Card + Badge + Typography + StarRoundedIcon | Mobile tab rail card for ComparisonPage. Provider name + package name + price. Recommended badge in normal flow with negative margin overlap — **filled brand + StarRoundedIcon** (matches desktop ComparisonColumnCard treatment, size="small" at 14px icon). **Fixed 235px width** (was 210). Border `brand-600` when recommended (consistent with primary). No glow — uses standard `shadow-sm` like other cards. `pt: 3.5` inside card. Shared by V1 and V2 (extracted 2026-04-12). |
|
| ComparisonTabCard | done | Card + Badge + Typography + StarRoundedIcon | Mobile tab rail card for ComparisonPage. Provider name + package name + price. Recommended badge in normal flow with negative margin overlap — **filled brand + StarRoundedIcon** (matches desktop ComparisonColumnCard treatment, size="small" at 14px icon). **Fixed 235px width** (was 210). Border `brand-600` when recommended (consistent with primary). No glow — uses standard `shadow-sm` like other cards. `pt: 3.5` inside card. Shared by V1 and V2 (extracted 2026-04-12). |
|
||||||
| NearbyPackageCard | done | Card (outlined, interactive) + Typography + StarRoundedIcon + LocationOnOutlinedIcon | Compact card representing a package offered by a nearby verified provider — package name + price + provider + rating + location. Used in the "Similar packages from verified providers nearby" section of PackagesStep for unverified tiers. Click is a route change to that verified provider's PackagesStep with this package loaded. Extracted from UnverifiedPackageT2/T3 during 2026-04-17 consolidation. |
|
| NearbyPackageCard | done | Card (outlined, interactive) + Typography + StarRoundedIcon + LocationOnOutlinedIcon | Compact card representing a package offered by a nearby verified provider — package name + price + provider + rating + location. Used in the "Similar packages from verified providers nearby" section of PackagesStep for unverified tiers. Click is a route change to that verified provider's PackagesStep with this package loaded. Extracted from UnverifiedPackageT2/T3 during 2026-04-17 consolidation. |
|
||||||
|
| ClusterPopup | done | Paper + Typography + IconButton + ButtonBase + inline provider-rows (fixed verified-icon slot + name + location + rating) | Cluster list popup — appears when a cluster marker is clicked. Header bar ("N providers in this area" + close X), scrollable stack of image-free provider rows (reserved verified-icon slot so titles align across tiers, name in copper for verified / neutral for unverified, location + rating meta). Verified-first sort order. 320px wide, matches MapPopup's card + nub + drop-shadow. Click on a row calls `onSelectProvider(id)`; in the ProviderMap flow that pans+zooms the map to the provider's coords (zoom 15) and opens their single-provider popup — there's no back-to-list. See D043, D044. |
|
||||||
|
|
||||||
## Organisms
|
## Organisms
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ duplicates) and MUST update it after completing one.
|
|||||||
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
|
| ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. |
|
||||||
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
|
| Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). |
|
||||||
| Footer | done | Link × n + Typography + Divider + Container + Grid | Light grey (surface.subtle) site footer — matches header. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
|
| Footer | done | Link × n + Typography + Divider + Container + Grid | Light grey (surface.subtle) site footer — matches header. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). |
|
||||||
|
| ProviderMap | done | MapPin + ClusterMarker + MapPopup + ClusterPopup + `@vis.gl/react-google-maps` + `@googlemaps/markerclusterer` | Google Maps provider map with clustering. **Individual pins**: `MapPin` per provider — click morphs it into a `MapPopup` at the same coord. **Clusters**: pins within 70px of each other collapse into a `ClusterMarker` (count badge) *only while zoomed out at level 13 or below* — past that, every pin shows individually. Click a cluster → `ClusterPopup` list at the cluster centroid, verified-first. **Click a row → map pans + zooms to that provider's coords (zoom 15) and opens their `MapPopup`**; cluster state cleared (no back-to-list). Map-background click reverts to pins. Markers are rendered imperatively (via `createRoot` into AdvancedMarker content elements) so `markerclusterer` can group them; popup layer remains declarative React. `selectedProviderId` force-opens a popup for external selection. Subtle empty state when no API key or no providers have coords (no throw). API key read from `VITE_GOOGLE_MAPS_API_KEY`. Desktop-only for now; mobile map sheet deferred. See D041, D042, D043, D044. |
|
||||||
|
|
||||||
## Templates
|
## Templates
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,152 @@ Each entry follows this structure:
|
|||||||
|
|
||||||
## Sessions
|
## Sessions
|
||||||
|
|
||||||
|
### Session 2026-04-22 — Session-end handoff
|
||||||
|
|
||||||
|
**Status at end of session:** Map work reached a stable checkpoint. User asked to resume in a new session rather than continue accumulating context. All work committed.
|
||||||
|
|
||||||
|
**What's next (for the next session to pick up):**
|
||||||
|
1. **ProvidersStep component polish** — user wants iteration on the step itself and some of its constituent components (likely SearchBar, filter chips, FilterPanel, ProviderCard layout, sort/view-mode controls). No specific issues flagged yet — a fresh design review pass will surface them.
|
||||||
|
2. **Mobile layout for ProvidersStep + ProviderMap.** The existing `list-map` WizardLayout hides the right (map) panel entirely on mobile via `display: { xs: 'none', md: 'flex' }`. The `viewMode: 'list' | 'map'` toggle is wired in state but doesn't yet control panel visibility on small screens. Two possible approaches to discuss: (a) wire `viewMode` to swap panels on mobile (list OR map, full-bleed), or (b) a bottom-sheet overlay pattern where the map is full-bleed and the list is a drawer. Worth a design conversation before building.
|
||||||
|
3. **Demo deploy** (`/preflight` + `npm run demo:publish`) — held until the above lands and the user is ready to share.
|
||||||
|
|
||||||
|
**Deferred** (previously flagged, still open): animated morph between pin and popup landed this session, so it's off the list; the only remaining map follow-up is mobile — folded into item 2 above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Session 2026-04-21c — ClusterMarker + ClusterPopup + clustering in ProviderMap
|
||||||
|
|
||||||
|
**Agent(s):** Claude Opus 4.7 (1M context)
|
||||||
|
|
||||||
|
**Context:** User iterated on clustering in Figma Make (reference: https://www.figma.com/make/704nCLj7uFqIQzBmAA21ql/Funeral-Provider-Finder-Map). Picked the "list-popup" approach over zoom-in: clicking a cluster morphs it into a scrollable list of providers rather than zooming the map. Directive: style pin + popup to match FA design system, use a version of existing cards in the list rows, impose rules on when clustering activates.
|
||||||
|
|
||||||
|
**Decisions made (D043):**
|
||||||
|
- **Library**: `@googlemaps/markerclusterer` (15KB gz, MIT, official Google team). `GridAlgorithm` for screen-pixel-based clustering — not geographic distance (pixel distance adapts to zoom automatically).
|
||||||
|
- **Rules**: `gridSize: 70` pixels, `maxZoom: 13` — past zoom 13 every pin shows individually no matter how close, so tight geographic clusters only form when the user's actually zoomed out.
|
||||||
|
- **UI model**: cluster click → `ClusterPopup` list at centroid (no zoom-in). Row click → drill into `MapPopup` with back chevron to return to list. Map click → revert all.
|
||||||
|
- **Row layout**: inlined 48px-thumbnail row inside `ClusterPopup`, *not* `ProviderCardCompact`. That molecule's 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
|
### Session 2026-04-20 — PackageDetail polish + PackagesStep spacing/drill-in + NearbyPackageCard elevation
|
||||||
|
|
||||||
**Agent(s):** Claude Opus 4.7 (1M context)
|
**Agent(s):** Claude Opus 4.7 (1M context)
|
||||||
@@ -1369,3 +1515,37 @@ Each entry follows this structure:
|
|||||||
- If v2 chosen: add location autocomplete, write flow logic reference doc
|
- If v2 chosen: add location autocomplete, write flow logic reference doc
|
||||||
|
|
||||||
---
|
---
|
||||||
|
### Session 2026-04-20 — Demo slice hosting end-to-end
|
||||||
|
|
||||||
|
**Agent(s):** Claude Opus 4.7 (1M context) + workstation server agent
|
||||||
|
|
||||||
|
**Work completed:**
|
||||||
|
- Scaffolded `src/demo/` (shared fixtures + Zustand basket + URL sync) and `src/demo/apps/arrangement/` (Providers → Packages → Comparison flow) consuming existing page components with mocked data
|
||||||
|
- Per-slice Vite build via `vite.demo.config.ts` (`--mode <slice>` selects app folder + base prefix + outDir)
|
||||||
|
- Wrote `nginx/parsons-demos.conf` (server agent fixed regex ordering bug — asset cache regex must come before SPA fallback regex per nginx first-match rule)
|
||||||
|
- Wrote `scripts/deploy-demo.sh` (rsync wrapper, slice-aware, gitignored due to server-specific paths)
|
||||||
|
- Wrote `docs/reference/client-demo-deploy.md` (server runbook)
|
||||||
|
- Wired `assetUrl()` helper at `src/demo/shared/assets.ts` — wraps `import.meta.env.BASE_URL` so public-asset URLs resolve under `/<slice>/` in production but stay flat in dev
|
||||||
|
- Added `new-demo-slice` skill at `.claude/skills/new-demo-slice/SKILL.md`
|
||||||
|
- Added npm scripts: `demo:dev`, `demo:build`, `demo:publish`
|
||||||
|
- 7 demo providers across verified/tier3/tier2 with real venue photography from `brandassets/images/venues/`
|
||||||
|
- Verified-provider packages restructured to share canonical 9-item Essentials per FA convention (factory helpers in `packages.ts`); only Optionals + Extras vary per provider/package
|
||||||
|
- ProviderCard logo `objectFit: cover → contain` so wide logos don't crop
|
||||||
|
- Demo deployed and live at `https://parsons.tensordesign.com.au/arrangement/` behind basic auth (user: `client`, htpasswd at `/config/nginx/.htpasswd-parsons`)
|
||||||
|
|
||||||
|
**Decisions made:**
|
||||||
|
- Use TrueNAS `/mnt/config/docker/parsons-demos/` as document root (boot-pool `/srv/` not persistable on TrueNAS SCALE)
|
||||||
|
- Single htpasswd covering all slices (split per-slice if/when client-specific auth is needed)
|
||||||
|
- nginx auth covers root path too — change to `auth_basic off;` if landing page needs to be public
|
||||||
|
- Did NOT prune publicDir bloat (1.1GB build) — user waved off, storage isn't constrained
|
||||||
|
- Server-side: rsync target uses TrueNAS `truenas` SSH alias as user `truenas_admin` (UID 950 = swag's container user, `:ro` mount)
|
||||||
|
|
||||||
|
**Open questions:**
|
||||||
|
- None blocking — everything live and working
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- ProviderMap on ProvidersStep — currently empty `mapPanel` slot. Build as new organism via `/build-organism ProviderMap` (needs Leaflet/MapLibre + lat/lng added to provider fixtures + verified-vs-unverified pin treatment), then wire into demo
|
||||||
|
- PackageDetail "Added ✓" state — Compare button silently dedupes when basket already contains current selection; should show stateful feedback. New session via `/build-molecule` or `/polish` on PackageDetail
|
||||||
|
- (Low priority) Asset prune — switch publicDir to allowlist if deploy size becomes annoying
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
83
package-lock.json
generated
83
package-lock.json
generated
@@ -10,9 +10,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@googlemaps/markerclusterer": "^2.6.2",
|
||||||
"@mui/icons-material": "^5.16.0",
|
"@mui/icons-material": "^5.16.0",
|
||||||
"@mui/material": "^5.16.0",
|
"@mui/material": "^5.16.0",
|
||||||
"@mui/system": "^5.16.0",
|
"@mui/system": "^5.16.0",
|
||||||
|
"@vis.gl/react-google-maps": "^1.8.3",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
@@ -1460,6 +1462,26 @@
|
|||||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@googlemaps/js-api-loader": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/google.maps": "^3.53.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@googlemaps/markerclusterer": {
|
||||||
|
"version": "2.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.6.2.tgz",
|
||||||
|
"integrity": "sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
|
"fast-equals": "^5.2.2",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -4108,6 +4130,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/google.maps": {
|
||||||
|
"version": "3.64.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
|
||||||
|
"integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -4171,6 +4205,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/supercluster": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -4480,6 +4523,21 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vis.gl/react-google-maps": {
|
||||||
|
"version": "1.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vis.gl/react-google-maps/-/react-google-maps-1.8.3.tgz",
|
||||||
|
"integrity": "sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@googlemaps/js-api-loader": "^2.0.2",
|
||||||
|
"@types/google.maps": "^3.54.10",
|
||||||
|
"fast-deep-equal": "^3.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -6473,9 +6531,17 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
@@ -7865,6 +7931,12 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -10563,6 +10635,15 @@
|
|||||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/supercluster": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
|||||||
@@ -27,9 +27,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.13.0",
|
"@emotion/react": "^11.13.0",
|
||||||
"@emotion/styled": "^11.13.0",
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@googlemaps/markerclusterer": "^2.6.2",
|
||||||
"@mui/icons-material": "^5.16.0",
|
"@mui/icons-material": "^5.16.0",
|
||||||
"@mui/material": "^5.16.0",
|
"@mui/material": "^5.16.0",
|
||||||
"@mui/system": "^5.16.0",
|
"@mui/system": "^5.16.0",
|
||||||
|
"@vis.gl/react-google-maps": "^1.8.3",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"react-router-dom": "^7.14.1",
|
"react-router-dom": "^7.14.1",
|
||||||
|
|||||||
77
src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx
Normal file
77
src/components/atoms/ClusterMarker/ClusterMarker.stories.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ClusterMarker } from './ClusterMarker';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ClusterMarker> = {
|
||||||
|
title: 'Atoms/ClusterMarker',
|
||||||
|
component: ClusterMarker,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
backgrounds: {
|
||||||
|
default: 'map',
|
||||||
|
values: [{ name: 'map', value: '#E5E3DF' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
onClick: { action: 'clicked' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ClusterMarker>;
|
||||||
|
|
||||||
|
/** Cluster containing at least one verified provider — promoted palette */
|
||||||
|
export const MixedOrVerified: Story = {
|
||||||
|
args: {
|
||||||
|
count: 5,
|
||||||
|
hasVerified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cluster of all-unverified providers — neutral palette */
|
||||||
|
export const AllUnverified: Story = {
|
||||||
|
args: {
|
||||||
|
count: 3,
|
||||||
|
hasVerified: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Small cluster — pair of providers */
|
||||||
|
export const Pair: Story = {
|
||||||
|
args: {
|
||||||
|
count: 2,
|
||||||
|
hasVerified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Large cluster — double-digit count */
|
||||||
|
export const LargeCluster: Story = {
|
||||||
|
args: {
|
||||||
|
count: 27,
|
||||||
|
hasVerified: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Side-by-side comparison — verified vs unverified at various counts */
|
||||||
|
export const PaletteGrid: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: 6,
|
||||||
|
p: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClusterMarker count={2} hasVerified />
|
||||||
|
<ClusterMarker count={5} hasVerified />
|
||||||
|
<ClusterMarker count={12} hasVerified />
|
||||||
|
<ClusterMarker count={99} hasVerified />
|
||||||
|
<ClusterMarker count={2} />
|
||||||
|
<ClusterMarker count={5} />
|
||||||
|
<ClusterMarker count={12} />
|
||||||
|
<ClusterMarker count={99} />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
};
|
||||||
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
161
src/components/atoms/ClusterMarker/ClusterMarker.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA ClusterMarker atom */
|
||||||
|
export interface ClusterMarkerProps {
|
||||||
|
/** Number of providers in this cluster */
|
||||||
|
count: number;
|
||||||
|
/** True if any provider in the cluster is verified — drives the promoted palette */
|
||||||
|
hasVerified?: boolean;
|
||||||
|
/** Click handler — opens the cluster popup */
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
/** MUI sx prop for the root element */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NUB_SIZE = 'var(--fa-map-pin-nub-size)';
|
||||||
|
const BADGE_SIZE = 36;
|
||||||
|
|
||||||
|
// ─── Colour sets — matches MapPin ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const colours = {
|
||||||
|
verified: {
|
||||||
|
bg: 'var(--fa-color-brand-700)',
|
||||||
|
text: 'var(--fa-color-white)',
|
||||||
|
border: 'var(--fa-color-brand-700)',
|
||||||
|
nub: 'var(--fa-color-brand-700)',
|
||||||
|
},
|
||||||
|
unverified: {
|
||||||
|
bg: 'var(--fa-color-neutral-100)',
|
||||||
|
text: 'var(--fa-color-neutral-800)',
|
||||||
|
border: 'var(--fa-color-neutral-300)',
|
||||||
|
nub: 'var(--fa-color-neutral-100)',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cluster map marker for the FA design system.
|
||||||
|
*
|
||||||
|
* Circular pill with a count, representing N provider pins grouped at the
|
||||||
|
* same screen location. Sibling to `MapPin` — same palette language (verified
|
||||||
|
* promoted, unverified neutral), same nub treatment, same shadow.
|
||||||
|
*
|
||||||
|
* `hasVerified` drives the palette: if *any* provider in the cluster is
|
||||||
|
* verified, the cluster adopts the promoted (brand-700) palette. All-unverified
|
||||||
|
* clusters use the neutral palette.
|
||||||
|
*
|
||||||
|
* Designed for use as the `render`-ed output of `@googlemaps/markerclusterer`.
|
||||||
|
* Pure CSS + SVG — no canvas. role="button" + keyboard + focus ring.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* <ClusterMarker count={5} hasVerified onClick={...} />
|
||||||
|
* <ClusterMarker count={12} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ClusterMarker = React.forwardRef<HTMLDivElement, ClusterMarkerProps>(
|
||||||
|
({ count, hasVerified = false, onClick, sx }, ref) => {
|
||||||
|
const palette = hasVerified ? colours.verified : colours.unverified;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick(e as unknown as React.MouseEvent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const label = `${count} providers in this area`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 150ms ease-in-out',
|
||||||
|
// Fade in on mount — matches MapPin and popups for a consistent
|
||||||
|
// entry timing across the map.
|
||||||
|
'@keyframes clusterMarkerIn': {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
to: { opacity: 1 },
|
||||||
|
},
|
||||||
|
animation: 'clusterMarkerIn 180ms ease-out',
|
||||||
|
'&:hover': { transform: 'scale(1.08)' },
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: 'none',
|
||||||
|
'& > .ClusterMarker-badge': {
|
||||||
|
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Circular badge */}
|
||||||
|
<Box
|
||||||
|
className="ClusterMarker-badge"
|
||||||
|
sx={{
|
||||||
|
width: BADGE_SIZE,
|
||||||
|
height: BADGE_SIZE,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: palette.bg,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: palette.border,
|
||||||
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
color: palette.text,
|
||||||
|
fontFamily: 'var(--fa-font-family-body)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Nub — same SVG pattern as MapPin for visual continuity */}
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 16 8"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: `calc(2 * ${NUB_SIZE})`,
|
||||||
|
height: NUB_SIZE,
|
||||||
|
marginTop: '-1px',
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
|
||||||
|
<path
|
||||||
|
d="M 0 0 L 8 8 L 16 0"
|
||||||
|
fill="none"
|
||||||
|
stroke={palette.border}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ClusterMarker.displayName = 'ClusterMarker';
|
||||||
|
export default ClusterMarker;
|
||||||
1
src/components/atoms/ClusterMarker/index.ts
Normal file
1
src/components/atoms/ClusterMarker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ClusterMarker, type ClusterMarkerProps } from './ClusterMarker';
|
||||||
@@ -21,8 +21,8 @@ const meta: Meta<typeof MapPin> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof MapPin>;
|
type Story = StoryObj<typeof MapPin>;
|
||||||
|
|
||||||
/** Verified provider with name and price — warm brand label */
|
/** Verified provider — promoted brand palette (dark copper bg, white text) */
|
||||||
export const VerifiedWithPrice: Story = {
|
export const Verified: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'H.Parsons Funeral Directors',
|
name: 'H.Parsons Funeral Directors',
|
||||||
price: 900,
|
price: 900,
|
||||||
@@ -31,7 +31,7 @@ export const VerifiedWithPrice: Story = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Unverified provider — neutral grey label */
|
/** Unverified provider — neutral grey label */
|
||||||
export const UnverifiedWithPrice: Story = {
|
export const Unverified: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Smith & Sons Funerals',
|
name: 'Smith & Sons Funerals',
|
||||||
price: 1200,
|
price: 1200,
|
||||||
@@ -39,66 +39,7 @@ export const UnverifiedWithPrice: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Active/selected state — inverted colours, slight scale-up */
|
/** Custom price label (e.g. "POA" for providers without a fixed starting price) */
|
||||||
export const Active: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'H.Parsons Funeral Directors',
|
|
||||||
price: 900,
|
|
||||||
verified: true,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Active unverified */
|
|
||||||
export const ActiveUnverified: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Smith & Sons Funerals',
|
|
||||||
price: 1200,
|
|
||||||
verified: false,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Name only — no price line */
|
|
||||||
export const NameOnly: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Lady Anne Funerals',
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Name only, unverified */
|
|
||||||
export const NameOnlyUnverified: Story = {
|
|
||||||
args: {
|
|
||||||
name: 'Local Funeral Services',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Price-only pill — no name, verified */
|
|
||||||
export const PriceOnly: Story = {
|
|
||||||
args: {
|
|
||||||
price: 900,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Price-only pill — unverified */
|
|
||||||
export const PriceOnlyUnverified: Story = {
|
|
||||||
args: {
|
|
||||||
price: 1200,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Price-only pill — active */
|
|
||||||
export const PriceOnlyActive: Story = {
|
|
||||||
args: {
|
|
||||||
price: 900,
|
|
||||||
verified: true,
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Custom price label */
|
|
||||||
export const CustomPriceLabel: Story = {
|
export const CustomPriceLabel: Story = {
|
||||||
args: {
|
args: {
|
||||||
name: 'Premium Services',
|
name: 'Premium Services',
|
||||||
@@ -141,7 +82,7 @@ export const MapSimulation: Story = {
|
|||||||
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
|
<MapPin name="H.Parsons" price={900} verified onClick={() => {}} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
|
<Box sx={{ position: 'absolute', top: 150, left: 280 }}>
|
||||||
<MapPin name="Lady Anne Funerals" price={1450} verified active onClick={() => {}} />
|
<MapPin name="Lady Anne Funerals" price={1450} verified onClick={() => {}} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
|
<Box sx={{ position: 'absolute', top: 260, left: 140 }}>
|
||||||
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
|
<MapPin name="Mackay Family" price={2200} verified onClick={() => {}} />
|
||||||
@@ -152,12 +93,7 @@ export const MapSimulation: Story = {
|
|||||||
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
|
<MapPin name="Smith & Sons" price={1100} onClick={() => {}} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
|
<Box sx={{ position: 'absolute', top: 300, left: 400 }}>
|
||||||
<MapPin name="Local Provider" onClick={() => {}} />
|
<MapPin name="Local Provider" price={1600} onClick={() => {}} />
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Name only verified */}
|
|
||||||
<Box sx={{ position: 'absolute', top: 40, left: 500 }}>
|
|
||||||
<MapPin name="Kenneallys" verified onClick={() => {}} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,16 +6,14 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
|||||||
|
|
||||||
/** Props for the FA MapPin atom */
|
/** Props for the FA MapPin atom */
|
||||||
export interface MapPinProps {
|
export interface MapPinProps {
|
||||||
/** Provider or venue name — omit for a price-only pill */
|
/** Provider or venue name (required — shown as line 1) */
|
||||||
name?: string;
|
name: string;
|
||||||
/** Starting package price in dollars — shown as "From $X" */
|
/** Starting package price in dollars — shown as "From $X" on line 2 */
|
||||||
price?: number;
|
price?: number;
|
||||||
/** Custom price label (e.g. "POA") — overrides formatted price */
|
/** Custom price label (e.g. "POA") — overrides formatted price */
|
||||||
priceLabel?: string;
|
priceLabel?: string;
|
||||||
/** Whether this provider/venue is verified (brand colour vs neutral) */
|
/** Whether this provider/venue is verified (brand palette vs neutral palette) */
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
/** Whether this pin is currently active/selected */
|
|
||||||
active?: boolean;
|
|
||||||
/** Click handler */
|
/** Click handler */
|
||||||
onClick?: (e: React.MouseEvent) => void;
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
/** MUI sx prop for the root element */
|
/** MUI sx prop for the root element */
|
||||||
@@ -33,28 +31,18 @@ const MAX_WIDTH = 180;
|
|||||||
|
|
||||||
const colours = {
|
const colours = {
|
||||||
verified: {
|
verified: {
|
||||||
bg: 'var(--fa-color-brand-100)',
|
bg: 'var(--fa-color-brand-700)',
|
||||||
name: 'var(--fa-color-brand-900)',
|
name: 'var(--fa-color-white)',
|
||||||
price: 'var(--fa-color-brand-600)',
|
price: 'var(--fa-color-brand-200)',
|
||||||
activeBg: 'var(--fa-color-brand-700)',
|
nub: 'var(--fa-color-brand-700)',
|
||||||
activeName: 'var(--fa-color-white)',
|
border: 'var(--fa-color-brand-700)',
|
||||||
activePrice: 'var(--fa-color-brand-200)',
|
|
||||||
nub: 'var(--fa-color-brand-100)',
|
|
||||||
activeNub: 'var(--fa-color-brand-700)',
|
|
||||||
border: 'var(--fa-color-brand-300)',
|
|
||||||
activeBorder: 'var(--fa-color-brand-700)',
|
|
||||||
},
|
},
|
||||||
unverified: {
|
unverified: {
|
||||||
bg: 'var(--fa-color-neutral-100)',
|
bg: 'var(--fa-color-neutral-100)',
|
||||||
name: 'var(--fa-color-neutral-800)',
|
name: 'var(--fa-color-neutral-800)',
|
||||||
price: 'var(--fa-color-neutral-500)',
|
price: 'var(--fa-color-neutral-500)',
|
||||||
activeBg: 'var(--fa-color-neutral-700)',
|
|
||||||
activeName: 'var(--fa-color-white)',
|
|
||||||
activePrice: 'var(--fa-color-neutral-200)',
|
|
||||||
nub: 'var(--fa-color-neutral-100)',
|
nub: 'var(--fa-color-neutral-100)',
|
||||||
activeNub: 'var(--fa-color-neutral-700)',
|
|
||||||
border: 'var(--fa-color-neutral-300)',
|
border: 'var(--fa-color-neutral-300)',
|
||||||
activeBorder: 'var(--fa-color-neutral-700)',
|
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -68,26 +56,25 @@ const colours = {
|
|||||||
* the exact map location.
|
* the exact map location.
|
||||||
*
|
*
|
||||||
* - **Line 1**: Provider name (bold, truncated)
|
* - **Line 1**: Provider name (bold, truncated)
|
||||||
* - **Line 2**: "From $X" (smaller, secondary colour) — optional
|
* - **Line 2**: "From $X" (smaller, secondary colour)
|
||||||
*
|
*
|
||||||
* Visual distinction:
|
* Visual distinction:
|
||||||
* - **Verified** providers: warm brand palette (gold bg, copper text)
|
* - **Verified** providers: warm brand palette (dark copper bg, white text)
|
||||||
* - **Unverified** providers: neutral grey palette
|
* - **Unverified** providers: neutral grey palette
|
||||||
* - **Active/selected**: inverted colours (dark bg, white text) + scale-up
|
|
||||||
*
|
*
|
||||||
* Designed for use as custom HTML markers in Mapbox GL / Google Maps.
|
* Designed for use as custom HTML markers in Google Maps. Pure CSS — no
|
||||||
* Pure CSS — no canvas, no SVG dependency.
|
* canvas, no SVG dependency. Selection/popup behaviour is handled at the
|
||||||
|
* organism level (ProviderMap swaps pin → popup on click).
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
|
* <MapPin name="H.Parsons" price={900} verified onClick={...} />
|
||||||
* <MapPin name="Smith & Sons" /> {/* Name only, unverified *\/}
|
* <MapPin name="Smith & Sons" price={1200} />
|
||||||
* <MapPin price={900} verified /> {/* Price-only pill, no name *\/}
|
* <MapPin name="Botanical" priceLabel="POA" verified />
|
||||||
* <MapPin name="H.Parsons" price={900} verified active />
|
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
||||||
({ name, price, priceLabel, verified = false, active = false, onClick, sx }, ref) => {
|
({ name, price, priceLabel, verified = false, onClick, sx }, ref) => {
|
||||||
const palette = verified ? colours.verified : colours.unverified;
|
const palette = verified ? colours.verified : colours.unverified;
|
||||||
const hasPrice = price != null || priceLabel != null;
|
const hasPrice = price != null || priceLabel != null;
|
||||||
|
|
||||||
@@ -106,7 +93,7 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`${name ?? (verified ? 'Verified' : 'Unverified') + ' provider'}${hasPrice ? `, packages from $${price?.toLocaleString('en-AU') ?? priceLabel}` : ''}${verified ? ', verified' : ''}${active ? ' (selected)' : ''}`}
|
aria-label={`${name}${hasPrice ? `, packages from ${priceLabel ?? `$${price?.toLocaleString('en-AU')}`}` : ''}${verified ? ', verified' : ''}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
sx={[
|
sx={[
|
||||||
@@ -116,7 +103,13 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'transform 150ms ease-in-out',
|
transition: 'transform 150ms ease-in-out',
|
||||||
transform: active ? 'scale(1.08)' : 'scale(1)',
|
// Fade in on mount — matches the popup's exit timing so the pin
|
||||||
|
// reappears smoothly when a popup closes.
|
||||||
|
'@keyframes mapPinIn': {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
to: { opacity: 1 },
|
||||||
|
},
|
||||||
|
animation: 'mapPinIn 180ms ease-out',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: 'scale(1.08)',
|
transform: 'scale(1.08)',
|
||||||
},
|
},
|
||||||
@@ -142,53 +135,41 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|||||||
py: 0.5,
|
py: 0.5,
|
||||||
px: PIN_PX,
|
px: PIN_PX,
|
||||||
borderRadius: PIN_RADIUS,
|
borderRadius: PIN_RADIUS,
|
||||||
backgroundColor: active ? palette.activeBg : palette.bg,
|
backgroundColor: palette.bg,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: active ? palette.activeBorder : palette.border,
|
borderColor: palette.border,
|
||||||
boxShadow: active ? 'var(--fa-shadow-md)' : 'var(--fa-shadow-sm)',
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
transition:
|
|
||||||
'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
{name && (
|
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
fontFamily: 'var(--fa-font-family-body)',
|
||||||
lineHeight: 1.3,
|
lineHeight: 1.3,
|
||||||
color: active ? palette.activeName : palette.name,
|
color: palette.name,
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
transition: 'color 150ms ease-in-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Price line */}
|
{/* Price line */}
|
||||||
{hasPrice && (
|
{hasPrice && (
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: !name ? 12 : 11,
|
fontSize: 11,
|
||||||
fontWeight: !name ? 700 : 600,
|
fontWeight: 600,
|
||||||
fontFamily: (t: Theme) => t.typography.fontFamily,
|
fontFamily: 'var(--fa-font-family-body)',
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
color: !name
|
color: palette.price,
|
||||||
? active
|
|
||||||
? palette.activeName
|
|
||||||
: palette.name
|
|
||||||
: active
|
|
||||||
? palette.activePrice
|
|
||||||
: palette.price,
|
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
transition: 'color 150ms ease-in-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{priceText}
|
{priceText}
|
||||||
@@ -196,19 +177,33 @@ export const MapPin = React.forwardRef<HTMLDivElement, MapPinProps>(
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Nub — downward pointer */}
|
{/* Nub — downward pointer. Two SVG paths:
|
||||||
<Box
|
• fill is an extended pentagon that overhangs 3 units *into* the
|
||||||
|
pill's bg so sub-pixel scaling artifacts (hover transform) can't
|
||||||
|
expose the pill's bottom border through the seam;
|
||||||
|
• stroke is a separate open path on the two slanted sides only,
|
||||||
|
so the nub outline is continuous with the pill's border.
|
||||||
|
overflow: visible lets the fill render above the viewBox. */}
|
||||||
|
<svg
|
||||||
aria-hidden
|
aria-hidden
|
||||||
sx={{
|
viewBox="0 0 16 8"
|
||||||
width: 0,
|
style={{
|
||||||
height: 0,
|
display: 'block',
|
||||||
borderLeft: `${NUB_SIZE} solid transparent`,
|
width: `calc(2 * ${NUB_SIZE})`,
|
||||||
borderRight: `${NUB_SIZE} solid transparent`,
|
height: NUB_SIZE,
|
||||||
borderTop: `${NUB_SIZE} solid`,
|
marginTop: '-1px',
|
||||||
borderTopColor: active ? palette.activeNub : palette.nub,
|
overflow: 'visible',
|
||||||
mt: '-1px',
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<path d="M 0 -3 L 16 -3 L 16 0 L 8 8 L 0 0 Z" fill={palette.bg} />
|
||||||
|
<path
|
||||||
|
d="M 0 0 L 8 8 L 16 0"
|
||||||
|
fill="none"
|
||||||
|
stroke={palette.border}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
|
</svg>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
114
src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx
Normal file
114
src/components/molecules/ClusterPopup/ClusterPopup.stories.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ClusterPopup } from './ClusterPopup';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ClusterPopup> = {
|
||||||
|
title: 'Molecules/ClusterPopup',
|
||||||
|
component: ClusterPopup,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
backgrounds: {
|
||||||
|
default: 'map',
|
||||||
|
values: [{ name: 'map', value: '#E5E3DF' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ p: 4 }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ClusterPopup>;
|
||||||
|
|
||||||
|
// Fixture data — mirrors the shape used in the demo
|
||||||
|
const mixedCluster = [
|
||||||
|
{
|
||||||
|
id: 'parsons',
|
||||||
|
name: 'H.Parsons Funeral Directors',
|
||||||
|
location: 'Wentworth, NSW',
|
||||||
|
verified: true,
|
||||||
|
rating: 4.6,
|
||||||
|
startingPrice: 1800,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rankins',
|
||||||
|
name: 'Rankins Funeral Services',
|
||||||
|
location: 'Warrawong, NSW',
|
||||||
|
verified: true,
|
||||||
|
rating: 4.8,
|
||||||
|
startingPrice: 2450,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wollongong-city',
|
||||||
|
name: 'Wollongong City Funerals',
|
||||||
|
location: 'Wollongong, NSW',
|
||||||
|
verified: false,
|
||||||
|
rating: 4.2,
|
||||||
|
startingPrice: 3400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'botanical',
|
||||||
|
name: 'Botanical Funerals',
|
||||||
|
location: 'Newtown, NSW',
|
||||||
|
verified: false,
|
||||||
|
rating: 4.9,
|
||||||
|
startingPrice: 5200,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Mixed-tier cluster — verified providers sorted to top */
|
||||||
|
export const Mixed: Story = {
|
||||||
|
args: {
|
||||||
|
providers: mixedCluster,
|
||||||
|
onSelectProvider: (id) => {
|
||||||
|
alert(`Drill into ${id}`);
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
alert('Close cluster');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Small pair — two providers at the same location */
|
||||||
|
export const Pair: Story = {
|
||||||
|
args: {
|
||||||
|
providers: mixedCluster.slice(0, 2),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All verified — every provider in the cluster is a partner */
|
||||||
|
export const AllVerified: Story = {
|
||||||
|
args: {
|
||||||
|
providers: mixedCluster.filter((p) => p.verified),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** All unverified — no partners in this cluster */
|
||||||
|
export const AllUnverified: Story = {
|
||||||
|
args: {
|
||||||
|
providers: mixedCluster.filter((p) => !p.verified),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tall cluster — scrolls when providers exceed visible area */
|
||||||
|
export const TallCluster: Story = {
|
||||||
|
args: {
|
||||||
|
providers: [
|
||||||
|
...mixedCluster,
|
||||||
|
...mixedCluster.map((p) => ({ ...p, id: `${p.id}-2`, name: `${p.name} (Branch 2)` })),
|
||||||
|
],
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
onClose: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
360
src/components/molecules/ClusterPopup/ClusterPopup.tsx
Normal file
360
src/components/molecules/ClusterPopup/ClusterPopup.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import ButtonBase from '@mui/material/ButtonBase';
|
||||||
|
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||||
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||||
|
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||||
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A provider summary used in the cluster list */
|
||||||
|
export interface ClusterPopupProvider {
|
||||||
|
/** Unique provider ID */
|
||||||
|
id: string;
|
||||||
|
/** Provider display name */
|
||||||
|
name: string;
|
||||||
|
/** Location text (suburb, city) */
|
||||||
|
location: string;
|
||||||
|
/** Whether this is a verified/partner provider — drives sort order + colour accents */
|
||||||
|
verified?: boolean;
|
||||||
|
/** Average rating */
|
||||||
|
rating?: number;
|
||||||
|
/** Starting package price in dollars — shown as "From $X" on the right */
|
||||||
|
startingPrice?: number;
|
||||||
|
/** Custom price label (e.g. "POA") — overrides the formatted price */
|
||||||
|
priceLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for the FA ClusterPopup molecule */
|
||||||
|
export interface ClusterPopupProps {
|
||||||
|
/** Providers in this cluster */
|
||||||
|
providers: ClusterPopupProvider[];
|
||||||
|
/** Click handler — fires when a provider row is clicked */
|
||||||
|
onSelectProvider: (id: string) => void;
|
||||||
|
/** Close handler — fires when the close button is clicked */
|
||||||
|
onClose?: () => void;
|
||||||
|
/** When true, animates the popup out (opacity + scale) without unmounting.
|
||||||
|
* Callers should unmount after the transition completes (180ms). */
|
||||||
|
exiting?: boolean;
|
||||||
|
/** MUI sx prop for the root element */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POPUP_WIDTH = 320;
|
||||||
|
const MAX_CONTENT_HEIGHT = 360;
|
||||||
|
const NUB_SIZE = 8;
|
||||||
|
/** Fixed width reserved for the verified-icon slot so all row titles share
|
||||||
|
* the same x-origin regardless of whether the row is verified. */
|
||||||
|
const VERIFIED_SLOT_WIDTH = 18;
|
||||||
|
|
||||||
|
// ─── Row sub-component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProviderRowProps {
|
||||||
|
provider: ClusterPopupProvider;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single provider row inside the cluster list. Image-free layout:
|
||||||
|
* verified-icon slot (fixed width so titles align across rows) + name +
|
||||||
|
* location/rating meta. Full-width clickable surface. Clicking triggers
|
||||||
|
* `onClick` — in `ProviderMap` that pans+zooms the map to the provider's
|
||||||
|
* location and opens their single-provider popup.
|
||||||
|
*/
|
||||||
|
const ProviderRow: React.FC<ProviderRowProps> = ({ provider, onClick }) => {
|
||||||
|
const hasPrice = provider.startingPrice != null || provider.priceLabel != null;
|
||||||
|
const priceText =
|
||||||
|
provider.priceLabel ??
|
||||||
|
(provider.startingPrice != null ? `$${provider.startingPrice.toLocaleString('en-AU')}` : null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonBase
|
||||||
|
onClick={(e) => {
|
||||||
|
// stopPropagation so the DOM click doesn't bubble to Map.onClick
|
||||||
|
// (which would clear state the same frame we're trying to drill in).
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
// flex-start so the verified-icon slot aligns with the name's top line,
|
||||||
|
// not the vertical centre of the row.
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 1,
|
||||||
|
p: 1.25,
|
||||||
|
borderRadius: 1,
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background-color 120ms ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: provider.verified
|
||||||
|
? 'var(--fa-color-brand-50)'
|
||||||
|
: 'var(--fa-color-surface-subtle)',
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid var(--fa-color-interactive-focus)',
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Verified-icon slot — reserved width + fixed line-height so the icon
|
||||||
|
sits vertically on the name's line-box regardless of whether the
|
||||||
|
row has location/rating/price content below. */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: VERIFIED_SLOT_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
height: '1.25em',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{provider.verified && (
|
||||||
|
<VerifiedOutlinedIcon
|
||||||
|
sx={{ fontSize: 16, color: 'var(--fa-color-brand-600)' }}
|
||||||
|
aria-label="Verified provider"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Text column — name + location/rating meta */}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
color: provider.verified ? 'var(--fa-color-brand-700)' : 'text.primary',
|
||||||
|
minWidth: 0,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
}}
|
||||||
|
maxLines={1}
|
||||||
|
>
|
||||||
|
{provider.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
color: 'text.secondary',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
|
<LocationOnOutlinedIcon sx={{ fontSize: 12 }} aria-hidden />
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
|
||||||
|
{provider.location}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{provider.rating != null && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
|
<StarRoundedIcon sx={{ fontSize: 12, color: 'warning.main' }} aria-hidden />
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 11 }}>
|
||||||
|
{provider.rating}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Price column — right-aligned, matches MapPopup's "From $X" typography.
|
||||||
|
Verified providers get the brand-600 copper price; unverified get
|
||||||
|
text.primary. "From" label uses caption/secondary for hierarchy. */}
|
||||||
|
{hasPrice && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
pt: '1px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: 10 }}>
|
||||||
|
From
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 13,
|
||||||
|
color: provider.verified ? 'var(--fa-color-brand-600)' : 'text.primary',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{priceText}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ButtonBase>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cluster popup card for the FA design system.
|
||||||
|
*
|
||||||
|
* Appears when a cluster marker is clicked. Shows the providers grouped at
|
||||||
|
* that map location as a scrollable stack of image-free rows — each row: a
|
||||||
|
* fixed-width verified-icon slot (so titles align across mixed-tier lists) +
|
||||||
|
* provider name (copper for verified, neutral for unverified) + location and
|
||||||
|
* rating meta. Clicking a row calls `onSelectProvider(id)`. In the
|
||||||
|
* ProviderMap flow, that pans and zooms the map to the provider's location
|
||||||
|
* before opening their single-provider popup — restoring spatial context
|
||||||
|
* that a list-only popup otherwise loses.
|
||||||
|
*
|
||||||
|
* Verified providers are sorted to the top of the list (business outcome:
|
||||||
|
* promote partner providers in any crowded cluster).
|
||||||
|
*
|
||||||
|
* Sibling to `MapPopup` — same card + nub treatment, same drop-shadow, same
|
||||||
|
* 320px width, same `surface-subtle` header bar convention. Designed to
|
||||||
|
* render inside a Google Maps `AdvancedMarker`.
|
||||||
|
*
|
||||||
|
* Composes: Paper + Typography + IconButton + ButtonBase + icons.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* <ClusterPopup
|
||||||
|
* providers={[
|
||||||
|
* { id: 'p1', name: 'H.Parsons', location: 'Wentworth', verified: true, rating: 4.6 },
|
||||||
|
* { id: 'p2', name: 'Smith & Sons', location: 'Cronulla', verified: false, rating: 4.2 },
|
||||||
|
* ]}
|
||||||
|
* onSelectProvider={(id) => drillIntoProvider(id)}
|
||||||
|
* onClose={() => closePopup()}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ClusterPopup = React.forwardRef<HTMLDivElement, ClusterPopupProps>(
|
||||||
|
({ providers, onSelectProvider, onClose, exiting = false, sx }, ref) => {
|
||||||
|
// Verified-first sort (stable within each tier)
|
||||||
|
const sorted = React.useMemo(
|
||||||
|
() =>
|
||||||
|
[...providers].sort((a, b) => Number(b.verified ?? false) - Number(a.verified ?? false)),
|
||||||
|
[providers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
// Swallow clicks on any empty space inside the popup (header, scroll
|
||||||
|
// gutter, etc.) so they don't bubble to Map.onClick and close us.
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
sx={[
|
||||||
|
{
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
|
||||||
|
opacity: exiting ? 0 : 1,
|
||||||
|
transform: exiting ? 'scale(0.9)' : 'scale(1)',
|
||||||
|
'@keyframes clusterPopupIn': {
|
||||||
|
from: { opacity: 0, transform: 'scale(0.9)' },
|
||||||
|
to: { opacity: 1, transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
animation: exiting ? undefined : 'clusterPopupIn 180ms ease-out',
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
width: POPUP_WIDTH,
|
||||||
|
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
maxHeight: MAX_CONTENT_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
|
bgcolor: 'var(--fa-color-surface-subtle)',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapOutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} aria-hidden />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', flex: 1 }}>
|
||||||
|
{providers.length} providers in this area
|
||||||
|
</Typography>
|
||||||
|
{onClose && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
aria-label="Close cluster popup"
|
||||||
|
sx={{ mr: -0.5 }}
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Provider list — scrollable */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
overflowY: 'auto',
|
||||||
|
p: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
// Thin scrollbar styling
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
'&::-webkit-scrollbar': { width: 6 },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
background: 'rgba(0,0,0,0.2)',
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sorted.map((p) => (
|
||||||
|
<ProviderRow key={p.id} provider={p} onClick={() => onSelectProvider(p.id)} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Nub — matches MapPopup (fill-only, soft shadow carries the depth) */}
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
width={NUB_SIZE * 2}
|
||||||
|
height={NUB_SIZE}
|
||||||
|
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
|
||||||
|
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
|
||||||
|
fill="var(--fa-color-white)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ClusterPopup.displayName = 'ClusterPopup';
|
||||||
|
export default ClusterPopup;
|
||||||
1
src/components/molecules/ClusterPopup/index.ts
Normal file
1
src/components/molecules/ClusterPopup/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ClusterPopup, type ClusterPopupProps, type ClusterPopupProvider } from './ClusterPopup';
|
||||||
@@ -132,7 +132,7 @@ export const WithPin: Story = {
|
|||||||
verified
|
verified
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>
|
/>
|
||||||
<MapPin name="H.Parsons" price={900} verified active />
|
<MapPin name="H.Parsons" price={900} verified />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export interface MapPopupProps {
|
|||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
/** Click handler — entire card is clickable */
|
/** Click handler — entire card is clickable */
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
/** When true, animates the popup out (opacity + scale) without unmounting.
|
||||||
|
* Callers should unmount after the transition completes (180ms). */
|
||||||
|
exiting?: boolean;
|
||||||
/** MUI sx prop for the root element */
|
/** MUI sx prop for the root element */
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
@@ -85,6 +88,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
capacity,
|
capacity,
|
||||||
verified = false,
|
verified = false,
|
||||||
onClick,
|
onClick,
|
||||||
|
exiting = false,
|
||||||
sx,
|
sx,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -103,12 +107,21 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
}
|
}
|
||||||
}, [name]);
|
}, [name]);
|
||||||
|
|
||||||
|
// Swallow clicks on the popup so they don't bubble to an enclosing
|
||||||
|
// Map.onClick (which would close the popup mid-click). Always applied,
|
||||||
|
// even when onClick is unset, because callers consistently render this
|
||||||
|
// molecule inside a map context where ambient clicks should not escape.
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role={onClick ? 'button' : undefined}
|
role={onClick ? 'button' : undefined}
|
||||||
tabIndex={onClick ? 0 : undefined}
|
tabIndex={onClick ? 0 : undefined}
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
onClick
|
onClick
|
||||||
? (e: React.KeyboardEvent) => {
|
? (e: React.KeyboardEvent) => {
|
||||||
@@ -127,8 +140,17 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
|
filter: 'drop-shadow(0 2px 8px rgba(0,0,0,0.15))',
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
transition: 'transform 150ms ease-in-out',
|
transformOrigin: 'bottom center',
|
||||||
'&:hover': onClick
|
transition: 'opacity 180ms ease-out, transform 180ms ease-out',
|
||||||
|
opacity: exiting ? 0 : 1,
|
||||||
|
transform: exiting ? 'scale(0.9)' : 'scale(1)',
|
||||||
|
'@keyframes mapPopupIn': {
|
||||||
|
from: { opacity: 0, transform: 'scale(0.9)' },
|
||||||
|
to: { opacity: 1, transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
animation: exiting ? undefined : 'mapPopupIn 180ms ease-out',
|
||||||
|
'&:hover':
|
||||||
|
onClick && !exiting
|
||||||
? {
|
? {
|
||||||
transform: 'scale(1.02)',
|
transform: 'scale(1.02)',
|
||||||
}
|
}
|
||||||
@@ -149,6 +171,7 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
borderRadius: 'var(--fa-card-border-radius-default)',
|
borderRadius: 'var(--fa-card-border-radius-default)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Image ── */}
|
{/* ── Image ── */}
|
||||||
@@ -279,19 +302,20 @@ export const MapPopup = React.forwardRef<HTMLDivElement, MapPopupProps>(
|
|||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Nub — downward pointer connecting to pin */}
|
{/* Nub — downward pointer. SVG (fill-only; MapPopup uses a drop-shadow
|
||||||
<Box
|
for depth instead of a hard border, so no stroke needed) */}
|
||||||
|
<svg
|
||||||
aria-hidden
|
aria-hidden
|
||||||
sx={{
|
width={NUB_SIZE * 2}
|
||||||
width: 0,
|
height={NUB_SIZE}
|
||||||
height: 0,
|
viewBox={`0 0 ${NUB_SIZE * 2} ${NUB_SIZE}`}
|
||||||
borderLeft: `${NUB_SIZE}px solid transparent`,
|
style={{ display: 'block', marginTop: '-1px', overflow: 'visible' }}
|
||||||
borderRight: `${NUB_SIZE}px solid transparent`,
|
>
|
||||||
borderTop: `${NUB_SIZE}px solid`,
|
<path
|
||||||
borderTopColor: 'background.paper',
|
d={`M 0 0 L ${NUB_SIZE} ${NUB_SIZE} L ${NUB_SIZE * 2} 0`}
|
||||||
mt: '-1px',
|
fill="var(--fa-color-white)"
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</svg>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
110
src/components/organisms/ProviderMap/ProviderMap.stories.tsx
Normal file
110
src/components/organisms/ProviderMap/ProviderMap.stories.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { ProviderMap } from './ProviderMap';
|
||||||
|
import { providers as demoProviders } from '../../../demo/shared/fixtures/providers';
|
||||||
|
import type { ProviderData } from '../../pages/ProvidersStep';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ProviderMap> = {
|
||||||
|
title: 'Organisms/ProviderMap',
|
||||||
|
component: ProviderMap,
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Google Map showing provider pins with click-to-open popup. Uses the MapPin atom for markers and the MapPopup molecule for the popup card. Auto-fits the viewport to all providers with coords. Clicking a popup triggers `onSelectProvider`.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ width: '100vw', height: '100vh', display: 'flex' }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof ProviderMap>;
|
||||||
|
|
||||||
|
// Cast: DemoProvider adds `tier` over ProviderData, structural subset for the map
|
||||||
|
const providers = demoProviders as ProviderData[];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** All 7 demo providers with real NSW/QLD coordinates. Map fits bounds across them. */
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
providers,
|
||||||
|
onSelectProvider: (id) => {
|
||||||
|
alert(`Navigate to provider ${id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One provider pre-selected — its pin renders in the active (inverted) state. */
|
||||||
|
export const WithSelectedProvider: Story = {
|
||||||
|
args: {
|
||||||
|
providers,
|
||||||
|
selectedProviderId: 'parsons',
|
||||||
|
onSelectProvider: (id) => {
|
||||||
|
alert(`Navigate to provider ${id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Interactive demo — clicking a popup clears/re-selects as if navigating. */
|
||||||
|
export const InteractiveSelection: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const StoryWrapper = () => {
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
return (
|
||||||
|
<ProviderMap
|
||||||
|
{...args}
|
||||||
|
selectedProviderId={selected}
|
||||||
|
onSelectProvider={(id) => setSelected((prev) => (prev === id ? null : id))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return <StoryWrapper />;
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
providers,
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Providers without coords — falls back to the "Map unavailable" empty state. */
|
||||||
|
export const NoCoords: Story = {
|
||||||
|
args: {
|
||||||
|
providers: providers.map(({ coords: _omit, ...p }) => p),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** No API key supplied — renders the empty state without attempting to load Google Maps. */
|
||||||
|
export const NoApiKey: Story = {
|
||||||
|
args: {
|
||||||
|
providers,
|
||||||
|
apiKey: '',
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Single provider — map centres on that coord with zoom 13. */
|
||||||
|
export const SingleProvider: Story = {
|
||||||
|
args: {
|
||||||
|
providers: [providers[0]],
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mixed — some providers with coords, some without. Only those with coords render. */
|
||||||
|
export const PartialCoords: Story = {
|
||||||
|
args: {
|
||||||
|
providers: providers.map((p, i) => (i % 2 === 0 ? p : { ...p, coords: undefined })),
|
||||||
|
onSelectProvider: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
487
src/components/organisms/ProviderMap/ProviderMap.tsx
Normal file
487
src/components/organisms/ProviderMap/ProviderMap.tsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import {
|
||||||
|
APIProvider,
|
||||||
|
Map as GoogleMap,
|
||||||
|
AdvancedMarker,
|
||||||
|
useMap,
|
||||||
|
useMapsLibrary,
|
||||||
|
} from '@vis.gl/react-google-maps';
|
||||||
|
import { MarkerClusterer, GridAlgorithm } from '@googlemaps/markerclusterer';
|
||||||
|
import { MapPin } from '../../atoms/MapPin';
|
||||||
|
import { ClusterMarker } from '../../atoms/ClusterMarker';
|
||||||
|
import { MapPopup } from '../../molecules/MapPopup';
|
||||||
|
import { ClusterPopup } from '../../molecules/ClusterPopup';
|
||||||
|
import { Typography } from '../../atoms/Typography';
|
||||||
|
import type { ProviderData } from '../../pages/ProvidersStep';
|
||||||
|
|
||||||
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Sydney — fallback centre when no providers have coords and no default supplied */
|
||||||
|
const FALLBACK_CENTER = { lat: -33.8688, lng: 151.2093 };
|
||||||
|
const FALLBACK_ZOOM = 5;
|
||||||
|
/** Google Maps requires a mapId for AdvancedMarker support */
|
||||||
|
const MAP_ID = 'fa-provider-map';
|
||||||
|
/** fitBounds padding (applied as google.maps.Padding) */
|
||||||
|
const BOUNDS_PADDING = { top: 64, right: 48, bottom: 64, left: 48 };
|
||||||
|
/** Screen-pixel radius at which nearby pins collapse into a cluster */
|
||||||
|
const CLUSTER_GRID_SIZE = 70;
|
||||||
|
/** Zoom level above which clustering is disabled (pins show individually) */
|
||||||
|
const CLUSTER_MAX_ZOOM = 13;
|
||||||
|
/** Zoom level the map animates to on cluster drill-in (street-level, past
|
||||||
|
* CLUSTER_MAX_ZOOM so nearby cluster members break apart into their own pins) */
|
||||||
|
const DRILL_IN_ZOOM = 15;
|
||||||
|
/** Exit-animation duration for popups on close — keep in sync with the
|
||||||
|
* transition values set on MapPopup/ClusterPopup. */
|
||||||
|
const POPUP_EXIT_MS = 180;
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA ProviderMap organism */
|
||||||
|
export interface ProviderMapProps {
|
||||||
|
/** Providers to render as pins. Providers without coords are filtered out silently. */
|
||||||
|
providers: ProviderData[];
|
||||||
|
/** ID of the provider whose popup should open (external selection, e.g. list hover) */
|
||||||
|
selectedProviderId?: string | null;
|
||||||
|
/** Called when the user clicks through a popup — usually triggers navigation */
|
||||||
|
onSelectProvider: (id: string) => void;
|
||||||
|
/** Initial map centre — used only when no providers have coords */
|
||||||
|
defaultCenter?: { lat: number; lng: number };
|
||||||
|
/** Initial zoom — used only when no providers have coords */
|
||||||
|
defaultZoom?: number;
|
||||||
|
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
|
||||||
|
apiKey?: string;
|
||||||
|
/** MUI sx prop for the root element */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveCluster {
|
||||||
|
providers: ProviderData[];
|
||||||
|
position: google.maps.LatLngLiteral;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal components ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fits the map to the bounds of all providers with coords. Runs whenever the
|
||||||
|
* provider list changes. Sited inside APIProvider so `useMap()` resolves.
|
||||||
|
*/
|
||||||
|
const FitBounds: React.FC<{ providers: ProviderData[] }> = ({ providers }) => {
|
||||||
|
const map = useMap();
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
const withCoords = providers.filter((p) => p.coords);
|
||||||
|
if (withCoords.length === 0) return;
|
||||||
|
if (withCoords.length === 1) {
|
||||||
|
map.setCenter(withCoords[0].coords!);
|
||||||
|
map.setZoom(13);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = new window.google.maps.LatLngBounds();
|
||||||
|
withCoords.forEach((p) => bounds.extend(p.coords!));
|
||||||
|
map.fitBounds(bounds, BOUNDS_PADDING);
|
||||||
|
}, [map, providers]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the Google Map instance into a parent ref so imperative
|
||||||
|
* actions (panTo, setZoom) can be triggered from outside the Map context.
|
||||||
|
*/
|
||||||
|
const MapRefCapture: React.FC<{
|
||||||
|
mapRef: React.MutableRefObject<google.maps.Map | null>;
|
||||||
|
}> = ({ mapRef }) => {
|
||||||
|
const map = useMap();
|
||||||
|
React.useEffect(() => {
|
||||||
|
mapRef.current = map;
|
||||||
|
}, [map, mapRef]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative marker layer — builds AdvancedMarker instances with React
|
||||||
|
* content, groups them via MarkerClusterer, and rebuilds whenever the
|
||||||
|
* visible provider set changes.
|
||||||
|
*
|
||||||
|
* Providers listed in `hiddenIds` are excluded from the map (their popup is
|
||||||
|
* currently showing instead).
|
||||||
|
*/
|
||||||
|
const MarkerLayer: React.FC<{
|
||||||
|
providers: ProviderData[];
|
||||||
|
hiddenIds: Set<string>;
|
||||||
|
onPinClick: (id: string) => void;
|
||||||
|
onClusterClick: (providers: ProviderData[], position: google.maps.LatLngLiteral) => void;
|
||||||
|
}> = ({ providers, hiddenIds, onPinClick, onClusterClick }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const markerLibrary = useMapsLibrary('marker');
|
||||||
|
|
||||||
|
// Stash callbacks in a ref so the effect below doesn't re-run (and rebuild
|
||||||
|
// every marker) when the parent passes fresh arrow-function references.
|
||||||
|
const onPinClickRef = React.useRef(onPinClick);
|
||||||
|
const onClusterClickRef = React.useRef(onClusterClick);
|
||||||
|
React.useEffect(() => {
|
||||||
|
onPinClickRef.current = onPinClick;
|
||||||
|
onClusterClickRef.current = onClusterClick;
|
||||||
|
}, [onPinClick, onClusterClick]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!map || !markerLibrary) return;
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
const markerToProvider = new Map<google.maps.marker.AdvancedMarkerElement, ProviderData>();
|
||||||
|
|
||||||
|
const markers = providers
|
||||||
|
.filter((p) => p.coords && !hiddenIds.has(p.id))
|
||||||
|
.map((p) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const root = createRoot(el);
|
||||||
|
// MapPin's own onClick stays for keyboard a11y (Enter/Space via its
|
||||||
|
// onKeyDown). stopPropagation guards against the DOM click bubbling
|
||||||
|
// to the Map's onClick and closing the popup the same frame it opens.
|
||||||
|
root.render(
|
||||||
|
<MapPin
|
||||||
|
name={p.name}
|
||||||
|
price={p.startingPrice}
|
||||||
|
verified={p.verified}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPinClickRef.current(p.id);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
const marker = new markerLibrary.AdvancedMarkerElement({
|
||||||
|
position: p.coords,
|
||||||
|
content: el,
|
||||||
|
gmpClickable: true,
|
||||||
|
});
|
||||||
|
// Also listen at the Google Maps level + stop the GMaps event so
|
||||||
|
// Map's onClick can't fire when a pin is clicked via mouse. Safe to
|
||||||
|
// fire twice with keyboard — handlePinClick is idempotent.
|
||||||
|
marker.addListener('click', (event: google.maps.MapMouseEvent) => {
|
||||||
|
event.stop();
|
||||||
|
onPinClickRef.current(p.id);
|
||||||
|
});
|
||||||
|
markerToProvider.set(marker, p);
|
||||||
|
return marker;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clusterer = new MarkerClusterer({
|
||||||
|
map,
|
||||||
|
markers,
|
||||||
|
algorithm: new GridAlgorithm({
|
||||||
|
maxZoom: CLUSTER_MAX_ZOOM,
|
||||||
|
gridSize: CLUSTER_GRID_SIZE,
|
||||||
|
}),
|
||||||
|
// Override the library's default "zoom to fit cluster" on click —
|
||||||
|
// we open the cluster popup instead. The event shape the library
|
||||||
|
// passes varies: sometimes a google.maps.MapMouseEvent (has .stop),
|
||||||
|
// sometimes a plain DOM MouseEvent. Stop whichever we got so the
|
||||||
|
// click doesn't also fire Map.onClick and clear our state.
|
||||||
|
onClusterClick: (event, cluster) => {
|
||||||
|
const anyEvent = event as unknown as {
|
||||||
|
stop?: () => void;
|
||||||
|
stopPropagation?: () => void;
|
||||||
|
domEvent?: { stopPropagation?: () => void };
|
||||||
|
};
|
||||||
|
anyEvent.stop?.();
|
||||||
|
anyEvent.stopPropagation?.();
|
||||||
|
anyEvent.domEvent?.stopPropagation?.();
|
||||||
|
|
||||||
|
const providersInCluster = cluster.markers
|
||||||
|
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
|
||||||
|
.filter((p): p is ProviderData => !!p);
|
||||||
|
const clusterPosition =
|
||||||
|
cluster.position instanceof window.google.maps.LatLng
|
||||||
|
? cluster.position.toJSON()
|
||||||
|
: (cluster.position as google.maps.LatLngLiteral);
|
||||||
|
onClusterClickRef.current(providersInCluster, clusterPosition);
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
render: ({ count, position, markers: clusterMarkers }) => {
|
||||||
|
const providersInCluster = clusterMarkers
|
||||||
|
.map((m) => markerToProvider.get(m as google.maps.marker.AdvancedMarkerElement))
|
||||||
|
.filter((p): p is ProviderData => !!p);
|
||||||
|
const hasVerified = providersInCluster.some((p) => p.verified);
|
||||||
|
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const root = createRoot(el);
|
||||||
|
// Visual only — click is handled at the MarkerClusterer level above.
|
||||||
|
root.render(<ClusterMarker count={count} hasVerified={hasVerified} />);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
return new markerLibrary.AdvancedMarkerElement({
|
||||||
|
position,
|
||||||
|
content: el,
|
||||||
|
gmpClickable: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clusterer.clearMarkers();
|
||||||
|
// Defer unmount so React doesn't warn about unmounting during render.
|
||||||
|
setTimeout(() => {
|
||||||
|
roots.forEach((r) => r.unmount());
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
}, [map, markerLibrary, providers, hiddenIds]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Empty-state shown when no API key is configured or no providers have coords. */
|
||||||
|
const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason }) => (
|
||||||
|
<Box sx={{ m: 'auto', textAlign: 'center', px: 3 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||||
|
Map unavailable
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{reason === 'no-key'
|
||||||
|
? 'Google Maps API key not configured.'
|
||||||
|
: 'No provider locations to display.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Map showing provider pins with clustering + click-to-open popups.
|
||||||
|
*
|
||||||
|
* **Interaction model:**
|
||||||
|
* - Clicking an individual pin **morphs** it into a `MapPopup` at the same
|
||||||
|
* coord. Clicking the map background reverts.
|
||||||
|
* - Pins within `CLUSTER_GRID_SIZE` (70px) of each other collapse into a
|
||||||
|
* `ClusterMarker` — but only while zoomed out at level `CLUSTER_MAX_ZOOM`
|
||||||
|
* (13) or below. Zoom in past that and every pin shows individually.
|
||||||
|
* - Clicking a cluster opens a `ClusterPopup` listing its providers
|
||||||
|
* (verified-first). Clicking a row **pans and zooms the map to that
|
||||||
|
* provider's location** (zoom 15 = past the clustering ceiling, so the
|
||||||
|
* other cluster members separate into their own pins around the selected
|
||||||
|
* one) and opens that provider's `MapPopup`. The cluster state is cleared
|
||||||
|
* — there's no back-to-list; the user's path forward is clear rather than
|
||||||
|
* hierarchical.
|
||||||
|
*
|
||||||
|
* **Viewport:** auto-fits to include every provider with coords on load and
|
||||||
|
* when the list changes. Single-provider maps centre with zoom 13.
|
||||||
|
*
|
||||||
|
* **Empty states:** if no API key is set or no providers have coords, a
|
||||||
|
* subtle empty state renders in place (no throw).
|
||||||
|
*
|
||||||
|
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
|
||||||
|
* (molecules). Clustering via `@googlemaps/markerclusterer`.
|
||||||
|
*/
|
||||||
|
export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
providers,
|
||||||
|
selectedProviderId,
|
||||||
|
onSelectProvider,
|
||||||
|
defaultCenter = FALLBACK_CENTER,
|
||||||
|
defaultZoom = FALLBACK_ZOOM,
|
||||||
|
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
|
||||||
|
sx,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [activeProviderId, setActiveProviderId] = React.useState<string | null>(null);
|
||||||
|
const [activeCluster, setActiveCluster] = React.useState<ActiveCluster | null>(null);
|
||||||
|
const [exiting, setExiting] = React.useState(false);
|
||||||
|
const mapRef = React.useRef<google.maps.Map | null>(null);
|
||||||
|
const exitTimerRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Helper: cancel any pending exit timer so rapid clicks don't clobber
|
||||||
|
// newly-opened popups with a leftover clear from a previous close.
|
||||||
|
const cancelExit = React.useCallback(() => {
|
||||||
|
if (exitTimerRef.current) {
|
||||||
|
window.clearTimeout(exitTimerRef.current);
|
||||||
|
exitTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setExiting(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const withCoords = React.useMemo(() => providers.filter((p) => p.coords), [providers]);
|
||||||
|
|
||||||
|
// External selection (e.g. list hover) force-opens a popup. Internal click wins.
|
||||||
|
const effectiveProviderId = activeProviderId ?? selectedProviderId ?? null;
|
||||||
|
|
||||||
|
const activeProvider = React.useMemo(
|
||||||
|
() =>
|
||||||
|
effectiveProviderId ? (withCoords.find((p) => p.id === effectiveProviderId) ?? null) : null,
|
||||||
|
[withCoords, effectiveProviderId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pins hidden from the map (because their popup is showing instead).
|
||||||
|
const hiddenIds = React.useMemo(() => {
|
||||||
|
const s = new Set<string>();
|
||||||
|
if (effectiveProviderId) s.add(effectiveProviderId);
|
||||||
|
if (activeCluster) {
|
||||||
|
activeCluster.providers.forEach((p) => s.add(p.id));
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}, [effectiveProviderId, activeCluster]);
|
||||||
|
|
||||||
|
const handlePinClick = React.useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
cancelExit();
|
||||||
|
setActiveProviderId(id);
|
||||||
|
setActiveCluster(null);
|
||||||
|
},
|
||||||
|
[cancelExit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClusterClick = React.useCallback(
|
||||||
|
(clusterProviders: ProviderData[], position: google.maps.LatLngLiteral) => {
|
||||||
|
cancelExit();
|
||||||
|
setActiveProviderId(null);
|
||||||
|
setActiveCluster({ providers: clusterProviders, position });
|
||||||
|
},
|
||||||
|
[cancelExit],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Shared close path — animate the popup out (exiting=true triggers the
|
||||||
|
* CSS transition in MapPopup / ClusterPopup), then actually clear state
|
||||||
|
* after the transition completes so the pin can fade back in. */
|
||||||
|
const closeWithExit = React.useCallback(() => {
|
||||||
|
if (!activeProviderId && !activeCluster) return;
|
||||||
|
if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current);
|
||||||
|
setExiting(true);
|
||||||
|
exitTimerRef.current = window.setTimeout(() => {
|
||||||
|
setActiveProviderId(null);
|
||||||
|
setActiveCluster(null);
|
||||||
|
setExiting(false);
|
||||||
|
exitTimerRef.current = null;
|
||||||
|
}, POPUP_EXIT_MS);
|
||||||
|
}, [activeProviderId, activeCluster]);
|
||||||
|
|
||||||
|
const handleMapClick = closeWithExit;
|
||||||
|
const handleCloseCluster = closeWithExit;
|
||||||
|
|
||||||
|
/** Cluster list → single-provider drill-in.
|
||||||
|
* Pans + zooms the map to the provider's coords (zoom 15 = past
|
||||||
|
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
|
||||||
|
* pins around the selected one), then clears the cluster state and
|
||||||
|
* opens the single-provider popup. */
|
||||||
|
const handleDrillIntoProvider = React.useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
cancelExit();
|
||||||
|
const provider = withCoords.find((p) => p.id === id);
|
||||||
|
if (provider?.coords && mapRef.current) {
|
||||||
|
mapRef.current.panTo(provider.coords);
|
||||||
|
mapRef.current.setZoom(DRILL_IN_ZOOM);
|
||||||
|
}
|
||||||
|
setActiveProviderId(id);
|
||||||
|
setActiveCluster(null);
|
||||||
|
},
|
||||||
|
[withCoords, cancelExit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootSx = [
|
||||||
|
{
|
||||||
|
position: 'relative' as const,
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 300,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'var(--fa-color-surface-cool)',
|
||||||
|
},
|
||||||
|
...(Array.isArray(sx) ? sx : [sx]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Empty states
|
||||||
|
if (!apiKey) {
|
||||||
|
return (
|
||||||
|
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
||||||
|
<MapEmptyState reason="no-key" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (withCoords.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
||||||
|
<MapEmptyState reason="no-coords" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
||||||
|
<APIProvider apiKey={apiKey}>
|
||||||
|
<GoogleMap
|
||||||
|
defaultCenter={defaultCenter}
|
||||||
|
defaultZoom={defaultZoom}
|
||||||
|
mapId={MAP_ID}
|
||||||
|
disableDefaultUI
|
||||||
|
zoomControl
|
||||||
|
gestureHandling="greedy"
|
||||||
|
onClick={handleMapClick}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<FitBounds providers={withCoords} />
|
||||||
|
<MapRefCapture mapRef={mapRef} />
|
||||||
|
|
||||||
|
<MarkerLayer
|
||||||
|
providers={withCoords}
|
||||||
|
hiddenIds={hiddenIds}
|
||||||
|
onPinClick={handlePinClick}
|
||||||
|
onClusterClick={handleClusterClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Single-provider popup (pin click OR post-zoom cluster drill-in) */}
|
||||||
|
{activeProvider && (
|
||||||
|
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
|
||||||
|
<MapPopup
|
||||||
|
name={activeProvider.name}
|
||||||
|
imageUrl={activeProvider.imageUrl}
|
||||||
|
price={activeProvider.startingPrice}
|
||||||
|
location={activeProvider.location}
|
||||||
|
rating={activeProvider.rating}
|
||||||
|
verified={activeProvider.verified}
|
||||||
|
exiting={exiting}
|
||||||
|
onClick={() => onSelectProvider(activeProvider.id)}
|
||||||
|
/>
|
||||||
|
</AdvancedMarker>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cluster list popup — shown while a cluster is active and no
|
||||||
|
provider has been drilled into. Drilling clears activeCluster,
|
||||||
|
which swaps this for the single-provider popup above. */}
|
||||||
|
{activeCluster && !activeProviderId && (
|
||||||
|
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
|
||||||
|
<ClusterPopup
|
||||||
|
providers={activeCluster.providers.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
location: p.location,
|
||||||
|
verified: p.verified,
|
||||||
|
rating: p.rating,
|
||||||
|
startingPrice: p.startingPrice,
|
||||||
|
}))}
|
||||||
|
exiting={exiting}
|
||||||
|
onSelectProvider={handleDrillIntoProvider}
|
||||||
|
onClose={handleCloseCluster}
|
||||||
|
/>
|
||||||
|
</AdvancedMarker>
|
||||||
|
)}
|
||||||
|
</GoogleMap>
|
||||||
|
</APIProvider>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ProviderMap.displayName = 'ProviderMap';
|
||||||
|
export default ProviderMap;
|
||||||
1
src/components/organisms/ProviderMap/index.ts
Normal file
1
src/components/organisms/ProviderMap/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ProviderMap, type ProviderMapProps } from './ProviderMap';
|
||||||
@@ -49,6 +49,8 @@ export interface ProviderData {
|
|||||||
distanceKm?: number;
|
distanceKm?: number;
|
||||||
/** Brief description */
|
/** Brief description */
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Geographic coordinates for map display */
|
||||||
|
coords?: { lat: number; lng: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A funeral type option for the filter */
|
/** A funeral type option for the filter */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ProviderSortBy,
|
type ProviderSortBy,
|
||||||
type ListViewMode,
|
type ListViewMode,
|
||||||
} from '../../../../components/pages/ProvidersStep';
|
} from '../../../../components/pages/ProvidersStep';
|
||||||
|
import { ProviderMap } from '../../../../components/organisms/ProviderMap';
|
||||||
import { providers } from '../../../shared/fixtures/providers';
|
import { providers } from '../../../shared/fixtures/providers';
|
||||||
import { demoNav } from '../DemoNav';
|
import { demoNav } from '../DemoNav';
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ export function ProvidersRoute() {
|
|||||||
onViewModeChange={setView}
|
onViewModeChange={setView}
|
||||||
onBack={() => window.history.back()}
|
onBack={() => window.history.back()}
|
||||||
navigation={demoNav}
|
navigation={demoNav}
|
||||||
|
mapPanel={
|
||||||
|
<ProviderMap
|
||||||
|
providers={filtered}
|
||||||
|
onSelectProvider={(id) => navigate(`/providers/${id}/packages`)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const providers: DemoProvider[] = [
|
|||||||
reviewCount: 7,
|
reviewCount: 7,
|
||||||
startingPrice: 1800,
|
startingPrice: 1800,
|
||||||
distanceKm: 2.3,
|
distanceKm: 2.3,
|
||||||
|
coords: { lat: -34.1074, lng: 141.9166 },
|
||||||
description:
|
description:
|
||||||
'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.',
|
'H.Parsons delivers premium funeral services with exceptional care and support, guiding families through every step with empathy and expertise.',
|
||||||
},
|
},
|
||||||
@@ -35,6 +36,7 @@ export const providers: DemoProvider[] = [
|
|||||||
reviewCount: 23,
|
reviewCount: 23,
|
||||||
startingPrice: 2450,
|
startingPrice: 2450,
|
||||||
distanceKm: 5.1,
|
distanceKm: 5.1,
|
||||||
|
coords: { lat: -34.487, lng: 150.897 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'wollongong-city',
|
id: 'wollongong-city',
|
||||||
@@ -46,6 +48,7 @@ export const providers: DemoProvider[] = [
|
|||||||
reviewCount: 15,
|
reviewCount: 15,
|
||||||
startingPrice: 3400,
|
startingPrice: 3400,
|
||||||
distanceKm: 6.8,
|
distanceKm: 6.8,
|
||||||
|
coords: { lat: -34.4278, lng: 150.8931 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'killick',
|
id: 'killick',
|
||||||
@@ -59,6 +62,7 @@ export const providers: DemoProvider[] = [
|
|||||||
reviewCount: 15,
|
reviewCount: 15,
|
||||||
startingPrice: 3100,
|
startingPrice: 3100,
|
||||||
distanceKm: 8.4,
|
distanceKm: 8.4,
|
||||||
|
coords: { lat: -26.5408, lng: 151.8388 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mackay',
|
id: 'mackay',
|
||||||
@@ -72,6 +76,7 @@ export const providers: DemoProvider[] = [
|
|||||||
reviewCount: 87,
|
reviewCount: 87,
|
||||||
startingPrice: 2800,
|
startingPrice: 2800,
|
||||||
distanceKm: 18.2,
|
distanceKm: 18.2,
|
||||||
|
coords: { lat: -33.3644, lng: 151.3728 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mannings',
|
id: 'mannings',
|
||||||
@@ -85,6 +90,7 @@ export const providers: DemoProvider[] = [
|
|||||||
reviewCount: 31,
|
reviewCount: 31,
|
||||||
startingPrice: 2600,
|
startingPrice: 2600,
|
||||||
distanceKm: 22.0,
|
distanceKm: 22.0,
|
||||||
|
coords: { lat: -36.6742, lng: 149.8417 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'botanical',
|
id: 'botanical',
|
||||||
@@ -96,6 +102,7 @@ export const providers: DemoProvider[] = [
|
|||||||
reviewCount: 8,
|
reviewCount: 8,
|
||||||
startingPrice: 5200,
|
startingPrice: 5200,
|
||||||
distanceKm: 15.0,
|
distanceKm: 15.0,
|
||||||
|
coords: { lat: -33.8988, lng: 151.1794 },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user