diff --git a/src/components/organisms/ProviderMap/ProviderMap.tsx b/src/components/organisms/ProviderMap/ProviderMap.tsx index 94ba294..0a74758 100644 --- a/src/components/organisms/ProviderMap/ProviderMap.tsx +++ b/src/components/organisms/ProviderMap/ProviderMap.tsx @@ -39,6 +39,26 @@ const POPUP_EXIT_MS = 180; // ─── Types ────────────────────────────────────────────────────────────────── +/** Shape of the currently-active provider or cluster selection, emitted to + * callers that opt into external popup rendering (see `externalisePopups`). */ +export interface ProviderMapActiveState { + /** Active single provider, if a pin was tapped (or a cluster row drilled into) */ + provider: ProviderData | null; + /** Active cluster, if a cluster marker was tapped and no row has been drilled into */ + cluster: { providers: ProviderData[]; position: { lat: number; lng: number } } | null; + /** True while the exit animation is running — callers may want to mirror it */ + exiting: boolean; +} + +/** Imperative handle exposed via ref. Used when rendering popups externally. */ +export interface ProviderMapHandle { + /** Close the currently-active popup (animated). No-op if nothing is open. */ + clearActive: () => void; + /** Pan + zoom the map to a provider's coords and set them as the active + * single-provider selection. Equivalent to a cluster-row tap. */ + drillIntoProvider: (id: string) => void; +} + /** Props for the FA ProviderMap organism */ export interface ProviderMapProps { /** Providers to render as pins. Providers without coords are filtered out silently. */ @@ -53,6 +73,14 @@ export interface ProviderMapProps { defaultZoom?: number; /** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */ apiKey?: string; + /** When true, suppress the organism's own MapPopup + ClusterPopup rendering. + * The active state is still tracked internally (pins still hide when active) + * and emitted via `onActiveChange` so callers can render a drawer, sheet, + * or other external container. Used by the mobile map-first layout. */ + externalisePopups?: boolean; + /** Fires whenever the active provider/cluster state changes. Paired with + * `externalisePopups` — the caller uses this to drive external UI. */ + onActiveChange?: (state: ProviderMapActiveState) => void; /** MUI sx prop for the root element */ sx?: SxProps; } @@ -276,7 +304,7 @@ const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason }) * Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup` * (molecules). Clustering via `@googlemaps/markerclusterer`. */ -export const ProviderMap = React.forwardRef( +export const ProviderMap = React.forwardRef( ( { providers, @@ -285,6 +313,8 @@ export const ProviderMap = React.forwardRef( defaultCenter = FALLBACK_CENTER, defaultZoom = FALLBACK_ZOOM, apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY, + externalisePopups = false, + onActiveChange, sx, }, ref, @@ -369,6 +399,27 @@ export const ProviderMap = React.forwardRef( const handleMapClick = closeWithExit; const handleCloseCluster = closeWithExit; + // Emit active-state changes when the caller is rendering popups externally. + const onActiveChangeRef = React.useRef(onActiveChange); + React.useEffect(() => { + onActiveChangeRef.current = onActiveChange; + }, [onActiveChange]); + React.useEffect(() => { + onActiveChangeRef.current?.({ + provider: activeProvider, + cluster: activeCluster + ? { + providers: activeCluster.providers, + position: { + lat: activeCluster.position.lat, + lng: activeCluster.position.lng, + }, + } + : null, + exiting, + }); + }, [activeProvider, activeCluster, exiting]); + /** Cluster list → single-provider drill-in. * Pans + zooms the map to the provider's coords (zoom 15 = past * CLUSTER_MAX_ZOOM so nearby cluster members separate into individual @@ -388,6 +439,16 @@ export const ProviderMap = React.forwardRef( [withCoords, cancelExit], ); + // Imperative handle for external callers (drawer close, cluster-row tap). + React.useImperativeHandle( + ref, + () => ({ + clearActive: closeWithExit, + drillIntoProvider: handleDrillIntoProvider, + }), + [closeWithExit, handleDrillIntoProvider], + ); + const rootSx = [ { position: 'relative' as const, @@ -404,14 +465,14 @@ export const ProviderMap = React.forwardRef( // Empty states if (!apiKey) { return ( - + ); } if (withCoords.length === 0) { return ( - + ); @@ -440,8 +501,9 @@ export const ProviderMap = React.forwardRef( onClusterClick={handleClusterClick} /> - {/* Single-provider popup (pin click OR post-zoom cluster drill-in) */} - {activeProvider && ( + {/* Internal popups — skipped when caller externalises them (e.g. + mobile drawer). Active state still flows via onActiveChange. */} + {!externalisePopups && activeProvider && ( ( {/* Cluster list popup — shown while a cluster is active and no provider has been drilled into. Drilling clears activeCluster, which swaps this for the single-provider popup above. */} - {activeCluster && !activeProviderId && ( + {!externalisePopups && activeCluster && !activeProviderId && ( ({ diff --git a/src/components/organisms/ProviderMap/index.ts b/src/components/organisms/ProviderMap/index.ts index 167cc8f..8ed445d 100644 --- a/src/components/organisms/ProviderMap/index.ts +++ b/src/components/organisms/ProviderMap/index.ts @@ -1 +1,6 @@ -export { ProviderMap, type ProviderMapProps } from './ProviderMap'; +export { + ProviderMap, + type ProviderMapProps, + type ProviderMapHandle, + type ProviderMapActiveState, +} from './ProviderMap'; diff --git a/src/components/pages/ProvidersStep/ProvidersStep.tsx b/src/components/pages/ProvidersStep/ProvidersStep.tsx index 6c4cb42..a11edc6 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.tsx @@ -1,5 +1,7 @@ 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'; @@ -9,18 +11,30 @@ import MenuItem from '@mui/material/MenuItem'; import Menu from '@mui/material/Menu'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButton from '@mui/material/ToggleButton'; +import useMediaQuery from '@mui/material/useMediaQuery'; import SwapVertIcon from '@mui/icons-material/SwapVert'; 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 { + ProviderMap, + type ProviderMapActiveState, + type ProviderMapHandle, +} from '../../organisms/ProviderMap'; import { Button } from '../../atoms/Button'; import { Chip } from '../../atoms/Chip'; import { IconButton } from '../../atoms/IconButton'; +import { Link } from '../../atoms/Link'; import { Switch } from '../../atoms/Switch'; import { Typography } from '../../atoms/Typography'; import { Divider } from '../../atoms/Divider'; @@ -253,6 +267,13 @@ export const ProvidersStep: React.FC = ({ // committed filter value; the draft lives here until the user confirms. const [searchDraft, setSearchDraft] = React.useState(''); + // ─── Mobile map-first plumbing ─── + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const mapRef = React.useRef(null); + const [mapActive, setMapActive] = React.useState(null); + const showMobileMapLayout = isMobile && viewMode === 'map'; + const commitSearch = (next: string) => { const trimmed = next.trim(); if (!trimmed) return; @@ -310,6 +331,557 @@ export const ProvidersStep: React.FC = ({ onFilterChange({ ...filterValues, funeralTypes: next }); }; + // ─── Shared JSX fragments (used by desktop + mobile-map layouts) ─────────── + + /** The full filter-dialog content — used by both desktop's sticky FilterPanel + * and the mobile-map floating FilterPanel. */ + const filterDialogChildren = ( + <> + {/* ── Location ── */} + + + Location + + { + const last = newValue[newValue.length - 1] ?? ''; + onSearchChange(typeof last === 'string' ? last : ''); + }} + options={[]} + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ), + }} + /> + )} + size="small" + /> + + + + + {/* ── Service tradition ── */} + + + Service tradition + + onFilterChange({ ...filterValues, tradition: newValue })} + options={traditionOptions} + renderInput={(params) => ( + + )} + clearOnEscape + size="small" + /> + + + + + {/* ── Funeral type ── */} + + + Funeral type + + + {funeralTypeOptions.map((option) => ( + handleFuneralTypeToggle(option.value)} + variant="outlined" + size="small" + /> + ))} + + + + + + {/* ── Provider features ── */} + + onFilterChange({ ...filterValues, verifiedOnly: checked })} + /> + } + label="Verified providers only" + sx={{ mx: 0 }} + /> + + onFilterChange({ ...filterValues, onlineArrangements: checked }) + } + /> + } + label="Online arrangements available" + sx={{ mx: 0 }} + /> + + + + + {/* ── Price range ── */} + + + Price range + + + + onFilterChange({ + ...filterValues, + priceRange: newValue as [number, number], + }) + } + min={minPrice} + max={maxPrice} + step={100} + valueLabelDisplay="auto" + valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`} + color="primary" + /> + + + setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))} + onBlur={commitPriceRange} + onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()} + InputProps={{ + startAdornment: $, + }} + inputProps={{ + inputMode: 'numeric', + 'aria-label': 'Minimum price', + style: { padding: '6px 0' }, + }} + sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }} + /> + + – + + setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))} + onBlur={commitPriceRange} + onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()} + InputProps={{ + startAdornment: $, + }} + inputProps={{ + inputMode: 'numeric', + 'aria-label': 'Maximum price', + style: { padding: '6px 0' }, + }} + sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }} + /> + + + + ); + + // ─── Mobile map-first layout ─────────────────────────────────────────────── + + if (showMobileMapLayout) { + const active = mapActive ?? null; + const drawerOpen = !!(active && (active.provider || active.cluster)); + const drawerProvider = active?.provider ?? null; + const drawerCluster = active?.cluster ?? null; + + return ( + + {navigation} + + + {/* Full-bleed map */} + + + + + {/* Floating control strip */} + + {/* Search input (mobile variant reuses the committed-chip pattern) */} + { + if (reason === 'reset') return; + setSearchDraft(newDraft); + }} + onChange={(_, newValue) => { + if (newValue.length === 0) { + onSearchChange(''); + return; + } + const last = newValue[newValue.length - 1]; + if (typeof last === 'string') commitSearch(last); + }} + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...chipProps } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + + commitSearch(searchDraft)} + sx={{ + width: 28, + height: 28, + borderRadius: '50%', + bgcolor: 'primary.main', + color: 'primary.contrastText', + '&:hover': { bgcolor: 'primary.dark' }, + '&:focus-visible': { outline: 'none' }, + }} + > + + + + ), + }} + /> + )} + sx={{ + '& .MuiOutlinedInput-root.Mui-focused': { + boxShadow: 'none', + '& .MuiOutlinedInput-notchedOutline': { + borderColor: 'var(--fa-color-neutral-300)', + borderWidth: 1, + }, + }, + }} + /> + + {/* Control row: Filters, Sort, view toggle */} + + + {filterDialogChildren} + + + + setSortAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + {SORT_OPTIONS.map((opt) => ( + { + onSortChange?.(opt.value); + setSortAnchor(null); + }} + sx={{ fontSize: '0.813rem' }} + > + {opt.label} + + ))} + + + {/* View toggle — icon-only on mobile to keep the row tight */} + val && onViewModeChange?.(val as ListViewMode)} + size="small" + aria-label="View mode" + sx={{ + ml: 'auto', + flexShrink: 0, + '& .MuiToggleButton-root': { + px: 1, + py: 0.5, + '&.Mui-selected': { + bgcolor: 'var(--fa-color-brand-100)', + color: 'primary.main', + '&:hover': { bgcolor: 'var(--fa-color-brand-200)' }, + }, + }, + }} + > + + + + + + + + + + + {/* Bottom drawer — slides up when a pin/cluster is active */} + + {/* Close X */} + mapRef.current?.clearActive()} + sx={{ + position: 'absolute', + top: 8, + right: 8, + zIndex: 1, + width: 32, + height: 32, + bgcolor: 'background.paper', + boxShadow: 'var(--fa-shadow-sm)', + '&:hover': { bgcolor: 'background.paper' }, + }} + > + + + + {/* Single-provider drawer content — entire card clickable */} + {drawerProvider && ( + + onSelectProvider(drawerProvider.id)} + aria-label={`${drawerProvider.name}, ${drawerProvider.location}. Tap to view packages.`} + /> + + )} + + {/* Cluster list drawer content — tap row to drill in */} + {drawerCluster && !drawerProvider && ( + + + {drawerCluster.providers.length} providers in this area + + + {[...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, + borderBottom: '1px solid', + borderColor: 'divider', + '&:last-of-type': { borderBottom: 'none' }, + '&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' }, + }} + > + {/* Verified-icon slot (aligns all names) */} + + {p.verified && ( + + )} + + + + {p.name} + + + {p.location} + {p.rating != null && ( + + + {p.rating.toFixed(1)} + + )} + + + + ))} + + + )} + + + + {/* Sticky help bar (matches WizardLayout) */} + + + + Need help? Call us on{' '} + + 1800 987 888 + + + + + ); + } + + // ─── Desktop + mobile-list layout ────────────────────────────────────────── + return ( = ({ onClear={handleClear} sx={{ '& .MuiButton-root:focus-visible': { outline: 'none' } }} > - {/* ── Location ── */} - - - Location - - { - // Take the last entered value as the active search - const last = newValue[newValue.length - 1] ?? ''; - onSearchChange(typeof last === 'string' ? last : ''); - }} - options={[]} - renderInput={(params) => ( - - - - - {params.InputProps.startAdornment} - - ), - }} - /> - )} - size="small" - /> - - - - - {/* ── Service tradition ── */} - - - Service tradition - - onFilterChange({ ...filterValues, tradition: newValue })} - options={traditionOptions} - renderInput={(params) => ( - - )} - clearOnEscape - size="small" - /> - - - - - {/* ── Funeral type ── */} - - - Funeral type - - - {funeralTypeOptions.map((option) => ( - handleFuneralTypeToggle(option.value)} - variant="outlined" - size="small" - /> - ))} - - - - - - {/* ── Provider features ── */} - - - onFilterChange({ ...filterValues, verifiedOnly: checked }) - } - /> - } - label="Verified providers only" - sx={{ mx: 0 }} - /> - - onFilterChange({ ...filterValues, onlineArrangements: checked }) - } - /> - } - label="Online arrangements available" - sx={{ mx: 0 }} - /> - - - - - {/* ── Price range ── */} - - - Price range - - - - onFilterChange({ - ...filterValues, - priceRange: newValue as [number, number], - }) - } - min={minPrice} - max={maxPrice} - step={100} - valueLabelDisplay="auto" - valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`} - color="primary" - /> - - - setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))} - onBlur={commitPriceRange} - onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()} - InputProps={{ - startAdornment: $, - }} - inputProps={{ - inputMode: 'numeric', - 'aria-label': 'Minimum price', - style: { padding: '6px 0' }, - }} - sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }} - /> - - – - - setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))} - onBlur={commitPriceRange} - onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()} - InputProps={{ - startAdornment: $, - }} - inputProps={{ - inputMode: 'numeric', - 'aria-label': 'Maximum price', - style: { padding: '6px 0' }, - }} - sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }} - /> - - + {filterDialogChildren} {/* Sort — compact menu button, pushed right */} @@ -731,6 +1133,38 @@ export const ProvidersStep: React.FC = ({ ))} + + {/* Mobile-only view toggle — switches to the map-first layout */} + val && onViewModeChange?.(val as ListViewMode)} + size="small" + aria-label="View mode" + sx={{ + display: { xs: 'inline-flex', md: 'none' }, + '& .MuiToggleButton-root': { + px: 1, + py: 0.5, + gap: 0.5, + textTransform: 'none', + fontSize: '0.75rem', + fontWeight: 500, + '&.Mui-selected': { + bgcolor: 'var(--fa-color-brand-100)', + color: 'primary.main', + '&:hover': { bgcolor: 'var(--fa-color-brand-200)' }, + }, + }, + }} + > + + + + + + + {/* Results count — below controls */}