ProvidersStep: mobile map-first layout with bottom drawer
On xs + viewMode=map, render a map-first layout: full-bleed map, floating card-shaped control strip at the top (search + Filters + Sort + compact List/Map toggle), and a bottom drawer that slides up when a pin or cluster is tapped. The desktop list-map layout is unchanged. On xs + viewMode=list, the List/Map toggle now appears in the sticky control bar (icon-only) so users can reach the map from the list view. On desktop the toggle stays on the map panel as before. Drawer content: - Single pin → the existing ProviderCard molecule, entire card clickable (navigates to packages) - Cluster → a list of image-free rows (verified icon slot + name + location + rating), tap a row to pan+zoom into the provider - Close X on the drawer clears the active state To support externalising popups, ProviderMap gains two opt-in props (`externalisePopups`, `onActiveChange`) and an imperative handle (`clearActive`, `drillIntoProvider`). Desktop behaviour unchanged when these aren't used. The forwardRef now exposes the handle rather than the DOM element; no existing callsite passed a DOM ref. The filter-dialog children are now defined once as a shared JSX fragment used by both desktop and mobile FilterPanel instances. Header + subhead are suppressed on the mobile map view (per concept reference); they remain on desktop and mobile list for orientation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,26 @@ const POPUP_EXIT_MS = 180;
|
|||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── 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 */
|
/** Props for the FA ProviderMap organism */
|
||||||
export interface ProviderMapProps {
|
export interface ProviderMapProps {
|
||||||
/** Providers to render as pins. Providers without coords are filtered out silently. */
|
/** Providers to render as pins. Providers without coords are filtered out silently. */
|
||||||
@@ -53,6 +73,14 @@ export interface ProviderMapProps {
|
|||||||
defaultZoom?: number;
|
defaultZoom?: number;
|
||||||
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
|
/** Google Maps API key. Defaults to `import.meta.env.VITE_GOOGLE_MAPS_API_KEY`. */
|
||||||
apiKey?: string;
|
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 */
|
/** MUI sx prop for the root element */
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
}
|
}
|
||||||
@@ -276,7 +304,7 @@ const MapEmptyState: React.FC<{ reason: 'no-key' | 'no-coords' }> = ({ reason })
|
|||||||
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
|
* Composes `MapPin` + `ClusterMarker` (atoms) + `MapPopup` + `ClusterPopup`
|
||||||
* (molecules). Clustering via `@googlemaps/markerclusterer`.
|
* (molecules). Clustering via `@googlemaps/markerclusterer`.
|
||||||
*/
|
*/
|
||||||
export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
export const ProviderMap = React.forwardRef<ProviderMapHandle, ProviderMapProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
providers,
|
providers,
|
||||||
@@ -285,6 +313,8 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
|||||||
defaultCenter = FALLBACK_CENTER,
|
defaultCenter = FALLBACK_CENTER,
|
||||||
defaultZoom = FALLBACK_ZOOM,
|
defaultZoom = FALLBACK_ZOOM,
|
||||||
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
|
apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
|
||||||
|
externalisePopups = false,
|
||||||
|
onActiveChange,
|
||||||
sx,
|
sx,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -369,6 +399,27 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
|||||||
const handleMapClick = closeWithExit;
|
const handleMapClick = closeWithExit;
|
||||||
const handleCloseCluster = 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.
|
/** Cluster list → single-provider drill-in.
|
||||||
* Pans + zooms the map to the provider's coords (zoom 15 = past
|
* Pans + zooms the map to the provider's coords (zoom 15 = past
|
||||||
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
|
* CLUSTER_MAX_ZOOM so nearby cluster members separate into individual
|
||||||
@@ -388,6 +439,16 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
|||||||
[withCoords, cancelExit],
|
[withCoords, cancelExit],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Imperative handle for external callers (drawer close, cluster-row tap).
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
clearActive: closeWithExit,
|
||||||
|
drillIntoProvider: handleDrillIntoProvider,
|
||||||
|
}),
|
||||||
|
[closeWithExit, handleDrillIntoProvider],
|
||||||
|
);
|
||||||
|
|
||||||
const rootSx = [
|
const rootSx = [
|
||||||
{
|
{
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
@@ -404,14 +465,14 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
|||||||
// Empty states
|
// Empty states
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
<Box role="application" aria-label="Provider map" sx={rootSx}>
|
||||||
<MapEmptyState reason="no-key" />
|
<MapEmptyState reason="no-key" />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (withCoords.length === 0) {
|
if (withCoords.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box ref={ref} role="application" aria-label="Provider map" sx={rootSx}>
|
<Box role="application" aria-label="Provider map" sx={rootSx}>
|
||||||
<MapEmptyState reason="no-coords" />
|
<MapEmptyState reason="no-coords" />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -440,8 +501,9 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
|||||||
onClusterClick={handleClusterClick}
|
onClusterClick={handleClusterClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Single-provider popup (pin click OR post-zoom cluster drill-in) */}
|
{/* Internal popups — skipped when caller externalises them (e.g.
|
||||||
{activeProvider && (
|
mobile drawer). Active state still flows via onActiveChange. */}
|
||||||
|
{!externalisePopups && activeProvider && (
|
||||||
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
|
<AdvancedMarker position={activeProvider.coords!} zIndex={1000}>
|
||||||
<MapPopup
|
<MapPopup
|
||||||
name={activeProvider.name}
|
name={activeProvider.name}
|
||||||
@@ -459,7 +521,7 @@ export const ProviderMap = React.forwardRef<HTMLDivElement, ProviderMapProps>(
|
|||||||
{/* Cluster list popup — shown while a cluster is active and no
|
{/* Cluster list popup — shown while a cluster is active and no
|
||||||
provider has been drilled into. Drilling clears activeCluster,
|
provider has been drilled into. Drilling clears activeCluster,
|
||||||
which swaps this for the single-provider popup above. */}
|
which swaps this for the single-provider popup above. */}
|
||||||
{activeCluster && !activeProviderId && (
|
{!externalisePopups && activeCluster && !activeProviderId && (
|
||||||
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
|
<AdvancedMarker position={activeCluster.position} zIndex={1000}>
|
||||||
<ClusterPopup
|
<ClusterPopup
|
||||||
providers={activeCluster.providers.map((p) => ({
|
providers={activeCluster.providers.map((p) => ({
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
export { ProviderMap, type ProviderMapProps } from './ProviderMap';
|
export {
|
||||||
|
ProviderMap,
|
||||||
|
type ProviderMapProps,
|
||||||
|
type ProviderMapHandle,
|
||||||
|
type ProviderMapActiveState,
|
||||||
|
} from './ProviderMap';
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Box from '@mui/material/Box';
|
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 TextField from '@mui/material/TextField';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import Autocomplete from '@mui/material/Autocomplete';
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
@@ -9,18 +11,30 @@ import MenuItem from '@mui/material/MenuItem';
|
|||||||
import Menu from '@mui/material/Menu';
|
import Menu from '@mui/material/Menu';
|
||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
import ToggleButton from '@mui/material/ToggleButton';
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
import SwapVertIcon from '@mui/icons-material/SwapVert';
|
import SwapVertIcon from '@mui/icons-material/SwapVert';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
|
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
|
||||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||||
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
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 type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
import { WizardLayout } from '../../templates/WizardLayout';
|
import { WizardLayout } from '../../templates/WizardLayout';
|
||||||
import { ProviderCard } from '../../molecules/ProviderCard';
|
import { ProviderCard } from '../../molecules/ProviderCard';
|
||||||
import { FilterPanel } from '../../molecules/FilterPanel';
|
import { FilterPanel } from '../../molecules/FilterPanel';
|
||||||
|
import {
|
||||||
|
ProviderMap,
|
||||||
|
type ProviderMapActiveState,
|
||||||
|
type ProviderMapHandle,
|
||||||
|
} from '../../organisms/ProviderMap';
|
||||||
import { Button } from '../../atoms/Button';
|
import { Button } from '../../atoms/Button';
|
||||||
import { Chip } from '../../atoms/Chip';
|
import { Chip } from '../../atoms/Chip';
|
||||||
import { IconButton } from '../../atoms/IconButton';
|
import { IconButton } from '../../atoms/IconButton';
|
||||||
|
import { Link } from '../../atoms/Link';
|
||||||
import { Switch } from '../../atoms/Switch';
|
import { Switch } from '../../atoms/Switch';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
import { Divider } from '../../atoms/Divider';
|
import { Divider } from '../../atoms/Divider';
|
||||||
@@ -253,6 +267,13 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
// committed filter value; the draft lives here until the user confirms.
|
// committed filter value; the draft lives here until the user confirms.
|
||||||
const [searchDraft, setSearchDraft] = React.useState('');
|
const [searchDraft, setSearchDraft] = React.useState('');
|
||||||
|
|
||||||
|
// ─── Mobile map-first plumbing ───
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const mapRef = React.useRef<ProviderMapHandle>(null);
|
||||||
|
const [mapActive, setMapActive] = React.useState<ProviderMapActiveState | null>(null);
|
||||||
|
const showMobileMapLayout = isMobile && viewMode === 'map';
|
||||||
|
|
||||||
const commitSearch = (next: string) => {
|
const commitSearch = (next: string) => {
|
||||||
const trimmed = next.trim();
|
const trimmed = next.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
@@ -310,6 +331,557 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
onFilterChange({ ...filterValues, funeralTypes: next });
|
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 ── */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||||
|
Location
|
||||||
|
</Typography>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
const last = newValue[newValue.length - 1] ?? '';
|
||||||
|
onSearchChange(typeof last === 'string' ? last : '');
|
||||||
|
}}
|
||||||
|
options={[]}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
|
||||||
|
size="small"
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment: (
|
||||||
|
<>
|
||||||
|
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
||||||
|
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 18 }} />
|
||||||
|
</InputAdornment>
|
||||||
|
{params.InputProps.startAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Service tradition ── */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||||
|
Service tradition
|
||||||
|
</Typography>
|
||||||
|
<Autocomplete
|
||||||
|
value={filterValues.tradition}
|
||||||
|
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
|
||||||
|
options={traditionOptions}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} placeholder="Search traditions..." size="small" />
|
||||||
|
)}
|
||||||
|
clearOnEscape
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Funeral type ── */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||||
|
Funeral type
|
||||||
|
</Typography>
|
||||||
|
<Box sx={chipWrapSx}>
|
||||||
|
{funeralTypeOptions.map((option) => (
|
||||||
|
<Chip
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
selected={filterValues.funeralTypes.includes(option.value)}
|
||||||
|
onClick={() => handleFuneralTypeToggle(option.value)}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Provider features ── */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={filterValues.verifiedOnly}
|
||||||
|
onChange={(_, checked) => onFilterChange({ ...filterValues, verifiedOnly: checked })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Verified providers only"
|
||||||
|
sx={{ mx: 0 }}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={filterValues.onlineArrangements}
|
||||||
|
onChange={(_, checked) =>
|
||||||
|
onFilterChange({ ...filterValues, onlineArrangements: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Online arrangements available"
|
||||||
|
sx={{ mx: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Price range ── */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
||||||
|
Price range
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ px: 2.5, mb: 1 }}>
|
||||||
|
<Slider
|
||||||
|
value={filterValues.priceRange}
|
||||||
|
onChange={(_, newValue) =>
|
||||||
|
onFilterChange({
|
||||||
|
...filterValues,
|
||||||
|
priceRange: newValue as [number, number],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={minPrice}
|
||||||
|
max={maxPrice}
|
||||||
|
step={100}
|
||||||
|
valueLabelDisplay="auto"
|
||||||
|
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={priceMinInput}
|
||||||
|
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
onBlur={commitPriceRange}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
inputMode: 'numeric',
|
||||||
|
'aria-label': 'Minimum price',
|
||||||
|
style: { padding: '6px 0' },
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
–
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={priceMaxInput}
|
||||||
|
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
onBlur={commitPriceRange}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
inputMode: 'numeric',
|
||||||
|
'aria-label': 'Maximum price',
|
||||||
|
style: { padding: '6px 0' },
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{navigation}
|
||||||
|
|
||||||
|
<Box component="main" sx={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
||||||
|
{/* Full-bleed map */}
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, display: 'flex' }}>
|
||||||
|
<ProviderMap
|
||||||
|
ref={mapRef}
|
||||||
|
providers={providers}
|
||||||
|
onSelectProvider={onSelectProvider}
|
||||||
|
externalisePopups
|
||||||
|
onActiveChange={setMapActive}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Floating control strip */}
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 2,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: 'var(--fa-shadow-md)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search input (mobile variant reuses the committed-chip pattern) */}
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
options={[]}
|
||||||
|
forcePopupIcon={false}
|
||||||
|
clearIcon={null}
|
||||||
|
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
||||||
|
inputValue={searchDraft}
|
||||||
|
onInputChange={(_, newDraft, reason) => {
|
||||||
|
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 <Chip key={key} label={option} size="small" {...chipProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
|
||||||
|
size="small"
|
||||||
|
inputProps={{
|
||||||
|
...params.inputProps,
|
||||||
|
'aria-label': 'Search providers by town or suburb',
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
startAdornment: (
|
||||||
|
<>
|
||||||
|
<InputAdornment position="start" sx={{ ml: 0.5, mr: 0.5 }}>
|
||||||
|
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
|
||||||
|
</InputAdornment>
|
||||||
|
{params.InputProps.startAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end" sx={{ mr: 0.5 }}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Search"
|
||||||
|
onClick={() => commitSearch(searchDraft)}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
'&:hover': { bgcolor: 'primary.dark' },
|
||||||
|
'&:focus-visible': { outline: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root.Mui-focused': {
|
||||||
|
boxShadow: 'none',
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'var(--fa-color-neutral-300)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Control row: Filters, Sort, view toggle */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<FilterPanel
|
||||||
|
activeCount={activeCount}
|
||||||
|
onClear={handleClear}
|
||||||
|
sx={{ '& .MuiButton-root:focus-visible': { outline: 'none' } }}
|
||||||
|
>
|
||||||
|
{filterDialogChildren}
|
||||||
|
</FilterPanel>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
startIcon={<SwapVertIcon sx={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => setSortAnchor(e.currentTarget)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-label={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}`}
|
||||||
|
sx={{ textTransform: 'none', '&:focus-visible': { outline: 'none' } }}
|
||||||
|
>
|
||||||
|
<Box component="span" sx={{ color: 'text.secondary', fontWeight: 400, mr: 0.5 }}>
|
||||||
|
Sort:
|
||||||
|
</Box>
|
||||||
|
{SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={sortAnchor}
|
||||||
|
open={Boolean(sortAnchor)}
|
||||||
|
onClose={() => setSortAnchor(null)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
{SORT_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem
|
||||||
|
key={opt.value}
|
||||||
|
selected={opt.value === sortBy}
|
||||||
|
onClick={() => {
|
||||||
|
onSortChange?.(opt.value);
|
||||||
|
setSortAnchor(null);
|
||||||
|
}}
|
||||||
|
sx={{ fontSize: '0.813rem' }}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* View toggle — icon-only on mobile to keep the row tight */}
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={viewMode}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, val) => 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)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="list" aria-label="List view">
|
||||||
|
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="map" aria-label="Map view">
|
||||||
|
<MapOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* 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',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
boxShadow: 'var(--fa-shadow-lg)',
|
||||||
|
transform: drawerOpen ? 'translateY(0)' : 'translateY(100%)',
|
||||||
|
opacity: mapActive?.exiting ? 0 : 1,
|
||||||
|
transition: 'transform 220ms ease-out, opacity 180ms ease-out',
|
||||||
|
pointerEvents: drawerOpen ? 'auto' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close X */}
|
||||||
|
<IconButton
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={() => 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' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{/* Single-provider drawer content — entire card clickable */}
|
||||||
|
{drawerProvider && (
|
||||||
|
<Box sx={{ p: 1.5 }}>
|
||||||
|
<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.`}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cluster list drawer content — tap row to drill in */}
|
||||||
|
{drawerCluster && !drawerProvider && (
|
||||||
|
<Box sx={{ pt: 5, pb: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="labelLg"
|
||||||
|
sx={{ px: 2, pb: 1, color: 'text.secondary', display: 'block' }}
|
||||||
|
>
|
||||||
|
{drawerCluster.providers.length} providers in this area
|
||||||
|
</Typography>
|
||||||
|
<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,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
'&:last-of-type': { borderBottom: 'none' },
|
||||||
|
'&:hover': { bgcolor: 'var(--fa-color-surface-subtle)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Verified-icon slot (aligns all names) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 18,
|
||||||
|
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>
|
||||||
|
</ButtonBase>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Sticky help bar (matches WizardLayout) */}
|
||||||
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
py: 1.5,
|
||||||
|
px: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary" component="span">
|
||||||
|
<PhoneIcon sx={{ fontSize: 16, verticalAlign: 'text-bottom', mr: 0.5 }} />
|
||||||
|
Need help? Call us on{' '}
|
||||||
|
<Link href="tel:1800987888" sx={{ fontWeight: 600 }}>
|
||||||
|
1800 987 888
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Desktop + mobile-list layout ──────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WizardLayout
|
<WizardLayout
|
||||||
variant="list-map"
|
variant="list-map"
|
||||||
@@ -519,177 +1091,7 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
sx={{ '& .MuiButton-root:focus-visible': { outline: 'none' } }}
|
sx={{ '& .MuiButton-root:focus-visible': { outline: 'none' } }}
|
||||||
>
|
>
|
||||||
{/* ── Location ── */}
|
{filterDialogChildren}
|
||||||
<Box>
|
|
||||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
||||||
Location
|
|
||||||
</Typography>
|
|
||||||
<Autocomplete
|
|
||||||
multiple
|
|
||||||
freeSolo
|
|
||||||
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
|
||||||
onChange={(_, newValue) => {
|
|
||||||
// Take the last entered value as the active search
|
|
||||||
const last = newValue[newValue.length - 1] ?? '';
|
|
||||||
onSearchChange(typeof last === 'string' ? last : '');
|
|
||||||
}}
|
|
||||||
options={[]}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
|
|
||||||
size="small"
|
|
||||||
InputProps={{
|
|
||||||
...params.InputProps,
|
|
||||||
startAdornment: (
|
|
||||||
<>
|
|
||||||
<InputAdornment position="start" sx={{ ml: 0.5 }}>
|
|
||||||
<LocationOnOutlinedIcon
|
|
||||||
sx={{ color: 'text.secondary', fontSize: 18 }}
|
|
||||||
/>
|
|
||||||
</InputAdornment>
|
|
||||||
{params.InputProps.startAdornment}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* ── Service tradition ── */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
||||||
Service tradition
|
|
||||||
</Typography>
|
|
||||||
<Autocomplete
|
|
||||||
value={filterValues.tradition}
|
|
||||||
onChange={(_, newValue) => onFilterChange({ ...filterValues, tradition: newValue })}
|
|
||||||
options={traditionOptions}
|
|
||||||
renderInput={(params) => (
|
|
||||||
<TextField {...params} placeholder="Search traditions..." size="small" />
|
|
||||||
)}
|
|
||||||
clearOnEscape
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* ── Funeral type ── */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
||||||
Funeral type
|
|
||||||
</Typography>
|
|
||||||
<Box sx={chipWrapSx}>
|
|
||||||
{funeralTypeOptions.map((option) => (
|
|
||||||
<Chip
|
|
||||||
key={option.value}
|
|
||||||
label={option.label}
|
|
||||||
selected={filterValues.funeralTypes.includes(option.value)}
|
|
||||||
onClick={() => handleFuneralTypeToggle(option.value)}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* ── Provider features ── */}
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={filterValues.verifiedOnly}
|
|
||||||
onChange={(_, checked) =>
|
|
||||||
onFilterChange({ ...filterValues, verifiedOnly: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Verified providers only"
|
|
||||||
sx={{ mx: 0 }}
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={filterValues.onlineArrangements}
|
|
||||||
onChange={(_, checked) =>
|
|
||||||
onFilterChange({ ...filterValues, onlineArrangements: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Online arrangements available"
|
|
||||||
sx={{ mx: 0 }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* ── Price range ── */}
|
|
||||||
<Box>
|
|
||||||
<Typography variant="labelLg" sx={sectionHeadingSx}>
|
|
||||||
Price range
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ px: 2.5, mb: 1 }}>
|
|
||||||
<Slider
|
|
||||||
value={filterValues.priceRange}
|
|
||||||
onChange={(_, newValue) =>
|
|
||||||
onFilterChange({
|
|
||||||
...filterValues,
|
|
||||||
priceRange: newValue as [number, number],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
min={minPrice}
|
|
||||||
max={maxPrice}
|
|
||||||
step={100}
|
|
||||||
valueLabelDisplay="auto"
|
|
||||||
valueLabelFormat={(v) => `$${v.toLocaleString('en-AU')}`}
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
value={priceMinInput}
|
|
||||||
onChange={(e) => setPriceMinInput(e.target.value.replace(/[^0-9]/g, ''))}
|
|
||||||
onBlur={commitPriceRange}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
|
||||||
}}
|
|
||||||
inputProps={{
|
|
||||||
inputMode: 'numeric',
|
|
||||||
'aria-label': 'Minimum price',
|
|
||||||
style: { padding: '6px 0' },
|
|
||||||
}}
|
|
||||||
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
–
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
value={priceMaxInput}
|
|
||||||
onChange={(e) => setPriceMaxInput(e.target.value.replace(/[^0-9]/g, ''))}
|
|
||||||
onBlur={commitPriceRange}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && commitPriceRange()}
|
|
||||||
InputProps={{
|
|
||||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
|
||||||
}}
|
|
||||||
inputProps={{
|
|
||||||
inputMode: 'numeric',
|
|
||||||
'aria-label': 'Maximum price',
|
|
||||||
style: { padding: '6px 0' },
|
|
||||||
}}
|
|
||||||
sx={{ flex: 1, '& .MuiOutlinedInput-root': { fontSize: '0.875rem' } }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
{/* Sort — compact menu button, pushed right */}
|
{/* Sort — compact menu button, pushed right */}
|
||||||
@@ -731,6 +1133,38 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Mobile-only view toggle — switches to the map-first layout */}
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={viewMode}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, val) => 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)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="list" aria-label="List view">
|
||||||
|
<ViewListOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="map" aria-label="Map view">
|
||||||
|
<MapOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Results count — below controls */}
|
{/* Results count — below controls */}
|
||||||
|
|||||||
Reference in New Issue
Block a user