- {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({
{t("Actions.seeNearbyListings")}
- {listing.type !== "residential" && (
+ {realListing.type !== "residential" && (
<>
@@ -239,37 +259,37 @@ const ListingRead = memo(function Listing({
)}
- {listing.photos?.length > 0 && (
+ {realListing.photos && realListing.photos.length > 0 && (
{t("Common.photos")}
- {!user && listing.type === "residential" ? (
+ {!user && realListing.type === "residential" ? (
{t.rich("Listings.read.signInForPhotos", {
- link: (chunks) => (
+ link: (chunks: ReactNode) => (
{chunks}
),
})}
) : (
- <>
-
- >
+
)}
)}
- {listing.links?.length > 0 && (
+ {realListing.links && realListing.links.length > 0 && (
{t("Common.links")}
-
+
)}
@@ -279,7 +299,7 @@ const ListingRead = memo(function Listing({
variant="secondary"
size="small"
width="contained"
- href={`/listings/${listing.slug}`}
+ href={`/listings/${realListing.slug}`}
>
{t("Actions.viewFullListing")}
@@ -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();