ProvidersStep: extract MapProviderDrawer + unify control chrome + fix search button drift
- New molecule MapProviderDrawer lifts the mobile-map bottom drawer out of ProvidersStep (~120 lines): Paper + close-X header + single-pin ProviderCard content / cluster-list content + slide-up animation. Props: `active: ProviderMapActiveState | null`, `onClose`, `onSelectProvider`, `onDrillIntoProvider`. Three Storybook states (SingleProvider, Cluster, ClusterPair, Closed) so the drawer can be iterated without a live map. ProvidersStep now consumes it as a single line wired to mapRef.clearActive + mapRef.drillIntoProvider. - Shared visual tokens for the control cluster (Search, Filters, Sort by, List/Map toggle) factored into a CONTROL_CHROME constant and three typed sx objects (controlButtonSx, controlToggleSx, controlInputSx, filterTriggerSx) so all four controls share the same outline, radius, fill, and shadow across mobile list, mobile map, and desktop. Desktop map-panel floating toggle also re-threaded through controlToggleSx. - Mobile list control order now matches mobile map: Sort by is grouped left next to Filters (not pushed right with a ml:auto wrapper), and the List/Map toggle is right-pinned via ml:auto on xs. Desktop keeps Sort pushed right (no toggle rendered on desktop in this slot). - Fix: the magnifying-glass commit button was drifting 19–30px left as the input filled with chips / draft text. Root cause: overriding `InputProps.endAdornment` on Autocomplete bypasses MUI's `.MuiAutocomplete-endAdornment` absolute positioning, leaving our `.MuiInputAdornment-positionEnd` as `position: static` in flex flow. controlInputSx now re-absolutely-anchors the end adornment at the right edge and reserves `pr: 5` so input content can't slide under it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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". `<form>` 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: <value>` 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. |
|
||||
|
||||
@@ -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: <value>` (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: <value>" 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.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { MapProviderDrawer } from './MapProviderDrawer';
|
||||
|
||||
const meta: Meta<typeof MapProviderDrawer> = {
|
||||
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) => (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: 390,
|
||||
height: 700,
|
||||
mx: 'auto',
|
||||
overflow: 'hidden',
|
||||
// Very rough map-tile fill so the drawer has contrast behind it.
|
||||
background: 'linear-gradient(135deg, #C9DFC4 0%, #B5D4F0 50%, #C9DFC4 100%)',
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MapProviderDrawer>;
|
||||
|
||||
// ─── 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'),
|
||||
},
|
||||
};
|
||||
252
src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx
Normal file
252
src/components/molecules/MapProviderDrawer/MapProviderDrawer.tsx
Normal file
@@ -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<Theme>;
|
||||
}
|
||||
|
||||
// ─── Cluster row ────────────────────────────────────────────────────────────
|
||||
|
||||
const ClusterRow: React.FC<{
|
||||
provider: ProviderData;
|
||||
onClick: () => void;
|
||||
}> = ({ provider: p, onClick }) => (
|
||||
<ButtonBase
|
||||
onClick={onClick}
|
||||
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 the desktop ClusterPopup row treatment.
|
||||
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, empty slot keeps other names
|
||||
left-aligned on the same x-origin. */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 18,
|
||||
height: '1.25em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{p.verified && <VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} />}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: p.verified ? 'primary.main' : 'text.primary',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
|
||||
<Typography variant="caption">{p.location}</Typography>
|
||||
{p.rating != null && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} />
|
||||
<Typography variant="caption">{p.rating.toFixed(1)}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{p.startingPrice != null && (
|
||||
<Box sx={{ flexShrink: 0, textAlign: 'right', pl: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
From
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: p.verified ? 'primary.main' : 'text.primary' }}
|
||||
>
|
||||
${p.startingPrice.toLocaleString('en-AU')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</ButtonBase>
|
||||
);
|
||||
|
||||
// ─── 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 `<main>`). 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<HTMLDivElement, MapProviderDrawerProps>(
|
||||
({ 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 (
|
||||
<Paper
|
||||
ref={ref}
|
||||
elevation={0}
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={!isOpen}
|
||||
sx={[
|
||||
{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 3,
|
||||
maxHeight: '60vh',
|
||||
overflow: 'auto',
|
||||
borderRadius: 0,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
boxShadow: 'var(--fa-shadow-lg)',
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform 220ms ease-out',
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
visibility: isOpen || isExiting ? 'visible' : 'hidden',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : [sx]),
|
||||
]}
|
||||
>
|
||||
{/* Header strip — holds the close X (and the cluster count when
|
||||
applicable) so neither sits over the card image below. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 40,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{cluster && !provider && (
|
||||
<Typography variant="labelLg" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
{cluster.providers.length} providers in this area
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: 'text.secondary',
|
||||
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
|
||||
}}
|
||||
>
|
||||
<CloseRoundedIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Single-provider content — entire card clickable. Card runs
|
||||
edge-to-edge with all corners squared; the drawer Paper provides
|
||||
the top radius. */}
|
||||
{provider && (
|
||||
<ProviderCard
|
||||
name={provider.name}
|
||||
location={provider.location}
|
||||
verified={provider.verified}
|
||||
imageUrl={provider.imageUrl}
|
||||
logoUrl={provider.logoUrl}
|
||||
rating={provider.rating}
|
||||
reviewCount={provider.reviewCount}
|
||||
startingPrice={provider.startingPrice}
|
||||
onClick={() => 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 && (
|
||||
<Box sx={{ pb: 1 }}>
|
||||
{[...cluster.providers]
|
||||
.sort((a, b) => Number(!!b.verified) - Number(!!a.verified))
|
||||
.map((p) => (
|
||||
<ClusterRow key={p.id} provider={p} onClick={() => onDrillIntoProvider(p.id)} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MapProviderDrawer.displayName = 'MapProviderDrawer';
|
||||
export default MapProviderDrawer;
|
||||
1
src/components/molecules/MapProviderDrawer/index.ts
Normal file
1
src/components/molecules/MapProviderDrawer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MapProviderDrawer, type MapProviderDrawerProps } from './MapProviderDrawer';
|
||||
@@ -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<ProvidersStepProps> = ({
|
||||
// ─── 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 (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -574,7 +653,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
</>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end" sx={{ mr: 0.5 }}>
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="Search"
|
||||
onClick={() => commitSearch(searchDraft)}
|
||||
@@ -595,53 +674,19 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
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. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FilterPanel
|
||||
activeCount={activeCount}
|
||||
onClear={handleClear}
|
||||
sx={{
|
||||
'& .MuiButton-root': {
|
||||
height: 32,
|
||||
bgcolor: 'background.paper',
|
||||
borderColor: 'var(--fa-color-neutral-300)',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.paper',
|
||||
borderColor: 'var(--fa-color-neutral-300)',
|
||||
},
|
||||
'&:focus-visible': { outline: 'none' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
|
||||
{filterDialogChildren}
|
||||
</FilterPanel>
|
||||
|
||||
{/* 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. */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
@@ -649,18 +694,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
onClick={(e) => setSortAnchor(e.currentTarget)}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}`}
|
||||
sx={{
|
||||
height: 32,
|
||||
bgcolor: 'background.paper',
|
||||
borderColor: 'var(--fa-color-neutral-300)',
|
||||
boxShadow: 'var(--fa-shadow-sm)',
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
bgcolor: 'background.paper',
|
||||
borderColor: 'var(--fa-color-neutral-300)',
|
||||
},
|
||||
'&:focus-visible': { outline: 'none' },
|
||||
}}
|
||||
sx={controlButtonSx}
|
||||
>
|
||||
Sort by
|
||||
</Button>
|
||||
@@ -686,35 +720,15 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
{/* 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. */}
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, val) => 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]}
|
||||
>
|
||||
<ToggleButton value="list" aria-label="List view">
|
||||
List
|
||||
@@ -727,182 +741,12 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
</Box>
|
||||
|
||||
{/* Bottom drawer — slides up when a pin/cluster is active */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
role="dialog"
|
||||
aria-label={
|
||||
drawerProvider
|
||||
? `${drawerProvider.name} details`
|
||||
: drawerCluster
|
||||
? `${drawerCluster.providers.length} providers in this area`
|
||||
: undefined
|
||||
}
|
||||
aria-hidden={!drawerOpen}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 3,
|
||||
maxHeight: '60vh',
|
||||
overflow: 'auto',
|
||||
borderRadius: 0,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
boxShadow: 'var(--fa-shadow-lg)',
|
||||
transform: drawerOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform 220ms ease-out',
|
||||
pointerEvents: drawerOpen ? 'auto' : 'none',
|
||||
visibility: drawerOpen || mapActive?.exiting ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Drawer header — holds the close X (and the cluster count when
|
||||
applicable) so it doesn't sit over the card image */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: 40,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{drawerCluster && !drawerProvider && (
|
||||
<Typography variant="labelLg" sx={{ color: 'text.secondary', display: 'block' }}>
|
||||
{drawerCluster.providers.length} providers in this area
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
onClick={() => mapRef.current?.clearActive()}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: 'text.secondary',
|
||||
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
|
||||
}}
|
||||
>
|
||||
<CloseRoundedIcon sx={{ fontSize: 20 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* 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 && (
|
||||
<ProviderCard
|
||||
name={drawerProvider.name}
|
||||
location={drawerProvider.location}
|
||||
verified={drawerProvider.verified}
|
||||
imageUrl={drawerProvider.imageUrl}
|
||||
logoUrl={drawerProvider.logoUrl}
|
||||
rating={drawerProvider.rating}
|
||||
reviewCount={drawerProvider.reviewCount}
|
||||
startingPrice={drawerProvider.startingPrice}
|
||||
onClick={() => onSelectProvider(drawerProvider.id)}
|
||||
aria-label={`${drawerProvider.name}, ${drawerProvider.location}. Tap to view packages.`}
|
||||
sx={{ borderRadius: 0, boxShadow: 'none', border: 'none' }}
|
||||
<MapProviderDrawer
|
||||
active={mapActive}
|
||||
onClose={() => mapRef.current?.clearActive()}
|
||||
onSelectProvider={onSelectProvider}
|
||||
onDrillIntoProvider={(id) => mapRef.current?.drillIntoProvider(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cluster list drawer content — tap row to drill in */}
|
||||
{drawerCluster && !drawerProvider && (
|
||||
<Box sx={{ pb: 1 }}>
|
||||
<Box>
|
||||
{[...drawerCluster.providers]
|
||||
.sort((a, b) => Number(!!b.verified) - Number(!!a.verified))
|
||||
.map((p) => (
|
||||
<ButtonBase
|
||||
key={p.id}
|
||||
onClick={() => 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 */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 18,
|
||||
height: '1.25em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{p.verified && (
|
||||
<VerifiedOutlinedIcon sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: p.verified ? 'primary.main' : 'text.primary',
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">{p.location}</Typography>
|
||||
{p.rating != null && (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}>
|
||||
<StarRoundedIcon sx={{ fontSize: 14, color: 'warning.main' }} />
|
||||
<Typography variant="caption">{p.rating.toFixed(1)}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Price column — right-aligned "From $X" */}
|
||||
{p.startingPrice != null && (
|
||||
<Box sx={{ flexShrink: 0, textAlign: 'right', pl: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', color: 'text.secondary' }}
|
||||
>
|
||||
From
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: p.verified ? 'primary.main' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
${p.startingPrice.toLocaleString('en-AU')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</ButtonBase>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Sticky help bar (matches WizardLayout) */}
|
||||
@@ -943,38 +787,19 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
sx={sx}
|
||||
secondaryPanel={
|
||||
<Box sx={{ position: 'relative', flex: 1, display: 'flex' }}>
|
||||
{/* 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. */}
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, val) => 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 } },
|
||||
]}
|
||||
>
|
||||
<ToggleButton value="list" aria-label="List view">
|
||||
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
@@ -1113,17 +938,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
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<ProvidersStepProps> = ({
|
||||
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. */}
|
||||
<FilterPanel
|
||||
activeCount={activeCount}
|
||||
onClear={handleClear}
|
||||
sx={{
|
||||
'& .MuiButton-root': {
|
||||
height: { xs: 32, md: undefined },
|
||||
bgcolor: { xs: 'background.paper', md: undefined },
|
||||
borderColor: { xs: 'var(--fa-color-neutral-300)', md: undefined },
|
||||
boxShadow: { xs: 'var(--fa-shadow-sm)', md: 'none' },
|
||||
'&:hover': {
|
||||
bgcolor: { xs: 'background.paper', md: undefined },
|
||||
borderColor: { xs: 'var(--fa-color-neutral-300)', md: undefined },
|
||||
},
|
||||
'&:focus-visible': { outline: 'none' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FilterPanel activeCount={activeCount} onClear={handleClear} sx={filterTriggerSx}>
|
||||
{filterDialogChildren}
|
||||
</FilterPanel>
|
||||
|
||||
{/* Sort — mobile shows a compact "Sort by" text button matching
|
||||
the Filters chip style; desktop keeps the full "Sort: <value>"
|
||||
label with its swap icon. */}
|
||||
<Box sx={{ ml: 'auto' }}>
|
||||
{/* Sort — mobile shows a compact "Sort by" (grouped left next to
|
||||
Filters, matching the map-view order); desktop shows the full
|
||||
"Sort: <value>" with its swap icon, pushed to the right. */}
|
||||
<Box sx={{ ml: { xs: 0, md: 'auto' } }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
@@ -1169,18 +965,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
onClick={(e) => setSortAnchor(e.currentTarget)}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}`}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
height: { xs: 32, md: undefined },
|
||||
bgcolor: { xs: 'background.paper', md: undefined },
|
||||
borderColor: { xs: 'var(--fa-color-neutral-300)', md: undefined },
|
||||
boxShadow: { xs: 'var(--fa-shadow-sm)', md: 'none' },
|
||||
'&:hover': {
|
||||
bgcolor: { xs: 'background.paper', md: undefined },
|
||||
borderColor: { xs: 'var(--fa-color-neutral-300)', md: undefined },
|
||||
},
|
||||
'&:focus-visible': { outline: 'none' },
|
||||
}}
|
||||
sx={controlButtonSx}
|
||||
>
|
||||
{isMobile ? (
|
||||
'Sort by'
|
||||
@@ -1216,36 +1001,18 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
{/* Mobile-only view toggle — matches the map-view floating toggle:
|
||||
text labels (List / Map), white fill, neutral-300 border, shadow,
|
||||
14px / 600 type to align with the Filters + Sort by buttons. */}
|
||||
{/* Mobile-only view toggle — pinned to the right via ml: auto on xs.
|
||||
Shares the same CONTROL_CHROME as Filters + Sort. */}
|
||||
<ToggleButtonGroup
|
||||
value={viewMode}
|
||||
exclusive
|
||||
onChange={(_, val) => val && onViewModeChange?.(val as ListViewMode)}
|
||||
size="small"
|
||||
aria-label="View mode"
|
||||
sx={{
|
||||
display: { xs: 'inline-flex', md: 'none' },
|
||||
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={[
|
||||
{ display: { xs: 'inline-flex', md: 'none' }, ml: 'auto', flexShrink: 0 },
|
||||
controlToggleSx,
|
||||
]}
|
||||
>
|
||||
<ToggleButton value="list" aria-label="List view">
|
||||
List
|
||||
|
||||
Reference in New Issue
Block a user