diff --git a/docs/memory/component-registry.md b/docs/memory/component-registry.md index 50027f7..c106e15 100644 --- a/docs/memory/component-registry.md +++ b/docs/memory/component-registry.md @@ -28,7 +28,7 @@ duplicates) and MUST update it after completing one. | Card | done | elevated, outlined × default, compact, none padding × interactive × selected | card.borderRadius/padding/shadow/border/background, color.surface.raised/subtle/warm, color.border.default/brand, shadow.md/lg | Content container. Elevated (shadow) or outlined (border). Interactive adds hover bg fill + shadow lift. Selected adds brand border + warm bg. Three padding presets. | | Switch | done | bordered style × checked, unchecked, disabled | switch.track.width/height/borderRadius, switch.thumb.size, color.interactive.*, color.neutral.400 | Toggle for add-ons/options. Wraps MUI Switch. Bordered pill, brand.500 fill when active. From Parsons 1.0 Figma Style One. | | Radio | done | checked, unchecked, disabled | radio.size/dotSize, color.interactive.*, color.neutral.400 | Single-select option. Wraps MUI Radio. Brand.500 fill when selected. From Parsons 1.0 Figma. | -| MapPin | done | name+price × 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). | +| MapPin | done | name+price × verified, unverified | mapPin.paddingX/borderRadius/nub.size, color.brand-200/700, color.neutral-100-800 | Two-line label map marker: [verified tick (inline SVG) +] name (bold, required, truncated 210px) + "From $X" (centred, semibold). Verified providers get a Material Verified icon to the left of the name, same colour as the name text. 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, or emits via `onActiveChange` when `externalisePopups` is set). Pure CSS + inline SVG (no MUI icon — mounted via createRoot outside ThemeProvider). role="button" + keyboard + focus ring. Name-only / price-only variants dropped in D042. | | ClusterMarker | done | count × verified (promoted), unverified (neutral) | color.brand-700, color.neutral-100-800, mapPin.nub.size | Circular 36px count badge for pin clusters. `hasVerified` flag drives the palette (if any provider in the cluster is verified, use the promoted brand-700 palette; else neutral grey). Same nub treatment + shadow tokens as MapPin for visual cohesion. Pure CSS + SVG. role="button" + keyboard + focus ring. Designed as the `render`-ed output of `@googlemaps/markerclusterer`. See D043. | | ColourToggle | planned | inactive, hover, active, locked × single, two-colour × desktop, mobile | | Circular colour swatch picker for products. Custom component. Deferred until product detail organisms. | | Slider | planned | single, range × desktop, mobile | | Price range filter. Wraps MUI Slider. Deferred until search/filtering molecules. | @@ -76,7 +76,7 @@ duplicates) and MUST update it after completing one. | ArrangementForm | planned | StepIndicator + ServiceSelector + AddOnOption + Button + Typography | Multi-step arrangement wizard. Deferred — build remaining atoms/molecules first. | | Navigation | done | AppBar + Link + IconButton + Button + Divider + Drawer | Responsive site header. Desktop: logo left, links right, optional CTA. Mobile: hamburger + drawer with nav items, CTA, help footer. Sticky, grey surface bg (surface.subtle). Real FA logo from brandassets/. Maps to Figma Main Nav (14:108) + Mobile Header (2391:41508). | | Footer | done | Link × n + Typography + Divider + Container + Grid | Light grey (surface.subtle) site footer — matches header. Logo + tagline + contact (phone/email) + link group columns + legal bar. Semantic HTML (footer, nav, ul). Critique: 38/40 (Excellent). | -| ProviderMap | done | MapPin + ClusterMarker + MapPopup + ClusterPopup + `@vis.gl/react-google-maps` + `@googlemaps/markerclusterer` | Google Maps provider map with clustering. **Individual pins**: `MapPin` per provider — click morphs it into a `MapPopup` at the same coord. **Clusters**: pins within 70px of each other collapse into a `ClusterMarker` (count badge) *only while zoomed out at level 13 or below* — past that, every pin shows individually. Click a cluster → `ClusterPopup` list at the cluster centroid, verified-first. **Click a row → map pans + zooms to that provider's coords (zoom 15) and opens their `MapPopup`**; cluster state cleared (no back-to-list). Map-background click reverts to pins. Markers are rendered imperatively (via `createRoot` into AdvancedMarker content elements) so `markerclusterer` can group them; popup layer remains declarative React. `selectedProviderId` force-opens a popup for external selection. Subtle empty state when no API key or no providers have coords (no throw). API key read from `VITE_GOOGLE_MAPS_API_KEY`. Desktop-only for now; mobile map sheet deferred. See D041, D042, D043, D044. | +| 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. **Externalisable popups (D047):** opt-in `externalisePopups` prop suppresses the internal MapPopup/ClusterPopup rendering; `onActiveChange` callback emits the active provider/cluster/exiting state; imperative ref exposes `ProviderMapHandle` with `clearActive()` + `drillIntoProvider(id)` so external callers (e.g. the mobile bottom drawer) can render the popup content themselves. `forwardRef` type is `ProviderMapHandle`, not `HTMLDivElement`. Subtle empty state when no API key or no providers have coords (no throw). API key read from `VITE_GOOGLE_MAPS_API_KEY`. See D041, D042, D043, D044, D047. | ## Templates @@ -89,8 +89,8 @@ duplicates) and MUST update it after completing one. | Component | Status | Composed of | Notes | |-----------|--------|-------------|-------| | IntroStep | done | WizardLayout (centered-form) + ToggleButtonGroup × 2 + Collapse + Typography + Button + Divider | Wizard step 1 — entry point. forWhom (Myself/Someone else) + hasPassedAway (Yes/No) with progressive disclosure. Auto-sets hasPassedAway="no" for "Myself". `
` wrapper, aria-live subheading, grief-sensitive copy. Pure presentation. Audit: 18/20 → 20/20 after fixes. | -| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + SearchBar + Chip + Typography + Button | Wizard step 2 — provider selection. List-map split: provider cards w/ radiogroup + search + filter chips (left), map slot (right). aria-live results count, back link. ProviderCard extended with HTML/ARIA passthrough. Audit: 18/20. | -| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + NearbyPackageCard + PackageDetail + Divider + Link + Typography | Wizard step 3 — package selection. **Tier-aware unified page** (replaces the old PackagesStep + UnverifiedPackageT2 + UnverifiedPackageT3 trio, 2026-04-17). `providerTier: 'verified' \| 'tier3' \| 'tier2'` drives heading, subhead, `arrangeLabel`, `priceDisclaimer`, and `itemizedUnavailable` via a `TIER_COPY` map. Discriminated `secondaryList`: `same-provider-more` (ServiceOption list, verified) or `nearby-verified` (NearbyPackageCard list, unverified). Same-provider-more **shows top 3 inline**; at >3 shows 3 + `See all N packages from [Provider] →` Link that fires `onSeeAllPackages`. `showAllFromProvider` prop renders a flat "All packages from [Provider]" variant (no grouping, no secondary list, preserves `selectedPackageId`). Primary list suppresses the "Matching your preferences" accent-bar heading when no secondary list is present (so the label only appears when there's something to contrast against). Desktop polished; mobile polish pending. | +| ProvidersStep | done | WizardLayout (list-map) + ProviderCard + ProviderMap + FilterPanel + Autocomplete (chip search) + Chip + Typography + Button + ToggleButtonGroup | Wizard step 2 — provider selection. **Desktop + mobile-list**: list-map WizardLayout — provider cards (click-to-navigate) + sticky bar with committed-chip search (Autocomplete multiple+freeSolo capped to 1, primary-filled search commit button — D046) + Filters dialog + `Sort: ` button + mobile-only `List|Map` toggle. Results count bolded. **Mobile + viewMode=map (D045)**: custom layout — full-bleed map + floating card-shaped control strip (search + Filters + `Sort by` + `List|Map` toggle, all white-fill/neutral-300/shadow-sm/32px/14px-600, matching chrome across modes) + bottom drawer (slides up on pin/cluster tap, close X in a 40px header strip). Single-pin drawer renders ProviderCard edge-to-edge with top-only rounded corners; cluster drawer renders inline list of verified-slot + name + location + rating + "From $X" rows, tap a row → drill-in via `mapRef.drillIntoProvider`. Drawer close fully clears via `mapRef.clearActive`. Header + subhead hidden on mobile map. Filter dialog children extracted into a shared `filterDialogChildren` JSX used by both desktop + mobile FilterPanel instances; Location field removed, Funeral-type chips size=medium, Reset filters always visible (disabled when 0 active), provider-feature switches align to first text line. ProviderMap instantiated internally for mobile map view (with `externalisePopups` + `onActiveChange` + `ref`); desktop continues to use the `mapPanel` slot. Audit: 18/20 (pre-2026-04-23 expansion). | +| PackagesStep | done | WizardLayout (list-detail) + ProviderCardCompact + ServiceOption + NearbyPackageCard + PackageDetail + Divider + Link + Typography | Wizard step 3 — package selection. **Tier-aware unified page** (replaces the old PackagesStep + UnverifiedPackageT2 + UnverifiedPackageT3 trio, 2026-04-17). `providerTier: 'verified' \| 'tier3' \| 'tier2'` drives heading, subhead, `arrangeLabel`, `priceDisclaimer`, and `itemizedUnavailable` via a `TIER_COPY` map. Discriminated `secondaryList`: `same-provider-more` (ServiceOption list, verified) or `nearby-verified` (NearbyPackageCard list, unverified). Same-provider-more **shows top 3 inline**; at >3 shows 3 + `See all N packages from [Provider] →` Link that fires `onSeeAllPackages`. `showAllFromProvider` prop renders a flat "All packages from [Provider]" variant. Primary list suppresses the "Matching your preferences" accent-bar heading when no secondary list is present. **Mobile drill-in (2026-04-23 fix):** local `hasDrilledIn` flag — the mobile layout only swaps to the detail view after an explicit user tap on a package, so parent-seeded `selectedPackageId` (common on desktop for auto-display) doesn't force mobile users straight into detail. Back resets the flag. | | ~~PreviewStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). Package preview + "what's next" checklist now in the dialog's preview step. | | ~~AuthGateStep~~ | removed | — | Replaced by ArrangementDialog organism (D-E). SSO/email auth flow now in the dialog's auth step. | | DateTimeStep | done | WizardLayout (centered-form) + Input + TextField (date) + RadioGroup + Collapse + Divider + Button + Link | Wizard step 6 — details & scheduling. Deceased name (Input atom, external label) + preferred dates (up to 3, progressive disclosure) + time-of-day radios. Service tradition removed (flows from provider/package). Dividers between sections. Grief-sensitive labels. Save-and-exit CTA. | diff --git a/docs/memory/session-log.md b/docs/memory/session-log.md index f2523e4..bf31f58 100644 --- a/docs/memory/session-log.md +++ b/docs/memory/session-log.md @@ -26,6 +26,38 @@ Each entry follows this structure: ## Sessions +### Session 2026-04-23 — ProvidersStep polish + mobile map-first layout + deploy + +**Agent(s):** Claude Opus 4.7 (1M context) + +**Work completed:** +- **ProvidersStep desktop polish** (Track 1 of the 2026-04-22 handoff): + - Sort button now reads `Sort: ` (was a bare "Recommended" indistinguishable from a filter); price sort labels cleaned of their internal colons to avoid double-colon rendering. + - Results count bolds the number in primary text. + - `viewMode` toggle on the map panel kept as the mobile affordance (desktop toggle still visible but unchanged per user). +- **Sticky search — committed-chip pattern (D046):** replaced the raw `TextField` with an `Autocomplete multiple + freeSolo` capped at 1 location. Typing produces a draft; Enter or the right-hand primary search-icon button commits to an FA Chip (grey/neutral), tap X to clear. Icon spacing tightened, focus ring stripped on the search + Filters + Sort by controls per user call. +- **Mobile map-first layout (D045):** on `xs` + `viewMode=map`, ProvidersStep branches to a custom layout: nav + full-bleed map + floating control card (search + Filters + Sort by + `List|Map` toggle) + bottom drawer + help bar. h1 + subhead dropped on mobile map view to save vertical space; remain on desktop and mobile list. Drawer slides up on pin/cluster tap, slides down on close X. Single-pin drawer renders a `ProviderCard` edge-to-edge (entire card clickable → onSelectProvider); cluster drawer renders an inline list of verified-slot + name + location + rating + "From $X" rows, tap a row to drill in (pan+zoom + swap drawer content to the single-pin card). Close X lives in a 40px header strip so it never overlaps the Verified badge. +- **ProviderMap externalisable popups (D047):** opt-in `externalisePopups` prop + `onActiveChange` callback + imperative `ProviderMapHandle` (`clearActive`, `drillIntoProvider`). Desktop behaviour unchanged when these aren't used; mobile drawer consumes them. `forwardRef` type changed from `HTMLDivElement` to `ProviderMapHandle`; no existing callsite passes a DOM ref so safe. +- **MapPin verified icon:** inline Material Verified (outlined) SVG on the left of the name for verified providers. Inline SVG, not `@mui/icons-material`, because MapPin is mounted via `createRoot` outside the ThemeProvider. Max label width bumped 180→210px. +- **Mobile cluster drawer rows:** verified icon now aligns with the name's top line (matches desktop `ClusterPopup` fix from D043 refinement) + new right-aligned "From $X" price column. +- **Controls unified across mobile views:** mobile list-view sticky controls match the map-view floating chips — white fill, neutral-300 border, shadow-sm, 32px height, 14px/600 text. Mobile sort label switches from "Sort: " to compact "Sort by" (desktop keeps verbose label). Desktop map-panel floating toggle resized to match Filters/Sort button height + type. +- **Filter dialog cleanup (desktop + mobile):** Location field removed (the sticky search is primary); Funeral-type chips bumped small → medium; Reset filters button always renders (disabled when no filters active); provider-feature switches (Verified only, Online arrangements) align to the first text line. +- **PackagesStep mobile drill-in bug fix:** added a local `hasDrilledIn` flag so the mobile layout only swaps to the detail view after an explicit user tap on a package. Previously the demo route seeding `selectedPackageId` to the first matching package (for desktop auto-display) also forced mobile into the detail view — users arriving from the map drawer saw a single package instead of the list. Back/forward from the detail resets the flag. +- **Drawer close animation:** `drawerOpen` now excludes the `exiting` phase so tapping the close X slides the drawer down immediately instead of lingering with an opacity fade-back-in (bug was: transform and opacity transitions interleaving wrong). +- **Whitelist localhost** for the Google Maps API key (user added `http://localhost:5180/*` and `http://localhost:6006/*` to HTTP referrer restrictions), unblocking local map/drawer end-to-end testing. +- **Preflight + deploy:** all checks pass (TS, Storybook build, token sync, ESLint, Prettier). Hex-colour warnings all pre-existing in HomePage. Deployed to `https://parsons.tensordesign.com.au/arrangement/`. + +**Decisions made:** D045, D046, D047 (see decisions-log). + +**Open questions:** +- User flagged "still more edits to be done" at checkpoint — pending list will come in the next session. + +**Next steps:** +- Pick up the user's remaining ProvidersStep polish items. +- Process the `00-Inbox/Mobile list-mode layout for ProvidersStep + ProviderMap.md` note (now implemented — can be archived when the user runs `/process-inbox`). + +--- + ### 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. diff --git a/src/components/molecules/MapProviderDrawer/MapProviderDrawer.stories.tsx b/src/components/molecules/MapProviderDrawer/MapProviderDrawer.stories.tsx new file mode 100644 index 0000000..c1d4c95 --- /dev/null +++ b/src/components/molecules/MapProviderDrawer/MapProviderDrawer.stories.tsx @@ -0,0 +1,146 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Box from '@mui/material/Box'; +import { MapProviderDrawer } from './MapProviderDrawer'; + +const meta: Meta = { + title: 'Molecules/MapProviderDrawer', + component: MapProviderDrawer, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + viewport: { defaultViewport: 'mobile1' }, + }, + decorators: [ + // Simulate the mobile map-view container: fixed-size, relatively-positioned, + // with a faux map background behind the drawer. + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// ─── Fixtures ─────────────────────────────────────────────────────────────── + +const parsons = { + id: 'parsons', + name: 'H.Parsons Funeral Directors', + location: 'Wentworth, NSW', + verified: true, + imageUrl: '/images/funeral-homes/parsons-chapel.jpg', + logoUrl: '/images/providers/parsons-logo.png', + rating: 4.6, + reviewCount: 7, + startingPrice: 1800, +}; + +const clusterProviders = [ + parsons, + { + id: 'rankins', + name: 'Rankins Funeral Services', + location: 'Warrawong, NSW', + verified: true, + rating: 4.8, + startingPrice: 2450, + }, + { + id: 'killick', + name: 'Killick Family Funerals', + location: 'Kingaroy, QLD', + verified: true, + rating: 4.9, + startingPrice: 3100, + }, + { + id: 'wollongong-city', + name: 'Wollongong City Funerals', + location: 'Wollongong, NSW', + verified: false, + rating: 4.2, + startingPrice: 3400, + }, +]; + +const log = + (label: string) => + (arg?: string): void => { + console.log(label, arg ?? ''); + }; + +// ─── Stories ──────────────────────────────────────────────────────────────── + +/** Single-provider drawer — the whole ProviderCard is clickable and fires + * `onSelectProvider` (in production, this navigates to the packages page). */ +export const SingleProvider: Story = { + args: { + active: { + provider: parsons, + cluster: null, + exiting: false, + }, + onClose: log('close'), + onSelectProvider: log('select'), + onDrillIntoProvider: log('drillInto'), + }, +}; + +/** Cluster drawer — verified-first list of rows. Tapping a row fires + * `onDrillIntoProvider`; in production this pans + zooms the map and + * swaps the drawer's `active` to a single-provider state. */ +export const Cluster: Story = { + args: { + active: { + provider: null, + cluster: { + providers: clusterProviders, + position: { lat: -34.42, lng: 150.89 }, + }, + exiting: false, + }, + onClose: log('close'), + onSelectProvider: log('select'), + onDrillIntoProvider: log('drillInto'), + }, +}; + +/** Closed state — the drawer is in the DOM but translated off-screen. */ +export const Closed: Story = { + args: { + active: null, + onClose: log('close'), + onSelectProvider: log('select'), + onDrillIntoProvider: log('drillInto'), + }, +}; + +/** Small cluster of two — verified pair. */ +export const ClusterPair: Story = { + args: { + active: { + provider: null, + cluster: { + providers: clusterProviders.slice(0, 2), + position: { lat: -34.42, lng: 150.89 }, + }, + exiting: false, + }, + onClose: log('close'), + onSelectProvider: log('select'), + onDrillIntoProvider: log('drillInto'), + }, +}; diff --git a/src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx b/src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx new file mode 100644 index 0000000..82aa174 --- /dev/null +++ b/src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import ButtonBase from '@mui/material/ButtonBase'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; +import StarRoundedIcon from '@mui/icons-material/StarRounded'; +import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { IconButton } from '../../atoms/IconButton'; +import { Typography } from '../../atoms/Typography'; +import { ProviderCard } from '../ProviderCard'; +import type { ProviderData } from '../../pages/ProvidersStep'; +import type { ProviderMapActiveState } from '../../organisms/ProviderMap'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +/** Props for the FA MapProviderDrawer molecule */ +export interface MapProviderDrawerProps { + /** Current active state from `ProviderMap` (wire via `onActiveChange`). + * `null` = no active pin/cluster; drawer is hidden. */ + active: ProviderMapActiveState | null; + /** Fires when the close X is tapped. Typically wired to the map's + * imperative `clearActive()`. */ + onClose: () => void; + /** Fires when the single-provider card is tapped (entire card clickable). + * Typically navigates to that provider's packages. */ + onSelectProvider: (id: string) => void; + /** Fires when a cluster row is tapped. Typically wired to the map's + * imperative `drillIntoProvider()` which pans + zooms + swaps the + * drawer's content to a single-provider card. */ + onDrillIntoProvider: (id: string) => void; + /** MUI sx prop for the root Paper — merged onto the default positioning. */ + sx?: SxProps; +} + +// ─── Cluster row ──────────────────────────────────────────────────────────── + +const ClusterRow: React.FC<{ + provider: ProviderData; + onClick: () => void; +}> = ({ provider: p, onClick }) => ( + + {/* Verified-icon slot — height tuned to the name's line-box so the + tick aligns with the title top, empty slot keeps other names + left-aligned on the same x-origin. */} + + {p.verified && } + + + + + {p.name} + + + {p.location} + {p.rating != null && ( + + + {p.rating.toFixed(1)} + + )} + + + + {p.startingPrice != null && ( + + + From + + + ${p.startingPrice.toLocaleString('en-AU')} + + + )} + +); + +// ─── Component ────────────────────────────────────────────────────────────── + +/** + * Bottom drawer that surfaces `ProviderMap`'s popup content outside the + * map itself. Used by the mobile map-first layout (see D045): the map + * runs full-bleed, and when a pin or cluster is tapped the drawer slides + * up from the bottom with the appropriate content. + * + * **Two content states, driven by `active`:** + * - `active.provider` → renders a `ProviderCard` edge-to-edge, entire card + * clickable (fires `onSelectProvider`) + * - `active.cluster` → renders a verified-first list of rows (verified icon + * slot + name + location + rating + "From $X"); tapping a row fires + * `onDrillIntoProvider` which is wired to the map's imperative + * `drillIntoProvider()` (pans + zooms, then swaps `active` to that + * provider — the drawer content flips to the single-provider card). + * + * **Animation:** slides up via `transform: translateY()` + 220ms transition. + * When `active.exiting` is true, the drawer slides down immediately (the + * map organism is in the middle of its 180ms exit fade on the hidden pin + * beneath). `visibility: hidden` kicks in only after the slide completes, + * so the drawer stays in the DOM for the exit animation. + * + * **Positioning:** uses `position: absolute; bottom: 0; left: 0; right: 0` + * by default — the consumer MUST render this inside a relatively-positioned + * container (typically the map-view `
`). Override via `sx` if needed. + * + * Related: row layout mirrors `ClusterPopup` (the anchored on-map variant); + * future consolidation possible if both container contracts converge. + */ +export const MapProviderDrawer = React.forwardRef( + ({ active, onClose, onSelectProvider, onDrillIntoProvider, sx }, ref) => { + const provider = active?.provider ?? null; + const cluster = active?.cluster ?? null; + const isOpen = !!(active && !active.exiting && (provider || cluster)); + const isExiting = !!active?.exiting; + + const ariaLabel = provider + ? `${provider.name} details` + : cluster + ? `${cluster.providers.length} providers in this area` + : undefined; + + return ( + + {/* Header strip — holds the close X (and the cluster count when + applicable) so neither sits over the card image below. */} + + {cluster && !provider && ( + + {cluster.providers.length} providers in this area + + )} + + + + + + {/* Single-provider content — entire card clickable. Card runs + edge-to-edge with all corners squared; the drawer Paper provides + the top radius. */} + {provider && ( + onSelectProvider(provider.id)} + aria-label={`${provider.name}, ${provider.location}. Tap to view packages.`} + sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }} + /> + )} + + {/* Cluster list content — tap a row to drill in */} + {cluster && !provider && ( + + {[...cluster.providers] + .sort((a, b) => Number(!!b.verified) - Number(!!a.verified)) + .map((p) => ( + onDrillIntoProvider(p.id)} /> + ))} + + )} + + ); + }, +); + +MapProviderDrawer.displayName = 'MapProviderDrawer'; +export default MapProviderDrawer; diff --git a/src/components/molecules/MapProviderDrawer/index.ts b/src/components/molecules/MapProviderDrawer/index.ts new file mode 100644 index 0000000..89a232a --- /dev/null +++ b/src/components/molecules/MapProviderDrawer/index.ts @@ -0,0 +1 @@ +export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer'; diff --git a/src/components/pages/ProvidersStep/ProvidersStep.tsx b/src/components/pages/ProvidersStep/ProvidersStep.tsx index ee9a98c..3db5ee5 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.tsx @@ -1,7 +1,5 @@ import React from 'react'; import Box from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; -import ButtonBase from '@mui/material/ButtonBase'; import TextField from '@mui/material/TextField'; import InputAdornment from '@mui/material/InputAdornment'; import Autocomplete from '@mui/material/Autocomplete'; @@ -17,15 +15,13 @@ import SearchIcon from '@mui/icons-material/Search'; import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined'; import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; -import StarRoundedIcon from '@mui/icons-material/StarRounded'; -import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined'; -import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import PhoneIcon from '@mui/icons-material/Phone'; import type { SxProps, Theme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles'; import { WizardLayout } from '../../templates/WizardLayout'; import { ProviderCard } from '../../molecules/ProviderCard'; import { FilterPanel } from '../../molecules/FilterPanel'; +import { MapProviderDrawer } from '../../molecules/MapProviderDrawer'; import { ProviderMap, type ProviderMapActiveState, @@ -212,6 +208,98 @@ const chipWrapSx = { gap: 1, } as const; +/** + * Shared visual tokens for the ProvidersStep control chips. Search, Filters, + * Sort by, and the List/Map toggle all reference these so their outline / + * radius / fill / shadow / height read as one coherent set. Kept on the page + * (not promoted to a design-system-wide primitive) because this is a + * page-local "control cluster" pattern — Button and Input already own their + * own radii in the theme. + */ +const CONTROL_CHROME = { + height: 32, + borderColor: 'var(--fa-color-neutral-300)', + borderRadius: 'var(--fa-button-border-radius-default)', + bgcolor: 'background.paper', + boxShadow: 'var(--fa-shadow-sm)', +} as const; + +/** sx for an outlined Button carrying CONTROL_CHROME (used for Sort by). */ +const controlButtonSx = { + height: CONTROL_CHROME.height, + bgcolor: CONTROL_CHROME.bgcolor, + borderColor: CONTROL_CHROME.borderColor, + borderRadius: CONTROL_CHROME.borderRadius, + boxShadow: CONTROL_CHROME.boxShadow, + textTransform: 'none', + '&:hover': { bgcolor: CONTROL_CHROME.bgcolor, borderColor: CONTROL_CHROME.borderColor }, + '&:focus-visible': { outline: 'none' }, +} as const; + +/** sx for the FilterPanel wrapper — targets its internal trigger Button. */ +const filterTriggerSx = { + '& .MuiButton-root': controlButtonSx, +} as const; + +/** sx for a ToggleButtonGroup carrying CONTROL_CHROME (used for List/Map). */ +const controlToggleSx = { + borderRadius: CONTROL_CHROME.borderRadius, + boxShadow: CONTROL_CHROME.boxShadow, + '& .MuiToggleButton-root': { + height: CONTROL_CHROME.height, + px: 1.5, + py: 0, + textTransform: 'none', + fontSize: 'var(--fa-button-font-size-sm)', + fontWeight: 600, + borderColor: CONTROL_CHROME.borderColor, + bgcolor: CONTROL_CHROME.bgcolor, + '&:hover': { bgcolor: CONTROL_CHROME.bgcolor }, + '&.Mui-selected': { + bgcolor: 'var(--fa-color-brand-100)', + color: 'primary.main', + '&:hover': { bgcolor: 'var(--fa-color-brand-200)' }, + }, + }, +} as const; + +/** sx for the Autocomplete/TextField search input carrying CONTROL_CHROME. + * Absolute-anchors the end adornment (commit button) to the right edge — + * MUI's stock Autocomplete does this on `.MuiAutocomplete-endAdornment`, + * but overriding `InputProps.endAdornment` puts the content in a + * `.MuiInputAdornment-positionEnd` (which is static by default), so the + * button slides left as chips/draft fill the input. `paddingRight` on the + * OutlinedInput reserves the lane so input content can't run under it. */ +const controlInputSx = { + '& .MuiOutlinedInput-root': { + bgcolor: CONTROL_CHROME.bgcolor, + boxShadow: CONTROL_CHROME.boxShadow, + borderRadius: CONTROL_CHROME.borderRadius, + pr: 5, + position: 'relative', + }, + '& .MuiOutlinedInput-root .MuiInputAdornment-positionEnd': { + position: 'absolute', + right: 8, + top: '50%', + transform: 'translateY(-50%)', + height: 'auto', + maxHeight: 'none', + m: 0, + }, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: CONTROL_CHROME.borderColor, + borderWidth: 1, + }, + '& .MuiOutlinedInput-root.Mui-focused': { + boxShadow: CONTROL_CHROME.boxShadow, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: CONTROL_CHROME.borderColor, + borderWidth: 1, + }, + }, +} as const; + // ─── Component ─────────────────────────────────────────────────────────────── /** @@ -480,15 +568,6 @@ export const ProvidersStep: React.FC = ({ // ─── Mobile map-first layout ─────────────────────────────────────────────── if (showMobileMapLayout) { - const active = mapActive ?? null; - // Drawer is "open" only when there's an active selection AND the map - // isn't in the middle of its exit animation. Flipping to false on - // `exiting` kicks off the slide-down transform immediately, so the user - // sees the drawer leave as soon as they tap the close X. - const drawerOpen = !!(active && !active.exiting && (active.provider || active.cluster)); - const drawerProvider = active?.provider ?? null; - const drawerCluster = active?.cluster ?? null; - return ( = ({ ), endAdornment: ( - + commitSearch(searchDraft)} @@ -595,53 +674,19 @@ export const ProvidersStep: React.FC = ({ }} /> )} - sx={{ - '& .MuiOutlinedInput-root': { - bgcolor: 'background.paper', - boxShadow: 'var(--fa-shadow-sm)', - borderRadius: 'var(--fa-button-border-radius-default)', - }, - '& .MuiOutlinedInput-notchedOutline': { - borderColor: 'var(--fa-color-neutral-300)', - borderWidth: 1, - }, - '& .MuiOutlinedInput-root.Mui-focused': { - boxShadow: 'var(--fa-shadow-sm)', - '& .MuiOutlinedInput-notchedOutline': { - borderColor: 'var(--fa-color-neutral-300)', - borderWidth: 1, - }, - }, - }} + sx={controlInputSx} /> - {/* Control row: Filters, Sort (icon-only on mobile), view toggle. - Each control carries its own white fill so it reads cleanly - over any map tile — no shared container. Heights aligned at - 32px to match Button small + ToggleButton small. */} + {/* Control row: Filters, Sort by, view toggle. + Each control reads as part of one chip set — shared outline, + radius, fill, and shadow via CONTROL_CHROME. */} - + {filterDialogChildren} - {/* Sort — compact text trigger on mobile. Current value lives - in the menu (selected state); aria-label spells it out. */} + {/* Sort — compact "Sort by" trigger; current value surfaces in + the menu's selected state and in the aria-label. */} @@ -686,35 +720,15 @@ export const ProvidersStep: React.FC = ({ ))} - {/* View toggle — text labels on mobile, aligned height with - the buttons; font matches Filters/Sort (14px / 600) */} + {/* View toggle — right-aligned; same outline/radius/fill/shadow + as Filters + Sort, with brand fill on the selected side. */} val && onViewModeChange?.(val as ListViewMode)} size="small" aria-label="View mode" - sx={{ - ml: 'auto', - flexShrink: 0, - boxShadow: 'var(--fa-shadow-sm)', - '& .MuiToggleButton-root': { - height: 32, - px: 1.5, - py: 0, - textTransform: 'none', - fontSize: 'var(--fa-button-font-size-sm)', - fontWeight: 600, - borderColor: 'var(--fa-color-neutral-300)', - bgcolor: 'background.paper', - '&:hover': { bgcolor: 'background.paper' }, - '&.Mui-selected': { - bgcolor: 'var(--fa-color-brand-100)', - color: 'primary.main', - '&:hover': { bgcolor: 'var(--fa-color-brand-200)' }, - }, - }, - }} + sx={[{ ml: 'auto', flexShrink: 0 }, controlToggleSx]} > List @@ -727,182 +741,12 @@ export const ProvidersStep: React.FC = ({ {/* Bottom drawer — slides up when a pin/cluster is active */} - - {/* Drawer header — holds the close X (and the cluster count when - applicable) so it doesn't sit over the card image */} - - {drawerCluster && !drawerProvider && ( - - {drawerCluster.providers.length} providers in this area - - )} - mapRef.current?.clearActive()} - size="small" - sx={{ - ml: 'auto', - width: 32, - height: 32, - color: 'text.secondary', - '&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' }, - }} - > - - - - - {/* Single-provider drawer content — entire card clickable. Card - runs edge-to-edge inside the drawer with its own corners - squared; the drawer Paper provides the top radius. */} - {drawerProvider && ( - onSelectProvider(drawerProvider.id)} - aria-label={`${drawerProvider.name}, ${drawerProvider.location}. Tap to view packages.`} - sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }} - /> - )} - - {/* Cluster list drawer content — tap row to drill in */} - {drawerCluster && !drawerProvider && ( - - - {[...drawerCluster.providers] - .sort((a, b) => Number(!!b.verified) - Number(!!a.verified)) - .map((p) => ( - mapRef.current?.drillIntoProvider(p.id)} - sx={{ - width: '100%', - justifyContent: 'flex-start', - textAlign: 'left', - px: 2, - py: 1.25, - gap: 1, - // Start-align so the verified icon sits on the - // name's baseline (matches desktop ClusterPopup) - alignItems: 'flex-start', - borderBottom: '1px solid', - borderColor: 'divider', - '&:last-of-type': { borderBottom: 'none' }, - '&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' }, - }} - > - {/* Verified-icon slot — height tuned to the name's - line-box so the tick aligns with the title top */} - - {p.verified && ( - - )} - - - - {p.name} - - - {p.location} - {p.rating != null && ( - - - {p.rating.toFixed(1)} - - )} - - - {/* Price column — right-aligned "From $X" */} - {p.startingPrice != null && ( - - - From - - - ${p.startingPrice.toLocaleString('en-AU')} - - - )} - - ))} - - - )} - + mapRef.current?.clearActive()} + onSelectProvider={onSelectProvider} + onDrillIntoProvider={(id) => mapRef.current?.drillIntoProvider(id)} + /> {/* Sticky help bar (matches WizardLayout) */} @@ -943,38 +787,19 @@ export const ProvidersStep: React.FC = ({ sx={sx} secondaryPanel={ - {/* Floating view toggle — sized to match Filters/Sort buttons */} + {/* Floating view toggle — same chrome as the sticky-bar controls, + anchored to the map panel's top-left. */} val && onViewModeChange?.(val as ListViewMode)} size="small" aria-label="View mode" - sx={{ - position: 'absolute', - top: 12, - left: 12, - zIndex: 1, - bgcolor: 'background.paper', - boxShadow: 'var(--fa-shadow-md)', - '& .MuiToggleButton-root': { - height: 'var(--fa-button-height-sm)', - px: 1.5, - py: 0, - fontSize: 'var(--fa-button-font-size-sm)', - fontWeight: 600, - gap: 0.75, - border: '1px solid', - borderColor: 'divider', - textTransform: 'none', - '&.Mui-selected': { - bgcolor: 'var(--fa-color-brand-100)', - color: 'primary.main', - borderColor: 'primary.main', - '&:hover': { bgcolor: 'var(--fa-color-brand-200)' }, - }, - }, - }} + sx={[ + { position: 'absolute', top: 12, left: 12, zIndex: 1 }, + controlToggleSx, + { '& .MuiToggleButton-root': { gap: 0.75 } }, + ]} > @@ -1113,17 +938,7 @@ export const ProvidersStep: React.FC = ({ }} /> )} - sx={{ - mb: 1.5, - // Kill the custom brand focus ring + border colour change on focus - '& .MuiOutlinedInput-root.Mui-focused': { - boxShadow: 'none', - '& .MuiOutlinedInput-notchedOutline': { - borderColor: 'var(--fa-color-neutral-300)', - borderWidth: 1, - }, - }, - }} + sx={[controlInputSx, { mb: 1.5 }]} /> {/* Control bar — filters + sort */} @@ -1134,33 +949,14 @@ export const ProvidersStep: React.FC = ({ gap: 1, }} > - {/* Filters — on mobile, matches the map-view floating chip style - (white fill, neutral-300 border, shadow-sm). On desktop, - default Button small look. */} - + {filterDialogChildren} - {/* Sort — mobile shows a compact "Sort by" text button matching - the Filters chip style; desktop keeps the full "Sort: " - label with its swap icon. */} - + {/* Sort — mobile shows a compact "Sort by" (grouped left next to + Filters, matching the map-view order); desktop shows the full + "Sort: " with its swap icon, pushed to the right. */} +