From b52d180347aaf21b5e654f3d28cedd0ad84921b3 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:52:38 +1000 Subject: [PATCH 01/12] refactor map page to TSX and split into focused modules - Extract map concerns into hooks: useMapListingUrl (URL sync + optimistic pin selection to fix tap flicker), useListingsInView (debounced, cancellable, padded viewport fetch that preserves prior pins), useMapCenter (centralised fly-to rules), useIpInitialLocation, and useMapDrawerScroll. - Split MapPageClient into MapPageClient + MapListingDrawer and MapImmersive into MapImmersive + MapPinLayer; move shared types and helpers into src/utils/mapUtils.ts. - Convert MapPageClient, MapImmersive, MapPin, MapSearch, and MapSidebar (plus their barrels) from JSX to TSX with typed props. - Strip extraneous console logs and add i18n strings for the return-to- listing button and the pin-loading chip. Made-with: Cursor --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + src/app/actions.ts | 1 - src/components/MapImmersive/MapImmersive.jsx | 412 ---------- src/components/MapImmersive/MapImmersive.tsx | 298 +++++++ src/components/MapImmersive/MapPinLayer.tsx | 59 ++ .../MapImmersive/{index.js => index.ts} | 0 .../MapPageClient/MapListingDrawer.tsx | 316 +++++++ .../MapPageClient/MapPageClient.jsx | 777 ------------------ .../MapPageClient/MapPageClient.tsx | 249 ++++++ .../MapPageClient/{index.js => index.ts} | 0 .../MapPin/{MapPin.jsx => MapPin.tsx} | 121 ++- src/components/MapPin/{index.js => index.ts} | 0 .../{MapSearch.jsx => MapSearch.tsx} | 63 +- .../MapSearch/{index.js => index.ts} | 0 .../{MapSidebar.jsx => MapSidebar.tsx} | 43 +- .../MapSidebar/{index.js => index.ts} | 0 src/hooks/useIpInitialLocation.ts | 75 ++ src/hooks/useListingsInView.ts | 112 +++ src/hooks/useMapCenter.ts | 153 ++++ src/hooks/useMapDrawerScroll.ts | 83 ++ src/hooks/useMapListingUrl.ts | 200 +++++ src/utils/mapUtils.ts | 105 +++ 24 files changed, 1776 insertions(+), 1297 deletions(-) delete mode 100644 src/components/MapImmersive/MapImmersive.jsx create mode 100644 src/components/MapImmersive/MapImmersive.tsx create mode 100644 src/components/MapImmersive/MapPinLayer.tsx rename src/components/MapImmersive/{index.js => index.ts} (100%) create mode 100644 src/components/MapPageClient/MapListingDrawer.tsx delete mode 100644 src/components/MapPageClient/MapPageClient.jsx create mode 100644 src/components/MapPageClient/MapPageClient.tsx rename src/components/MapPageClient/{index.js => index.ts} (100%) rename src/components/MapPin/{MapPin.jsx => MapPin.tsx} (75%) rename src/components/MapPin/{index.js => index.ts} (100%) rename src/components/MapSearch/{MapSearch.jsx => MapSearch.tsx} (52%) rename src/components/MapSearch/{index.js => index.ts} (100%) rename src/components/MapSidebar/{MapSidebar.jsx => MapSidebar.tsx} (83%) rename src/components/MapSidebar/{index.js => index.ts} (100%) create mode 100644 src/hooks/useIpInitialLocation.ts create mode 100644 src/hooks/useListingsInView.ts create mode 100644 src/hooks/useMapCenter.ts create mode 100644 src/hooks/useMapDrawerScroll.ts create mode 100644 src/hooks/useMapListingUrl.ts create mode 100644 src/utils/mapUtils.ts diff --git a/messages/de.json b/messages/de.json index 31f71712..dbe374f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -378,6 +378,8 @@ "searchPlaceholder": "Suchen", "searchError": "Etwas ist schiefgelaufen. Erneut versuchen?", "searchNoResults": "Keine Ergebnisse. Tippe weiter oder verfeinere deine Suche", + "returnToListing": "Zurück zum Eintrag", + "loadingPins": "Wird geladen…", "didYouKnow": "Wusstest du schon?", "steps": { "find": { diff --git a/messages/en.json b/messages/en.json index a3e8cfbc..7d6c4beb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -378,6 +378,8 @@ "searchPlaceholder": "Search", "searchError": "Something went wrong. Try again?", "searchNoResults": "No results. Keep typing or refine your search", + "returnToListing": "Return to listing", + "loadingPins": "Loading…", "didYouKnow": "Did you know?", "steps": { "find": { diff --git a/messages/es.json b/messages/es.json index 0efbfa31..3f7cb965 100644 --- a/messages/es.json +++ b/messages/es.json @@ -378,6 +378,8 @@ "searchPlaceholder": "Buscar", "searchError": "Algo salió mal. ¿Intentarlo de nuevo?", "searchNoResults": "Sin resultados. Sigue escribiendo o ajusta tu búsqueda", + "returnToListing": "Volver al anuncio", + "loadingPins": "Cargando…", "didYouKnow": "¿Sabías que?", "steps": { "find": { diff --git a/src/app/actions.ts b/src/app/actions.ts index 7a521b73..f9c2141c 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -494,7 +494,6 @@ export async function fetchListingsInView( return []; } - console.log(`Successfully fetched ${data?.length || 0} listings`); return data || []; } catch (error) { console.error("Fatal error in fetchListingsInView:", { diff --git a/src/components/MapImmersive/MapImmersive.jsx b/src/components/MapImmersive/MapImmersive.jsx deleted file mode 100644 index 47af789b..00000000 --- a/src/components/MapImmersive/MapImmersive.jsx +++ /dev/null @@ -1,412 +0,0 @@ -"use client"; -import { useEffect, useState, useCallback, useRef } from "react"; - -import Map, { - Marker, - NavigationControl, - AttributionControl, - GeolocateControl, -} from "react-map-gl/maplibre"; -import maplibregl from "maplibre-gl"; -import "maplibre-gl/dist/maplibre-gl.css"; -import { Protocol } from "pmtiles"; -import layers from "protomaps-themes-base"; - -import LoadingSpinner from "@/components/LoadingSpinner"; -import MapPin from "@/components/MapPin"; -import MapSearch from "@/components/MapSearch"; -import Button from "@/components/Button"; - -import { styled } from "@pigment-css/react"; - -const snapPoints = ["148px", "355px", 1]; - -const ReturnToListingButton = styled(Button)({ - position: "absolute", - top: "20px", - left: "50%", - transform: "translateX(-50%)", - zIndex: 1, // Could be interfering with drawer scroll, try setting to 0 - - "@media (min-width: 768px)": { - top: "auto", - bottom: "20px", - }, -}); - -const attributionControlMobileStyle = { - // Optically position attribution control above-right of TabBar - marginRight: `calc(clamp(var(--spacing-tabBar-marginX), calc(((100vw - var(--spacing-tabBar-maxWidth)) / 2)), 100vw) + 4px)`, - marginBottom: "5.25rem", // Place above bottom TabBar - opacity: 0.875, -}; -const attributionControlDesktopStyle = { - opacity: 1, - marginRight: "10px", - marginBottom: "10px", -}; - -// Default coordinates for Brisbane, Australia -const DEFAULT_COORDINATES = { - longitude: 153.0322, - latitude: -27.4683, - zoom: 9, -}; - -function getListingCoordinates(listing) { - return listing?.coordinates ?? null; -} - -function hasValidCoordinates(listing) { - const coordinates = getListingCoordinates(listing); - - return ( - listing && - !listing.error && - typeof coordinates?.latitude === "number" && - typeof coordinates?.longitude === "number" && - Number.isFinite(coordinates.latitude) && - Number.isFinite(coordinates.longitude) - ); -} - -export default function MapImmersive({ - mapRef, - searchInputRef, - listings, - selectedListing, - listingSlug, - initialCoordinates, - onBoundsChange, - isLoading, - onMapClick, - onMarkerClick, - onSearchPick, - setMapController, - handleSearchPick, - mapController, - DrawerTrigger, - preventDrawerClose, - selectedPinId, - setSelectedPinId, - isDesktop, - countryCode, -}) { - const selectedListingCoordinates = getListingCoordinates(selectedListing); - const hasAppliedInitialPositionRef = useRef(false); - const centeredListingIdRef = useRef(null); - const [lastKnownPosition, setLastKnownPosition] = useState(null); - const [isListingInView, setIsListingInView] = useState(true); - const hasInitialPosition = - selectedListing || initialCoordinates || lastKnownPosition; - - const [snap, setSnap] = useState(snapPoints[0]); - const [isOpen, setIsOpen] = useState(false); - - const handleOpenChange = (open) => { - console.log("about to open?", open); - - if (open) { - console.log("opening. Resetting snap point"); - setSnap(snapPoints[0]); - } - setIsOpen(open); - }; - - // Initial fetch when map loads - const handleMapLoad = useCallback(() => { - console.log("Map loaded"); - - // If there's a selected listing with valid coords, center on it instead of using IP location - if (hasValidCoordinates(selectedListing)) { - const coordinates = getListingCoordinates(selectedListing); - - mapRef.current?.flyTo({ - center: [coordinates.longitude, coordinates.latitude], - zoom: 12, - duration: 0, - }); - hasAppliedInitialPositionRef.current = true; - centeredListingIdRef.current = selectedListing.id; - } - - const bounds = mapRef.current.getMap().getBounds(); - console.log("Bounds:", bounds); - onBoundsChange(bounds); - }, [onBoundsChange, selectedListing]); - - // Fetch on map move - const handleMapMove = useCallback(() => { - if (!mapRef.current) return; // Add check for mapRef.current so this isn't called when user navigates to a different page. - const map = mapRef.current.getMap(); - if (!map) return; // Add safety check for map object - const bounds = map.getBounds(); - onBoundsChange(bounds); - - // Check if selected listing is in view (only when it has valid coords) - if (hasValidCoordinates(selectedListing)) { - const coordinates = getListingCoordinates(selectedListing); - const isInView = bounds.contains([ - coordinates.longitude, - coordinates.latitude, - ]); - setIsListingInView(isInView); - } - }, [onBoundsChange, selectedListing]); - - const handleFlyToListing = useCallback(() => { - if (!hasValidCoordinates(selectedListing) || !mapRef.current) return; - - const coordinates = getListingCoordinates(selectedListing); - - mapRef.current.flyTo({ - center: [coordinates.longitude, coordinates.latitude], - duration: 1500, - }); - }, [selectedListing]); - - useEffect(() => { - let protocol = new Protocol(); - maplibregl.addProtocol("pmtiles", protocol.tile); - - return () => { - maplibregl.removeProtocol("pmtiles"); - }; - }, []); - - useEffect(() => { - if ( - hasAppliedInitialPositionRef.current || - hasValidCoordinates(selectedListing) || - !initialCoordinates || - !mapRef.current - ) { - return; - } - - hasAppliedInitialPositionRef.current = true; - mapRef.current.flyTo({ - center: [initialCoordinates.longitude, initialCoordinates.latitude], - zoom: initialCoordinates.zoom, - duration: 0, - }); - }, [initialCoordinates, mapRef, selectedListing]); - - useEffect(() => { - if ( - !mapRef.current || - !hasValidCoordinates(selectedListing) || - centeredListingIdRef.current === selectedListing.id - ) { - return; - } - - const coordinates = getListingCoordinates(selectedListing); - const map = mapRef.current.getMap(); - const bounds = map.getBounds(); - const isInView = bounds.contains([ - coordinates.longitude, - coordinates.latitude, - ]); - - if (isInView) { - hasAppliedInitialPositionRef.current = true; - centeredListingIdRef.current = selectedListing.id; - setIsListingInView(true); - return; - } - - mapRef.current.flyTo({ - center: [coordinates.longitude, coordinates.latitude], - zoom: 12, - duration: 900, - }); - hasAppliedInitialPositionRef.current = true; - centeredListingIdRef.current = selectedListing.id; - }, [ - mapRef, - selectedListing, - selectedListingCoordinates?.latitude, - selectedListingCoordinates?.longitude, - ]); - - // Set mapController to set relationship between MapSearch and MapImmersive - // Can't get this to work, perhaps delete all mapController and createMapLibreGlMapController code if I can't get it working - // useEffect(() => { - // if (mapRef.current) return; // stops map from intializing more than once - // setMapController(createMapLibreGlMapController(mapRef.current, maplibregl)); - // }, [onBoundsChange]); - - // TODO: low-priority: IF location is active AND it leaves the bounding box (i.e. user has moved the map), add a button to recenter (and zoom) map on selected listing - - // Update lastKnownPosition when we have a valid position - useEffect(() => { - if (hasValidCoordinates(selectedListing)) { - const coordinates = getListingCoordinates(selectedListing); - - setLastKnownPosition({ - latitude: coordinates.latitude, - longitude: coordinates.longitude, - }); - } else if (initialCoordinates && !lastKnownPosition) { - setLastKnownPosition(initialCoordinates); - } - }, [selectedListing, initialCoordinates]); - - // Check if listing is in view whenever the map moves or selectedListing changes - useEffect(() => { - if (!mapRef.current || !hasValidCoordinates(selectedListing)) { - setIsListingInView(true); - return; - } - - const bounds = mapRef.current.getMap().getBounds(); - const coordinates = getListingCoordinates(selectedListing); - const isInView = bounds.contains([ - coordinates.longitude, - coordinates.latitude, - ]); - console.log("isInView", isInView); - setIsListingInView(isInView); - }, [selectedListing]); - - // Update when selectedListing changes - useEffect(() => { - setSelectedPinId(selectedListing?.id || null); - }, [selectedListing]); - - const handleMapClick = (event) => { - console.log("Map clicked without marker click"); - if (selectedPinId) { - setSelectedPinId(null); // This will update pin visuals immediately - onMapClick(event); // This will handle the drawer closing - } - }; - - return ( -
- {isLoading ? : null} - - {hasInitialPosition && ( - <> - Protomaps', - }, - }, - layers: layers("protomaps", "light"), - }} - renderWorldCopies={true} - initialViewState={{ - longitude: - (hasValidCoordinates(selectedListing) - ? selectedListingCoordinates.longitude - : null) ?? - initialCoordinates?.longitude ?? - DEFAULT_COORDINATES.longitude, - latitude: - (hasValidCoordinates(selectedListing) - ? selectedListingCoordinates.latitude - : null) ?? - initialCoordinates?.latitude ?? - DEFAULT_COORDINATES.latitude, - zoom: selectedListing - ? 8 - : initialCoordinates?.zoom || DEFAULT_COORDINATES.zoom, - }} - animationOptions={{ duration: 200 }} - onMoveEnd={handleMapMove} - onLoad={handleMapLoad} - onClick={handleMapClick} - > - - - - - - {listings - .filter((listing) => hasValidCoordinates(listing)) - .map((listing) => ( - - { - event.originalEvent.stopPropagation(); - setSelectedPinId(listing.id); // Update pin visuals immediately - onMarkerClick(listing.id); // Handle the rest of the selection logic - }} - style={{ - zIndex: selectedPinId === listing.id ? 1 : 0, - }} - > - - - - ))} - - - - - {/* selectedListing purposefully does not clear when returning to listing, as it clashes with the router. So we need to check for listingSlug too */} - {selectedListing && listingSlug && !isListingInView && ( - - Return to listing - - )} - - )} -
- ); -} diff --git a/src/components/MapImmersive/MapImmersive.tsx b/src/components/MapImmersive/MapImmersive.tsx new file mode 100644 index 00000000..e965fbac --- /dev/null +++ b/src/components/MapImmersive/MapImmersive.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import type { ComponentType, Ref } from "react"; + +import Map, { + NavigationControl, + AttributionControl, + GeolocateControl, + type MapRef, + type ViewStateChangeEvent, + type MapLayerMouseEvent, +} from "react-map-gl/maplibre"; +import maplibregl from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { Protocol } from "pmtiles"; +import layers from "protomaps-themes-base"; +import { useTranslations } from "next-intl"; + +import MapSearch from "@/components/MapSearch"; +import Button from "@/components/Button"; +import MapPinLayer from "./MapPinLayer"; + +import { styled } from "@pigment-css/react"; + +import { useMapCenter } from "@/hooks/useMapCenter"; +import { + DEFAULT_COORDINATES, + ZOOM_LEVEL_DEFAULT, + ZOOM_LEVEL_SELECTED, + getListingCoordinates, + hasValidCoordinates, + type ListingCoordinates, + type ListingMarker, + type SelectedListing, +} from "@/utils/mapUtils"; + +type MapImmersiveProps = { + mapRef: Ref; + searchInputRef?: Ref; + listings: ListingMarker[]; + isFetching: boolean; + selectedListing: SelectedListing | null; + selectedListingId: number | null; + listingSlug: string | null; + initialCoordinates: (ListingCoordinates & { zoom: number }) | null; + onBoundsChange: (bounds: maplibregl.LngLatBounds) => void; + onMapClick: () => void; + onMarkerClick: (listing: ListingMarker) => void; + onSearchPick: (event: { feature?: { center?: [number, number] } }) => void; + DrawerTrigger: ComponentType<{ children?: React.ReactNode }>; + isDesktop: boolean; + countryCode: string | null; +}; + +const MapSearchComponent = MapSearch as ComponentType<{ + onPick: (event: { feature?: { center?: [number, number] } }) => void; + searchInputRef?: Ref; + countryCode?: string | null; + style?: React.CSSProperties; +}>; + +const ButtonComponent = Button as ComponentType<{ + onClick?: () => void; + variant?: string; + size?: string; + width?: string; + children?: React.ReactNode; +}>; + +const MapContainer = styled("div")({ + position: "relative", + width: "100%", + height: "100%", + backgroundColor: "lightblue", +}); + +const ReturnToListingButton = styled(ButtonComponent)({ + position: "absolute", + top: "20px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 1, + + "@media (min-width: 768px)": { + top: "auto", + bottom: "20px", + }, +}); + +const LoadingChip = styled("div")(({ theme }) => ({ + position: "absolute", + top: "0.75rem", + right: "0.75rem", + zIndex: 1, + padding: "0.25rem 0.75rem", + borderRadius: "999px", + background: theme.colors.background.top, + boxShadow: "0 1px 6px rgba(0, 0, 0, 0.08)", + fontSize: "0.75rem", + fontWeight: 500, + color: theme.colors.text.secondary, + opacity: 0.9, + pointerEvents: "none", + transition: "opacity 150ms ease", +})); + +const attributionControlMobileStyle: React.CSSProperties = { + marginRight: `calc(clamp(var(--spacing-tabBar-marginX), calc(((100vw - var(--spacing-tabBar-maxWidth)) / 2)), 100vw) + 4px)`, + marginBottom: "5.25rem", + opacity: 0.875, +}; + +const attributionControlDesktopStyle: React.CSSProperties = { + opacity: 1, + marginRight: "10px", + marginBottom: "10px", +}; + +const searchStyle: React.CSSProperties = { + position: "absolute", + top: "0.75rem", + left: "0.75rem", + zIndex: 1, +}; + +// MapLibre style spec — protomaps tiles with the bundled light theme. Kept as +// a factory so the Map receives a stable object only when the key changes. +function buildMapStyle() { + return { + version: 8 as const, + glyphs: + "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf", + sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/light", + sources: { + protomaps: { + type: "vector" as const, + url: `https://api.protomaps.com/tiles/v4.json?key=${process.env.NEXT_PUBLIC_PROTOMAPS_API_KEY}`, + attribution: 'Protomaps', + }, + }, + layers: layers("protomaps", "light", "en"), + }; +} + +function resolveInitialViewState( + selectedListing: SelectedListing | null, + initialCoordinates: (ListingCoordinates & { zoom: number }) | null +) { + const selectedCoords = getListingCoordinates(selectedListing); + const isSelected = + hasValidCoordinates(selectedListing) && selectedCoords !== null; + + return { + longitude: + (isSelected ? selectedCoords!.longitude : undefined) ?? + initialCoordinates?.longitude ?? + DEFAULT_COORDINATES.longitude, + latitude: + (isSelected ? selectedCoords!.latitude : undefined) ?? + initialCoordinates?.latitude ?? + DEFAULT_COORDINATES.latitude, + zoom: isSelected + ? ZOOM_LEVEL_SELECTED + : (initialCoordinates?.zoom ?? ZOOM_LEVEL_DEFAULT), + }; +} + +export default function MapImmersive({ + mapRef, + searchInputRef, + listings, + isFetching, + selectedListing, + selectedListingId, + listingSlug, + initialCoordinates, + onBoundsChange, + onMapClick, + onMarkerClick, + onSearchPick, + DrawerTrigger, + isDesktop, + countryCode, +}: MapImmersiveProps) { + const t = useTranslations("Map"); + const mapRefObject = mapRef as React.RefObject; + + const { isSelectedInView, handleMapLoad, handleMapMoveEnd, flyToSelected } = + useMapCenter({ + mapRef: mapRefObject, + selectedListing, + }); + + const hasInitialPosition = + hasValidCoordinates(selectedListing) || Boolean(initialCoordinates); + + useEffect(() => { + const protocol = new Protocol(); + maplibregl.addProtocol("pmtiles", protocol.tile); + + return () => { + maplibregl.removeProtocol("pmtiles"); + }; + }, []); + + const handleLoad = useCallback(() => { + handleMapLoad(); + const map = mapRefObject.current?.getMap(); + if (map) { + onBoundsChange(map.getBounds()); + } + }, [handleMapLoad, mapRefObject, onBoundsChange]); + + const handleMoveEnd = useCallback( + (_event: ViewStateChangeEvent) => { + const map = mapRefObject.current?.getMap(); + if (!map) return; + onBoundsChange(map.getBounds()); + handleMapMoveEnd(); + }, + [handleMapMoveEnd, mapRefObject, onBoundsChange] + ); + + const handleMapClickInternal = useCallback( + (_event: MapLayerMouseEvent) => { + if (selectedListingId !== null || listingSlug) { + onMapClick(); + } + }, + [listingSlug, onMapClick, selectedListingId] + ); + + const showReturnButton = Boolean( + selectedListing && listingSlug && !isSelectedInView + ); + + return ( + + {hasInitialPosition && ( + <> + + + + + + + + + + + + {isFetching && {t("loadingPins")}} + + {showReturnButton && ( + + {t("returnToListing")} + + )} + + )} + + ); +} diff --git a/src/components/MapImmersive/MapPinLayer.tsx b/src/components/MapImmersive/MapPinLayer.tsx new file mode 100644 index 00000000..daad356f --- /dev/null +++ b/src/components/MapImmersive/MapPinLayer.tsx @@ -0,0 +1,59 @@ +"use client"; + +import type { ComponentType } from "react"; +import { Marker } from "react-map-gl/maplibre"; + +import MapPin from "@/components/MapPin"; +import { + hasValidCoordinates, + type ListingCoordinates, + type ListingMarker, +} from "@/utils/mapUtils"; + +type MapPinLayerProps = { + listings: ListingMarker[]; + selectedListingId: number | null; + DrawerTrigger: ComponentType<{ children?: React.ReactNode }>; + onMarkerClick: (listing: ListingMarker) => void; +}; + +type MapPinType = "business" | "community" | "residential"; + +function toPinType(type: ListingMarker["type"]): MapPinType | undefined { + return typeof type === "string" ? (type as MapPinType) : undefined; +} + +export default function MapPinLayer({ + listings, + selectedListingId, + DrawerTrigger, + onMarkerClick, +}: MapPinLayerProps) { + return ( + <> + {listings + .filter((listing) => hasValidCoordinates(listing)) + .map((listing) => { + const coords = listing.coordinates as ListingCoordinates; + const isSelected = selectedListingId === listing.id; + + return ( + + { + event.originalEvent.stopPropagation(); + onMarkerClick(listing); + }} + style={{ zIndex: isSelected ? 1 : 0 }} + > + + + + ); + })} + + ); +} diff --git a/src/components/MapImmersive/index.js b/src/components/MapImmersive/index.ts similarity index 100% rename from src/components/MapImmersive/index.js rename to src/components/MapImmersive/index.ts diff --git a/src/components/MapPageClient/MapListingDrawer.tsx b/src/components/MapPageClient/MapListingDrawer.tsx new file mode 100644 index 00000000..a63bb242 --- /dev/null +++ b/src/components/MapPageClient/MapListingDrawer.tsx @@ -0,0 +1,316 @@ +"use client"; + +import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; +import { Drawer } from "vaul"; +import { styled } from "@pigment-css/react"; +import { useTranslations } from "next-intl"; + +import ListingRead from "@/components/ListingRead"; +import Button from "@/components/Button"; +import IconButton from "@/components/IconButton"; + +import { + getListingDisplayName, + getListingDisplayType, +} from "@/utils/listingUtils"; +import type { SelectedListing } from "@/utils/mapUtils"; + +type MapListingDrawerProps = { + user: { id: string } | null; + selectedListing: SelectedListing | null; + isDesktop: boolean; + hasTouch: boolean; + isDrawerHeaderShown: boolean; + isFullSnap: boolean; + isPartialSnap: boolean; + onToggleSnap: () => void; + onClose: () => void; + isChatDrawerOpen: boolean; + setIsChatDrawerOpen: (value: boolean) => void; + drawerContentRef: React.MutableRefObject; +}; + +const ListingReadComponent = ListingRead as React.ComponentType<{ + user: unknown; + listing: SelectedListing | null; + presentation?: string; + isChatDrawerOpen?: boolean; + setIsChatDrawerOpen?: (value: boolean) => void; +}>; + +const IconButtonComponent = IconButton as React.ComponentType<{ + icon: string; + onClick?: () => void; + className?: string; +}>; + +const sidebarWidth = "clamp(20rem, 30vw, 30rem)"; + +const sharedButtonStyles = { + pointerEvents: "all" as const, +}; + +const StyledIconButtonAbsolute = styled(IconButtonComponent)({ + ...sharedButtonStyles, + position: "absolute", + right: "0.75rem", +}); + +const StyledIconButtonStationary = styled(IconButtonComponent)({ + ...sharedButtonStyles, + position: "relative", +}); + +const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({ + borderBottom: "none", + borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`, + + position: "fixed", + bottom: "0", + left: "0", + right: "0", + + height: "97%", // Take up full height to prevent awkward drawer pop-ups when minimal content + + background: theme.colors.background.sunk, + + border: `0.5px solid ${theme.colors.border.base}`, + boxShadow: `0px -3px 3px 1px rgba(0, 0, 0, 0.06)`, + + overflowX: "hidden", + + "&::after": { + display: "none", // Otherwise seems to visibly block the drawer content + }, + + "@media (min-width: 768px)": { + background: theme.colors.background.top, + borderRadius: theme.corners.base, + boxShadow: `-3px 0px 3px 1px rgba(0, 0, 0, 0.03)`, + + height: "unset", + top: "24px", + right: "24px", + bottom: "24px", + left: "unset", + outline: "none", + width: sidebarWidth, + }, +})); + +const StyledDrawerHeader = styled("header")({ + flex: 1, + + position: "sticky", + top: "0", + // Create a new stacking context to ensure header content stays above + // avatar whose rotation transform caused a new stacking context + zIndex: 1, + width: "100%", + + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + textAlign: "center", +}); + +const StyledDrawerHeaderInner = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + width: "100%", + + padding: "1rem", + background: theme.colors.background.sunk, + borderBottom: `1px solid ${theme.colors.border.base}`, + boxShadow: `0px 1px 8px 0px ${theme.colors.border.base}`, + + transform: "translateY(-0.5px)", // Avoid clipping on Retina screens + + "@media (min-width: 768px)": { + background: theme.colors.background.slight, + transform: "unset", + }, +})); + +const StyledHeaderText = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "0.25rem", + width: "100%", + padding: "0 2.5rem", // Padding to account for the icon button + + "& h3, p": { + lineHeight: "100%", + overflow: "hidden", + whiteSpace: "nowrap", + display: "block", + textOverflow: "ellipsis", + }, + "& h3": { + fontWeight: "500", + fontSize: "0.85rem", + color: theme.colors.text.secondary, + }, + "& p": { + fontSize: "0.8rem", + color: theme.colors.text.tertiary, + }, +})); + +const StyledDrawerInner = styled("div")({ + width: "100%", + padding: "1rem 0", // Commented out X axis to allow overflow for things like photo x-scroll + paddingTop: "2rem", + marginTop: "-3.5rem", // To account for sticky header + + overflowY: "auto", + overflowX: "hidden", // Prevent horizontal scrolling + + display: "flex", + flexDirection: "column", + gap: "3rem", // Match in ListingRead + marginBottom: "1.5rem", // Visual buffer +}); + +const DrawerHandleContainer = styled("div")({ + position: "absolute", + top: "0.5rem", + left: "50%", + transform: "translateX(-50%)", +}); + +const ButtonSet = styled("div")({ + display: "flex", + flexDirection: "row", + gap: "0.5rem", + position: "absolute", + right: "0.75rem", +}); + +const NoListingFound = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "2rem", + padding: "2rem", + color: theme.colors.text.secondary, + + "& > header": { + display: "flex", + flexDirection: "column", + gap: "0.25rem", + + "& > *": { + textAlign: "center", + textWrap: "balance", + }, + }, +})); + +export default function MapListingDrawer({ + user, + selectedListing, + isDesktop, + hasTouch, + isDrawerHeaderShown, + isFullSnap, + isPartialSnap, + onToggleSnap, + onClose, + isChatDrawerOpen, + setIsChatDrawerOpen, + drawerContentRef, +}: MapListingDrawerProps) { + const t = useTranslations(); + + const showErrorPanel = Boolean(selectedListing?.error); + + return ( + + + + {t("Map.drawerTitle")} + {t("Map.drawerDescription")} + + + + + +

+ {selectedListing + ? getListingDisplayName(selectedListing, user) + : ""} +

+

+ {selectedListing ? getListingDisplayType(selectedListing) : ""} +

+
+
+ + {!isDesktop ? ( + + + + + ) : ( + + )} + + {hasTouch && !isDesktop && ( + + + + )} +
+ + + {showErrorPanel ? ( + +
+

{t("Map.emptyTitle")}

+

{t("Map.emptyBody")}

+
+ +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/src/components/MapPageClient/MapPageClient.jsx b/src/components/MapPageClient/MapPageClient.jsx deleted file mode 100644 index 0db8a6de..00000000 --- a/src/components/MapPageClient/MapPageClient.jsx +++ /dev/null @@ -1,777 +0,0 @@ -"use client"; -import { useState, useCallback, useRef, useEffect } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import { debounce } from "lodash"; - -import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; // TODO: Build own version: https://www.joshwcomeau.com/snippets/react-components/visually-hidden/ -import { Drawer } from "vaul"; -const snapPoints = [0.35, 1]; - -import { createClient } from "@/utils/supabase/client"; - -import { fetchListingsInView } from "@/app/actions"; - -import { config, geolocation } from "@maptiler/client"; - -import MapSearch from "@/components/MapSearch"; -import MapImmersive from "@/components/MapImmersive"; -import ListingRead from "@/components/ListingRead"; - -import Button from "@/components/Button"; -import IconButton from "@/components/IconButton"; -import MapSidebar from "@/components/MapSidebar"; - -import { styled } from "@pigment-css/react"; -import { - getListingDisplayName, - getListingDisplayType, -} from "@/utils/listingUtils"; -import { useDeviceContext } from "@/hooks/useDeviceContext"; -import { useTranslations } from "next-intl"; - -const sidebarWidth = "clamp(20rem, 30vw, 30rem)"; -const pagePadding = "24px"; -const ZOOM_LEVEL_DEFAULT = 7.5; // TODO: 11 is a sensible default once the map is more populated - -// Default coordinates for Brisbane, Australia -// const DEFAULT_COORDINATES = { -// longitude: 153.0322, -// latitude: -27.415, -// zoom: 9, -// }; - -// For IP geolocation API -config.apiKey = process.env.NEXT_PUBLIC_MAPTILER_API_KEY; - -const StyledMapPage = styled("main")(({ theme }) => ({ - flex: 1, - gap: theme.spacing.gap.desktop, - alignItems: "stretch", - display: "flex", - flexDirection: "row", -})); - -const StyledMapWrapper = styled("div")(({ theme }) => ({ - display: "flex", - flexDirection: "column", - gap: "1rem", - flex: 1, - // touchAction: "none", - - // Prepare for tab bar on mobile - height: "100%", - "@media (min-width: 768px)": { - borderRadius: theme.corners.base, - border: `1px solid ${theme.colors.border.base}`, - overflow: "hidden", - }, -})); - -const DrawerHandleContainer = styled("div")(({ theme }) => ({ - position: "absolute", - top: "0.5rem", - left: "50%", - transform: "translateX(-50%)", -})); - -const ButtonSet = styled("div")({ - display: "flex", - flexDirection: "row", - gap: "0.5rem", - position: "absolute", - right: "0.75rem", -}); -const sharedButtonStyles = { - pointerEvents: "all", // Ignore pointer-events: none on parent -}; - -const StyledIconButtonAbsolute = styled(IconButton)({ - ...sharedButtonStyles, - position: "absolute", - right: "0.75rem", -}); - -const StyledIconButtonStationary = styled(IconButton)({ - ...sharedButtonStyles, - position: "relative", -}); - -const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({ - // display: "flex", - // flexDirection: "column", - border: `2px solid ${theme.colors.border.base}`, // border-gray-200 - borderBottom: "none", - borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`, - - position: "fixed", - bottom: "0", - left: "0", - right: "0", - - height: "97%", // Take up full height to prevent awkward drawer pop-ups when minimal content - // maxHeight: "97%", - - // overscrollBehavior: "unset", - // margin: "0 -1px", // mx-[-1px] - - background: theme.colors.background.sunk, - - border: `0.5px solid ${theme.colors.border.base}`, - boxShadow: `0px -3px 3px 1px rgba(0, 0, 0, 0.06)`, - - overflowX: "hidden", - // overflowY: "hidden", // Necessary to focus on the drawer content - - "&::after": { - display: "none", // Otherwise seems to visibly block the drawer content - }, - - "@media (min-width: 768px)": { - background: theme.colors.background.top, - borderRadius: theme.corners.base, - boxShadow: `-3px 0px 3px 1px rgba(0, 0, 0, 0.03)`, - - height: "unset", - top: "24px", - right: "24px", - bottom: "24px", - left: "unset", - outline: "none", - width: sidebarWidth, - }, -})); - -const StyledDrawerHeader = styled("header")({ - // flex justify-between items-center absolute top-0 w-full py-2 px-4 rounded-t-lg - flex: 1, - - position: "sticky", - top: "0", - // Create a new stacking context to ensure header content stays above avatar whose rotation transform caused a new stacking context - zIndex: 1, - width: "100%", - - display: "flex", - // alignItems: "center", - - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - textAlign: "center", - // padding: "0.5rem 1rem", -}); - -const StyledDrawerHeaderInner = styled("div")(({ theme }) => ({ - display: "flex", - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - width: "100%", - - padding: "1rem", - background: theme.colors.background.sunk, - borderBottom: `1px solid ${theme.colors.border.base}`, - boxShadow: `0px 1px 8px 0px ${theme.colors.border.base}`, - // Ensure header content stays above avatar whose rotation transform causes a new stacking context - // position: "relative", - // zIndex: 1, - - transform: "translateY(-0.5px)", // Avoid clipping on Retina screens - - "@media (min-width: 768px)": { - background: theme.colors.background.slight, - transform: "unset", - }, -})); - -const StyledHeaderText = styled("div")(({ theme }) => ({ - display: "flex", - flexDirection: "column", - gap: "0.25rem", - width: "100%", - padding: "0 2.5rem", // Padding to account for the icon button - - "& h3, p": { - lineHeight: "100%", - overflow: "hidden", - whiteSpace: "nowrap", - display: "block", - textOverflow: "ellipsis", - }, - "& h3": { - fontWeight: "500", - fontSize: "0.85rem", - color: theme.colors.text.secondary, - }, - "& p": { - fontSize: "0.8rem", - color: theme.colors.text.tertiary, - }, -})); - -const StyledDrawerInner = styled("div")(({ theme }) => ({ - width: "100%", - // Normal classes - padding: "1rem 0", // Commented out X axis to allow overflow for things like photo x-scroll - paddingTop: "2rem", - marginTop: "-3.5rem", // To account for sticky header - - // Attempts to smooth drawer scroll - // touchAction: "unset !important", - // pointerEvents: "unset !important", - overflowY: "auto", - overflowX: "hidden", // Prevent horizontal scrolling - - // Seems to help with drawer scroll getting stuck, possibly placebo - // overscrollBehavior: "auto", - // touchAction: "pan-y", // Prevents zoom gesture which stuffs up general layout, should be revisted for accessibility - - // Set same flex properties in ListingRead > Column, given these columns should be invisible when drawer - display: "flex", - flexDirection: "column", - gap: "3rem", // Match in ListingRead - marginBottom: "1.5rem", // Visual buffer -})); - -const NoListingFound = styled("div")(({ theme }) => ({ - display: "flex", - flexDirection: "column", - gap: "2rem", - padding: "2rem", - color: theme.colors.text.secondary, - - "& > header": { - display: "flex", - flexDirection: "column", - gap: "0.25rem", - - "& > *": { - textAlign: "center", - textWrap: "balance", - }, - }, -})); - -// export default async function MapPage() { -export default function MapPageClient({ - user, - initialListingSlug, - initialListing, -}) { - const t = useTranslations(); - const mapRef = useRef(null); - const searchInputRef = useRef(null); - const drawerContentRef = useRef(null); - const [initialCoordinates, setInitialCoordinates] = useState(null); - const [countryCode, setCountryCode] = useState(null); - - const searchParams = useSearchParams(); - const router = useRouter(); - const supabase = createClient(); - - const [listings, setListings] = useState([]); - const [selectedListing, setSelectedListing] = useState( - initialListing || null - ); - // Set mapController to set relationship between MapSearch and MapImmersive - const [mapController, setMapController] = useState(); // https://docs.maptiler.com/react/maplibre-gl-js/geocoding-control/ - - const [isLoading, setIsLoading] = useState(true); - // const [isDesktop, setIsDesktop] = useState(false); - const [snap, setSnap] = useState(snapPoints[0]); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const [isChatDrawerOpen, setIsChatDrawerOpen] = useState(false); - const [isDrawerHeaderShown, setIsDrawerHeaderShown] = useState(false); - const [selectedPinId, setSelectedPinId] = useState(null); - const [listingSlug, setListingSlug] = useState(null); - const { isDesktop, hasTouch } = useDeviceContext(); - - useEffect(() => { - if (isDesktop) { - setSnap(snapPoints[1]); - console.log("Viewport is desktop"); - } else { - console.log("Viewport is mobile"); - } - }, [isDesktop]); - - useEffect(() => { - console.log("Managing HTML classes", { hasTouch, snap }); - // const listingSlug = searchParams.get("listing"); - setListingSlug(searchParams.get("listing")); - - if (isDesktop) return; - - // Always add map class for touch devices - document.documentElement.classList.add("map"); - - // Manage drawer-fully-open class based on both snap AND URL state - if (snap === snapPoints[1] && listingSlug) { - document.documentElement.classList.add("drawer-fully-open"); - } else { - document.documentElement.classList.remove("drawer-fully-open"); - } - - // Cleanup on unmount - return () => { - document.documentElement.classList.remove("map"); - document.documentElement.classList.remove("drawer-fully-open"); - }; - }, [snap, isDesktop, searchParams]); // Include all dependencies - - // Load listing from URL param on mount - useEffect(() => { - const listingSlug = searchParams.get("listing"); - if (listingSlug) { - loadListingBySlug(listingSlug); - // If there is a selected listing upon mount, open the drawerc - setIsDrawerOpen(true); - } else { - // Clear selected listing if no slug in URL - // setSelectedListing(null); - // If the user traversed the history back to where there was (possibly) no slug, close the drawer - setIsDrawerOpen(false); - setSelectedPinId(null); - } - }, [searchParams]); // This will run when the URL changes - - // Add this new effect to handle initial location - useEffect(() => { - const listingSlug = searchParams.get("listing"); - // Only fetch IP location if there's no listing in URL - if (!listingSlug) { - // TODO: see if there is location data already set from local storage, and return that first if so - // Perhaps do this on the homepage/first page loaded and then use that data for the map - // And then store that data in local storage for future use in the same session/browser - async function initializeLocation() { - console.log("No listing slug. Initializing location"); - - try { - // Create a timeout promise - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Location timeout")), 3000); - }); - - // Race between the geolocation request and timeout - const response = await Promise.race([ - geolocation.info(), - timeoutPromise, - ]); - - if (response && response.latitude && response.longitude) { - setCountryCode(response.country_code); // Used in MapSearch until proximity feature is fixed - setInitialCoordinates({ - latitude: response.latitude, - longitude: response.longitude, - zoom: ZOOM_LEVEL_DEFAULT, // TODO: Increase zoom when more listings are available. Also in MapImmersive - }); - } - } catch (error) { - console.warn( - "Could not determine location from MapTiler:", - error.message - ); - // No need to set fallback coordinates - MapImmersive will handle that - } - } - - initializeLocation(); - } - }, []); - - const loadListingBySlug = async (slug) => { - // If the slug matches initial data, use that instead of fetching - if (slug === initialListingSlug && initialListing) { - setSelectedListing(initialListing); - return; - } - - try { - const { data, error } = await supabase - .from(user ? "listings_private_data" : "listings_public_data") - .select() - .eq("slug", slug) - .single(); - - if (error) { - setSelectedListing({ - error: true, - message: t("Listings.edit.notFound"), - }); - return; - } - - setSelectedListing(data); - } catch (err) { - console.warn("loadListingBySlug failed:", err); - setSelectedListing({ error: true, message: t("Listings.edit.notFound") }); - } - }; - - const debouncedBoundsChange = useCallback( - debounce(async (bounds) => { - setIsLoading(true); - try { - const data = await fetchListingsInView( - bounds._sw.lat, - bounds._sw.lng, - bounds._ne.lat, - bounds._ne.lng - ); - setListings(data); - } catch (error) { - console.error("Error fetching listings:", error); - } finally { - setIsLoading(false); - } - }, 300), // 300ms delay - [] - ); - - const handleBoundsChange = useCallback( - async (bounds) => { - debouncedBoundsChange(bounds); - }, - [debouncedBoundsChange] - ); - - const handleSnapChange = () => { - console.log("Handling snap change", snap, snapPoints[0]); - if (snap === snapPoints[0]) { - setSnap(snapPoints[1]); - } else { - if (drawerContentRef.current) { - drawerContentRef.current.scrollTop = 0; - } - setSnap(snapPoints[0]); - } - }; - - const handleMarkerClick = async (listingId) => { - // If the clicked marker is already selected AND the drawer is already open, do nothing and return early - if (selectedListing?.id === listingId && isDrawerOpen) { - return; - } - - try { - const { data, error } = await supabase - .from(user ? "listings_private_data" : "listings_public_data") - .select() - .eq("id", listingId) - .single(); - - if (error) { - setSelectedListing({ - error: true, - message: t("Listings.edit.notFound"), - }); - setIsDrawerOpen(true); - setSnap(snapPoints[0]); - return; - } - - // Close the chat drawer if it's open - setIsChatDrawerOpen(false); - setSelectedListing(data); - setIsDrawerOpen(true); - setSnap(snapPoints[0]); - setIsDrawerHeaderShown(false); - - if (drawerContentRef.current) { - drawerContentRef.current.scrollTop = 0; - } - - router.push(`/map?listing=${data.slug}`, { scroll: false }); - } catch (err) { - console.warn("handleMarkerClick failed:", err); - setSelectedListing({ error: true, message: t("Listings.edit.notFound") }); - setIsDrawerOpen(true); - setSnap(snapPoints[0]); - } - }; - - const handleMapClick = () => { - console.log("Map clicked without marker click"); - if (selectedListing) { - handleCloseListing(); - setIsDrawerOpen(false); - setIsChatDrawerOpen(false); - } - }; - - // Mobile scroll listener - useEffect(() => { - if (isDesktop || (!isDesktop && snap !== 1)) { - setIsDrawerHeaderShown(false); - return; - } - - console.log("Setting up mobile scroll listener"); - - const handleScroll = () => { - if (drawerContentRef.current) { - const scrollTop = drawerContentRef.current.scrollTop; - setIsDrawerHeaderShown(scrollTop > 16); // When to show sticky drawer header - } - }; - - if (drawerContentRef.current) { - console.log("Adding mobile scroll listener"); - drawerContentRef.current.addEventListener("scroll", handleScroll); - } else { - console.warn( - "drawerContentRef.current is null for mobile, cannot add scroll listener." - ); - } - - return () => { - if (drawerContentRef.current) { - drawerContentRef.current.removeEventListener("scroll", handleScroll); - } - }; - }, [snap]); // Only depends on snap for mobile - - // Desktop scroll listener - useEffect(() => { - if (isDesktop && isDrawerOpen) { - console.log("Setting up desktop scroll listener"); - - const handleScroll = () => { - if (drawerContentRef.current) { - const scrollTop = drawerContentRef.current.scrollTop; - // console.log("Desktop Scroll position:", scrollTop); - setIsDrawerHeaderShown(scrollTop > 16); - } - }; - - const observer = new MutationObserver(() => { - const drawerContent = drawerContentRef.current; - if (drawerContent) { - drawerContent.addEventListener("scroll", handleScroll); - observer.disconnect(); // Stop observing once the listener is added - } - }); - - // Start observing the drawer content for changes - observer.observe(document.body, { childList: true, subtree: true }); - - return () => { - observer.disconnect(); // Clean up the observer on unmount - }; - } - }, [isDesktop, isDrawerOpen]); // Depends on isDesktop and isDrawerOpen for desktop - - const handleSearchPick = useCallback((event) => { - // console.log("searchInputRef", searchInputRef); - // Quirk in MapTiler's Geocoding component: they consider tapping close an 'onPick - // Return early if that's the case - if (!event.feature?.center) return; - - console.log("Search picked", event); - // Blur the input - // Not needed because the Geocoding component handles this - // Delete ref and prop drilling if I don't end up using it for other reasons - // searchInputRef.current.blur(); - - // Return those new coordinates - const nextCoordinates = { - latitude: event.feature?.center[1], - longitude: event.feature?.center[0], - }; - - console.log("Flying to", nextCoordinates); - mapRef.current?.flyTo({ - center: [nextCoordinates.longitude, nextCoordinates.latitude], - duration: 3200, // TODO: Make this dynamic based on distance from current location - zoom: ZOOM_LEVEL_DEFAULT, // Defaulting to a conservative amount of zoomed-out. TODO: set zoom level dynamically based on how many listings are around the area - }); - }, []); - - const handleCloseListing = useCallback(() => { - console.log("Closing listing"); - setIsDrawerOpen(false); - setIsChatDrawerOpen(false); - setSelectedPinId(null); - setSnap(snapPoints[0]); // Helps to remove conditional CSS class from html - - // setSelectedListing(null); // This is purposefully not set to null, as it clashes with the router, which handles clearing listing state - router.push("/map", { scroll: false, shallow: true }); - }, [router]); - - // Add an effect to handle browser back/forward - useEffect(() => { - // Check URL state whenever searchParams changes - const listingSlug = searchParams.get("listing"); - - if (!listingSlug) { - // No listing in URL, ensure drawer-fully-open is removed - console.log("No listing in URL, removing drawer-fully-open class"); - document.documentElement.classList.remove("drawer-fully-open"); - setSnap(snapPoints[0]); - } - }, [searchParams]); // Only depend on searchParams changes - - // useEffect(() => { - // const handlePopstate = () => { - // // Check if the modal is open on mobile devices - // // Replace the condition with your modal open check logic - // const isModalOpenOnMobile = true; // Replace with your own logic - // if (isModalOpenOnMobile) { - // // Close the modal when navigating using browser's back/forward buttons - // // Implement your own modal close logic here - - // console.log("Removing drawer-fully-open class"); - // document.documentElement.classList.remove("drawer-fully-open"); - // } - // }; - - // window.addEventListener("popstate", handlePopstate); - - // return () => { - // window.removeEventListener("popstate", handlePopstate); - // }; - // }, []); - - return ( - - - { - // console.log("Drawer open change", open); - }} - // onDrag={(drag) => console.log("Drawer drag", drag)} - // onRelease={(release) => { - // console.log("Drawer release", release); - // }} - // scrollLockTimeout={1} // Not sure but seems to make the mobile drawer more responsive - // onAnimationEnd={(event) => { - // console.log("Animation ended", event); - // }} - - // data-vaul-delayed-snap-points={false} // Seems to smooth out some of the snapping but I can't call it - > - - - - - - {t("Map.drawerTitle")} - - {t("Map.drawerDescription")} - - - - - - -

- {getListingDisplayName(selectedListing, user)} -

-

{getListingDisplayType(selectedListing)}

-
-
- - {!isDesktop ? ( - - - - - ) : ( - - )} - - {hasTouch && !isDesktop && ( - - - - )} -
- - {/* Page content */} - - {selectedListing?.error ? ( - -
-

{t("Map.emptyTitle")}

-

{t("Map.emptyBody")}

-
- -
- ) : ( - - )} -
-
-
-
-
- {isDesktop && } -
- ); -} diff --git a/src/components/MapPageClient/MapPageClient.tsx b/src/components/MapPageClient/MapPageClient.tsx new file mode 100644 index 00000000..fa194eee --- /dev/null +++ b/src/components/MapPageClient/MapPageClient.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Drawer } from "vaul"; +import { config } from "@maptiler/client"; +import type { MapRef } from "react-map-gl/maplibre"; +import { styled } from "@pigment-css/react"; + +import MapImmersive from "@/components/MapImmersive"; +import MapSidebar from "@/components/MapSidebar"; +import MapListingDrawer from "./MapListingDrawer"; + +import { useDeviceContext } from "@/hooks/useDeviceContext"; +import { useListingsInView } from "@/hooks/useListingsInView"; +import { useMapListingUrl } from "@/hooks/useMapListingUrl"; +import { useIpInitialLocation } from "@/hooks/useIpInitialLocation"; +import { useMapDrawerScroll } from "@/hooks/useMapDrawerScroll"; +import { + ZOOM_LEVEL_DEFAULT, + type ListingMarker, + type SelectedListing, +} from "@/utils/mapUtils"; + +type MapPageClientProps = { + user: { id: string } | null; + initialListingSlug?: string | null; + initialListing?: SelectedListing | null; +}; + +type GeocodingPickEvent = { + feature?: { center?: [number, number] }; +}; + +const snapPoints: (number | string)[] = [0.35, 1]; + +// For IP geolocation API +config.apiKey = process.env.NEXT_PUBLIC_MAPTILER_API_KEY ?? ""; + +const StyledMapPage = styled("main")(({ theme }) => ({ + flex: 1, + gap: theme.spacing.gap.desktop, + alignItems: "stretch", + display: "flex", + flexDirection: "row", +})); + +const StyledMapWrapper = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "1rem", + flex: 1, + height: "100%", + "@media (min-width: 768px)": { + borderRadius: theme.corners.base, + border: `1px solid ${theme.colors.border.base}`, + overflow: "hidden", + }, +})); + +export default function MapPageClient({ + user, + initialListingSlug, + initialListing, +}: MapPageClientProps) { + const mapRef = useRef(null); + const searchInputRef = useRef(null); + + const { isDesktop, hasTouch } = useDeviceContext(); + + const { + listingSlug, + selectedListing, + selectedListingId, + isListingSelected, + selectListingById, + closeListing, + } = useMapListingUrl({ user, initialListingSlug, initialListing }); + + const { listings, isFetching, requestBounds } = useListingsInView(); + + const { initialCoordinates, countryCode } = useIpInitialLocation({ + skip: Boolean(initialListingSlug), + }); + + const [snap, setSnap] = useState(snapPoints[0]); + const [isChatDrawerOpen, setIsChatDrawerOpen] = useState(false); + + const isFullSnap = snap === snapPoints[1]; + const isPartialSnap = snap === snapPoints[0]; + + const { drawerContentRef, isDrawerHeaderShown, setIsDrawerHeaderShown } = + useMapDrawerScroll({ + isDesktop, + isFullSnap, + isListingSelected, + }); + + // Snap to full on desktop, partial on mobile. + useEffect(() => { + if (isDesktop) { + setSnap(snapPoints[1]); + } + }, [isDesktop]); + + // Manage html classes that other styles hook into. Only on mobile — desktop + // doesn't take over the viewport. + useEffect(() => { + if (isDesktop) return; + + document.documentElement.classList.add("map"); + + if (isFullSnap && listingSlug) { + document.documentElement.classList.add("drawer-fully-open"); + } else { + document.documentElement.classList.remove("drawer-fully-open"); + } + + return () => { + document.documentElement.classList.remove("map"); + document.documentElement.classList.remove("drawer-fully-open"); + }; + }, [isFullSnap, isDesktop, listingSlug]); + + // Keep drawer/html state consistent with URL changes (including browser + // back/forward). Also resets scroll + header when a new listing opens. + useEffect(() => { + if (!listingSlug) { + document.documentElement.classList.remove("drawer-fully-open"); + setSnap(snapPoints[0]); + setIsChatDrawerOpen(false); + } else { + setSnap(snapPoints[0]); + setIsDrawerHeaderShown(false); + if (drawerContentRef.current) { + drawerContentRef.current.scrollTop = 0; + } + } + }, [listingSlug, drawerContentRef, setIsDrawerHeaderShown]); + + const handleSnapChange = useCallback(() => { + setSnap((previous) => { + const next = previous === snapPoints[0] ? snapPoints[1] : snapPoints[0]; + if (next === snapPoints[0] && drawerContentRef.current) { + drawerContentRef.current.scrollTop = 0; + } + return next; + }); + }, [drawerContentRef]); + + const handleMarkerClick = useCallback( + (listing: ListingMarker) => { + if (listing.id === selectedListingId && isListingSelected) return; + + setIsChatDrawerOpen(false); + setIsDrawerHeaderShown(false); + if (drawerContentRef.current) { + drawerContentRef.current.scrollTop = 0; + } + + // Optimistic pin grow + single fetch (selectListingById sets the + // optimistic id synchronously, fetches by id, then pushes the URL). + void selectListingById(listing.id); + }, + [ + isListingSelected, + selectListingById, + selectedListingId, + drawerContentRef, + setIsDrawerHeaderShown, + ] + ); + + const handleMapClick = useCallback(() => { + if (isListingSelected) { + closeListing(); + } + }, [closeListing, isListingSelected]); + + const handleSearchPick = useCallback((event: GeocodingPickEvent) => { + // Quirk in MapTiler's Geocoding component: tapping close is also an + // "onPick" with no center. Ignore those. + const center = event?.feature?.center; + if (!center) return; + + mapRef.current?.flyTo({ + center: [center[0], center[1]], + duration: 3200, + zoom: ZOOM_LEVEL_DEFAULT, + }); + }, []); + + const handleDrawerOpenChange = useCallback(() => { + // Drawer-driven close (e.g. escape key on desktop) should also update + // the URL. + if (isListingSelected) { + closeListing(); + } + }, [closeListing, isListingSelected]); + + return ( + + + + + + + + + {isDesktop && } + + ); +} diff --git a/src/components/MapPageClient/index.js b/src/components/MapPageClient/index.ts similarity index 100% rename from src/components/MapPageClient/index.js rename to src/components/MapPageClient/index.ts diff --git a/src/components/MapPin/MapPin.jsx b/src/components/MapPin/MapPin.tsx similarity index 75% rename from src/components/MapPin/MapPin.jsx rename to src/components/MapPin/MapPin.tsx index ab9a33bf..f9ccd229 100644 --- a/src/components/MapPin/MapPin.jsx +++ b/src/components/MapPin/MapPin.tsx @@ -3,7 +3,17 @@ import MapCommunityIcon from "../MapCommunityIcon"; import MapResidentialIcon from "../MapResidentialIcon"; import { styled } from "@pigment-css/react"; -const UnselectedPin = styled("div")(({ theme }) => ({ +type ListingPinType = "business" | "community" | "residential"; + +type MapPinProps = { + selected?: boolean; + // Accept any string so callers that hold generic listing types (e.g. + // LocationSelect) can still pass them in; unknown values just render the + // default pin without a specialised icon. + type?: string; +}; + +const UnselectedPin = styled("div")({ cursor: "pointer", width: "48px", height: "48px", @@ -11,7 +21,7 @@ const UnselectedPin = styled("div")(({ theme }) => ({ display: "flex", justifyContent: "center", alignItems: "center", -})); +}); const UnselectedPinInner = styled("div")(({ theme }) => ({ boxShadow: `0 0 0 2.5px ${theme.colors.marker.border}`, @@ -50,28 +60,6 @@ const UnselectedPinInner = styled("div")(({ theme }) => ({ ], })); -const SelectedPin = styled("div")(({ theme }) => ({ - display: "flex", - cursor: "pointer", - - // . TODO: is there a way to group these two CSS declarations? - // I.e. so the transition is only declared once - [`& ${SelectedPinDot}`]: { - transition: "transform 75ms ease-in-out", - }, - [`& ${SelectedPinRing}`]: { - transition: "transform 75ms ease-in-out", - }, - "&:hover": { - [`& ${SelectedPinDot}`]: { - transform: "scale(1.05)", - }, - [`& ${SelectedPinRing}`]: { - transform: "scale(1.25)", - }, - }, -})); - const SelectedPinRing = styled("div")(({ theme }) => ({ width: "80px", height: "80px", @@ -90,7 +78,34 @@ const SelectedPinDot = styled("div")(({ theme }) => ({ boxShadow: `0 0 1px 1px ${theme.colors.border.elevated}`, })); -const SelectedPinIcon = styled("svg")(({ theme }) => ({ +const SelectedPin = styled("div")({ + display: "flex", + cursor: "pointer", + + [`& ${SelectedPinDot}`]: { + transition: "transform 75ms ease-in-out", + }, + [`& ${SelectedPinRing}`]: { + transition: "transform 75ms ease-in-out", + }, + "&:hover": { + [`& ${SelectedPinDot}`]: { + transform: "scale(1.05)", + }, + [`& ${SelectedPinRing}`]: { + transform: "scale(1.25)", + }, + }, +}); + +// Cast to `any` for the styled() call because Pigment's `variants` inference +// narrows the shared `type` prop to the literal of the first variant, which +// doesn't reflect what we actually want here (a string union). +const SelectedPinIcon = ( + styled("svg") as unknown as ( + arg: unknown + ) => React.ComponentType & { type?: string }> +)(({ theme }: { theme: any }) => ({ fill: theme.colors.text.ui.emptyState, // Backup fill for when type is not specified stroke: theme.colors.marker.border, strokeWidth: "1.5px", @@ -128,50 +143,25 @@ const SelectedPinIcon = styled("svg")(({ theme }) => ({ ], })); -const SelectedPinVisual = styled("svg")(({ theme }) => ({ - fill: theme.colors.marker.dot, -})); - const ICON = `M18.149 15.8139C18.2078 15.7251 18.2326 15.6533 18.2915 15.5646C19.3387 13.9878 20 12.0412 20 10C20 4.4 15.5 0 10 0C4.5 0 0 4.5 0 10C0 11.8662 0.522404 13.6453 1.40473 15.0937C1.52799 15.296 1.62851 15.5285 1.79602 15.696C1.79734 15.6974 1.79867 15.6987 1.8 15.7C1.90535 15.8054 1.94349 15.9666 2.02739 16.0897C2.18874 16.3264 2.36323 16.5632 2.6 16.8C4.5396 19.1126 7.70356 22.2044 9.18572 23.6258C9.64236 24.0637 10.3577 24.0638 10.8151 23.6266C12.2976 22.2097 15.4607 19.1376 17.4 16.9C17.5711 16.6433 17.8155 16.3866 18.0078 16.1299C18.07 16.0467 18.0918 15.9006 18.149 15.8139Z`; -const pinStyleCoarse = { - backgroundColor: "rgba(0, 0, 255, 0.15)", - borderRadius: "50%", - // width: "200px", - // height: "200px", - display: "flex", - justifyContent: "center", - alignItems: "center", +const iconMap: Record< + ListingPinType, + React.ComponentType<{ size?: string }> +> = { + business: MapBusinessIcon as React.ComponentType<{ size?: string }>, + community: MapCommunityIcon as React.ComponentType<{ size?: string }>, + residential: MapResidentialIcon as React.ComponentType<{ size?: string }>, }; -const iconMap = { - business: MapBusinessIcon, - community: MapCommunityIcon, - residential: MapResidentialIcon, -}; - -function MapPin({ - selected = false, - type, - zoomLevel = null, - distanceAcrossMapWidth = 0, - mapWidth = 0, -}) { - // console.log("zoomLevel", zoomLevel); - const basicSize = 2 ** (zoomLevel * 0.565); - // const size = 1000 / (1 + Math.exp(-10 * (zoomLevel - 10))); - // const size = 100 * zoomLevel ** 0.5; - - // at 14 zoom level, size is 20 - //at 22 zoom level, size is 100 - - const km = 0.5; - const smartSize = (mapWidth / distanceAcrossMapWidth) * km; - - // console.log("size", smartSize, "zoomLevel", zoomLevel); - // console.log(distanceAcrossMapWidth, mapWidth, { smartSize }); +function isListingPinType(value: string | undefined): value is ListingPinType { + return ( + value === "business" || value === "community" || value === "residential" + ); +} - const IconComponent = type && iconMap[type]; +function MapPin({ selected = false, type }: MapPinProps) { + const IconComponent = isListingPinType(type) ? iconMap[type] : null; if (selected) { return ( @@ -190,10 +180,11 @@ function MapPin({ return ( - + {IconComponent && } ); } + export default MapPin; diff --git a/src/components/MapPin/index.js b/src/components/MapPin/index.ts similarity index 100% rename from src/components/MapPin/index.js rename to src/components/MapPin/index.ts diff --git a/src/components/MapSearch/MapSearch.jsx b/src/components/MapSearch/MapSearch.tsx similarity index 52% rename from src/components/MapSearch/MapSearch.jsx rename to src/components/MapSearch/MapSearch.tsx index 07bb53d5..61fa8364 100644 --- a/src/components/MapSearch/MapSearch.jsx +++ b/src/components/MapSearch/MapSearch.tsx @@ -1,42 +1,61 @@ "use client"; -import { useCallback, useEffect, useState, useRef } from "react"; + +import type { ComponentType, CSSProperties, Ref } from "react"; import { GeocodingControl } from "@maptiler/geocoding-control/react"; import "@maptiler/geocoding-control/style.css"; // TODO REMOVE (TURN ON AND OFF TO PREVIEW STYLES) import { useTranslations } from "next-intl"; +type GeocodingPickEvent = { + feature?: { center?: [number, number] }; +}; + +type MapSearchProps = { + onPick: (event: GeocodingPickEvent) => void; + searchInputRef?: Ref; + countryCode?: string | null; + style?: CSSProperties; +}; + +const GeocodingControlComponent = GeocodingControl as ComponentType<{ + clearOnBlur?: boolean; + collapsed?: boolean; + ref?: Ref; + debounceSearch?: number; + apiKey?: string; + proximity?: { type: string }[]; + country?: string | null; + types?: string[]; + minLength?: number; + placeholder?: string; + errorMessage?: string; + noResultsMessage?: string; + onPick?: (event: GeocodingPickEvent) => void; +}>; + // TODO: Add a 'required' prop for forms that require a location function MapSearch({ onPick, - mapController, searchInputRef, countryCode, - ...props -}) { + style, +}: MapSearchProps) { const t = useTranslations("Map"); return ( -
- + { + onPick={(event: GeocodingPickEvent) => { onPick(event); }} - // flyToSelected={true} />
); diff --git a/src/components/MapSearch/index.js b/src/components/MapSearch/index.ts similarity index 100% rename from src/components/MapSearch/index.js rename to src/components/MapSearch/index.ts diff --git a/src/components/MapSidebar/MapSidebar.jsx b/src/components/MapSidebar/MapSidebar.tsx similarity index 83% rename from src/components/MapSidebar/MapSidebar.jsx rename to src/components/MapSidebar/MapSidebar.tsx index 70f526cb..b545942b 100644 --- a/src/components/MapSidebar/MapSidebar.jsx +++ b/src/components/MapSidebar/MapSidebar.tsx @@ -1,10 +1,22 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { styled } from "@pigment-css/react"; -import { facts } from "@/data/facts"; import { useTranslations } from "next-intl"; + +import { facts } from "@/data/facts"; + +type MapSidebarProps = { + user: { id: string } | null | undefined; + covered: boolean; +}; + +type Fact = { + fact: string; + source?: string; +}; + const sidebarWidth = "clamp(20rem, 30vw, 30rem);"; const StyledSidebar = styled("div")(({ theme }) => ({ @@ -26,7 +38,7 @@ const StyledSidebar = styled("div")(({ theme }) => ({ overflowY: "hidden", })); -const Fact = styled("div")(({ theme }) => ({ +const FactBlock = styled("div")(({ theme }) => ({ display: "flex", flexDirection: "column", gap: "1rem", @@ -106,16 +118,17 @@ const StepList = styled("ol")(({ theme }) => ({ leadingTrim: "both", fontSize: "1em", fontWeight: "700", - // Use same override as StepHeader in PeelsHowItWorks - // Override the oldstyle numbers just in this case, since that was affecting optical alignment + // Override the oldstyle numbers just in this case, since that was + // affecting optical alignment fontVariantNumeric: "lining-nums", }, }, })); -export default function MapSidebar({ user, covered }) { +export default function MapSidebar({ user, covered }: MapSidebarProps) { const t = useTranslations(); - const [randomFact, setRandomFact] = useState(null); + const [randomFact, setRandomFact] = useState(null); + const steps = [ { title: t("Map.steps.find.title"), @@ -132,7 +145,6 @@ export default function MapSidebar({ user, covered }) { ]; useEffect(() => { - // Only generate a random fact if there is NO selected listing, not when one is opened if (!covered) { setRandomFact(facts[Math.floor(Math.random() * facts.length)]); } @@ -140,15 +152,8 @@ export default function MapSidebar({ user, covered }) { return ( - {/* */} {user && randomFact && ( - // TODO - // If user has sent >0 messages, show a fun composting fact - // Otherwise show the fundamentals (1, 2, 3) of Peels - +

{t("Map.didYouKnow")}

{randomFact.fact}

{randomFact.source && ( @@ -160,12 +165,12 @@ export default function MapSidebar({ user, covered }) {

)} -
+ )} {!user && ( - {steps.map((step, index) => ( -
  • + {steps.map((step) => ( +
  • {step.title}

    {step.description}

  • diff --git a/src/components/MapSidebar/index.js b/src/components/MapSidebar/index.ts similarity index 100% rename from src/components/MapSidebar/index.js rename to src/components/MapSidebar/index.ts diff --git a/src/hooks/useIpInitialLocation.ts b/src/hooks/useIpInitialLocation.ts new file mode 100644 index 00000000..2816f6c5 --- /dev/null +++ b/src/hooks/useIpInitialLocation.ts @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { geolocation } from "@maptiler/client"; + +import { ZOOM_LEVEL_DEFAULT, type ListingCoordinates } from "@/utils/mapUtils"; + +type UseIpInitialLocationArgs = { + // Skip when the page already has a listing slug (deep-linked selections + // centre on the listing instead of the user's IP location). + skip?: boolean; +}; + +type UseIpInitialLocationResult = { + initialCoordinates: (ListingCoordinates & { zoom: number }) | null; + countryCode: string | null; +}; + +// One-time IP-based initial centre. MapImmersive falls back to +// DEFAULT_COORDINATES if this fails or is skipped. +export function useIpInitialLocation({ + skip = false, +}: UseIpInitialLocationArgs = {}): UseIpInitialLocationResult { + const [initialCoordinates, setInitialCoordinates] = useState< + (ListingCoordinates & { zoom: number }) | null + >(null); + const [countryCode, setCountryCode] = useState(null); + + useEffect(() => { + if (skip) return; + + let cancelled = false; + + async function initializeLocation() { + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Location timeout")), 3000); + }); + + const response = (await Promise.race([ + geolocation.info(), + timeoutPromise, + ])) as { + latitude?: number; + longitude?: number; + country_code?: string; + }; + + if (cancelled) return; + + if (response?.latitude && response?.longitude) { + setCountryCode(response.country_code ?? null); + setInitialCoordinates({ + latitude: response.latitude, + longitude: response.longitude, + zoom: ZOOM_LEVEL_DEFAULT, + }); + } + } catch (error) { + console.warn( + "Could not determine location from MapTiler:", + (error as Error).message + ); + } + } + + initializeLocation(); + + return () => { + cancelled = true; + }; + }, [skip]); + + return { initialCoordinates, countryCode }; +} diff --git a/src/hooks/useListingsInView.ts b/src/hooks/useListingsInView.ts new file mode 100644 index 00000000..8d236163 --- /dev/null +++ b/src/hooks/useListingsInView.ts @@ -0,0 +1,112 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { LngLatBounds } from "maplibre-gl"; + +import { fetchListingsInView } from "@/app/actions"; +import { padBounds, type ListingMarker } from "@/utils/mapUtils"; + +const DEBOUNCE_MS = 150; +const VIEWPORT_PAD_FACTOR = 0.3; + +type UseListingsInViewResult = { + listings: ListingMarker[]; + isFetching: boolean; + requestBounds: (bounds: LngLatBounds) => void; +}; + +type Debounced unknown> = T & { + cancel: () => void; +}; + +// Trailing-edge debounce. Keeps the hook dependency-light (no @types/lodash). +function debounce unknown>( + fn: T, + wait: number +): Debounced { + let timer: ReturnType | null = null; + + const debounced = ((...args: Parameters) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + fn(...args); + }, wait); + }) as Debounced; + + debounced.cancel = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + }; + + return debounced; +} + +// Fetches listings for the current map viewport. +// +// - Debounces rapid pan/zoom calls (150 ms). +// - Each request gets a sequence id; only the latest response is applied, so +// stale responses from abandoned viewports never overwrite fresh data. +// - Fetches a padded viewport (30% larger each side) so small pans reuse the +// already-loaded pins and do not feel like they "pause" to reload. +// - Never clears `listings` mid-fetch — cached pins stay visible while the +// next response is in flight. +export function useListingsInView(): UseListingsInViewResult { + const [listings, setListings] = useState([]); + const [isFetching, setIsFetching] = useState(false); + + const requestIdRef = useRef(0); + const inFlightCountRef = useRef(0); + + const runFetch = useCallback(async (bounds: LngLatBounds) => { + const padded = padBounds(bounds, VIEWPORT_PAD_FACTOR); + + const requestId = ++requestIdRef.current; + inFlightCountRef.current += 1; + setIsFetching(true); + + try { + const data = await fetchListingsInView( + padded.south, + padded.west, + padded.north, + padded.east + ); + + // Ignore stale responses — a newer request has already superseded this one. + if (requestId !== requestIdRef.current) return; + + setListings((data ?? []) as ListingMarker[]); + } catch (error) { + if (requestId !== requestIdRef.current) return; + console.error("Error fetching listings in view:", error); + } finally { + inFlightCountRef.current = Math.max(0, inFlightCountRef.current - 1); + if (inFlightCountRef.current === 0) { + setIsFetching(false); + } + } + }, []); + + const debouncedFetch = useMemo( + () => debounce(runFetch, DEBOUNCE_MS), + [runFetch] + ); + + useEffect(() => { + return () => { + debouncedFetch.cancel(); + }; + }, [debouncedFetch]); + + const requestBounds = useCallback( + (bounds: LngLatBounds) => { + debouncedFetch(bounds); + }, + [debouncedFetch] + ); + + return { listings, isFetching, requestBounds }; +} diff --git a/src/hooks/useMapCenter.ts b/src/hooks/useMapCenter.ts new file mode 100644 index 00000000..230d99f5 --- /dev/null +++ b/src/hooks/useMapCenter.ts @@ -0,0 +1,153 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; + +import { + FLY_DURATION, + ZOOM_LEVEL_SELECTED, + getListingCoordinates, + hasValidCoordinates, + isCoordinateInBounds, + type ListingCoordinates, + type SelectedListing, +} from "@/utils/mapUtils"; + +type UseMapCenterArgs = { + mapRef: React.RefObject; + selectedListing: SelectedListing | null; +}; + +type UseMapCenterResult = { + // Whether the selected listing's coordinate is currently inside the map viewport. + // Used to decide if the "Return to listing" button should show. + isSelectedInView: boolean; + // Called from the map's onLoad handler. Performs the single "initial jump" + // so the map never flickers from a temporary position to its real one. + handleMapLoad: () => void; + // Called from the map's onMoveEnd handler. + handleMapMoveEnd: () => void; + // Explicit fly-to for the "Return to listing" button. + flyToSelected: () => void; + // Explicit fly-to for a search pick. + flyToCoordinate: (coordinate: ListingCoordinates, zoom?: number) => void; +}; + +// Single owner of every programmatic map motion, so the four old fly-to rules +// (initial centre, URL-driven selection, return-to-listing, search pick) no +// longer race each other. +// +// Rules encoded here: +// 1. Initial mount — caller passes an initial centre via `initialViewState` +// on the ; this hook only takes over once the map has loaded. +// 2. URL-driven selection change — if the selected listing's coordinate is +// already in view, do not move. Otherwise flyTo with a short animation. +// 3. User taps "Return to listing" — flyTo with a slightly longer animation. +// 4. Search pick — flyTo the searched coordinate at the provided zoom. +export function useMapCenter({ + mapRef, + selectedListing, +}: UseMapCenterArgs): UseMapCenterResult { + // Start as `true` so the "Return to listing" button doesn't flash on before + // we've had a chance to measure anything. + const [isSelectedInView, setIsSelectedInView] = useState(true); + + // Ids we've already "handled" for URL-driven centring. Prevents us from + // flying to the same listing more than once as unrelated state changes. + const centeredListingIdRef = useRef(null); + // Tracks whether onLoad has run, so we don't double-centre on mount. + const hasHandledLoadRef = useRef(false); + + const recomputeIsInView = useCallback(() => { + const map = mapRef.current?.getMap(); + if (!map) return; + + if (!hasValidCoordinates(selectedListing)) { + setIsSelectedInView(true); + return; + } + + const coordinates = getListingCoordinates(selectedListing); + if (!coordinates) { + setIsSelectedInView(true); + return; + } + + setIsSelectedInView(isCoordinateInBounds(map.getBounds(), coordinates)); + }, [mapRef, selectedListing]); + + const handleMapLoad = useCallback(() => { + hasHandledLoadRef.current = true; + + // If we loaded with a selected listing, "claim" its id so the effect below + // doesn't try to fly to it again (the map's initialViewState already did). + if (hasValidCoordinates(selectedListing)) { + centeredListingIdRef.current = selectedListing.id ?? null; + } + + recomputeIsInView(); + }, [recomputeIsInView, selectedListing]); + + const handleMapMoveEnd = useCallback(() => { + recomputeIsInView(); + }, [recomputeIsInView]); + + // URL-driven selection centring. + useEffect(() => { + const map = mapRef.current?.getMap(); + if (!map || !hasHandledLoadRef.current) return; + if (!hasValidCoordinates(selectedListing)) return; + + const listingId = selectedListing.id ?? null; + if (listingId !== null && centeredListingIdRef.current === listingId) { + return; + } + + const coordinates = getListingCoordinates(selectedListing); + if (!coordinates) return; + + const inView = isCoordinateInBounds(map.getBounds(), coordinates); + centeredListingIdRef.current = listingId; + + if (inView) { + setIsSelectedInView(true); + return; + } + + mapRef.current?.flyTo({ + center: [coordinates.longitude, coordinates.latitude], + zoom: ZOOM_LEVEL_SELECTED, + duration: FLY_DURATION.urlSelection, + }); + }, [mapRef, selectedListing]); + + const flyToSelected = useCallback(() => { + if (!hasValidCoordinates(selectedListing)) return; + const coordinates = getListingCoordinates(selectedListing); + if (!coordinates) return; + + mapRef.current?.flyTo({ + center: [coordinates.longitude, coordinates.latitude], + duration: FLY_DURATION.returnToListing, + }); + }, [mapRef, selectedListing]); + + const flyToCoordinate = useCallback( + (coordinate: ListingCoordinates, zoom?: number) => { + mapRef.current?.flyTo({ + center: [coordinate.longitude, coordinate.latitude], + duration: FLY_DURATION.searchPick, + ...(typeof zoom === "number" ? { zoom } : {}), + }); + }, + [mapRef] + ); + + return { + isSelectedInView, + handleMapLoad, + handleMapMoveEnd, + flyToSelected, + flyToCoordinate, + }; +} diff --git a/src/hooks/useMapDrawerScroll.ts b/src/hooks/useMapDrawerScroll.ts new file mode 100644 index 00000000..ae7bebd7 --- /dev/null +++ b/src/hooks/useMapDrawerScroll.ts @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +type UseMapDrawerScrollArgs = { + isDesktop: boolean; + // True when the drawer is fully expanded (mobile: snapPoints[1]). + isFullSnap: boolean; + isListingSelected: boolean; +}; + +type UseMapDrawerScrollResult = { + drawerContentRef: React.MutableRefObject; + isDrawerHeaderShown: boolean; + setIsDrawerHeaderShown: React.Dispatch>; +}; + +const SCROLL_THRESHOLD = 16; + +// Shows/hides the sticky drawer header based on scroll position. +// - Mobile: attaches directly when the drawer is fully snapped. +// - Desktop: the drawer is portalled, so we wait for it to mount via a +// MutationObserver, then attach once. +export function useMapDrawerScroll({ + isDesktop, + isFullSnap, + isListingSelected, +}: UseMapDrawerScrollArgs): UseMapDrawerScrollResult { + const drawerContentRef = useRef(null); + const [isDrawerHeaderShown, setIsDrawerHeaderShown] = useState(false); + + useEffect(() => { + if (isDesktop || !isFullSnap) { + setIsDrawerHeaderShown(false); + return; + } + + const drawerContent = drawerContentRef.current; + if (!drawerContent) return; + + const handleScroll = () => { + setIsDrawerHeaderShown(drawerContent.scrollTop > SCROLL_THRESHOLD); + }; + + drawerContent.addEventListener("scroll", handleScroll); + return () => { + drawerContent.removeEventListener("scroll", handleScroll); + }; + }, [isDesktop, isFullSnap]); + + useEffect(() => { + if (!isDesktop || !isListingSelected) return; + + const handleScroll = () => { + if (drawerContentRef.current) { + setIsDrawerHeaderShown( + drawerContentRef.current.scrollTop > SCROLL_THRESHOLD + ); + } + }; + + const observer = new MutationObserver(() => { + const drawerContent = drawerContentRef.current; + if (drawerContent) { + drawerContent.addEventListener("scroll", handleScroll); + observer.disconnect(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + drawerContentRef.current?.removeEventListener("scroll", handleScroll); + }; + }, [isDesktop, isListingSelected]); + + return { + drawerContentRef, + isDrawerHeaderShown, + setIsDrawerHeaderShown, + }; +} diff --git a/src/hooks/useMapListingUrl.ts b/src/hooks/useMapListingUrl.ts new file mode 100644 index 00000000..eaa87598 --- /dev/null +++ b/src/hooks/useMapListingUrl.ts @@ -0,0 +1,200 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; + +import { createClient } from "@/utils/supabase/client"; +import type { SelectedListing } from "@/utils/mapUtils"; + +type UseMapListingUrlArgs = { + user: { id: string } | null | undefined; + initialListingSlug?: string | null; + initialListing?: SelectedListing | null; +}; + +type UseMapListingUrlResult = { + listingSlug: string | null; + selectedListing: SelectedListing | null; + // The id that should appear "selected" on the map. Combines the optimistic + // id from the most recent tap with the resolved listing's id, so the pin + // grows immediately on tap and stays grown through the fetch. + selectedListingId: number | null; + // True whenever there is a listing in the URL. Drives the drawer open state. + isListingSelected: boolean; + // Marker click handler. Sets the optimistic id synchronously, fetches the + // full listing by id, then updates state + URL in one pass. The URL effect + // skips the normal fetch because the slug is already resolved. + selectListingById: (id: number) => Promise; + // Drawer close handler. + closeListing: () => void; +}; + +// Owns the URL <-> selected-listing relationship for the map page. +// +// Goals: +// - Seed from SSR (`initialListing`) so a deep link doesn't re-fetch. +// - Fix the "grow -> shrink -> grow" pin flicker by: +// (a) setting an optimistic pin id synchronously on tap, and +// (b) only fetching the listing once per selection (removes the original +// double fetch where handleMarkerClick + loadListingBySlug both ran). +// - Leave `selectedListing` set while the drawer animates closed, so the +// last listing stays on-screen instead of flashing empty. +export function useMapListingUrl({ + user, + initialListingSlug, + initialListing, +}: UseMapListingUrlArgs): UseMapListingUrlResult { + const t = useTranslations(); + const router = useRouter(); + const searchParams = useSearchParams(); + const supabase = createClient(); + + const listingSlug = searchParams.get("listing"); + + const [selectedListing, setSelectedListing] = + useState(initialListing ?? null); + const [optimisticListingId, setOptimisticListingId] = useState( + initialListing && typeof initialListing.id === "number" + ? (initialListing.id as number) + : null + ); + + // The last slug we've resolved locally. Used to skip the fetch in the URL + // sync effect when we were the ones that set the URL. + const resolvedSlugRef = useRef( + initialListing?.slug ?? initialListingSlug ?? null + ); + + const tableName = user ? "listings_private_data" : "listings_public_data"; + + const fetchBySlug = useCallback( + async (slug: string) => { + try { + const { data, error } = await supabase + .from(tableName) + .select() + .eq("slug", slug) + .single(); + + if (error) { + setSelectedListing({ + error: true, + message: t("Listings.edit.notFound"), + }); + setOptimisticListingId(null); + return; + } + + setSelectedListing(data as SelectedListing); + resolvedSlugRef.current = slug; + const nextId = (data as { id?: number })?.id; + setOptimisticListingId(typeof nextId === "number" ? nextId : null); + } catch (err) { + console.warn("Failed to load listing by slug:", err); + setSelectedListing({ + error: true, + message: t("Listings.edit.notFound"), + }); + setOptimisticListingId(null); + } + }, + [supabase, t, tableName] + ); + + // Keep state aligned with the URL. Only fetch when the slug has actually + // changed from what we resolved locally. We intentionally do *not* clear + // `selectedListing` when the slug goes away — the drawer animates out and + // should keep showing the last listing until it's fully closed — but we + // do clear the pin-selection id so the pin snaps back immediately. + useEffect(() => { + if (!listingSlug) { + resolvedSlugRef.current = null; + setOptimisticListingId(null); + return; + } + + // On first mount: if SSR gave us the listing for this slug, use that. + if ( + listingSlug === initialListingSlug && + initialListing && + resolvedSlugRef.current !== listingSlug + ) { + setSelectedListing(initialListing); + resolvedSlugRef.current = listingSlug; + const nextId = initialListing.id; + setOptimisticListingId(typeof nextId === "number" ? nextId : null); + return; + } + + if (resolvedSlugRef.current === listingSlug) { + return; + } + + fetchBySlug(listingSlug); + }, [fetchBySlug, initialListing, initialListingSlug, listingSlug]); + + const selectListingById = useCallback( + async (id: number) => { + // Tap → pin grows immediately, even before the network round-trip. + setOptimisticListingId(id); + + try { + const { data, error } = await supabase + .from(tableName) + .select() + .eq("id", id) + .single(); + + if (error || !data) { + setSelectedListing({ + error: true, + message: t("Listings.edit.notFound"), + }); + setOptimisticListingId(null); + return; + } + + const listing = data as SelectedListing; + setSelectedListing(listing); + const slug = listing.slug; + + if (slug) { + // Claim this slug so the URL sync effect doesn't refetch. + resolvedSlugRef.current = slug; + router.push(`/map?listing=${slug}`, { scroll: false }); + } + } catch (err) { + console.warn("Failed to select listing by id:", err); + setSelectedListing({ + error: true, + message: t("Listings.edit.notFound"), + }); + setOptimisticListingId(null); + } + }, + [router, supabase, t, tableName] + ); + + const closeListing = useCallback(() => { + resolvedSlugRef.current = null; + setOptimisticListingId(null); + router.push("/map", { scroll: false }); + }, [router]); + + // Pin selection is driven entirely by the optimistic id, which is set on + // tap and cleared whenever the URL loses its listing slug (including on + // browser back/forward). `selectedListing` may stick around during the + // drawer close animation but the pin snaps back immediately, matching the + // prior behaviour. + const selectedListingId = optimisticListingId; + + return { + listingSlug, + selectedListing, + selectedListingId, + isListingSelected: Boolean(listingSlug), + selectListingById, + closeListing, + }; +} diff --git a/src/utils/mapUtils.ts b/src/utils/mapUtils.ts new file mode 100644 index 00000000..8bff598a --- /dev/null +++ b/src/utils/mapUtils.ts @@ -0,0 +1,105 @@ +import type { LngLatBounds } from "maplibre-gl"; + +export type ListingType = "business" | "community" | "residential"; + +export type ListingCoordinates = { + latitude: number; + longitude: number; +}; + +export type ListingMarker = { + id: number; + type: ListingType | string | null; + coordinates: ListingCoordinates | null; +}; + +export type SelectedListing = { + id?: number; + slug?: string; + name?: string; + type?: ListingType | string | null; + coordinates?: ListingCoordinates | null; + error?: boolean; + message?: string; + [key: string]: unknown; +}; + +export type BoundingBox = { + south: number; + west: number; + north: number; + east: number; +}; + +// Default coordinates for Brisbane, Australia +export const DEFAULT_COORDINATES: ListingCoordinates & { zoom: number } = { + latitude: -27.4683, + longitude: 153.0322, + zoom: 9, +}; + +export const ZOOM_LEVEL_DEFAULT = 11; +export const ZOOM_LEVEL_SELECTED = 14; + +// Durations (ms) for the different fly-to flows +export const FLY_DURATION = { + jump: 0, + urlSelection: 900, + returnToListing: 1500, + searchPick: 3200, +} as const; + +export function getListingCoordinates( + listing: SelectedListing | ListingMarker | null | undefined +): ListingCoordinates | null { + const coords = (listing as { coordinates?: ListingCoordinates | null }) + ?.coordinates; + return coords ?? null; +} + +export function hasValidCoordinates( + listing: SelectedListing | ListingMarker | null | undefined +): listing is (SelectedListing | ListingMarker) & { + coordinates: ListingCoordinates; +} { + const coordinates = getListingCoordinates(listing); + const error = (listing as { error?: boolean } | null | undefined)?.error; + + return Boolean( + listing && + !error && + coordinates && + typeof coordinates.latitude === "number" && + typeof coordinates.longitude === "number" && + Number.isFinite(coordinates.latitude) && + Number.isFinite(coordinates.longitude) + ); +} + +// Expand a viewport bbox by a fraction (e.g. 0.3 => 30% larger in each +// direction). This lets us fetch a slightly padded area so that small pans +// reuse already-loaded pins without hitting the network again. +export function padBounds(bounds: LngLatBounds, factor = 0.3): BoundingBox { + const sw = bounds.getSouthWest(); + const ne = bounds.getNorthEast(); + + const latSpan = ne.lat - sw.lat; + const lngSpan = ne.lng - sw.lng; + + const latPad = latSpan * factor; + const lngPad = lngSpan * factor; + + return { + south: Math.max(-90, sw.lat - latPad), + north: Math.min(90, ne.lat + latPad), + west: sw.lng - lngPad, + east: ne.lng + lngPad, + }; +} + +export function isCoordinateInBounds( + bounds: LngLatBounds, + coordinates: ListingCoordinates +): boolean { + return bounds.contains([coordinates.longitude, coordinates.latitude]); +} From 7b08f805f83376f8486923d212b269a6da8c26e9 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:39:37 +1000 Subject: [PATCH 02/12] refactor --- .../(stretched)/map/{page.js => page.tsx} | 31 +- ...ngChatDrawer.jsx => ListingChatDrawer.tsx} | 93 ++--- .../ListingChatDrawer/{index.js => index.ts} | 0 .../{ListingRead.jsx => ListingRead.tsx} | 328 ++++++++++-------- .../ListingRead/{index.js => index.ts} | 0 src/components/MapImmersive/index.ts | 2 - .../MapPageClient/MapPageClient.tsx | 249 ------------- src/components/MapPageClient/index.ts | 2 - src/components/MapSearch/index.ts | 2 - src/components/MapSidebar/index.ts | 2 - .../map/components/MapListingDrawerPanel.tsx} | 61 ++-- src/features/map/components/MapPageClient.tsx | 144 ++++++++ .../map/components}/MapPinLayer.tsx | 19 +- .../map/components}/MapSearch.tsx | 31 +- .../map/components}/MapSidebar.tsx | 10 +- .../map/components/MapView.tsx} | 159 +++++---- .../map}/hooks/useIpInitialLocation.ts | 22 +- .../map}/hooks/useListingsInView.ts | 4 +- src/{ => features/map}/hooks/useMapCenter.ts | 12 +- src/features/map/hooks/useMapDrawerState.ts | 166 +++++++++ .../map}/hooks/useMapListingUrl.ts | 22 +- src/features/map/index.ts | 2 + src/{utils => features/map/lib}/mapUtils.ts | 58 ++-- src/hooks/useMapDrawerScroll.ts | 83 ----- src/types/listing.ts | 85 +++++ 25 files changed, 834 insertions(+), 753 deletions(-) rename src/app/(core)/(interact)/(stretched)/map/{page.js => page.tsx} (63%) rename src/components/ListingChatDrawer/{ListingChatDrawer.jsx => ListingChatDrawer.tsx} (58%) rename src/components/ListingChatDrawer/{index.js => index.ts} (100%) rename src/components/ListingRead/{ListingRead.jsx => ListingRead.tsx} (56%) rename src/components/ListingRead/{index.js => index.ts} (100%) delete mode 100644 src/components/MapImmersive/index.ts delete mode 100644 src/components/MapPageClient/MapPageClient.tsx delete mode 100644 src/components/MapPageClient/index.ts delete mode 100644 src/components/MapSearch/index.ts delete mode 100644 src/components/MapSidebar/index.ts rename src/{components/MapPageClient/MapListingDrawer.tsx => features/map/components/MapListingDrawerPanel.tsx} (83%) create mode 100644 src/features/map/components/MapPageClient.tsx rename src/{components/MapImmersive => features/map/components}/MapPinLayer.tsx (76%) rename src/{components/MapSearch => features/map/components}/MapSearch.tsx (66%) rename src/{components/MapSidebar => features/map/components}/MapSidebar.tsx (96%) rename src/{components/MapImmersive/MapImmersive.tsx => features/map/components/MapView.tsx} (68%) rename src/{ => features/map}/hooks/useIpInitialLocation.ts (74%) rename src/{ => features/map}/hooks/useListingsInView.ts (96%) rename src/{ => features/map}/hooks/useMapCenter.ts (94%) create mode 100644 src/features/map/hooks/useMapDrawerState.ts rename src/{ => features/map}/hooks/useMapListingUrl.ts (91%) create mode 100644 src/features/map/index.ts rename src/{utils => features/map/lib}/mapUtils.ts (66%) delete mode 100644 src/hooks/useMapDrawerScroll.ts create mode 100644 src/types/listing.ts diff --git a/src/app/(core)/(interact)/(stretched)/map/page.js b/src/app/(core)/(interact)/(stretched)/map/page.tsx similarity index 63% rename from src/app/(core)/(interact)/(stretched)/map/page.js rename to src/app/(core)/(interact)/(stretched)/map/page.tsx index aa5eb58a..6a0019b7 100644 --- a/src/app/(core)/(interact)/(stretched)/map/page.js +++ b/src/app/(core)/(interact)/(stretched)/map/page.tsx @@ -1,19 +1,27 @@ +import { cache } from "react"; +import type { Metadata } from "next/types"; + import { createClient } from "@/utils/supabase/server"; import { siteConfig } from "@/config/site"; import { generateListingMetadata } from "@/utils/listingUtils"; -import MapPageClient from "@/components/MapPageClient"; -import { cache } from "react"; +import MapPageClient from "@/features/map"; +import type { Listing } from "@/types/listing"; + +type MapPageSearchParams = { + listing?: string; +}; + +type MapPageProps = { + searchParams: Promise; +}; -// Fetch data only once and use across metadata and page -const getInitialData = cache(async (listingSlug) => { +const getInitialData = cache(async (listingSlug: string | undefined) => { const supabase = await createClient(); - // Get user first const { data: { user }, } = await supabase.auth.getUser(); - // Then get listing data if slug exists const listingResponse = listingSlug ? await supabase .from(user ? "listings_private_data" : "listings_public_data") @@ -24,11 +32,13 @@ const getInitialData = cache(async (listingSlug) => { return { user, - listing: listingResponse?.data, + listing: (listingResponse?.data ?? null) as Listing | null, }; }); -export async function generateMetadata({ searchParams }) { +export async function generateMetadata({ + searchParams, +}: MapPageProps): Promise { const listingSlug = (await searchParams)?.listing; if (!listingSlug) { @@ -42,18 +52,17 @@ export async function generateMetadata({ searchParams }) { const { user, listing } = await getInitialData(listingSlug); - // Use shared utility to generate metadata return generateListingMetadata(listing, user); } -export default async function Page({ searchParams }) { +export default async function Page({ searchParams }: MapPageProps) { const listingSlug = (await searchParams)?.listing; const { user, listing } = await getInitialData(listingSlug); return ( ); diff --git a/src/components/ListingChatDrawer/ListingChatDrawer.jsx b/src/components/ListingChatDrawer/ListingChatDrawer.tsx similarity index 58% rename from src/components/ListingChatDrawer/ListingChatDrawer.jsx rename to src/components/ListingChatDrawer/ListingChatDrawer.tsx index 36dfde55..a5e55d37 100644 --- a/src/components/ListingChatDrawer/ListingChatDrawer.jsx +++ b/src/components/ListingChatDrawer/ListingChatDrawer.tsx @@ -1,4 +1,6 @@ "use client"; +import type { ReactNode } from "react"; +import type { User } from "@supabase/supabase-js"; import { useDeviceContext } from "@/hooks/useDeviceContext"; import { Drawer } from "vaul"; import Button from "@/components/Button"; @@ -6,6 +8,8 @@ import ChatWindow from "@/components/ChatWindow"; import ListingCta from "@/components/ListingCta"; import { styled } from "@pigment-css/react"; +import type { Listing } from "@/types/listing"; + const sidebarWidth = "clamp(20rem, 30vw, 30rem)"; const StyledDrawerOverlay = styled(Drawer.Overlay)({ @@ -15,7 +19,7 @@ const StyledDrawerOverlay = styled(Drawer.Overlay)({ }); const ListingCtaContainer = styled("div")({ - padding: "0 1rem", // Match padding from other parts of ListingRead + padding: "0 1rem", "& > *": { width: "100%", @@ -24,17 +28,16 @@ const ListingCtaContainer = styled("div")({ const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({ background: theme.colors.background.top, - borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`, // Match over drawer content + borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`, overflowX: "hidden", "&::after": { - display: "none", // Otherwise seems to include side scroll, even when overflowX hidden + display: "none", }, marginTop: "24px", - // maxHeight: "95%", - height: "95%", // Take up full height even if the message contents aren't overflowing yet + height: "95%", position: "fixed", bottom: "0", left: "0", @@ -43,7 +46,7 @@ const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({ flexDirection: "column", "@media (min-width: 768px)": { - borderRadius: theme.corners.base, // Match over drawer content + borderRadius: theme.corners.base, height: "unset", marginTop: "unset", top: "24px", @@ -52,37 +55,38 @@ const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({ left: "unset", outline: "none", width: sidebarWidth, - // height: "100%", }, })); -// We need to define two different drawer components, because depending on the 'modal' prop, a different number of hooks will be rendered -// React doesn't like when we conditionally change the number of hooks. It's better to just render a separate component for each case -// Shared drawer props to reduce repetition -const getDrawerProps = ({ - isNested, - isChatDrawerOpen, - setIsChatDrawerOpen, - isDesktop, - ...rest -}) => ({ - isNested, - direction: isDesktop ? "right" : undefined, - open: isChatDrawerOpen, - onOpenChange: setIsChatDrawerOpen, - ...rest, -}); - -const ModalDrawer = (props) => { - const DrawerComponent = props.isNested ? Drawer.NestedRoot : Drawer.Root; - return ; +type ListingChatDrawerProps = { + isNested?: boolean; + user: User | null; + listing: Listing; + isChatDrawerOpen: boolean; + setIsChatDrawerOpen: (open: boolean) => void; + existingThread: unknown; + listingDisplayName: string; }; -const NonModalDrawer = (props) => { - const DrawerComponent = props.isNested ? Drawer.NestedRoot : Drawer.Root; - return ; +type SharedDrawerProps = { + isNested?: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + direction?: "right"; + children?: ReactNode; }; +// We need two drawer variants because `modal` changes which hooks vaul renders. +function ModalDrawer({ isNested, ...rest }: SharedDrawerProps) { + const DrawerComponent = isNested ? Drawer.NestedRoot : Drawer.Root; + return ; +} + +function NonModalDrawer({ isNested, ...rest }: SharedDrawerProps) { + const DrawerComponent = isNested ? Drawer.NestedRoot : Drawer.Root; + return ; +} + export default function ListingChatDrawer({ isNested, user, @@ -90,16 +94,16 @@ export default function ListingChatDrawer({ isChatDrawerOpen, setIsChatDrawerOpen, existingThread, - listingDisplayName, - ...props -}) { + listingDisplayName: _listingDisplayName, +}: ListingChatDrawerProps) { const { isDesktop, hasTouch } = useDeviceContext(); - // We can infer modal behavior based on presentation - // If it's a mobile breakpoint, always use a model - // If it's a desktop breakpoint, only use a modal if it's NOT a nested drawer + // Mobile: always modal. Desktop: modal only if NOT a nested drawer. const shouldUseModal = !isDesktop || isNested === false; + const visibility = listing.visibility ?? undefined; + const isStub = listing.is_stub ?? undefined; + const drawerContent = ( <> @@ -108,14 +112,14 @@ export default function ListingChatDrawer({ ) : listing.is_stub ? ( ) : ( @@ -129,8 +133,8 @@ export default function ListingChatDrawer({ )} @@ -154,10 +158,9 @@ export default function ListingChatDrawer({ return ( {drawerContent} diff --git a/src/components/ListingChatDrawer/index.js b/src/components/ListingChatDrawer/index.ts similarity index 100% rename from src/components/ListingChatDrawer/index.js rename to src/components/ListingChatDrawer/index.ts diff --git a/src/components/ListingRead/ListingRead.jsx b/src/components/ListingRead/ListingRead.tsx similarity index 56% rename from src/components/ListingRead/ListingRead.jsx rename to src/components/ListingRead/ListingRead.tsx index 6e68b5a2..77bdf641 100644 --- a/src/components/ListingRead/ListingRead.jsx +++ b/src/components/ListingRead/ListingRead.tsx @@ -1,5 +1,7 @@ "use client"; import { Fragment, useState, memo, useEffect } from "react"; +import type { ComponentType, ReactNode } from "react"; +import type { User } from "@supabase/supabase-js"; import { Marker, NavigationControl } from "react-map-gl/maplibre"; import { createClient } from "@/utils/supabase/client"; @@ -17,29 +19,53 @@ import StrongLink from "@/components/StrongLink"; import { styled } from "@pigment-css/react"; import { useTranslations } from "next-intl"; -// Memoize the Listing component +import type { DemoListing, Listing } from "@/types/listing"; + +type Presentation = "full" | "drawer" | "demo"; + +type ListingReadListing = Listing | DemoListing; + +type ListingReadProps = { + user: User | null; + listing: ListingReadListing | null; + presentation?: Presentation; +}; + +function isDemoListing( + listing: ListingReadListing | null +): listing is DemoListing { + return Boolean(listing && (listing as DemoListing).is_demo === true); +} + const ListingRead = memo(function Listing({ user, listing, presentation = "full", - isChatDrawerOpen, - setIsChatDrawerOpen, -}) { +}: ListingReadProps) { const t = useTranslations(); - const router = presentation !== "demo" ? useRouter() : null; + // Hooks must be called unconditionally; router is unused in demo mode. + const router = useRouter(); - const [existingThread, setExistingThread] = useState(null); - const [mapZoomLevel, setMapZoomLevel] = useState(null); + const [existingThread, setExistingThread] = useState(null); + const [mapZoomLevel, setMapZoomLevel] = useState(null); + // Chat drawer state is owned here so that each selected listing gets a + // fresh drawer. The parent resets this by remounting with `key`. + const [isChatDrawerOpen, setIsChatDrawerOpen] = useState(false); - // Only initialize Supabase if not in demo mode const supabase = presentation !== "demo" ? createClient() : null; + const isDemo = presentation === "demo"; + const demoListing = isDemoListing(listing) ? listing : null; + const realListing = + !isDemo && listing && !isDemoListing(listing) ? (listing as Listing) : null; + // Load existing thread if any (only if not in demo mode) useEffect(() => { - if (presentation === "demo" || !supabase || !user || !listing) return; + if (isDemo || !supabase || !user || !realListing) return; // TODO: Should this only be called when the actual ListingChatDrawer is loaded? async function loadExistingThread() { + if (!supabase || !user || !realListing) return; const { data: thread, error } = await supabase .from("chat_threads_with_participants") .select( @@ -49,9 +75,9 @@ const ListingRead = memo(function Listing({ ` ) .match({ - listing_id: listing.id, + listing_id: realListing.id, initiator_id: user.id, - owner_id: listing.owner_id, + owner_id: realListing.owner_id, }) .maybeSingle(); @@ -64,28 +90,27 @@ const ListingRead = memo(function Listing({ } loadExistingThread(); - }, [listing?.id, user?.id, presentation, supabase]); + }, [realListing?.id, user?.id, isDemo, supabase, realListing]); const initialZoomLevel = 14; useEffect(() => { setMapZoomLevel(initialZoomLevel); }, []); - const listingDisplayName = - presentation === "demo" - ? listing?.name - ? listing.name - : listing.owner_first_name - : getListingDisplayName(listing, user); - const coordinates = listing?.coordinates; + const listingDisplayName: string = isDemo + ? (demoListing?.name ?? demoListing?.owner_first_name ?? "") + : realListing + ? getListingDisplayName(realListing, user) + : ""; + + const coordinates = realListing?.coordinates ?? null; - if (!listing && presentation !== "demo") { - console.log("Listing not found"); + if (!listing && !isDemo) { return null; } return ( - + - {presentation === "demo" ? ( + {isDemo && demoListing ? ( - ) : ( + ) : realListing ? ( - )} + ) : null} {listing?.description && ( @@ -124,35 +147,29 @@ const ListingRead = memo(function Listing({ ? t("Listings.read.donationDetails") : t("Listings.read.about")} - + )} - {listing?.accepted_items?.length > 0 && ( + {listing?.accepted_items && listing.accepted_items.length > 0 && (

    {t("Listings.read.accepted")}

    - +
    )} - {listing?.rejected_items?.length > 0 && ( + {listing?.rejected_items && listing.rejected_items.length > 0 && (

    {t("Listings.read.rejected")}

    - +
    )}
    - {presentation !== "demo" && ( + {realListing && !isDemo && ( - {presentation !== "drawer" && ( + {presentation !== "drawer" && coordinates && (

    {t("Listings.read.location")}

    @@ -171,21 +188,24 @@ const ListingRead = memo(function Listing({ anchor="center" onClick={(event) => { event.originalEvent.stopPropagation(); - router.push(`/map?listing=${listing.slug}`); + router.push(`/map?listing=${realListing.slug}`); }} > - + - {listing.type === "residential" ? ( + {realListing.type === "residential" ? (

    {t("Listings.read.residentialLocation", { name: listingDisplayName, - area: listing.area_name - ? listing.area_name + area: realListing.area_name + ? realListing.area_name : t("Listings.read.thisArea"), })}

    @@ -194,12 +214,12 @@ const ListingRead = memo(function Listing({ {t("Listings.read.nonResidentialLocation", { name: listingDisplayName, type: - listing.type === "business" + realListing.type === "business" ? t("Listings.read.businessType") - : listing.type === "community" + : realListing.type === "community" ? t("Listings.read.communityType") : "", - area: listing.area_name, + area: realListing.area_name ?? "", })}

    )} @@ -208,17 +228,17 @@ const ListingRead = memo(function Listing({ - {listing.type !== "residential" && ( + {realListing.type !== "residential" && ( <> @@ -293,14 +313,33 @@ const ListingRead = memo(function Listing({ export default ListingRead; +type PresentationVariantProps = { + presentation?: Presentation; + children?: ReactNode; +}; + +type ListingSectionVariantProps = PresentationVariantProps & { + overflowX?: "visible"; +}; + +// Pigment's variant typing narrows shared props to the first variant's literal. +// Cast the `styled()` factory so our variant props survive as a string union. +type StyledWithVariants

    = (arg: unknown) => ComponentType

    ; + +const styledDivWithPresentation = styled( + "div" +) as unknown as StyledWithVariants; +const styledSectionWithSection = styled( + "section" +) as unknown as StyledWithVariants; + const sharedColumnStyles = { - // Inherit same flex properties as parent, given these columns should be invisible when drawer display: "flex", flexDirection: "column", - gap: "3rem", // Match in MapPageClient (StyledDrawerInner) + gap: "3rem", }; -const ColumnMain = styled("div")(({ theme }) => ({ +const ColumnMain = styledDivWithPresentation(({ theme }: { theme: any }) => ({ ...sharedColumnStyles, variants: [ @@ -318,15 +357,13 @@ const ColumnMain = styled("div")(({ theme }) => ({ ], })); -const ColumnMinor = styled("div")(({ theme }) => ({ +const ColumnMinor = styledDivWithPresentation(({ theme }: { theme: any }) => ({ ...sharedColumnStyles, variants: [ { props: { presentation: "full" }, style: { - // Make second column gap smaller on larger breakpoint - "@media (min-width: 1280px)": { gap: "1.5rem", }, @@ -335,93 +372,92 @@ const ColumnMinor = styled("div")(({ theme }) => ({ ], })); -const ListingContents = styled("div")(({ theme }) => ({ - display: "flex", - flexDirection: "column", - gap: "3rem", // Match in MapPageClient (StyledDrawerInner) - - // Match styling of other sections - variants: [ - { - props: { presentation: "full" }, - style: { - padding: "1.5rem 0", - backgroundColor: theme.colors.background.top, - border: `1px solid ${theme.colors.border.base}`, - borderRadius: theme.corners.base, +const ListingContents = styledDivWithPresentation( + ({ theme }: { theme: any }) => ({ + display: "flex", + flexDirection: "column", + gap: "3rem", + + variants: [ + { + props: { presentation: "full" }, + style: { + padding: "1.5rem 0", + backgroundColor: theme.colors.background.top, + border: `1px solid ${theme.colors.border.base}`, + borderRadius: theme.corners.base, - "@media (min-width: 768px)": { - padding: "0 0.5rem", // 0.5rem + 1rem = 1.5rem used elsewhere in 'naked' ListingSection instances - backgroundColor: "unset", - border: "unset", - borderRadius: "unset", + "@media (min-width: 768px)": { + padding: "0 0.5rem", + backgroundColor: "unset", + border: "unset", + borderRadius: "unset", + }, }, }, + ], + }) +); + +const ListingSection = styledSectionWithSection( + ({ theme }: { theme: any }) => ({ + padding: " 0 1rem", + + "& h3": { + fontWeight: "500", + marginBottom: "0.5rem", + color: theme.colors.text.ui.secondary, }, - ], -})); - -const ListingSection = styled("section")(({ theme }) => ({ - // width: "100%", - padding: " 0 1rem", // Pad by default ,override on Photos section (overflowX: "visible") - "& h3": { - // Match newsletter issue headers - fontWeight: "500", - marginBottom: "0.5rem", - color: theme.colors.text.ui.secondary, - }, + "& p + p": { + marginTop: "0.5rem", + color: theme.colors.text.ui.primary, + }, - "& p + p": { - // Add paragraph spacing - marginTop: "0.5rem", - color: theme.colors.text.ui.primary, - }, + variants: [ + { + props: { overflowX: "visible" }, + style: { + padding: "0", + overflowX: "visible", - variants: [ - { - props: { overflowX: "visible" }, - style: { - padding: "0", // Pad by default, override on Photos section (overflowX: "visible") - overflowX: "visible", - - "& h3": { - padding: "0 1rem", // Account for removed padding on parent + "& h3": { + padding: "0 1rem", + }, }, }, - }, - { - // TODO: This 'overflowX: undefined' is ignored, targeting everything with presentation: full. Ideally I can only target the presentation: full items that DON'T have an overflowX prop defined - props: { overflowX: undefined, presentation: "full" }, - style: { - backgroundColor: theme.colors.background.top, - border: `1px solid ${theme.colors.border.base}`, - borderRadius: theme.corners.base, + { + props: { overflowX: undefined, presentation: "full" }, + style: { + backgroundColor: theme.colors.background.top, + border: `1px solid ${theme.colors.border.base}`, + borderRadius: theme.corners.base, - padding: "1rem 1rem 1.5rem", + padding: "1rem 1rem 1.5rem", - "@media (min-width: 768px)": { - padding: "1rem 1.5rem 1.5rem", + "@media (min-width: 768px)": { + padding: "1rem 1.5rem 1.5rem", + }, }, }, - }, - { - props: { overflowX: "visible", presentation: "full" }, - style: { - padding: "1rem 0 1.5rem", - - "@media (min-width: 768px)": { - "& h3": { - padding: "0 1.5rem", // Account for removed padding on parent + { + props: { overflowX: "visible", presentation: "full" }, + style: { + padding: "1rem 0 1.5rem", + + "@media (min-width: 768px)": { + "& h3": { + padding: "0 1.5rem", + }, }, }, }, - }, - ], -})); + ], + }) +); const DemoButtonContainer = styled("div")({ - padding: "0 1rem", // Match padding from other parts of ListingRead + padding: "0 1rem", }); const MapDetails = styled("div")(({ theme }) => ({ @@ -442,9 +478,9 @@ const ButtonGroup = styled("div")({ gap: "0.5rem", }); -// A much fancier version than just using whiteSpace: "pre-wrap", which renders looking like a completely new empty paragraph in between lines -const MultiParagraphCluster = ({ text }) => { - const paragraphs = text.split("\n").filter((line) => line.trim() !== ""); // Split by line breaks and filter out empty lines +// Split by line breaks and render each paragraph with inline link parsing. +function MultiParagraphCluster({ text }: { text: string }) { + const paragraphs = text.split("\n").filter((line) => line.trim() !== ""); return ( <> {paragraphs.map((paragraph, index) => ( @@ -462,4 +498,4 @@ const MultiParagraphCluster = ({ text }) => { ))} ); -}; +} diff --git a/src/components/ListingRead/index.js b/src/components/ListingRead/index.ts similarity index 100% rename from src/components/ListingRead/index.js rename to src/components/ListingRead/index.ts diff --git a/src/components/MapImmersive/index.ts b/src/components/MapImmersive/index.ts deleted file mode 100644 index 7933b3a1..00000000 --- a/src/components/MapImmersive/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./MapImmersive"; -export { default } from "./MapImmersive"; diff --git a/src/components/MapPageClient/MapPageClient.tsx b/src/components/MapPageClient/MapPageClient.tsx deleted file mode 100644 index fa194eee..00000000 --- a/src/components/MapPageClient/MapPageClient.tsx +++ /dev/null @@ -1,249 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { Drawer } from "vaul"; -import { config } from "@maptiler/client"; -import type { MapRef } from "react-map-gl/maplibre"; -import { styled } from "@pigment-css/react"; - -import MapImmersive from "@/components/MapImmersive"; -import MapSidebar from "@/components/MapSidebar"; -import MapListingDrawer from "./MapListingDrawer"; - -import { useDeviceContext } from "@/hooks/useDeviceContext"; -import { useListingsInView } from "@/hooks/useListingsInView"; -import { useMapListingUrl } from "@/hooks/useMapListingUrl"; -import { useIpInitialLocation } from "@/hooks/useIpInitialLocation"; -import { useMapDrawerScroll } from "@/hooks/useMapDrawerScroll"; -import { - ZOOM_LEVEL_DEFAULT, - type ListingMarker, - type SelectedListing, -} from "@/utils/mapUtils"; - -type MapPageClientProps = { - user: { id: string } | null; - initialListingSlug?: string | null; - initialListing?: SelectedListing | null; -}; - -type GeocodingPickEvent = { - feature?: { center?: [number, number] }; -}; - -const snapPoints: (number | string)[] = [0.35, 1]; - -// For IP geolocation API -config.apiKey = process.env.NEXT_PUBLIC_MAPTILER_API_KEY ?? ""; - -const StyledMapPage = styled("main")(({ theme }) => ({ - flex: 1, - gap: theme.spacing.gap.desktop, - alignItems: "stretch", - display: "flex", - flexDirection: "row", -})); - -const StyledMapWrapper = styled("div")(({ theme }) => ({ - display: "flex", - flexDirection: "column", - gap: "1rem", - flex: 1, - height: "100%", - "@media (min-width: 768px)": { - borderRadius: theme.corners.base, - border: `1px solid ${theme.colors.border.base}`, - overflow: "hidden", - }, -})); - -export default function MapPageClient({ - user, - initialListingSlug, - initialListing, -}: MapPageClientProps) { - const mapRef = useRef(null); - const searchInputRef = useRef(null); - - const { isDesktop, hasTouch } = useDeviceContext(); - - const { - listingSlug, - selectedListing, - selectedListingId, - isListingSelected, - selectListingById, - closeListing, - } = useMapListingUrl({ user, initialListingSlug, initialListing }); - - const { listings, isFetching, requestBounds } = useListingsInView(); - - const { initialCoordinates, countryCode } = useIpInitialLocation({ - skip: Boolean(initialListingSlug), - }); - - const [snap, setSnap] = useState(snapPoints[0]); - const [isChatDrawerOpen, setIsChatDrawerOpen] = useState(false); - - const isFullSnap = snap === snapPoints[1]; - const isPartialSnap = snap === snapPoints[0]; - - const { drawerContentRef, isDrawerHeaderShown, setIsDrawerHeaderShown } = - useMapDrawerScroll({ - isDesktop, - isFullSnap, - isListingSelected, - }); - - // Snap to full on desktop, partial on mobile. - useEffect(() => { - if (isDesktop) { - setSnap(snapPoints[1]); - } - }, [isDesktop]); - - // Manage html classes that other styles hook into. Only on mobile — desktop - // doesn't take over the viewport. - useEffect(() => { - if (isDesktop) return; - - document.documentElement.classList.add("map"); - - if (isFullSnap && listingSlug) { - document.documentElement.classList.add("drawer-fully-open"); - } else { - document.documentElement.classList.remove("drawer-fully-open"); - } - - return () => { - document.documentElement.classList.remove("map"); - document.documentElement.classList.remove("drawer-fully-open"); - }; - }, [isFullSnap, isDesktop, listingSlug]); - - // Keep drawer/html state consistent with URL changes (including browser - // back/forward). Also resets scroll + header when a new listing opens. - useEffect(() => { - if (!listingSlug) { - document.documentElement.classList.remove("drawer-fully-open"); - setSnap(snapPoints[0]); - setIsChatDrawerOpen(false); - } else { - setSnap(snapPoints[0]); - setIsDrawerHeaderShown(false); - if (drawerContentRef.current) { - drawerContentRef.current.scrollTop = 0; - } - } - }, [listingSlug, drawerContentRef, setIsDrawerHeaderShown]); - - const handleSnapChange = useCallback(() => { - setSnap((previous) => { - const next = previous === snapPoints[0] ? snapPoints[1] : snapPoints[0]; - if (next === snapPoints[0] && drawerContentRef.current) { - drawerContentRef.current.scrollTop = 0; - } - return next; - }); - }, [drawerContentRef]); - - const handleMarkerClick = useCallback( - (listing: ListingMarker) => { - if (listing.id === selectedListingId && isListingSelected) return; - - setIsChatDrawerOpen(false); - setIsDrawerHeaderShown(false); - if (drawerContentRef.current) { - drawerContentRef.current.scrollTop = 0; - } - - // Optimistic pin grow + single fetch (selectListingById sets the - // optimistic id synchronously, fetches by id, then pushes the URL). - void selectListingById(listing.id); - }, - [ - isListingSelected, - selectListingById, - selectedListingId, - drawerContentRef, - setIsDrawerHeaderShown, - ] - ); - - const handleMapClick = useCallback(() => { - if (isListingSelected) { - closeListing(); - } - }, [closeListing, isListingSelected]); - - const handleSearchPick = useCallback((event: GeocodingPickEvent) => { - // Quirk in MapTiler's Geocoding component: tapping close is also an - // "onPick" with no center. Ignore those. - const center = event?.feature?.center; - if (!center) return; - - mapRef.current?.flyTo({ - center: [center[0], center[1]], - duration: 3200, - zoom: ZOOM_LEVEL_DEFAULT, - }); - }, []); - - const handleDrawerOpenChange = useCallback(() => { - // Drawer-driven close (e.g. escape key on desktop) should also update - // the URL. - if (isListingSelected) { - closeListing(); - } - }, [closeListing, isListingSelected]); - - return ( - - - - - - - - - {isDesktop && } - - ); -} diff --git a/src/components/MapPageClient/index.ts b/src/components/MapPageClient/index.ts deleted file mode 100644 index c9439612..00000000 --- a/src/components/MapPageClient/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./MapPageClient"; -export { default } from "./MapPageClient"; diff --git a/src/components/MapSearch/index.ts b/src/components/MapSearch/index.ts deleted file mode 100644 index 6693599d..00000000 --- a/src/components/MapSearch/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./MapSearch"; -export { default } from "./MapSearch"; diff --git a/src/components/MapSidebar/index.ts b/src/components/MapSidebar/index.ts deleted file mode 100644 index ed7a5ac1..00000000 --- a/src/components/MapSidebar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./MapSidebar"; -export { default } from "./MapSidebar"; diff --git a/src/components/MapPageClient/MapListingDrawer.tsx b/src/features/map/components/MapListingDrawerPanel.tsx similarity index 83% rename from src/components/MapPageClient/MapListingDrawer.tsx rename to src/features/map/components/MapListingDrawerPanel.tsx index a63bb242..856ca813 100644 --- a/src/components/MapPageClient/MapListingDrawer.tsx +++ b/src/features/map/components/MapListingDrawerPanel.tsx @@ -4,19 +4,21 @@ import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; import { Drawer } from "vaul"; import { styled } from "@pigment-css/react"; import { useTranslations } from "next-intl"; +import type { User } from "@supabase/supabase-js"; import ListingRead from "@/components/ListingRead"; import Button from "@/components/Button"; import IconButton from "@/components/IconButton"; - import { getListingDisplayName, getListingDisplayType, } from "@/utils/listingUtils"; -import type { SelectedListing } from "@/utils/mapUtils"; +import { isListingError, type SelectedListing } from "@/types/listing"; + +import { SIDEBAR_WIDTH } from "../lib/mapUtils"; -type MapListingDrawerProps = { - user: { id: string } | null; +type MapListingDrawerPanelProps = { + user: User | null; selectedListing: SelectedListing | null; isDesktop: boolean; hasTouch: boolean; @@ -25,38 +27,20 @@ type MapListingDrawerProps = { isPartialSnap: boolean; onToggleSnap: () => void; onClose: () => void; - isChatDrawerOpen: boolean; - setIsChatDrawerOpen: (value: boolean) => void; drawerContentRef: React.MutableRefObject; }; -const ListingReadComponent = ListingRead as React.ComponentType<{ - user: unknown; - listing: SelectedListing | null; - presentation?: string; - isChatDrawerOpen?: boolean; - setIsChatDrawerOpen?: (value: boolean) => void; -}>; - -const IconButtonComponent = IconButton as React.ComponentType<{ - icon: string; - onClick?: () => void; - className?: string; -}>; - -const sidebarWidth = "clamp(20rem, 30vw, 30rem)"; - const sharedButtonStyles = { pointerEvents: "all" as const, }; -const StyledIconButtonAbsolute = styled(IconButtonComponent)({ +const StyledIconButtonAbsolute = styled(IconButton)({ ...sharedButtonStyles, position: "absolute", right: "0.75rem", }); -const StyledIconButtonStationary = styled(IconButtonComponent)({ +const StyledIconButtonStationary = styled(IconButton)({ ...sharedButtonStyles, position: "relative", }); @@ -94,7 +78,7 @@ const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({ bottom: "24px", left: "unset", outline: "none", - width: sidebarWidth, + width: SIDEBAR_WIDTH, }, })); @@ -209,7 +193,7 @@ const NoListingFound = styled("div")(({ theme }) => ({ }, })); -export default function MapListingDrawer({ +export default function MapListingDrawerPanel({ user, selectedListing, isDesktop, @@ -219,20 +203,20 @@ export default function MapListingDrawer({ isPartialSnap, onToggleSnap, onClose, - isChatDrawerOpen, - setIsChatDrawerOpen, drawerContentRef, -}: MapListingDrawerProps) { +}: MapListingDrawerPanelProps) { const t = useTranslations(); - const showErrorPanel = Boolean(selectedListing?.error); + const showErrorPanel = isListingError(selectedListing); + const listingForDisplay = isListingError(selectedListing) + ? null + : selectedListing; return (

    - {selectedListing - ? getListingDisplayName(selectedListing, user) + {listingForDisplay + ? getListingDisplayName(listingForDisplay, user) : ""}

    - {selectedListing ? getListingDisplayType(selectedListing) : ""} + {listingForDisplay + ? getListingDisplayType(listingForDisplay) + : ""}

    @@ -301,12 +287,11 @@ export default function MapListingDrawer({ ) : ( - )} diff --git a/src/features/map/components/MapPageClient.tsx b/src/features/map/components/MapPageClient.tsx new file mode 100644 index 00000000..418c79eb --- /dev/null +++ b/src/features/map/components/MapPageClient.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useCallback } from "react"; +import { Drawer } from "vaul"; +import { styled } from "@pigment-css/react"; +import type { User } from "@supabase/supabase-js"; + +import { useDeviceContext } from "@/hooks/useDeviceContext"; +import type { Listing, ListingMarker } from "@/types/listing"; + +import MapView from "./MapView"; +import MapListingDrawerPanel from "./MapListingDrawerPanel"; +import MapSidebar from "./MapSidebar"; +import { useMapListingUrl } from "../hooks/useMapListingUrl"; +import { useIpInitialLocation } from "../hooks/useIpInitialLocation"; +import { + MAP_DRAWER_SNAP_POINTS, + useMapDrawerState, +} from "../hooks/useMapDrawerState"; + +type MapPageClientProps = { + user: User | null; + initialListingSlug?: string | null; + initialListing?: Listing | null; +}; + +const StyledMapPage = styled("main")(({ theme }) => ({ + flex: 1, + gap: theme.spacing.gap.desktop, + alignItems: "stretch", + display: "flex", + flexDirection: "row", +})); + +const StyledMapWrapper = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + gap: "1rem", + flex: 1, + height: "100%", + "@media (min-width: 768px)": { + borderRadius: theme.corners.base, + border: `1px solid ${theme.colors.border.base}`, + overflow: "hidden", + }, +})); + +export default function MapPageClient({ + user, + initialListingSlug, + initialListing, +}: MapPageClientProps) { + const { isDesktop, hasTouch } = useDeviceContext(); + + const { + listingSlug, + selectedListing, + selectedListingId, + isListingSelected, + selectListingById, + closeListing, + } = useMapListingUrl({ user, initialListingSlug, initialListing }); + + const { initialCoordinates, countryCode } = useIpInitialLocation({ + skip: Boolean(initialListingSlug), + }); + + const { + drawerContentRef, + snap, + setSnap, + isFullSnap, + isPartialSnap, + isDrawerHeaderShown, + handleSnapChange, + } = useMapDrawerState({ isDesktop, listingSlug, isListingSelected }); + + const handleMarkerClick = useCallback( + (listing: ListingMarker) => { + if (listing.id === selectedListingId && isListingSelected) return; + + // Optimistic pin grow + single fetch (selectListingById sets the + // optimistic id synchronously, fetches by id, then pushes the URL). + void selectListingById(listing.id); + }, + [isListingSelected, selectListingById, selectedListingId] + ); + + const handleMapClick = useCallback(() => { + if (isListingSelected) { + closeListing(); + } + }, [closeListing, isListingSelected]); + + const handleDrawerOpenChange = useCallback(() => { + // Drawer-driven close (e.g. escape key on desktop) should also update + // the URL. + if (isListingSelected) { + closeListing(); + } + }, [closeListing, isListingSelected]); + + return ( + + + + + + + + + {isDesktop && } + + ); +} diff --git a/src/components/MapImmersive/MapPinLayer.tsx b/src/features/map/components/MapPinLayer.tsx similarity index 76% rename from src/components/MapImmersive/MapPinLayer.tsx rename to src/features/map/components/MapPinLayer.tsx index daad356f..df1cce8b 100644 --- a/src/components/MapImmersive/MapPinLayer.tsx +++ b/src/features/map/components/MapPinLayer.tsx @@ -4,11 +4,9 @@ import type { ComponentType } from "react"; import { Marker } from "react-map-gl/maplibre"; import MapPin from "@/components/MapPin"; -import { - hasValidCoordinates, - type ListingCoordinates, - type ListingMarker, -} from "@/utils/mapUtils"; +import type { ListingCoordinates, ListingMarker } from "@/types/listing"; + +import { hasValidCoordinates } from "../lib/mapUtils"; type MapPinLayerProps = { listings: ListingMarker[]; @@ -17,12 +15,6 @@ type MapPinLayerProps = { onMarkerClick: (listing: ListingMarker) => void; }; -type MapPinType = "business" | "community" | "residential"; - -function toPinType(type: ListingMarker["type"]): MapPinType | undefined { - return typeof type === "string" ? (type as MapPinType) : undefined; -} - export default function MapPinLayer({ listings, selectedListingId, @@ -49,7 +41,10 @@ export default function MapPinLayer({ }} style={{ zIndex: isSelected ? 1 : 0 }} > - + ); diff --git a/src/components/MapSearch/MapSearch.tsx b/src/features/map/components/MapSearch.tsx similarity index 66% rename from src/components/MapSearch/MapSearch.tsx rename to src/features/map/components/MapSearch.tsx index 61fa8364..0e2202ba 100644 --- a/src/components/MapSearch/MapSearch.tsx +++ b/src/features/map/components/MapSearch.tsx @@ -1,9 +1,9 @@ "use client"; -import type { ComponentType, CSSProperties, Ref } from "react"; +import type { CSSProperties } from "react"; import { GeocodingControl } from "@maptiler/geocoding-control/react"; -import "@maptiler/geocoding-control/style.css"; // TODO REMOVE (TURN ON AND OFF TO PREVIEW STYLES) +import "@maptiler/geocoding-control/style.css"; import { useTranslations } from "next-intl"; type GeocodingPickEvent = { @@ -12,31 +12,13 @@ type GeocodingPickEvent = { type MapSearchProps = { onPick: (event: GeocodingPickEvent) => void; - searchInputRef?: Ref; countryCode?: string | null; style?: CSSProperties; }; -const GeocodingControlComponent = GeocodingControl as ComponentType<{ - clearOnBlur?: boolean; - collapsed?: boolean; - ref?: Ref; - debounceSearch?: number; - apiKey?: string; - proximity?: { type: string }[]; - country?: string | null; - types?: string[]; - minLength?: number; - placeholder?: string; - errorMessage?: string; - noResultsMessage?: string; - onPick?: (event: GeocodingPickEvent) => void; -}>; - // TODO: Add a 'required' prop for forms that require a location -function MapSearch({ +export default function MapSearch({ onPick, - searchInputRef, countryCode, style, }: MapSearchProps) { @@ -44,10 +26,9 @@ function MapSearch({ return (
    - ); } - -export default MapSearch; diff --git a/src/components/MapSidebar/MapSidebar.tsx b/src/features/map/components/MapSidebar.tsx similarity index 96% rename from src/components/MapSidebar/MapSidebar.tsx rename to src/features/map/components/MapSidebar.tsx index b545942b..e043c5d0 100644 --- a/src/components/MapSidebar/MapSidebar.tsx +++ b/src/features/map/components/MapSidebar.tsx @@ -7,8 +7,12 @@ import { useTranslations } from "next-intl"; import { facts } from "@/data/facts"; +import type { User } from "@supabase/supabase-js"; + +import { SIDEBAR_WIDTH } from "../lib/mapUtils"; + type MapSidebarProps = { - user: { id: string } | null | undefined; + user: User | null | undefined; covered: boolean; }; @@ -17,8 +21,6 @@ type Fact = { source?: string; }; -const sidebarWidth = "clamp(20rem, 30vw, 30rem);"; - const StyledSidebar = styled("div")(({ theme }) => ({ backgroundColor: theme.colors.background.pit, color: theme.colors.text.secondary, @@ -30,7 +32,7 @@ const StyledSidebar = styled("div")(({ theme }) => ({ alignItems: "center", justifyContent: "center", textAlign: "center", - width: sidebarWidth, + width: SIDEBAR_WIDTH, height: "100%", wordWrap: "anywhere", // for source URLs on facts, remove when those go border: `2px dashed ${theme.colors.border.base}`, diff --git a/src/components/MapImmersive/MapImmersive.tsx b/src/features/map/components/MapView.tsx similarity index 68% rename from src/components/MapImmersive/MapImmersive.tsx rename to src/features/map/components/MapView.tsx index e965fbac..f1a63fd5 100644 --- a/src/components/MapImmersive/MapImmersive.tsx +++ b/src/features/map/components/MapView.tsx @@ -1,7 +1,7 @@ "use client"; -import { useCallback, useEffect } from "react"; -import type { ComponentType, Ref } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import type { ComponentType } from "react"; import Map, { NavigationControl, @@ -11,63 +11,48 @@ import Map, { type ViewStateChangeEvent, type MapLayerMouseEvent, } from "react-map-gl/maplibre"; -import maplibregl from "maplibre-gl"; +import maplibregl, { type LngLatBounds } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { Protocol } from "pmtiles"; import layers from "protomaps-themes-base"; import { useTranslations } from "next-intl"; +import { styled } from "@pigment-css/react"; -import MapSearch from "@/components/MapSearch"; import Button from "@/components/Button"; -import MapPinLayer from "./MapPinLayer"; - -import { styled } from "@pigment-css/react"; +import type { + ListingCoordinates, + ListingMarker, + SelectedListing, +} from "@/types/listing"; -import { useMapCenter } from "@/hooks/useMapCenter"; +import MapPinLayer from "./MapPinLayer"; +import MapSearch from "./MapSearch"; import { DEFAULT_COORDINATES, ZOOM_LEVEL_DEFAULT, ZOOM_LEVEL_SELECTED, getListingCoordinates, hasValidCoordinates, - type ListingCoordinates, - type ListingMarker, - type SelectedListing, -} from "@/utils/mapUtils"; - -type MapImmersiveProps = { - mapRef: Ref; - searchInputRef?: Ref; - listings: ListingMarker[]; - isFetching: boolean; +} from "../lib/mapUtils"; +import { useListingsInView } from "../hooks/useListingsInView"; +import { useMapCenter } from "../hooks/useMapCenter"; + +type GeocodingPickEvent = { + feature?: { center?: [number, number] }; +}; + +type MapViewProps = { selectedListing: SelectedListing | null; selectedListingId: number | null; listingSlug: string | null; initialCoordinates: (ListingCoordinates & { zoom: number }) | null; - onBoundsChange: (bounds: maplibregl.LngLatBounds) => void; onMapClick: () => void; onMarkerClick: (listing: ListingMarker) => void; - onSearchPick: (event: { feature?: { center?: [number, number] } }) => void; DrawerTrigger: ComponentType<{ children?: React.ReactNode }>; isDesktop: boolean; countryCode: string | null; }; -const MapSearchComponent = MapSearch as ComponentType<{ - onPick: (event: { feature?: { center?: [number, number] } }) => void; - searchInputRef?: Ref; - countryCode?: string | null; - style?: React.CSSProperties; -}>; - -const ButtonComponent = Button as ComponentType<{ - onClick?: () => void; - variant?: string; - size?: string; - width?: string; - children?: React.ReactNode; -}>; - const MapContainer = styled("div")({ position: "relative", width: "100%", @@ -75,7 +60,7 @@ const MapContainer = styled("div")({ backgroundColor: "lightblue", }); -const ReturnToListingButton = styled(ButtonComponent)({ +const ReturnToListingButton = styled(Button)({ position: "absolute", top: "20px", left: "50%", @@ -125,23 +110,23 @@ const searchStyle: React.CSSProperties = { }; // MapLibre style spec — protomaps tiles with the bundled light theme. Kept as -// a factory so the Map receives a stable object only when the key changes. -function buildMapStyle() { - return { - version: 8 as const, - glyphs: - "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf", - sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/light", - sources: { - protomaps: { - type: "vector" as const, - url: `https://api.protomaps.com/tiles/v4.json?key=${process.env.NEXT_PUBLIC_PROTOMAPS_API_KEY}`, - attribution: 'Protomaps', - }, +// a module-level constant because it has no runtime-dependent inputs (the env +// key is inlined at build time and the layers array is stable). Stable +// reference also keeps the Map from re-evaluating its style on re-renders. +const MAP_STYLE = { + version: 8 as const, + glyphs: + "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf", + sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/light", + sources: { + protomaps: { + type: "vector" as const, + url: `https://api.protomaps.com/tiles/v4.json?key=${process.env.NEXT_PUBLIC_PROTOMAPS_API_KEY}`, + attribution: 'Protomaps', }, - layers: layers("protomaps", "light", "en"), - }; -} + }, + layers: layers("protomaps", "light", "en"), +}; function resolveInitialViewState( selectedListing: SelectedListing | null, @@ -166,31 +151,32 @@ function resolveInitialViewState( }; } -export default function MapImmersive({ - mapRef, - searchInputRef, - listings, - isFetching, +export default function MapView({ selectedListing, selectedListingId, listingSlug, initialCoordinates, - onBoundsChange, onMapClick, onMarkerClick, - onSearchPick, DrawerTrigger, isDesktop, countryCode, -}: MapImmersiveProps) { +}: MapViewProps) { const t = useTranslations("Map"); - const mapRefObject = mapRef as React.RefObject; + const mapRef = useRef(null); - const { isSelectedInView, handleMapLoad, handleMapMoveEnd, flyToSelected } = - useMapCenter({ - mapRef: mapRefObject, - selectedListing, - }); + const { listings, isFetching, requestBounds } = useListingsInView(); + + const { + isSelectedInView, + handleMapLoad, + handleMapMoveEnd, + flyToSelected, + flyToCoordinate, + } = useMapCenter({ + mapRef, + selectedListing, + }); const hasInitialPosition = hasValidCoordinates(selectedListing) || Boolean(initialCoordinates); @@ -204,22 +190,29 @@ export default function MapImmersive({ }; }, []); + const emitBoundsChange = useCallback( + (bounds: LngLatBounds) => { + requestBounds(bounds); + }, + [requestBounds] + ); + const handleLoad = useCallback(() => { handleMapLoad(); - const map = mapRefObject.current?.getMap(); + const map = mapRef.current?.getMap(); if (map) { - onBoundsChange(map.getBounds()); + emitBoundsChange(map.getBounds()); } - }, [handleMapLoad, mapRefObject, onBoundsChange]); + }, [emitBoundsChange, handleMapLoad]); const handleMoveEnd = useCallback( (_event: ViewStateChangeEvent) => { - const map = mapRefObject.current?.getMap(); + const map = mapRef.current?.getMap(); if (!map) return; - onBoundsChange(map.getBounds()); + emitBoundsChange(map.getBounds()); handleMapMoveEnd(); }, - [handleMapMoveEnd, mapRefObject, onBoundsChange] + [emitBoundsChange, handleMapMoveEnd] ); const handleMapClickInternal = useCallback( @@ -231,6 +224,21 @@ export default function MapImmersive({ [listingSlug, onMapClick, selectedListingId] ); + const handleSearchPick = useCallback( + (event: GeocodingPickEvent) => { + // Quirk in MapTiler's Geocoding component: tapping close is also an + // "onPick" with no center. Ignore those. + const center = event?.feature?.center; + if (!center) return; + + flyToCoordinate( + { longitude: center[0], latitude: center[1] }, + ZOOM_LEVEL_DEFAULT + ); + }, + [flyToCoordinate] + ); + const showReturnButton = Boolean( selectedListing && listingSlug && !isSelectedInView ); @@ -242,7 +250,7 @@ export default function MapImmersive({ - diff --git a/src/hooks/useIpInitialLocation.ts b/src/features/map/hooks/useIpInitialLocation.ts similarity index 74% rename from src/hooks/useIpInitialLocation.ts rename to src/features/map/hooks/useIpInitialLocation.ts index 2816f6c5..1f4985e2 100644 --- a/src/hooks/useIpInitialLocation.ts +++ b/src/features/map/hooks/useIpInitialLocation.ts @@ -1,9 +1,11 @@ "use client"; import { useEffect, useState } from "react"; -import { geolocation } from "@maptiler/client"; +import { config, geolocation } from "@maptiler/client"; -import { ZOOM_LEVEL_DEFAULT, type ListingCoordinates } from "@/utils/mapUtils"; +import type { ListingCoordinates } from "@/types/listing"; + +import { ZOOM_LEVEL_DEFAULT } from "../lib/mapUtils"; type UseIpInitialLocationArgs = { // Skip when the page already has a listing slug (deep-linked selections @@ -16,7 +18,19 @@ type UseIpInitialLocationResult = { countryCode: string | null; }; -// One-time IP-based initial centre. MapImmersive falls back to +// Guarded one-time init so the key is only assigned in the browser (the hook +// only runs client-side) and only on the first consumer of the hook. +let hasConfiguredMapTiler = false; + +function ensureMapTilerConfig() { + if (hasConfiguredMapTiler) return; + const key = process.env.NEXT_PUBLIC_MAPTILER_API_KEY; + if (!key) return; + config.apiKey = key; + hasConfiguredMapTiler = true; +} + +// One-time IP-based initial centre. MapView falls back to // DEFAULT_COORDINATES if this fails or is skipped. export function useIpInitialLocation({ skip = false, @@ -29,6 +43,8 @@ export function useIpInitialLocation({ useEffect(() => { if (skip) return; + ensureMapTilerConfig(); + let cancelled = false; async function initializeLocation() { diff --git a/src/hooks/useListingsInView.ts b/src/features/map/hooks/useListingsInView.ts similarity index 96% rename from src/hooks/useListingsInView.ts rename to src/features/map/hooks/useListingsInView.ts index 8d236163..7d6bd5b8 100644 --- a/src/hooks/useListingsInView.ts +++ b/src/features/map/hooks/useListingsInView.ts @@ -4,7 +4,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LngLatBounds } from "maplibre-gl"; import { fetchListingsInView } from "@/app/actions"; -import { padBounds, type ListingMarker } from "@/utils/mapUtils"; +import type { ListingMarker } from "@/types/listing"; + +import { padBounds } from "../lib/mapUtils"; const DEBOUNCE_MS = 150; const VIEWPORT_PAD_FACTOR = 0.3; diff --git a/src/hooks/useMapCenter.ts b/src/features/map/hooks/useMapCenter.ts similarity index 94% rename from src/hooks/useMapCenter.ts rename to src/features/map/hooks/useMapCenter.ts index 230d99f5..e454e471 100644 --- a/src/hooks/useMapCenter.ts +++ b/src/features/map/hooks/useMapCenter.ts @@ -3,15 +3,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; +import type { ListingCoordinates, SelectedListing } from "@/types/listing"; + import { FLY_DURATION, ZOOM_LEVEL_SELECTED, getListingCoordinates, hasValidCoordinates, isCoordinateInBounds, - type ListingCoordinates, - type SelectedListing, -} from "@/utils/mapUtils"; +} from "../lib/mapUtils"; type UseMapCenterArgs = { mapRef: React.RefObject; @@ -82,7 +82,7 @@ export function useMapCenter({ // If we loaded with a selected listing, "claim" its id so the effect below // doesn't try to fly to it again (the map's initialViewState already did). if (hasValidCoordinates(selectedListing)) { - centeredListingIdRef.current = selectedListing.id ?? null; + centeredListingIdRef.current = selectedListing.id; } recomputeIsInView(); @@ -98,8 +98,8 @@ export function useMapCenter({ if (!map || !hasHandledLoadRef.current) return; if (!hasValidCoordinates(selectedListing)) return; - const listingId = selectedListing.id ?? null; - if (listingId !== null && centeredListingIdRef.current === listingId) { + const listingId = selectedListing.id; + if (centeredListingIdRef.current === listingId) { return; } diff --git a/src/features/map/hooks/useMapDrawerState.ts b/src/features/map/hooks/useMapDrawerState.ts new file mode 100644 index 00000000..a8667437 --- /dev/null +++ b/src/features/map/hooks/useMapDrawerState.ts @@ -0,0 +1,166 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { SNAP_POINTS } from "../lib/mapUtils"; + +type SnapPoint = number | string | null; + +type UseMapDrawerStateArgs = { + isDesktop: boolean; + listingSlug: string | null; + isListingSelected: boolean; +}; + +type UseMapDrawerStateResult = { + drawerContentRef: React.MutableRefObject; + snap: SnapPoint; + setSnap: React.Dispatch>; + isFullSnap: boolean; + isPartialSnap: boolean; + isDrawerHeaderShown: boolean; + handleSnapChange: () => void; +}; + +const SCROLL_THRESHOLD = 16; + +export const MAP_DRAWER_SNAP_POINTS: (number | string)[] = [ + SNAP_POINTS.partial, + SNAP_POINTS.full, +]; + +// Single owner for every piece of mobile/desktop drawer state that used to +// live spread across MapPageClient and useMapDrawerScroll: +// - snap point + derived flags +// - drawerContentRef + sticky-header visibility (desktop uses a portalled +// drawer, so we also watch the DOM for the drawer mounting) +// - the "map" / "drawer-fully-open" html class toggles on mobile +// - the scroll/snap/header reset when the selected listing changes +export function useMapDrawerState({ + isDesktop, + listingSlug, + isListingSelected, +}: UseMapDrawerStateArgs): UseMapDrawerStateResult { + const drawerContentRef = useRef(null); + const [snap, setSnap] = useState(SNAP_POINTS.partial); + const [isDrawerHeaderShown, setIsDrawerHeaderShown] = useState(false); + + const isFullSnap = snap === SNAP_POINTS.full; + const isPartialSnap = snap === SNAP_POINTS.partial; + + // Snap to full on desktop. On mobile we stay at the partial snap until the + // user explicitly expands or a new listing is opened. + useEffect(() => { + if (isDesktop) { + setSnap(SNAP_POINTS.full); + } + }, [isDesktop]); + + // Mobile-only html class toggles that other styles hook into. Desktop does + // not take over the viewport, so we don't touch the html classes there. + useEffect(() => { + if (isDesktop) return; + + document.documentElement.classList.add("map"); + + if (isFullSnap && listingSlug) { + document.documentElement.classList.add("drawer-fully-open"); + } else { + document.documentElement.classList.remove("drawer-fully-open"); + } + + return () => { + document.documentElement.classList.remove("map"); + document.documentElement.classList.remove("drawer-fully-open"); + }; + }, [isDesktop, isFullSnap, listingSlug]); + + // Reset snap + scroll + sticky header whenever the selected listing + // changes (including browser back/forward). + useEffect(() => { + if (!listingSlug) { + document.documentElement.classList.remove("drawer-fully-open"); + setSnap(SNAP_POINTS.partial); + return; + } + + setSnap(SNAP_POINTS.partial); + setIsDrawerHeaderShown(false); + if (drawerContentRef.current) { + drawerContentRef.current.scrollTop = 0; + } + }, [listingSlug]); + + // Mobile: we can attach the scroll handler directly when the drawer is + // fully snapped because the drawer content is the scroll container. + useEffect(() => { + if (isDesktop || !isFullSnap) { + setIsDrawerHeaderShown(false); + return; + } + + const drawerContent = drawerContentRef.current; + if (!drawerContent) return; + + const handleScroll = () => { + setIsDrawerHeaderShown(drawerContent.scrollTop > SCROLL_THRESHOLD); + }; + + drawerContent.addEventListener("scroll", handleScroll); + return () => { + drawerContent.removeEventListener("scroll", handleScroll); + }; + }, [isDesktop, isFullSnap]); + + // Desktop: the drawer is portalled, so it isn't in the tree on first + // render. Watch the document for it to mount, then attach once. + useEffect(() => { + if (!isDesktop || !isListingSelected) return; + + const handleScroll = () => { + if (drawerContentRef.current) { + setIsDrawerHeaderShown( + drawerContentRef.current.scrollTop > SCROLL_THRESHOLD + ); + } + }; + + const observer = new MutationObserver(() => { + const drawerContent = drawerContentRef.current; + if (drawerContent) { + drawerContent.addEventListener("scroll", handleScroll); + observer.disconnect(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return () => { + observer.disconnect(); + drawerContentRef.current?.removeEventListener("scroll", handleScroll); + }; + }, [isDesktop, isListingSelected]); + + const handleSnapChange = useCallback(() => { + setSnap((previous) => { + const next = + previous === SNAP_POINTS.partial + ? SNAP_POINTS.full + : SNAP_POINTS.partial; + if (next === SNAP_POINTS.partial && drawerContentRef.current) { + drawerContentRef.current.scrollTop = 0; + } + return next; + }); + }, []); + + return { + drawerContentRef, + snap, + setSnap, + isFullSnap, + isPartialSnap, + isDrawerHeaderShown, + handleSnapChange, + }; +} diff --git a/src/hooks/useMapListingUrl.ts b/src/features/map/hooks/useMapListingUrl.ts similarity index 91% rename from src/hooks/useMapListingUrl.ts rename to src/features/map/hooks/useMapListingUrl.ts index eaa87598..e5e69494 100644 --- a/src/hooks/useMapListingUrl.ts +++ b/src/features/map/hooks/useMapListingUrl.ts @@ -5,12 +5,13 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { createClient } from "@/utils/supabase/client"; -import type { SelectedListing } from "@/utils/mapUtils"; +import type { Listing, SelectedListing } from "@/types/listing"; +import type { User } from "@supabase/supabase-js"; type UseMapListingUrlArgs = { - user: { id: string } | null | undefined; + user: User | null | undefined; initialListingSlug?: string | null; - initialListing?: SelectedListing | null; + initialListing?: Listing | null; }; type UseMapListingUrlResult = { @@ -55,9 +56,7 @@ export function useMapListingUrl({ const [selectedListing, setSelectedListing] = useState(initialListing ?? null); const [optimisticListingId, setOptimisticListingId] = useState( - initialListing && typeof initialListing.id === "number" - ? (initialListing.id as number) - : null + initialListing?.id ?? null ); // The last slug we've resolved locally. Used to skip the fetch in the URL @@ -86,10 +85,10 @@ export function useMapListingUrl({ return; } - setSelectedListing(data as SelectedListing); + const listing = data as Listing; + setSelectedListing(listing); resolvedSlugRef.current = slug; - const nextId = (data as { id?: number })?.id; - setOptimisticListingId(typeof nextId === "number" ? nextId : null); + setOptimisticListingId(listing.id ?? null); } catch (err) { console.warn("Failed to load listing by slug:", err); setSelectedListing({ @@ -122,8 +121,7 @@ export function useMapListingUrl({ ) { setSelectedListing(initialListing); resolvedSlugRef.current = listingSlug; - const nextId = initialListing.id; - setOptimisticListingId(typeof nextId === "number" ? nextId : null); + setOptimisticListingId(initialListing.id ?? null); return; } @@ -155,7 +153,7 @@ export function useMapListingUrl({ return; } - const listing = data as SelectedListing; + const listing = data as Listing; setSelectedListing(listing); const slug = listing.slug; diff --git a/src/features/map/index.ts b/src/features/map/index.ts new file mode 100644 index 00000000..15702c83 --- /dev/null +++ b/src/features/map/index.ts @@ -0,0 +1,2 @@ +export { default } from "./components/MapPageClient"; +export { default as MapPageClient } from "./components/MapPageClient"; diff --git a/src/utils/mapUtils.ts b/src/features/map/lib/mapUtils.ts similarity index 66% rename from src/utils/mapUtils.ts rename to src/features/map/lib/mapUtils.ts index 8bff598a..1a0fdc12 100644 --- a/src/utils/mapUtils.ts +++ b/src/features/map/lib/mapUtils.ts @@ -1,28 +1,12 @@ import type { LngLatBounds } from "maplibre-gl"; -export type ListingType = "business" | "community" | "residential"; - -export type ListingCoordinates = { - latitude: number; - longitude: number; -}; - -export type ListingMarker = { - id: number; - type: ListingType | string | null; - coordinates: ListingCoordinates | null; -}; - -export type SelectedListing = { - id?: number; - slug?: string; - name?: string; - type?: ListingType | string | null; - coordinates?: ListingCoordinates | null; - error?: boolean; - message?: string; - [key: string]: unknown; -}; +import { + isListing, + type Listing, + type ListingCoordinates, + type ListingMarker, + type SelectedListing, +} from "@/types/listing"; export type BoundingBox = { south: number; @@ -49,25 +33,33 @@ export const FLY_DURATION = { searchPick: 3200, } as const; +// Shared sidebar width used by both the desktop layout and the map padding +// calculations so the map always accounts for the covered viewport area. +export const SIDEBAR_WIDTH = "clamp(20rem, 30vw, 30rem)"; + +// Snap points for the mobile listing drawer. 0.35 gives enough room for the +// listing header/CTA while keeping the map visible; 1 is the fully expanded +// state. +export const SNAP_POINTS = { + partial: 0.35, + full: 1, +} as const; + export function getListingCoordinates( - listing: SelectedListing | ListingMarker | null | undefined + listing: Listing | ListingMarker | SelectedListing | null | undefined ): ListingCoordinates | null { - const coords = (listing as { coordinates?: ListingCoordinates | null }) - ?.coordinates; - return coords ?? null; + if (!listing) return null; + if (!isListing(listing as SelectedListing)) return null; + return (listing as Listing | ListingMarker).coordinates ?? null; } export function hasValidCoordinates( - listing: SelectedListing | ListingMarker | null | undefined -): listing is (SelectedListing | ListingMarker) & { + listing: Listing | ListingMarker | SelectedListing | null | undefined +): listing is (Listing | ListingMarker) & { coordinates: ListingCoordinates; } { const coordinates = getListingCoordinates(listing); - const error = (listing as { error?: boolean } | null | undefined)?.error; - return Boolean( - listing && - !error && coordinates && typeof coordinates.latitude === "number" && typeof coordinates.longitude === "number" && diff --git a/src/hooks/useMapDrawerScroll.ts b/src/hooks/useMapDrawerScroll.ts deleted file mode 100644 index ae7bebd7..00000000 --- a/src/hooks/useMapDrawerScroll.ts +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; - -type UseMapDrawerScrollArgs = { - isDesktop: boolean; - // True when the drawer is fully expanded (mobile: snapPoints[1]). - isFullSnap: boolean; - isListingSelected: boolean; -}; - -type UseMapDrawerScrollResult = { - drawerContentRef: React.MutableRefObject; - isDrawerHeaderShown: boolean; - setIsDrawerHeaderShown: React.Dispatch>; -}; - -const SCROLL_THRESHOLD = 16; - -// Shows/hides the sticky drawer header based on scroll position. -// - Mobile: attaches directly when the drawer is fully snapped. -// - Desktop: the drawer is portalled, so we wait for it to mount via a -// MutationObserver, then attach once. -export function useMapDrawerScroll({ - isDesktop, - isFullSnap, - isListingSelected, -}: UseMapDrawerScrollArgs): UseMapDrawerScrollResult { - const drawerContentRef = useRef(null); - const [isDrawerHeaderShown, setIsDrawerHeaderShown] = useState(false); - - useEffect(() => { - if (isDesktop || !isFullSnap) { - setIsDrawerHeaderShown(false); - return; - } - - const drawerContent = drawerContentRef.current; - if (!drawerContent) return; - - const handleScroll = () => { - setIsDrawerHeaderShown(drawerContent.scrollTop > SCROLL_THRESHOLD); - }; - - drawerContent.addEventListener("scroll", handleScroll); - return () => { - drawerContent.removeEventListener("scroll", handleScroll); - }; - }, [isDesktop, isFullSnap]); - - useEffect(() => { - if (!isDesktop || !isListingSelected) return; - - const handleScroll = () => { - if (drawerContentRef.current) { - setIsDrawerHeaderShown( - drawerContentRef.current.scrollTop > SCROLL_THRESHOLD - ); - } - }; - - const observer = new MutationObserver(() => { - const drawerContent = drawerContentRef.current; - if (drawerContent) { - drawerContent.addEventListener("scroll", handleScroll); - observer.disconnect(); - } - }); - - observer.observe(document.body, { childList: true, subtree: true }); - - return () => { - observer.disconnect(); - drawerContentRef.current?.removeEventListener("scroll", handleScroll); - }; - }, [isDesktop, isListingSelected]); - - return { - drawerContentRef, - isDrawerHeaderShown, - setIsDrawerHeaderShown, - }; -} diff --git a/src/types/listing.ts b/src/types/listing.ts new file mode 100644 index 00000000..b26aab89 --- /dev/null +++ b/src/types/listing.ts @@ -0,0 +1,85 @@ +// Shared listing types mirroring the Supabase `listings_public_data` and +// `listings_private_data` views. Used by the map feature and the listings +// pages. + +export type ListingType = "business" | "community" | "residential"; + +export type ListingCoordinates = { + latitude: number; + longitude: number; +}; + +/** + * Shape of a row from `listings_public_data` (anonymous readers) and + * `listings_private_data` (authenticated readers). Owner-scoped fields are + * only populated on the private view and therefore optional. + */ +export interface Listing { + id: number; + created_at?: string; + name: string | null; + description: string | null; + accepted_items: string[] | null; + rejected_items: string[] | null; + photos: string[] | null; + links: string[] | null; + type: ListingType | null; + avatar: string | null; + slug: string; + coordinates: ListingCoordinates | null; + country_code: string | null; + area_name: string | null; + is_stub: boolean | null; + owner_has_multiple_non_residential_listings: boolean | null; + // Private view only + owner_id?: string | null; + visibility?: boolean | null; + owner_first_name?: string | null; + owner_avatar?: string | null; +} + +/** Error sentinel produced when a listing cannot be resolved from a slug/id. */ +export type ListingError = { + error: true; + message: string; +}; + +/** Either a fully-resolved listing or an error sentinel. */ +export type SelectedListing = Listing | ListingError; + +export function isListingError( + value: SelectedListing | null | undefined +): value is ListingError { + return Boolean(value) && (value as ListingError).error === true; +} + +export function isListing( + value: SelectedListing | null | undefined +): value is Listing { + return Boolean(value) && (value as ListingError).error !== true; +} + +/** Shape returned by the `listings_in_view` RPC — map markers only. */ +export type ListingMarker = { + id: number; + type: ListingType | null; + coordinates: ListingCoordinates | null; +}; + +/** + * Demo listings used by `PeelsMapDemo`. These don't come from the database, so + * they're a loose subset of `Listing`. Handled by `ListingRead` when + * `presentation === "demo"`. + */ +export interface DemoListing { + is_demo: true; + type: ListingType; + name?: string; + owner_first_name?: string; + avatar?: string; + description?: string; + accepted_items?: string[]; + rejected_items?: string[]; + area_name?: string; + map_position?: { x: number; y: number }; +} From bf83367115c68efc66172b69f78730350f256442 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:48:25 +1000 Subject: [PATCH 03/12] fix styling --- src/components/ListingRead/ListingRead.tsx | 28 +++++++--------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/components/ListingRead/ListingRead.tsx b/src/components/ListingRead/ListingRead.tsx index 77bdf641..87062c53 100644 --- a/src/components/ListingRead/ListingRead.tsx +++ b/src/components/ListingRead/ListingRead.tsx @@ -1,6 +1,6 @@ "use client"; import { Fragment, useState, memo, useEffect } from "react"; -import type { ComponentType, ReactNode } from "react"; +import type { ReactNode } from "react"; import type { User } from "@supabase/supabase-js"; import { Marker, NavigationControl } from "react-map-gl/maplibre"; @@ -315,31 +315,19 @@ export default ListingRead; type PresentationVariantProps = { presentation?: Presentation; - children?: ReactNode; }; type ListingSectionVariantProps = PresentationVariantProps & { overflowX?: "visible"; }; -// Pigment's variant typing narrows shared props to the first variant's literal. -// Cast the `styled()` factory so our variant props survive as a string union. -type StyledWithVariants

    = (arg: unknown) => ComponentType

    ; - -const styledDivWithPresentation = styled( - "div" -) as unknown as StyledWithVariants; -const styledSectionWithSection = styled( - "section" -) as unknown as StyledWithVariants; - const sharedColumnStyles = { display: "flex", flexDirection: "column", gap: "3rem", }; -const ColumnMain = styledDivWithPresentation(({ theme }: { theme: any }) => ({ +const ColumnMain = styled("div")(({ theme }) => ({ ...sharedColumnStyles, variants: [ @@ -357,7 +345,7 @@ const ColumnMain = styledDivWithPresentation(({ theme }: { theme: any }) => ({ ], })); -const ColumnMinor = styledDivWithPresentation(({ theme }: { theme: any }) => ({ +const ColumnMinor = styled("div")(({ theme }) => ({ ...sharedColumnStyles, variants: [ @@ -372,8 +360,8 @@ const ColumnMinor = styledDivWithPresentation(({ theme }: { theme: any }) => ({ ], })); -const ListingContents = styledDivWithPresentation( - ({ theme }: { theme: any }) => ({ +const ListingContents = styled("div")( + ({ theme }) => ({ display: "flex", flexDirection: "column", gap: "3rem", @@ -399,9 +387,9 @@ const ListingContents = styledDivWithPresentation( }) ); -const ListingSection = styledSectionWithSection( - ({ theme }: { theme: any }) => ({ - padding: " 0 1rem", +const ListingSection = styled("section")( + ({ theme }) => ({ + padding: "0 1rem", "& h3": { fontWeight: "500", From 60525ce4580f7a0cff307b389c614981b486d9e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 12:02:44 +0000 Subject: [PATCH 04/12] fix(map): address Copilot PR feedback on map hooks and MapView - Always render Map so DEFAULT_COORDINATES applies when IP lookup is null - Accept latitude/longitude 0 in MapTiler geolocation response - Refetch listing when public/private view changes after auth resolves - Loosen debounce helper typings (document why any[] is needed) Co-authored-by: Danny White --- src/features/map/components/MapView.tsx | 115 +++++++++--------- .../map/hooks/useIpInitialLocation.ts | 13 +- src/features/map/hooks/useListingsInView.ts | 6 +- src/features/map/hooks/useMapListingUrl.ts | 34 +++++- 4 files changed, 101 insertions(+), 67 deletions(-) diff --git a/src/features/map/components/MapView.tsx b/src/features/map/components/MapView.tsx index f1a63fd5..0b3e520a 100644 --- a/src/features/map/components/MapView.tsx +++ b/src/features/map/components/MapView.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef } from "react"; -import type { ComponentType } from "react"; +import type { ComponentType, CSSProperties } from "react"; import Map, { NavigationControl, @@ -90,19 +90,19 @@ const LoadingChip = styled("div")(({ theme }) => ({ transition: "opacity 150ms ease", })); -const attributionControlMobileStyle: React.CSSProperties = { +const attributionControlMobileStyle: CSSProperties = { marginRight: `calc(clamp(var(--spacing-tabBar-marginX), calc(((100vw - var(--spacing-tabBar-maxWidth)) / 2)), 100vw) + 4px)`, marginBottom: "5.25rem", opacity: 0.875, }; -const attributionControlDesktopStyle: React.CSSProperties = { +const attributionControlDesktopStyle: CSSProperties = { opacity: 1, marginRight: "10px", marginBottom: "10px", }; -const searchStyle: React.CSSProperties = { +const searchStyle: CSSProperties = { position: "absolute", top: "0.75rem", left: "0.75rem", @@ -178,9 +178,6 @@ export default function MapView({ selectedListing, }); - const hasInitialPosition = - hasValidCoordinates(selectedListing) || Boolean(initialCoordinates); - useEffect(() => { const protocol = new Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile); @@ -245,61 +242,59 @@ export default function MapView({ return ( - {hasInitialPosition && ( - <> - - - - - - - - - - + + + + + - {isFetching && {t("loadingPins")}} - - {showReturnButton && ( - - {t("returnToListing")} - - )} - - )} + + + + + + {isFetching && {t("loadingPins")}} + + {showReturnButton && ( + + {t("returnToListing")} + + )} + ); } diff --git a/src/features/map/hooks/useIpInitialLocation.ts b/src/features/map/hooks/useIpInitialLocation.ts index 1f4985e2..4f9eb38f 100644 --- a/src/features/map/hooks/useIpInitialLocation.ts +++ b/src/features/map/hooks/useIpInitialLocation.ts @@ -64,11 +64,18 @@ export function useIpInitialLocation({ if (cancelled) return; - if (response?.latitude && response?.longitude) { + const lat = response?.latitude; + const lng = response?.longitude; + if ( + typeof lat === "number" && + typeof lng === "number" && + Number.isFinite(lat) && + Number.isFinite(lng) + ) { setCountryCode(response.country_code ?? null); setInitialCoordinates({ - latitude: response.latitude, - longitude: response.longitude, + latitude: lat, + longitude: lng, zoom: ZOOM_LEVEL_DEFAULT, }); } diff --git a/src/features/map/hooks/useListingsInView.ts b/src/features/map/hooks/useListingsInView.ts index 7d6bd5b8..b30299a5 100644 --- a/src/features/map/hooks/useListingsInView.ts +++ b/src/features/map/hooks/useListingsInView.ts @@ -17,12 +17,14 @@ type UseListingsInViewResult = { requestBounds: (bounds: LngLatBounds) => void; }; -type Debounced unknown> = T & { +// `any[]` is intentional: the debounced function can be called with any +// argument list; `never[]` is too strict and fails to accept `runFetch`. +type Debounced unknown> = T & { cancel: () => void; }; // Trailing-edge debounce. Keeps the hook dependency-light (no @types/lodash). -function debounce unknown>( +function debounce unknown>( fn: T, wait: number ): Debounced { diff --git a/src/features/map/hooks/useMapListingUrl.ts b/src/features/map/hooks/useMapListingUrl.ts index e5e69494..95041612 100644 --- a/src/features/map/hooks/useMapListingUrl.ts +++ b/src/features/map/hooks/useMapListingUrl.ts @@ -65,8 +65,30 @@ export function useMapListingUrl({ initialListing?.slug ?? initialListingSlug ?? null ); + // Which Supabase view was used when we resolved `resolvedSlugRef`. When auth + // loads or the session ends, `tableName` flips — we must refetch so we do not + // keep showing columns from the wrong view. + const resolvedTableRef = useRef( + initialListingSlug && + initialListing?.slug && + initialListing.slug === initialListingSlug + ? user + ? "listings_private_data" + : "listings_public_data" + : null + ); + const tableName = user ? "listings_private_data" : "listings_public_data"; + // If the public/private view flips (e.g. session finished loading) while the + // same listing is open, refetch with the correct view. + useEffect(() => { + if (!listingSlug) return; + if (resolvedTableRef.current === null) return; + if (resolvedTableRef.current === tableName) return; + resolvedTableRef.current = null; + }, [listingSlug, tableName]); + const fetchBySlug = useCallback( async (slug: string) => { try { @@ -88,6 +110,7 @@ export function useMapListingUrl({ const listing = data as Listing; setSelectedListing(listing); resolvedSlugRef.current = slug; + resolvedTableRef.current = tableName; setOptimisticListingId(listing.id ?? null); } catch (err) { console.warn("Failed to load listing by slug:", err); @@ -109,6 +132,7 @@ export function useMapListingUrl({ useEffect(() => { if (!listingSlug) { resolvedSlugRef.current = null; + resolvedTableRef.current = null; setOptimisticListingId(null); return; } @@ -121,16 +145,20 @@ export function useMapListingUrl({ ) { setSelectedListing(initialListing); resolvedSlugRef.current = listingSlug; + resolvedTableRef.current = tableName; setOptimisticListingId(initialListing.id ?? null); return; } - if (resolvedSlugRef.current === listingSlug) { + if ( + resolvedSlugRef.current === listingSlug && + resolvedTableRef.current === tableName + ) { return; } fetchBySlug(listingSlug); - }, [fetchBySlug, initialListing, initialListingSlug, listingSlug]); + }, [fetchBySlug, initialListing, initialListingSlug, listingSlug, tableName]); const selectListingById = useCallback( async (id: number) => { @@ -160,6 +188,7 @@ export function useMapListingUrl({ if (slug) { // Claim this slug so the URL sync effect doesn't refetch. resolvedSlugRef.current = slug; + resolvedTableRef.current = tableName; router.push(`/map?listing=${slug}`, { scroll: false }); } } catch (err) { @@ -176,6 +205,7 @@ export function useMapListingUrl({ const closeListing = useCallback(() => { resolvedSlugRef.current = null; + resolvedTableRef.current = null; setOptimisticListingId(null); router.push("/map", { scroll: false }); }, [router]); From 308436c5ed263039b1750dc0d348b2df2c35e8f4 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:33:20 +1000 Subject: [PATCH 05/12] fix(map): address second-pass PR feedback - useIpInitialLocation: track setTimeout id and clear it in finally + effect cleanup so the losing race branch can't fire after the network call wins. - useMapListingUrl: add a monotonic requestTokenRef; fetchBySlug and selectListingById ignore stale responses (and close invalidates any in-flight fetch) so older requests can't overwrite newer selections. - MapPageClient: handleDrawerOpenChange now accepts the open arg and only calls closeListing when the drawer is actually closing. - mapUtils: narrow getListingCoordinates / hasValidCoordinates via an explicit isListingError check instead of casting through isListing. Made-with: Cursor --- src/features/map/components/MapPageClient.tsx | 18 +++++++----- .../map/hooks/useIpInitialLocation.ts | 25 +++++++++++++--- src/features/map/hooks/useMapListingUrl.ts | 18 ++++++++++++ src/features/map/lib/mapUtils.ts | 29 +++++++++++++------ 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/src/features/map/components/MapPageClient.tsx b/src/features/map/components/MapPageClient.tsx index 418c79eb..cb6f522d 100644 --- a/src/features/map/components/MapPageClient.tsx +++ b/src/features/map/components/MapPageClient.tsx @@ -92,13 +92,17 @@ export default function MapPageClient({ } }, [closeListing, isListingSelected]); - const handleDrawerOpenChange = useCallback(() => { - // Drawer-driven close (e.g. escape key on desktop) should also update - // the URL. - if (isListingSelected) { - closeListing(); - } - }, [closeListing, isListingSelected]); + const handleDrawerOpenChange = useCallback( + (open: boolean) => { + // Drawer-driven close (e.g. escape key on desktop) should also update + // the URL. We only act on the close transition — `open === true` is + // already reflected by `isListingSelected` from the URL. + if (!open && isListingSelected) { + closeListing(); + } + }, + [closeListing, isListingSelected] + ); return ( diff --git a/src/features/map/hooks/useIpInitialLocation.ts b/src/features/map/hooks/useIpInitialLocation.ts index 4f9eb38f..aabe8318 100644 --- a/src/features/map/hooks/useIpInitialLocation.ts +++ b/src/features/map/hooks/useIpInitialLocation.ts @@ -46,13 +46,20 @@ export function useIpInitialLocation({ ensureMapTilerConfig(); let cancelled = false; + let timeoutId: ReturnType | null = null; async function initializeLocation() { - try { - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Location timeout")), 3000); - }); + // Race the network call against a 3s timeout. We track the timeout id + // so we can clear it once the race settles — otherwise the losing + // branch can still fire and surface as an unhandled rejection. + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + timeoutId = null; + reject(new Error("Location timeout")); + }, 3000); + }); + try { const response = (await Promise.race([ geolocation.info(), timeoutPromise, @@ -80,10 +87,16 @@ export function useIpInitialLocation({ }); } } catch (error) { + if (cancelled) return; console.warn( "Could not determine location from MapTiler:", (error as Error).message ); + } finally { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } } } @@ -91,6 +104,10 @@ export function useIpInitialLocation({ return () => { cancelled = true; + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } }; }, [skip]); diff --git a/src/features/map/hooks/useMapListingUrl.ts b/src/features/map/hooks/useMapListingUrl.ts index 95041612..75e98d42 100644 --- a/src/features/map/hooks/useMapListingUrl.ts +++ b/src/features/map/hooks/useMapListingUrl.ts @@ -80,6 +80,12 @@ export function useMapListingUrl({ const tableName = user ? "listings_private_data" : "listings_public_data"; + // Monotonically increasing token for every in-flight listing fetch (by slug + // or by id). Responses only get to update state when their token is still + // the most recent one — avoids older requests overwriting newer selections + // when slugs flip rapidly or the public/private view changes mid-flight. + const requestTokenRef = useRef(0); + // If the public/private view flips (e.g. session finished loading) while the // same listing is open, refetch with the correct view. useEffect(() => { @@ -91,6 +97,8 @@ export function useMapListingUrl({ const fetchBySlug = useCallback( async (slug: string) => { + const token = ++requestTokenRef.current; + try { const { data, error } = await supabase .from(tableName) @@ -98,6 +106,8 @@ export function useMapListingUrl({ .eq("slug", slug) .single(); + if (token !== requestTokenRef.current) return; + if (error) { setSelectedListing({ error: true, @@ -113,6 +123,7 @@ export function useMapListingUrl({ resolvedTableRef.current = tableName; setOptimisticListingId(listing.id ?? null); } catch (err) { + if (token !== requestTokenRef.current) return; console.warn("Failed to load listing by slug:", err); setSelectedListing({ error: true, @@ -164,6 +175,7 @@ export function useMapListingUrl({ async (id: number) => { // Tap → pin grows immediately, even before the network round-trip. setOptimisticListingId(id); + const token = ++requestTokenRef.current; try { const { data, error } = await supabase @@ -172,6 +184,8 @@ export function useMapListingUrl({ .eq("id", id) .single(); + if (token !== requestTokenRef.current) return; + if (error || !data) { setSelectedListing({ error: true, @@ -192,6 +206,7 @@ export function useMapListingUrl({ router.push(`/map?listing=${slug}`, { scroll: false }); } } catch (err) { + if (token !== requestTokenRef.current) return; console.warn("Failed to select listing by id:", err); setSelectedListing({ error: true, @@ -204,6 +219,9 @@ export function useMapListingUrl({ ); const closeListing = useCallback(() => { + // Invalidate any in-flight fetches so their late responses don't reopen + // the drawer. + requestTokenRef.current += 1; resolvedSlugRef.current = null; resolvedTableRef.current = null; setOptimisticListingId(null); diff --git a/src/features/map/lib/mapUtils.ts b/src/features/map/lib/mapUtils.ts index 1a0fdc12..6b53a968 100644 --- a/src/features/map/lib/mapUtils.ts +++ b/src/features/map/lib/mapUtils.ts @@ -1,7 +1,7 @@ import type { LngLatBounds } from "maplibre-gl"; import { - isListing, + isListingError, type Listing, type ListingCoordinates, type ListingMarker, @@ -45,19 +45,30 @@ export const SNAP_POINTS = { full: 1, } as const; +// `Listing` and `ListingMarker` both carry a nullable `coordinates` field. +// `SelectedListing` can additionally be a `ListingError` sentinel, which has +// no coordinates. Narrowing explicitly here (rather than treating anything +// without `error === true` as a listing) keeps the intent readable. +type CoordinateBearing = Listing | ListingMarker; + +function hasCoordinateField( + listing: CoordinateBearing | SelectedListing | null | undefined +): listing is CoordinateBearing { + if (!listing) return false; + if (isListingError(listing as SelectedListing)) return false; + return "coordinates" in listing; +} + export function getListingCoordinates( - listing: Listing | ListingMarker | SelectedListing | null | undefined + listing: CoordinateBearing | SelectedListing | null | undefined ): ListingCoordinates | null { - if (!listing) return null; - if (!isListing(listing as SelectedListing)) return null; - return (listing as Listing | ListingMarker).coordinates ?? null; + if (!hasCoordinateField(listing)) return null; + return listing.coordinates ?? null; } export function hasValidCoordinates( - listing: Listing | ListingMarker | SelectedListing | null | undefined -): listing is (Listing | ListingMarker) & { - coordinates: ListingCoordinates; -} { + listing: CoordinateBearing | SelectedListing | null | undefined +): listing is CoordinateBearing & { coordinates: ListingCoordinates } { const coordinates = getListingCoordinates(listing); return Boolean( coordinates && From ff2194a272df62470d2e92915cee865611d1a643 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:09:56 +1000 Subject: [PATCH 06/12] fix(map): attach desktop drawer scroll listener when ref is already set Previously, the desktop branch of useMapDrawerState only attached the scroll listener inside the MutationObserver callback. If drawerContentRef.current was already populated when the effect ran (common on subsequent listing changes), no DOM mutation fired, the listener never attached, and the sticky drawer header stayed hidden. Now we attach immediately when the ref is set and only fall back to the observer when it isn't. Made-with: Cursor --- src/features/map/hooks/useMapDrawerState.ts | 45 +++++++++++++-------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/features/map/hooks/useMapDrawerState.ts b/src/features/map/hooks/useMapDrawerState.ts index a8667437..d7f69d0a 100644 --- a/src/features/map/hooks/useMapDrawerState.ts +++ b/src/features/map/hooks/useMapDrawerState.ts @@ -112,32 +112,45 @@ export function useMapDrawerState({ }; }, [isDesktop, isFullSnap]); - // Desktop: the drawer is portalled, so it isn't in the tree on first - // render. Watch the document for it to mount, then attach once. + // Desktop: the drawer is portalled, so it may not be in the tree on the + // first render. If the ref is already set by the time this effect runs + // (common on subsequent listing changes), attach directly; otherwise fall + // back to a MutationObserver that attaches once the portal mounts. useEffect(() => { if (!isDesktop || !isListingSelected) return; + let attachedTo: HTMLElement | null = null; + const handleScroll = () => { - if (drawerContentRef.current) { - setIsDrawerHeaderShown( - drawerContentRef.current.scrollTop > SCROLL_THRESHOLD - ); + if (attachedTo) { + setIsDrawerHeaderShown(attachedTo.scrollTop > SCROLL_THRESHOLD); } }; - const observer = new MutationObserver(() => { - const drawerContent = drawerContentRef.current; - if (drawerContent) { - drawerContent.addEventListener("scroll", handleScroll); - observer.disconnect(); - } - }); + const attach = (element: HTMLElement) => { + attachedTo = element; + element.addEventListener("scroll", handleScroll); + }; - observer.observe(document.body, { childList: true, subtree: true }); + let observer: MutationObserver | null = null; + + if (drawerContentRef.current) { + attach(drawerContentRef.current); + } else { + observer = new MutationObserver(() => { + const drawerContent = drawerContentRef.current; + if (drawerContent) { + attach(drawerContent); + observer?.disconnect(); + observer = null; + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } return () => { - observer.disconnect(); - drawerContentRef.current?.removeEventListener("scroll", handleScroll); + observer?.disconnect(); + attachedTo?.removeEventListener("scroll", handleScroll); }; }, [isDesktop, isListingSelected]); From 7d20b21255b043a1be6a1734ce840aaae656302f Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:41:53 +1000 Subject: [PATCH 07/12] fix(map): keep URL/UI in sync on tap failure; handle antimeridian in padBounds - selectListingById no longer sets the error sentinel when the fetch fails. Tap-driven fetches happen before the URL is pushed, so the drawer would either stay closed (swallowing the error) or desync UI and URL while pointing at the previous listing. The optimistic pin id is reverted and `selectedListing` is left untouched, so UI and URL stay consistent. - padBounds now wraps longitudes into [-180, 180] and splits the envelope into two when the padded viewport crosses the antimeridian. Callers iterate all returned boxes. - useListingsInView fetches all returned boxes in parallel and merges responses, deduping by id. Made-with: Cursor --- src/features/map/hooks/useListingsInView.ts | 25 ++++++++---- src/features/map/hooks/useMapListingUrl.ts | 16 ++++---- src/features/map/lib/mapUtils.ts | 42 +++++++++++++++++---- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/features/map/hooks/useListingsInView.ts b/src/features/map/hooks/useListingsInView.ts index b30299a5..bd20337a 100644 --- a/src/features/map/hooks/useListingsInView.ts +++ b/src/features/map/hooks/useListingsInView.ts @@ -65,24 +65,35 @@ export function useListingsInView(): UseListingsInViewResult { const inFlightCountRef = useRef(0); const runFetch = useCallback(async (bounds: LngLatBounds) => { - const padded = padBounds(bounds, VIEWPORT_PAD_FACTOR); + // `padBounds` returns 1 box normally, or 2 when the viewport crosses the + // antimeridian. We fetch each and merge, deduping by id. + const paddedBoxes = padBounds(bounds, VIEWPORT_PAD_FACTOR); const requestId = ++requestIdRef.current; inFlightCountRef.current += 1; setIsFetching(true); try { - const data = await fetchListingsInView( - padded.south, - padded.west, - padded.north, - padded.east + const responses = await Promise.all( + paddedBoxes.map((box) => + fetchListingsInView(box.south, box.west, box.north, box.east) + ) ); // Ignore stale responses — a newer request has already superseded this one. if (requestId !== requestIdRef.current) return; - setListings((data ?? []) as ListingMarker[]); + const seen = new Set(); + const merged: ListingMarker[] = []; + for (const response of responses) { + for (const marker of (response ?? []) as ListingMarker[]) { + if (seen.has(marker.id)) continue; + seen.add(marker.id); + merged.push(marker); + } + } + + setListings(merged); } catch (error) { if (requestId !== requestIdRef.current) return; console.error("Error fetching listings in view:", error); diff --git a/src/features/map/hooks/useMapListingUrl.ts b/src/features/map/hooks/useMapListingUrl.ts index 75e98d42..a3b0a0fd 100644 --- a/src/features/map/hooks/useMapListingUrl.ts +++ b/src/features/map/hooks/useMapListingUrl.ts @@ -187,10 +187,12 @@ export function useMapListingUrl({ if (token !== requestTokenRef.current) return; if (error || !data) { - setSelectedListing({ - error: true, - message: t("Listings.edit.notFound"), - }); + // Tap-driven fetches happen before the URL is pushed, so surfacing + // an error sentinel here would either be invisible (no listing in + // URL → drawer stays closed) or desync the UI from the URL (still + // pointing at the previous listing). Revert the optimistic pin and + // leave `selectedListing` alone instead. + console.warn("Failed to select listing by id:", error); setOptimisticListingId(null); return; } @@ -208,14 +210,10 @@ export function useMapListingUrl({ } catch (err) { if (token !== requestTokenRef.current) return; console.warn("Failed to select listing by id:", err); - setSelectedListing({ - error: true, - message: t("Listings.edit.notFound"), - }); setOptimisticListingId(null); } }, - [router, supabase, t, tableName] + [router, supabase, tableName] ); const closeListing = useCallback(() => { diff --git a/src/features/map/lib/mapUtils.ts b/src/features/map/lib/mapUtils.ts index 6b53a968..7ed00a9b 100644 --- a/src/features/map/lib/mapUtils.ts +++ b/src/features/map/lib/mapUtils.ts @@ -79,10 +79,23 @@ export function hasValidCoordinates( ); } +// Wrap a longitude into the canonical [-180, 180] range. MapLibre reports +// bounds as "unwrapped" coordinates (values outside the canonical range when +// the user has panned across the antimeridian), but the `listings_in_view` +// RPC feeds them into PostGIS' `st_makeenvelope`, which expects canonical +// longitudes. +function wrapLongitude(lng: number): number { + return ((((lng + 180) % 360) + 360) % 360) - 180; +} + // Expand a viewport bbox by a fraction (e.g. 0.3 => 30% larger in each // direction). This lets us fetch a slightly padded area so that small pans // reuse already-loaded pins without hitting the network again. -export function padBounds(bounds: LngLatBounds, factor = 0.3): BoundingBox { +// +// Returns 1 or 2 boxes. Two are returned when the padded viewport crosses +// the antimeridian (e.g. Fiji, NZ → Alaska), so the caller can fetch both +// halves and merge the results. +export function padBounds(bounds: LngLatBounds, factor = 0.3): BoundingBox[] { const sw = bounds.getSouthWest(); const ne = bounds.getNorthEast(); @@ -92,12 +105,27 @@ export function padBounds(bounds: LngLatBounds, factor = 0.3): BoundingBox { const latPad = latSpan * factor; const lngPad = lngSpan * factor; - return { - south: Math.max(-90, sw.lat - latPad), - north: Math.min(90, ne.lat + latPad), - west: sw.lng - lngPad, - east: ne.lng + lngPad, - }; + const south = Math.max(-90, sw.lat - latPad); + const north = Math.min(90, ne.lat + latPad); + + // If the padded viewport already covers the whole globe, just request the + // whole world (avoids degenerate envelopes in PostGIS). + if (lngSpan + 2 * lngPad >= 360) { + return [{ south, north, west: -180, east: 180 }]; + } + + const west = wrapLongitude(sw.lng - lngPad); + const east = wrapLongitude(ne.lng + lngPad); + + if (west <= east) { + return [{ south, north, west, east }]; + } + + // Crosses the antimeridian — split into two valid envelopes. + return [ + { south, north, west, east: 180 }, + { south, north, west: -180, east }, + ]; } export function isCoordinateInBounds( From 03ff0bd7d4e694de8c6151fe63dcf347d802f81b Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:44:07 +1000 Subject: [PATCH 08/12] fix(map): restore optimistic pin to resolved listing on tap failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When selectListingById's fetch fails while a previously resolved listing is still visible in the drawer, the pin selection was being cleared to null — leaving the drawer showing listing A with no pin highlighted. Now we capture the currently-resolved listing id (via a ref mirroring selectedListing.id) before the optimistic change, and revert to that id on failure so the pin and the drawer stay in sync. Made-with: Cursor --- src/features/map/hooks/useMapListingUrl.ts | 28 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/features/map/hooks/useMapListingUrl.ts b/src/features/map/hooks/useMapListingUrl.ts index a3b0a0fd..bfaadfea 100644 --- a/src/features/map/hooks/useMapListingUrl.ts +++ b/src/features/map/hooks/useMapListingUrl.ts @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { createClient } from "@/utils/supabase/client"; -import type { Listing, SelectedListing } from "@/types/listing"; +import { isListing, type Listing, type SelectedListing } from "@/types/listing"; import type { User } from "@supabase/supabase-js"; type UseMapListingUrlArgs = { @@ -86,6 +86,19 @@ export function useMapListingUrl({ // when slugs flip rapidly or the public/private view changes mid-flight. const requestTokenRef = useRef(0); + // Id of the listing currently rendered in the drawer (or null when nothing + // or an error is shown). Kept in a ref so `selectListingById` can revert + // the optimistic pin id after a failed tap without going through stale + // closure values. + const resolvedListingIdRef = useRef( + initialListing?.id ?? null + ); + useEffect(() => { + resolvedListingIdRef.current = isListing(selectedListing) + ? (selectedListing.id ?? null) + : null; + }, [selectedListing]); + // If the public/private view flips (e.g. session finished loading) while the // same listing is open, refetch with the correct view. useEffect(() => { @@ -173,6 +186,10 @@ export function useMapListingUrl({ const selectListingById = useCallback( async (id: number) => { + // Capture the pin id of the drawer's current resolved listing before + // the optimistic change, so we can restore it if the fetch fails. + const previousResolvedId = resolvedListingIdRef.current; + // Tap → pin grows immediately, even before the network round-trip. setOptimisticListingId(id); const token = ++requestTokenRef.current; @@ -190,10 +207,11 @@ export function useMapListingUrl({ // Tap-driven fetches happen before the URL is pushed, so surfacing // an error sentinel here would either be invisible (no listing in // URL → drawer stays closed) or desync the UI from the URL (still - // pointing at the previous listing). Revert the optimistic pin and - // leave `selectedListing` alone instead. + // pointing at the previous listing). Revert the optimistic pin to + // whatever the drawer is currently showing and leave + // `selectedListing` alone. console.warn("Failed to select listing by id:", error); - setOptimisticListingId(null); + setOptimisticListingId(previousResolvedId); return; } @@ -210,7 +228,7 @@ export function useMapListingUrl({ } catch (err) { if (token !== requestTokenRef.current) return; console.warn("Failed to select listing by id:", err); - setOptimisticListingId(null); + setOptimisticListingId(previousResolvedId); } }, [router, supabase, tableName] From 5e205ac75721078e9d362d58d7f307171c701c52 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:05:26 +1000 Subject: [PATCH 09/12] fix(map): cancel in-flight fetch when URL loses listingSlug; cleanups - useMapListingUrl: bump requestTokenRef when listingSlug becomes null so a late fetchBySlug response can't re-select the listing after browser back. - ListingRead: memoize the Supabase client so the thread-loading effect doesn't re-run on every render; drop unused mapZoomLevel state + effect (MapThumbnail already uses initialZoomLevel directly). - ListingChatDrawer: drop the dead listingDisplayName prop (was renamed to a discarded local); simplify isNested check to !isNested so an omitted prop is treated as "not nested". Made-with: Cursor --- .../ListingChatDrawer/ListingChatDrawer.tsx | 5 ++--- src/components/ListingRead/ListingRead.tsx | 15 ++++++++------- src/features/map/hooks/useMapListingUrl.ts | 4 ++++ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/ListingChatDrawer/ListingChatDrawer.tsx b/src/components/ListingChatDrawer/ListingChatDrawer.tsx index a5e55d37..d439a768 100644 --- a/src/components/ListingChatDrawer/ListingChatDrawer.tsx +++ b/src/components/ListingChatDrawer/ListingChatDrawer.tsx @@ -65,7 +65,6 @@ type ListingChatDrawerProps = { isChatDrawerOpen: boolean; setIsChatDrawerOpen: (open: boolean) => void; existingThread: unknown; - listingDisplayName: string; }; type SharedDrawerProps = { @@ -94,12 +93,12 @@ export default function ListingChatDrawer({ isChatDrawerOpen, setIsChatDrawerOpen, existingThread, - listingDisplayName: _listingDisplayName, }: ListingChatDrawerProps) { const { isDesktop, hasTouch } = useDeviceContext(); // Mobile: always modal. Desktop: modal only if NOT a nested drawer. - const shouldUseModal = !isDesktop || isNested === false; + // (`!isNested` treats an omitted prop the same as `false`.) + const shouldUseModal = !isDesktop || !isNested; const visibility = listing.visibility ?? undefined; const isStub = listing.is_stub ?? undefined; diff --git a/src/components/ListingRead/ListingRead.tsx b/src/components/ListingRead/ListingRead.tsx index 87062c53..1b46bc77 100644 --- a/src/components/ListingRead/ListingRead.tsx +++ b/src/components/ListingRead/ListingRead.tsx @@ -1,5 +1,5 @@ "use client"; -import { Fragment, useState, memo, useEffect } from "react"; +import { Fragment, useState, memo, useEffect, useMemo } from "react"; import type { ReactNode } from "react"; import type { User } from "@supabase/supabase-js"; @@ -47,12 +47,17 @@ const ListingRead = memo(function Listing({ const router = useRouter(); const [existingThread, setExistingThread] = useState(null); - const [mapZoomLevel, setMapZoomLevel] = useState(null); // Chat drawer state is owned here so that each selected listing gets a // fresh drawer. The parent resets this by remounting with `key`. const [isChatDrawerOpen, setIsChatDrawerOpen] = useState(false); - const supabase = presentation !== "demo" ? createClient() : null; + // `createClient()` builds a new Supabase browser client on every call, so + // memoize to keep the reference stable — otherwise the thread-loading + // effect below (which depends on `supabase`) would re-run on every render. + const supabase = useMemo( + () => (presentation !== "demo" ? createClient() : null), + [presentation] + ); const isDemo = presentation === "demo"; const demoListing = isDemoListing(listing) ? listing : null; @@ -93,9 +98,6 @@ const ListingRead = memo(function Listing({ }, [realListing?.id, user?.id, isDemo, supabase, realListing]); const initialZoomLevel = 14; - useEffect(() => { - setMapZoomLevel(initialZoomLevel); - }, []); const listingDisplayName: string = isDemo ? (demoListing?.name ?? demoListing?.owner_first_name ?? "") @@ -135,7 +137,6 @@ const ListingRead = memo(function Listing({ isChatDrawerOpen={isChatDrawerOpen} setIsChatDrawerOpen={setIsChatDrawerOpen} existingThread={existingThread} - listingDisplayName={listingDisplayName} /> ) : null} diff --git a/src/features/map/hooks/useMapListingUrl.ts b/src/features/map/hooks/useMapListingUrl.ts index bfaadfea..4dc4c9ec 100644 --- a/src/features/map/hooks/useMapListingUrl.ts +++ b/src/features/map/hooks/useMapListingUrl.ts @@ -155,6 +155,10 @@ export function useMapListingUrl({ // do clear the pin-selection id so the pin snaps back immediately. useEffect(() => { if (!listingSlug) { + // Invalidate any in-flight fetch so a late response can't re-select + // the listing the user just navigated away from (e.g. browser Back + // while a deep-link fetch is still in flight). + requestTokenRef.current += 1; resolvedSlugRef.current = null; resolvedTableRef.current = null; setOptimisticListingId(null); From 64e75221f156933576f178ded5954355f97c5c6e Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:39:57 +1000 Subject: [PATCH 10/12] fix: narrow thread effect deps; share ListingType with MapPin - ListingRead: the thread-loading effect depended on the whole realListing object, so any parent re-render with a new listing identity (even with the same id/owner_id) refetched the thread. Depend on the specific fields the effect reads instead. - MapPin: drop the duplicated ListingPinType union and key the icon map on the shared ListingType. Adding a new listing type now surfaces as a compile error in MapPin, keeping the two from drifting out of sync. Made-with: Cursor --- src/components/ListingRead/ListingRead.tsx | 19 ++++++++++++------- src/components/MapPin/MapPin.tsx | 15 ++++++--------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/components/ListingRead/ListingRead.tsx b/src/components/ListingRead/ListingRead.tsx index 1b46bc77..b3a04d88 100644 --- a/src/components/ListingRead/ListingRead.tsx +++ b/src/components/ListingRead/ListingRead.tsx @@ -64,13 +64,18 @@ const ListingRead = memo(function Listing({ const realListing = !isDemo && listing && !isDemoListing(listing) ? (listing as Listing) : null; - // Load existing thread if any (only if not in demo mode) + // Load existing thread if any (only if not in demo mode). Depend on the + // specific listing fields used inside the effect so a new `realListing` + // object identity with the same id/owner doesn't refire the query. + const listingId = realListing?.id; + const listingOwnerId = realListing?.owner_id; + const userId = user?.id; useEffect(() => { - if (isDemo || !supabase || !user || !realListing) return; + if (isDemo || !supabase || !userId || !listingId || !listingOwnerId) return; // TODO: Should this only be called when the actual ListingChatDrawer is loaded? async function loadExistingThread() { - if (!supabase || !user || !realListing) return; + if (!supabase) return; const { data: thread, error } = await supabase .from("chat_threads_with_participants") .select( @@ -80,9 +85,9 @@ const ListingRead = memo(function Listing({ ` ) .match({ - listing_id: realListing.id, - initiator_id: user.id, - owner_id: realListing.owner_id, + listing_id: listingId, + initiator_id: userId, + owner_id: listingOwnerId, }) .maybeSingle(); @@ -95,7 +100,7 @@ const ListingRead = memo(function Listing({ } loadExistingThread(); - }, [realListing?.id, user?.id, isDemo, supabase, realListing]); + }, [listingId, listingOwnerId, userId, isDemo, supabase]); const initialZoomLevel = 14; diff --git a/src/components/MapPin/MapPin.tsx b/src/components/MapPin/MapPin.tsx index f9ccd229..1196d873 100644 --- a/src/components/MapPin/MapPin.tsx +++ b/src/components/MapPin/MapPin.tsx @@ -3,7 +3,7 @@ import MapCommunityIcon from "../MapCommunityIcon"; import MapResidentialIcon from "../MapResidentialIcon"; import { styled } from "@pigment-css/react"; -type ListingPinType = "business" | "community" | "residential"; +import type { ListingType } from "@/types/listing"; type MapPinProps = { selected?: boolean; @@ -145,19 +145,16 @@ const SelectedPinIcon = ( const ICON = `M18.149 15.8139C18.2078 15.7251 18.2326 15.6533 18.2915 15.5646C19.3387 13.9878 20 12.0412 20 10C20 4.4 15.5 0 10 0C4.5 0 0 4.5 0 10C0 11.8662 0.522404 13.6453 1.40473 15.0937C1.52799 15.296 1.62851 15.5285 1.79602 15.696C1.79734 15.6974 1.79867 15.6987 1.8 15.7C1.90535 15.8054 1.94349 15.9666 2.02739 16.0897C2.18874 16.3264 2.36323 16.5632 2.6 16.8C4.5396 19.1126 7.70356 22.2044 9.18572 23.6258C9.64236 24.0637 10.3577 24.0638 10.8151 23.6266C12.2976 22.2097 15.4607 19.1376 17.4 16.9C17.5711 16.6433 17.8155 16.3866 18.0078 16.1299C18.07 16.0467 18.0918 15.9006 18.149 15.8139Z`; -const iconMap: Record< - ListingPinType, - React.ComponentType<{ size?: string }> -> = { +// Keyed on the shared `ListingType` union so adding a new type there becomes +// a compile error here — prevents the two from drifting out of sync. +const iconMap: Record> = { business: MapBusinessIcon as React.ComponentType<{ size?: string }>, community: MapCommunityIcon as React.ComponentType<{ size?: string }>, residential: MapResidentialIcon as React.ComponentType<{ size?: string }>, }; -function isListingPinType(value: string | undefined): value is ListingPinType { - return ( - value === "business" || value === "community" || value === "residential" - ); +function isListingPinType(value: string | undefined): value is ListingType { + return value !== undefined && value in iconMap; } function MapPin({ selected = false, type }: MapPinProps) { From 99cc01d8c9788fef3985470afced3b61e06e1a23 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:00:11 +1000 Subject: [PATCH 11/12] fix(map): restore IP-based initial centre; assorted review tweaks - MapView: restore the hasInitialPosition gate. MapLibre's initialViewState is consumed once at mount, so mounting before the IP lookup resolves locks the map to DEFAULT_COORDINATES (Brisbane) even after IP data arrives. The gate waits for either a selected listing or the IP fallback to resolve before mounting. - useIpInitialLocation: on timeout or error, fall back to DEFAULT_COORDINATES so the gate always eventually lifts (addresses the earlier concern about the map never rendering on IP failure). - mapUtils: drop the unused DEFAULT_COORDINATES.zoom; ZOOM_LEVEL_DEFAULT is already the canonical default zoom. - useMapListingUrl: memoize the Supabase client so fetchBySlug / selectListingById don't churn on every render. - MapPin: tighten isListingPinType with hasOwnProperty.call so inherited prototype keys like "toString" can't incorrectly narrow to ListingType. Made-with: Cursor --- src/components/MapPin/MapPin.tsx | 6 +- src/features/map/components/MapView.tsx | 112 ++++++++++-------- .../map/hooks/useIpInitialLocation.ts | 18 ++- src/features/map/hooks/useMapListingUrl.ts | 7 +- src/features/map/lib/mapUtils.ts | 7 +- 5 files changed, 90 insertions(+), 60 deletions(-) diff --git a/src/components/MapPin/MapPin.tsx b/src/components/MapPin/MapPin.tsx index 1196d873..459ec45d 100644 --- a/src/components/MapPin/MapPin.tsx +++ b/src/components/MapPin/MapPin.tsx @@ -154,7 +154,11 @@ const iconMap: Record> = { }; function isListingPinType(value: string | undefined): value is ListingType { - return value !== undefined && value in iconMap; + // Guard against inherited Object.prototype keys like `"toString"` that a + // plain `value in iconMap` check would accept. + return ( + value !== undefined && Object.prototype.hasOwnProperty.call(iconMap, value) + ); } function MapPin({ selected = false, type }: MapPinProps) { diff --git a/src/features/map/components/MapView.tsx b/src/features/map/components/MapView.tsx index 0b3e520a..05a5f31d 100644 --- a/src/features/map/components/MapView.tsx +++ b/src/features/map/components/MapView.tsx @@ -178,6 +178,14 @@ export default function MapView({ selectedListing, }); + // MapLibre's `initialViewState` is only consumed once at mount, so we wait + // for either a selected listing or the IP-based (or fallback) initial + // centre to resolve before mounting the Map. `useIpInitialLocation` + // always resolves (to DEFAULT_COORDINATES on failure), so this cannot + // stall indefinitely. + const hasInitialPosition = + hasValidCoordinates(selectedListing) || Boolean(initialCoordinates); + useEffect(() => { const protocol = new Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile); @@ -242,59 +250,61 @@ export default function MapView({ return ( - <> - - - - - + + + + + + + + + + - - - - - - {isFetching && {t("loadingPins")}} - - {showReturnButton && ( - - {t("returnToListing")} - - )} - + {isFetching && {t("loadingPins")}} + + {showReturnButton && ( + + {t("returnToListing")} + + )} + + )} ); } diff --git a/src/features/map/hooks/useIpInitialLocation.ts b/src/features/map/hooks/useIpInitialLocation.ts index aabe8318..9b42309f 100644 --- a/src/features/map/hooks/useIpInitialLocation.ts +++ b/src/features/map/hooks/useIpInitialLocation.ts @@ -5,7 +5,7 @@ import { config, geolocation } from "@maptiler/client"; import type { ListingCoordinates } from "@/types/listing"; -import { ZOOM_LEVEL_DEFAULT } from "../lib/mapUtils"; +import { DEFAULT_COORDINATES, ZOOM_LEVEL_DEFAULT } from "../lib/mapUtils"; type UseIpInitialLocationArgs = { // Skip when the page already has a listing slug (deep-linked selections @@ -30,8 +30,9 @@ function ensureMapTilerConfig() { hasConfiguredMapTiler = true; } -// One-time IP-based initial centre. MapView falls back to -// DEFAULT_COORDINATES if this fails or is skipped. +// One-time IP-based initial centre. On timeout or error we still resolve to +// `DEFAULT_COORDINATES` so MapView, which gates on `initialCoordinates` +// being set, always eventually mounts. export function useIpInitialLocation({ skip = false, }: UseIpInitialLocationArgs = {}): UseIpInitialLocationResult { @@ -48,6 +49,14 @@ export function useIpInitialLocation({ let cancelled = false; let timeoutId: ReturnType | null = null; + const applyFallback = () => { + if (cancelled) return; + setInitialCoordinates({ + ...DEFAULT_COORDINATES, + zoom: ZOOM_LEVEL_DEFAULT, + }); + }; + async function initializeLocation() { // Race the network call against a 3s timeout. We track the timeout id // so we can clear it once the race settles — otherwise the losing @@ -85,6 +94,8 @@ export function useIpInitialLocation({ longitude: lng, zoom: ZOOM_LEVEL_DEFAULT, }); + } else { + applyFallback(); } } catch (error) { if (cancelled) return; @@ -92,6 +103,7 @@ export function useIpInitialLocation({ "Could not determine location from MapTiler:", (error as Error).message ); + applyFallback(); } finally { if (timeoutId !== null) { clearTimeout(timeoutId); diff --git a/src/features/map/hooks/useMapListingUrl.ts b/src/features/map/hooks/useMapListingUrl.ts index 4dc4c9ec..60b0ef40 100644 --- a/src/features/map/hooks/useMapListingUrl.ts +++ b/src/features/map/hooks/useMapListingUrl.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -49,7 +49,10 @@ export function useMapListingUrl({ const t = useTranslations(); const router = useRouter(); const searchParams = useSearchParams(); - const supabase = createClient(); + // `createClient()` builds a new Supabase browser client each call, so + // memoize to keep the reference stable — otherwise `fetchBySlug` / + // `selectListingById` churn on every render. + const supabase = useMemo(() => createClient(), []); const listingSlug = searchParams.get("listing"); diff --git a/src/features/map/lib/mapUtils.ts b/src/features/map/lib/mapUtils.ts index 7ed00a9b..66d5e08a 100644 --- a/src/features/map/lib/mapUtils.ts +++ b/src/features/map/lib/mapUtils.ts @@ -15,11 +15,12 @@ export type BoundingBox = { east: number; }; -// Default coordinates for Brisbane, Australia -export const DEFAULT_COORDINATES: ListingCoordinates & { zoom: number } = { +// Default coordinates for Brisbane, Australia — the absolute fallback when +// IP-based geolocation is unavailable. Pair with `ZOOM_LEVEL_DEFAULT` for +// the zoom level. +export const DEFAULT_COORDINATES: ListingCoordinates = { latitude: -27.4683, longitude: 153.0322, - zoom: 9, }; export const ZOOM_LEVEL_DEFAULT = 11; From ae1858d5c90a9fdb9818a13322d6c0b68eb885b3 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:23:31 +1000 Subject: [PATCH 12/12] fix(map): resolve initialCoordinates even when IP lookup is skipped Deep links (skip: true) would leave initialCoordinates null forever; combined with a listing that has no coordinates or resolves to an error sentinel, the never mounted. Fall back to DEFAULT_COORDINATES on skip so MapView can always render. Made-with: Cursor --- src/features/map/components/MapView.tsx | 4 ++-- src/features/map/hooks/useIpInitialLocation.ts | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/features/map/components/MapView.tsx b/src/features/map/components/MapView.tsx index 05a5f31d..060455e5 100644 --- a/src/features/map/components/MapView.tsx +++ b/src/features/map/components/MapView.tsx @@ -181,8 +181,8 @@ export default function MapView({ // MapLibre's `initialViewState` is only consumed once at mount, so we wait // for either a selected listing or the IP-based (or fallback) initial // centre to resolve before mounting the Map. `useIpInitialLocation` - // always resolves (to DEFAULT_COORDINATES on failure), so this cannot - // stall indefinitely. + // always resolves (to DEFAULT_COORDINATES on failure or when skipped), so + // this cannot stall indefinitely. const hasInitialPosition = hasValidCoordinates(selectedListing) || Boolean(initialCoordinates); diff --git a/src/features/map/hooks/useIpInitialLocation.ts b/src/features/map/hooks/useIpInitialLocation.ts index 9b42309f..959f5717 100644 --- a/src/features/map/hooks/useIpInitialLocation.ts +++ b/src/features/map/hooks/useIpInitialLocation.ts @@ -30,9 +30,10 @@ function ensureMapTilerConfig() { hasConfiguredMapTiler = true; } -// One-time IP-based initial centre. On timeout or error we still resolve to -// `DEFAULT_COORDINATES` so MapView, which gates on `initialCoordinates` -// being set, always eventually mounts. +// One-time IP-based initial centre. On timeout, error, or when skipped we +// still resolve to `DEFAULT_COORDINATES` so MapView, which gates on +// `initialCoordinates` being set, always eventually mounts — including deep +// links to listings with `coordinates: null` or that resolve to an error. export function useIpInitialLocation({ skip = false, }: UseIpInitialLocationArgs = {}): UseIpInitialLocationResult { @@ -42,7 +43,16 @@ export function useIpInitialLocation({ const [countryCode, setCountryCode] = useState(null); useEffect(() => { - if (skip) return; + if (skip) { + // Deep-linked: we're not running the IP lookup, but MapView still + // needs a non-null initial centre in case the listing itself has no + // valid coordinates (error sentinel or coordinates: null). + setInitialCoordinates({ + ...DEFAULT_COORDINATES, + zoom: ZOOM_LEVEL_DEFAULT, + }); + return; + } ensureMapTilerConfig();