diff --git a/messages/de.json b/messages/de.json
index 31f71712..dbe374f9 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -378,6 +378,8 @@
"searchPlaceholder": "Suchen",
"searchError": "Etwas ist schiefgelaufen. Erneut versuchen?",
"searchNoResults": "Keine Ergebnisse. Tippe weiter oder verfeinere deine Suche",
+ "returnToListing": "Zurück zum Eintrag",
+ "loadingPins": "Wird geladen…",
"didYouKnow": "Wusstest du schon?",
"steps": {
"find": {
diff --git a/messages/en.json b/messages/en.json
index a3e8cfbc..7d6c4beb 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -378,6 +378,8 @@
"searchPlaceholder": "Search",
"searchError": "Something went wrong. Try again?",
"searchNoResults": "No results. Keep typing or refine your search",
+ "returnToListing": "Return to listing",
+ "loadingPins": "Loading…",
"didYouKnow": "Did you know?",
"steps": {
"find": {
diff --git a/messages/es.json b/messages/es.json
index 0efbfa31..3f7cb965 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -378,6 +378,8 @@
"searchPlaceholder": "Buscar",
"searchError": "Algo salió mal. ¿Intentarlo de nuevo?",
"searchNoResults": "Sin resultados. Sigue escribiendo o ajusta tu búsqueda",
+ "returnToListing": "Volver al anuncio",
+ "loadingPins": "Cargando…",
"didYouKnow": "¿Sabías que?",
"steps": {
"find": {
diff --git a/src/app/(core)/(interact)/(stretched)/map/page.js b/src/app/(core)/(interact)/(stretched)/map/page.tsx
similarity index 63%
rename from src/app/(core)/(interact)/(stretched)/map/page.js
rename to src/app/(core)/(interact)/(stretched)/map/page.tsx
index aa5eb58a..6a0019b7 100644
--- a/src/app/(core)/(interact)/(stretched)/map/page.js
+++ b/src/app/(core)/(interact)/(stretched)/map/page.tsx
@@ -1,19 +1,27 @@
+import { cache } from "react";
+import type { Metadata } from "next/types";
+
import { createClient } from "@/utils/supabase/server";
import { siteConfig } from "@/config/site";
import { generateListingMetadata } from "@/utils/listingUtils";
-import MapPageClient from "@/components/MapPageClient";
-import { cache } from "react";
+import MapPageClient from "@/features/map";
+import type { Listing } from "@/types/listing";
+
+type MapPageSearchParams = {
+ listing?: string;
+};
+
+type MapPageProps = {
+ searchParams: Promise;
+};
-// Fetch data only once and use across metadata and page
-const getInitialData = cache(async (listingSlug) => {
+const getInitialData = cache(async (listingSlug: string | undefined) => {
const supabase = await createClient();
- // Get user first
const {
data: { user },
} = await supabase.auth.getUser();
- // Then get listing data if slug exists
const listingResponse = listingSlug
? await supabase
.from(user ? "listings_private_data" : "listings_public_data")
@@ -24,11 +32,13 @@ const getInitialData = cache(async (listingSlug) => {
return {
user,
- listing: listingResponse?.data,
+ listing: (listingResponse?.data ?? null) as Listing | null,
};
});
-export async function generateMetadata({ searchParams }) {
+export async function generateMetadata({
+ searchParams,
+}: MapPageProps): Promise {
const listingSlug = (await searchParams)?.listing;
if (!listingSlug) {
@@ -42,18 +52,17 @@ export async function generateMetadata({ searchParams }) {
const { user, listing } = await getInitialData(listingSlug);
- // Use shared utility to generate metadata
return generateListingMetadata(listing, user);
}
-export default async function Page({ searchParams }) {
+export default async function Page({ searchParams }: MapPageProps) {
const listingSlug = (await searchParams)?.listing;
const { user, listing } = await getInitialData(listingSlug);
return (
);
diff --git a/src/app/actions.ts b/src/app/actions.ts
index 7a521b73..f9c2141c 100644
--- a/src/app/actions.ts
+++ b/src/app/actions.ts
@@ -494,7 +494,6 @@ export async function fetchListingsInView(
return [];
}
- console.log(`Successfully fetched ${data?.length || 0} listings`);
return data || [];
} catch (error) {
console.error("Fatal error in fetchListingsInView:", {
diff --git a/src/components/ListingChatDrawer/ListingChatDrawer.jsx b/src/components/ListingChatDrawer/ListingChatDrawer.tsx
similarity index 57%
rename from src/components/ListingChatDrawer/ListingChatDrawer.jsx
rename to src/components/ListingChatDrawer/ListingChatDrawer.tsx
index 36dfde55..d439a768 100644
--- a/src/components/ListingChatDrawer/ListingChatDrawer.jsx
+++ b/src/components/ListingChatDrawer/ListingChatDrawer.tsx
@@ -1,4 +1,6 @@
"use client";
+import type { ReactNode } from "react";
+import type { User } from "@supabase/supabase-js";
import { useDeviceContext } from "@/hooks/useDeviceContext";
import { Drawer } from "vaul";
import Button from "@/components/Button";
@@ -6,6 +8,8 @@ import ChatWindow from "@/components/ChatWindow";
import ListingCta from "@/components/ListingCta";
import { styled } from "@pigment-css/react";
+import type { Listing } from "@/types/listing";
+
const sidebarWidth = "clamp(20rem, 30vw, 30rem)";
const StyledDrawerOverlay = styled(Drawer.Overlay)({
@@ -15,7 +19,7 @@ const StyledDrawerOverlay = styled(Drawer.Overlay)({
});
const ListingCtaContainer = styled("div")({
- padding: "0 1rem", // Match padding from other parts of ListingRead
+ padding: "0 1rem",
"& > *": {
width: "100%",
@@ -24,17 +28,16 @@ const ListingCtaContainer = styled("div")({
const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({
background: theme.colors.background.top,
- borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`, // Match over drawer content
+ borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`,
overflowX: "hidden",
"&::after": {
- display: "none", // Otherwise seems to include side scroll, even when overflowX hidden
+ display: "none",
},
marginTop: "24px",
- // maxHeight: "95%",
- height: "95%", // Take up full height even if the message contents aren't overflowing yet
+ height: "95%",
position: "fixed",
bottom: "0",
left: "0",
@@ -43,7 +46,7 @@ const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({
flexDirection: "column",
"@media (min-width: 768px)": {
- borderRadius: theme.corners.base, // Match over drawer content
+ borderRadius: theme.corners.base,
height: "unset",
marginTop: "unset",
top: "24px",
@@ -52,37 +55,37 @@ const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({
left: "unset",
outline: "none",
width: sidebarWidth,
- // height: "100%",
},
}));
-// We need to define two different drawer components, because depending on the 'modal' prop, a different number of hooks will be rendered
-// React doesn't like when we conditionally change the number of hooks. It's better to just render a separate component for each case
-// Shared drawer props to reduce repetition
-const getDrawerProps = ({
- isNested,
- isChatDrawerOpen,
- setIsChatDrawerOpen,
- isDesktop,
- ...rest
-}) => ({
- isNested,
- direction: isDesktop ? "right" : undefined,
- open: isChatDrawerOpen,
- onOpenChange: setIsChatDrawerOpen,
- ...rest,
-});
-
-const ModalDrawer = (props) => {
- const DrawerComponent = props.isNested ? Drawer.NestedRoot : Drawer.Root;
- return ;
+type ListingChatDrawerProps = {
+ isNested?: boolean;
+ user: User | null;
+ listing: Listing;
+ isChatDrawerOpen: boolean;
+ setIsChatDrawerOpen: (open: boolean) => void;
+ existingThread: unknown;
};
-const NonModalDrawer = (props) => {
- const DrawerComponent = props.isNested ? Drawer.NestedRoot : Drawer.Root;
- return ;
+type SharedDrawerProps = {
+ isNested?: boolean;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ direction?: "right";
+ children?: ReactNode;
};
+// We need two drawer variants because `modal` changes which hooks vaul renders.
+function ModalDrawer({ isNested, ...rest }: SharedDrawerProps) {
+ const DrawerComponent = isNested ? Drawer.NestedRoot : Drawer.Root;
+ return ;
+}
+
+function NonModalDrawer({ isNested, ...rest }: SharedDrawerProps) {
+ const DrawerComponent = isNested ? Drawer.NestedRoot : Drawer.Root;
+ return ;
+}
+
export default function ListingChatDrawer({
isNested,
user,
@@ -90,15 +93,15 @@ export default function ListingChatDrawer({
isChatDrawerOpen,
setIsChatDrawerOpen,
existingThread,
- listingDisplayName,
- ...props
-}) {
+}: ListingChatDrawerProps) {
const { isDesktop, hasTouch } = useDeviceContext();
- // We can infer modal behavior based on presentation
- // If it's a mobile breakpoint, always use a model
- // If it's a desktop breakpoint, only use a modal if it's NOT a nested drawer
- const shouldUseModal = !isDesktop || isNested === false;
+ // Mobile: always modal. Desktop: modal only if NOT a nested drawer.
+ // (`!isNested` treats an omitted prop the same as `false`.)
+ const shouldUseModal = !isDesktop || !isNested;
+
+ const visibility = listing.visibility ?? undefined;
+ const isStub = listing.is_stub ?? undefined;
const drawerContent = (
<>
@@ -108,14 +111,14 @@ export default function ListingChatDrawer({
) : listing.is_stub ? (
) : (
@@ -129,8 +132,8 @@ export default function ListingChatDrawer({
)}
@@ -154,10 +157,9 @@ export default function ListingChatDrawer({
return (
{drawerContent}
diff --git a/src/components/ListingChatDrawer/index.js b/src/components/ListingChatDrawer/index.ts
similarity index 100%
rename from src/components/ListingChatDrawer/index.js
rename to src/components/ListingChatDrawer/index.ts
diff --git a/src/components/ListingRead/ListingRead.jsx b/src/components/ListingRead/ListingRead.tsx
similarity index 54%
rename from src/components/ListingRead/ListingRead.jsx
rename to src/components/ListingRead/ListingRead.tsx
index 6e68b5a2..b3a04d88 100644
--- a/src/components/ListingRead/ListingRead.jsx
+++ b/src/components/ListingRead/ListingRead.tsx
@@ -1,5 +1,7 @@
"use client";
-import { Fragment, useState, memo, useEffect } from "react";
+import { Fragment, useState, memo, useEffect, useMemo } from "react";
+import type { ReactNode } from "react";
+import type { User } from "@supabase/supabase-js";
import { Marker, NavigationControl } from "react-map-gl/maplibre";
import { createClient } from "@/utils/supabase/client";
@@ -17,29 +19,63 @@ import StrongLink from "@/components/StrongLink";
import { styled } from "@pigment-css/react";
import { useTranslations } from "next-intl";
-// Memoize the Listing component
+import type { DemoListing, Listing } from "@/types/listing";
+
+type Presentation = "full" | "drawer" | "demo";
+
+type ListingReadListing = Listing | DemoListing;
+
+type ListingReadProps = {
+ user: User | null;
+ listing: ListingReadListing | null;
+ presentation?: Presentation;
+};
+
+function isDemoListing(
+ listing: ListingReadListing | null
+): listing is DemoListing {
+ return Boolean(listing && (listing as DemoListing).is_demo === true);
+}
+
const ListingRead = memo(function Listing({
user,
listing,
presentation = "full",
- isChatDrawerOpen,
- setIsChatDrawerOpen,
-}) {
+}: ListingReadProps) {
const t = useTranslations();
- const router = presentation !== "demo" ? useRouter() : null;
-
- const [existingThread, setExistingThread] = useState(null);
- const [mapZoomLevel, setMapZoomLevel] = useState(null);
-
- // Only initialize Supabase if not in demo mode
- const supabase = presentation !== "demo" ? createClient() : null;
+ // Hooks must be called unconditionally; router is unused in demo mode.
+ const router = useRouter();
+
+ const [existingThread, setExistingThread] = 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);
+
+ // `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]
+ );
- // Load existing thread if any (only if not in demo mode)
+ const isDemo = presentation === "demo";
+ const demoListing = isDemoListing(listing) ? listing : null;
+ const realListing =
+ !isDemo && listing && !isDemoListing(listing) ? (listing as Listing) : null;
+
+ // Load existing thread if any (only if not in demo mode). 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 (presentation === "demo" || !supabase || !user || !listing) 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) return;
const { data: thread, error } = await supabase
.from("chat_threads_with_participants")
.select(
@@ -49,9 +85,9 @@ const ListingRead = memo(function Listing({
`
)
.match({
- listing_id: listing.id,
- initiator_id: user.id,
- owner_id: listing.owner_id,
+ listing_id: listingId,
+ initiator_id: userId,
+ owner_id: listingOwnerId,
})
.maybeSingle();
@@ -64,28 +100,24 @@ const ListingRead = memo(function Listing({
}
loadExistingThread();
- }, [listing?.id, user?.id, presentation, supabase]);
+ }, [listingId, listingOwnerId, userId, isDemo, supabase]);
const initialZoomLevel = 14;
- useEffect(() => {
- setMapZoomLevel(initialZoomLevel);
- }, []);
-
- const listingDisplayName =
- presentation === "demo"
- ? listing?.name
- ? listing.name
- : listing.owner_first_name
- : getListingDisplayName(listing, user);
- const coordinates = listing?.coordinates;
-
- if (!listing && presentation !== "demo") {
- console.log("Listing not found");
+
+ const listingDisplayName: string = isDemo
+ ? (demoListing?.name ?? demoListing?.owner_first_name ?? "")
+ : realListing
+ ? getListingDisplayName(realListing, user)
+ : "";
+
+ const coordinates = realListing?.coordinates ?? null;
+
+ if (!listing && !isDemo) {
return null;
}
return (
-
+
- {presentation === "demo" ? (
+ {isDemo && demoListing ? (
{t("Listings.read.contact", {
- name: listing.owner_first_name
- ? listing.owner_first_name
- : listing.name,
+ name: demoListing.owner_first_name ?? demoListing.name ?? "",
})}
- ) : (
+ ) : realListing ? (
- )}
+ ) : null}
{listing?.description && (
@@ -124,35 +153,29 @@ const ListingRead = memo(function Listing({
? t("Listings.read.donationDetails")
: t("Listings.read.about")}
-
+
)}
- {listing?.accepted_items?.length > 0 && (
+ {listing?.accepted_items && listing.accepted_items.length > 0 && (
{t("Listings.read.accepted")}
-
+
)}
- {listing?.rejected_items?.length > 0 && (
+ {listing?.rejected_items && listing.rejected_items.length > 0 && (
{t("Listings.read.rejected")}
-
+
)}
- {presentation !== "demo" && (
+ {realListing && !isDemo && (
- {presentation !== "drawer" && (
+ {presentation !== "drawer" && coordinates && (
{t("Listings.read.location")}
@@ -171,21 +194,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 +220,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 +234,17 @@ const ListingRead = memo(function Listing({
{t("Actions.seeNearbyListings")}
- {listing.type !== "residential" && (
+ {realListing.type !== "residential" && (
<>
@@ -239,37 +265,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 +305,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 +319,21 @@ const ListingRead = memo(function Listing({
export default ListingRead;
+type PresentationVariantProps = {
+ presentation?: Presentation;
+};
+
+type ListingSectionVariantProps = PresentationVariantProps & {
+ overflowX?: "visible";
+};
+
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 = styled("div")(({ theme }) => ({
...sharedColumnStyles,
variants: [
@@ -318,15 +351,13 @@ const ColumnMain = styled("div")(({ theme }) => ({
],
}));
-const ColumnMinor = styled("div")(({ theme }) => ({
+const ColumnMinor = styled("div")(({ theme }) => ({
...sharedColumnStyles,
variants: [
{
props: { presentation: "full" },
style: {
- // Make second column gap smaller on larger breakpoint
-
"@media (min-width: 1280px)": {
gap: "1.5rem",
},
@@ -335,93 +366,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 = styled("div")(
+ ({ theme }) => ({
+ 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 = styled("section")(
+ ({ theme }) => ({
+ 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": {
- // Add paragraph spacing
- marginTop: "0.5rem",
- color: theme.colors.text.ui.primary,
- },
+ "& p + p": {
+ marginTop: "0.5rem",
+ color: theme.colors.text.ui.primary,
+ },
- variants: [
- {
- props: { overflowX: "visible" },
- style: {
- padding: "0", // Pad by default, override on Photos section (overflowX: "visible")
- overflowX: "visible",
+ variants: [
+ {
+ props: { overflowX: "visible" },
+ style: {
+ padding: "0",
+ 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 +472,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 +492,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/MapImmersive.jsx b/src/components/MapImmersive/MapImmersive.jsx
deleted file mode 100644
index 47af789b..00000000
--- a/src/components/MapImmersive/MapImmersive.jsx
+++ /dev/null
@@ -1,412 +0,0 @@
-"use client";
-import { useEffect, useState, useCallback, useRef } from "react";
-
-import Map, {
- Marker,
- NavigationControl,
- AttributionControl,
- GeolocateControl,
-} from "react-map-gl/maplibre";
-import maplibregl from "maplibre-gl";
-import "maplibre-gl/dist/maplibre-gl.css";
-import { Protocol } from "pmtiles";
-import layers from "protomaps-themes-base";
-
-import LoadingSpinner from "@/components/LoadingSpinner";
-import MapPin from "@/components/MapPin";
-import MapSearch from "@/components/MapSearch";
-import Button from "@/components/Button";
-
-import { styled } from "@pigment-css/react";
-
-const snapPoints = ["148px", "355px", 1];
-
-const ReturnToListingButton = styled(Button)({
- position: "absolute",
- top: "20px",
- left: "50%",
- transform: "translateX(-50%)",
- zIndex: 1, // Could be interfering with drawer scroll, try setting to 0
-
- "@media (min-width: 768px)": {
- top: "auto",
- bottom: "20px",
- },
-});
-
-const attributionControlMobileStyle = {
- // Optically position attribution control above-right of TabBar
- marginRight: `calc(clamp(var(--spacing-tabBar-marginX), calc(((100vw - var(--spacing-tabBar-maxWidth)) / 2)), 100vw) + 4px)`,
- marginBottom: "5.25rem", // Place above bottom TabBar
- opacity: 0.875,
-};
-const attributionControlDesktopStyle = {
- opacity: 1,
- marginRight: "10px",
- marginBottom: "10px",
-};
-
-// Default coordinates for Brisbane, Australia
-const DEFAULT_COORDINATES = {
- longitude: 153.0322,
- latitude: -27.4683,
- zoom: 9,
-};
-
-function getListingCoordinates(listing) {
- return listing?.coordinates ?? null;
-}
-
-function hasValidCoordinates(listing) {
- const coordinates = getListingCoordinates(listing);
-
- return (
- listing &&
- !listing.error &&
- typeof coordinates?.latitude === "number" &&
- typeof coordinates?.longitude === "number" &&
- Number.isFinite(coordinates.latitude) &&
- Number.isFinite(coordinates.longitude)
- );
-}
-
-export default function MapImmersive({
- mapRef,
- searchInputRef,
- listings,
- selectedListing,
- listingSlug,
- initialCoordinates,
- onBoundsChange,
- isLoading,
- onMapClick,
- onMarkerClick,
- onSearchPick,
- setMapController,
- handleSearchPick,
- mapController,
- DrawerTrigger,
- preventDrawerClose,
- selectedPinId,
- setSelectedPinId,
- isDesktop,
- countryCode,
-}) {
- const selectedListingCoordinates = getListingCoordinates(selectedListing);
- const hasAppliedInitialPositionRef = useRef(false);
- const centeredListingIdRef = useRef(null);
- const [lastKnownPosition, setLastKnownPosition] = useState(null);
- const [isListingInView, setIsListingInView] = useState(true);
- const hasInitialPosition =
- selectedListing || initialCoordinates || lastKnownPosition;
-
- const [snap, setSnap] = useState(snapPoints[0]);
- const [isOpen, setIsOpen] = useState(false);
-
- const handleOpenChange = (open) => {
- console.log("about to open?", open);
-
- if (open) {
- console.log("opening. Resetting snap point");
- setSnap(snapPoints[0]);
- }
- setIsOpen(open);
- };
-
- // Initial fetch when map loads
- const handleMapLoad = useCallback(() => {
- console.log("Map loaded");
-
- // If there's a selected listing with valid coords, center on it instead of using IP location
- if (hasValidCoordinates(selectedListing)) {
- const coordinates = getListingCoordinates(selectedListing);
-
- mapRef.current?.flyTo({
- center: [coordinates.longitude, coordinates.latitude],
- zoom: 12,
- duration: 0,
- });
- hasAppliedInitialPositionRef.current = true;
- centeredListingIdRef.current = selectedListing.id;
- }
-
- const bounds = mapRef.current.getMap().getBounds();
- console.log("Bounds:", bounds);
- onBoundsChange(bounds);
- }, [onBoundsChange, selectedListing]);
-
- // Fetch on map move
- const handleMapMove = useCallback(() => {
- if (!mapRef.current) return; // Add check for mapRef.current so this isn't called when user navigates to a different page.
- const map = mapRef.current.getMap();
- if (!map) return; // Add safety check for map object
- const bounds = map.getBounds();
- onBoundsChange(bounds);
-
- // Check if selected listing is in view (only when it has valid coords)
- if (hasValidCoordinates(selectedListing)) {
- const coordinates = getListingCoordinates(selectedListing);
- const isInView = bounds.contains([
- coordinates.longitude,
- coordinates.latitude,
- ]);
- setIsListingInView(isInView);
- }
- }, [onBoundsChange, selectedListing]);
-
- const handleFlyToListing = useCallback(() => {
- if (!hasValidCoordinates(selectedListing) || !mapRef.current) return;
-
- const coordinates = getListingCoordinates(selectedListing);
-
- mapRef.current.flyTo({
- center: [coordinates.longitude, coordinates.latitude],
- duration: 1500,
- });
- }, [selectedListing]);
-
- useEffect(() => {
- let protocol = new Protocol();
- maplibregl.addProtocol("pmtiles", protocol.tile);
-
- return () => {
- maplibregl.removeProtocol("pmtiles");
- };
- }, []);
-
- useEffect(() => {
- if (
- hasAppliedInitialPositionRef.current ||
- hasValidCoordinates(selectedListing) ||
- !initialCoordinates ||
- !mapRef.current
- ) {
- return;
- }
-
- hasAppliedInitialPositionRef.current = true;
- mapRef.current.flyTo({
- center: [initialCoordinates.longitude, initialCoordinates.latitude],
- zoom: initialCoordinates.zoom,
- duration: 0,
- });
- }, [initialCoordinates, mapRef, selectedListing]);
-
- useEffect(() => {
- if (
- !mapRef.current ||
- !hasValidCoordinates(selectedListing) ||
- centeredListingIdRef.current === selectedListing.id
- ) {
- return;
- }
-
- const coordinates = getListingCoordinates(selectedListing);
- const map = mapRef.current.getMap();
- const bounds = map.getBounds();
- const isInView = bounds.contains([
- coordinates.longitude,
- coordinates.latitude,
- ]);
-
- if (isInView) {
- hasAppliedInitialPositionRef.current = true;
- centeredListingIdRef.current = selectedListing.id;
- setIsListingInView(true);
- return;
- }
-
- mapRef.current.flyTo({
- center: [coordinates.longitude, coordinates.latitude],
- zoom: 12,
- duration: 900,
- });
- hasAppliedInitialPositionRef.current = true;
- centeredListingIdRef.current = selectedListing.id;
- }, [
- mapRef,
- selectedListing,
- selectedListingCoordinates?.latitude,
- selectedListingCoordinates?.longitude,
- ]);
-
- // Set mapController to set relationship between MapSearch and MapImmersive
- // Can't get this to work, perhaps delete all mapController and createMapLibreGlMapController code if I can't get it working
- // useEffect(() => {
- // if (mapRef.current) return; // stops map from intializing more than once
- // setMapController(createMapLibreGlMapController(mapRef.current, maplibregl));
- // }, [onBoundsChange]);
-
- // TODO: low-priority: IF location is active AND it leaves the bounding box (i.e. user has moved the map), add a button to recenter (and zoom) map on selected listing
-
- // Update lastKnownPosition when we have a valid position
- useEffect(() => {
- if (hasValidCoordinates(selectedListing)) {
- const coordinates = getListingCoordinates(selectedListing);
-
- setLastKnownPosition({
- latitude: coordinates.latitude,
- longitude: coordinates.longitude,
- });
- } else if (initialCoordinates && !lastKnownPosition) {
- setLastKnownPosition(initialCoordinates);
- }
- }, [selectedListing, initialCoordinates]);
-
- // Check if listing is in view whenever the map moves or selectedListing changes
- useEffect(() => {
- if (!mapRef.current || !hasValidCoordinates(selectedListing)) {
- setIsListingInView(true);
- return;
- }
-
- const bounds = mapRef.current.getMap().getBounds();
- const coordinates = getListingCoordinates(selectedListing);
- const isInView = bounds.contains([
- coordinates.longitude,
- coordinates.latitude,
- ]);
- console.log("isInView", isInView);
- setIsListingInView(isInView);
- }, [selectedListing]);
-
- // Update when selectedListing changes
- useEffect(() => {
- setSelectedPinId(selectedListing?.id || null);
- }, [selectedListing]);
-
- const handleMapClick = (event) => {
- console.log("Map clicked without marker click");
- if (selectedPinId) {
- setSelectedPinId(null); // This will update pin visuals immediately
- onMapClick(event); // This will handle the drawer closing
- }
- };
-
- return (
-
- {isLoading ?
: null}
-
- {hasInitialPosition && (
- <>
-
Protomaps',
- },
- },
- layers: layers("protomaps", "light"),
- }}
- renderWorldCopies={true}
- initialViewState={{
- longitude:
- (hasValidCoordinates(selectedListing)
- ? selectedListingCoordinates.longitude
- : null) ??
- initialCoordinates?.longitude ??
- DEFAULT_COORDINATES.longitude,
- latitude:
- (hasValidCoordinates(selectedListing)
- ? selectedListingCoordinates.latitude
- : null) ??
- initialCoordinates?.latitude ??
- DEFAULT_COORDINATES.latitude,
- zoom: selectedListing
- ? 8
- : initialCoordinates?.zoom || DEFAULT_COORDINATES.zoom,
- }}
- animationOptions={{ duration: 200 }}
- onMoveEnd={handleMapMove}
- onLoad={handleMapLoad}
- onClick={handleMapClick}
- >
-
-
-
-
-
- {listings
- .filter((listing) => hasValidCoordinates(listing))
- .map((listing) => (
-
- {
- event.originalEvent.stopPropagation();
- setSelectedPinId(listing.id); // Update pin visuals immediately
- onMarkerClick(listing.id); // Handle the rest of the selection logic
- }}
- style={{
- zIndex: selectedPinId === listing.id ? 1 : 0,
- }}
- >
-
-
-
- ))}
-
-
-
-
- {/* selectedListing purposefully does not clear when returning to listing, as it clashes with the router. So we need to check for listingSlug too */}
- {selectedListing && listingSlug && !isListingInView && (
-
- Return to listing
-
- )}
- >
- )}
-
- );
-}
diff --git a/src/components/MapImmersive/index.js b/src/components/MapImmersive/index.js
deleted file mode 100644
index 7933b3a1..00000000
--- a/src/components/MapImmersive/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./MapImmersive";
-export { default } from "./MapImmersive";
diff --git a/src/components/MapPageClient/MapPageClient.jsx b/src/components/MapPageClient/MapPageClient.jsx
deleted file mode 100644
index 0db8a6de..00000000
--- a/src/components/MapPageClient/MapPageClient.jsx
+++ /dev/null
@@ -1,777 +0,0 @@
-"use client";
-import { useState, useCallback, useRef, useEffect } from "react";
-import { useSearchParams, useRouter } from "next/navigation";
-import { debounce } from "lodash";
-
-import * as VisuallyHidden from "@radix-ui/react-visually-hidden"; // TODO: Build own version: https://www.joshwcomeau.com/snippets/react-components/visually-hidden/
-import { Drawer } from "vaul";
-const snapPoints = [0.35, 1];
-
-import { createClient } from "@/utils/supabase/client";
-
-import { fetchListingsInView } from "@/app/actions";
-
-import { config, geolocation } from "@maptiler/client";
-
-import MapSearch from "@/components/MapSearch";
-import MapImmersive from "@/components/MapImmersive";
-import ListingRead from "@/components/ListingRead";
-
-import Button from "@/components/Button";
-import IconButton from "@/components/IconButton";
-import MapSidebar from "@/components/MapSidebar";
-
-import { styled } from "@pigment-css/react";
-import {
- getListingDisplayName,
- getListingDisplayType,
-} from "@/utils/listingUtils";
-import { useDeviceContext } from "@/hooks/useDeviceContext";
-import { useTranslations } from "next-intl";
-
-const sidebarWidth = "clamp(20rem, 30vw, 30rem)";
-const pagePadding = "24px";
-const ZOOM_LEVEL_DEFAULT = 7.5; // TODO: 11 is a sensible default once the map is more populated
-
-// Default coordinates for Brisbane, Australia
-// const DEFAULT_COORDINATES = {
-// longitude: 153.0322,
-// latitude: -27.415,
-// zoom: 9,
-// };
-
-// For IP geolocation API
-config.apiKey = process.env.NEXT_PUBLIC_MAPTILER_API_KEY;
-
-const StyledMapPage = styled("main")(({ theme }) => ({
- flex: 1,
- gap: theme.spacing.gap.desktop,
- alignItems: "stretch",
- display: "flex",
- flexDirection: "row",
-}));
-
-const StyledMapWrapper = styled("div")(({ theme }) => ({
- display: "flex",
- flexDirection: "column",
- gap: "1rem",
- flex: 1,
- // touchAction: "none",
-
- // Prepare for tab bar on mobile
- height: "100%",
- "@media (min-width: 768px)": {
- borderRadius: theme.corners.base,
- border: `1px solid ${theme.colors.border.base}`,
- overflow: "hidden",
- },
-}));
-
-const DrawerHandleContainer = styled("div")(({ theme }) => ({
- position: "absolute",
- top: "0.5rem",
- left: "50%",
- transform: "translateX(-50%)",
-}));
-
-const ButtonSet = styled("div")({
- display: "flex",
- flexDirection: "row",
- gap: "0.5rem",
- position: "absolute",
- right: "0.75rem",
-});
-const sharedButtonStyles = {
- pointerEvents: "all", // Ignore pointer-events: none on parent
-};
-
-const StyledIconButtonAbsolute = styled(IconButton)({
- ...sharedButtonStyles,
- position: "absolute",
- right: "0.75rem",
-});
-
-const StyledIconButtonStationary = styled(IconButton)({
- ...sharedButtonStyles,
- position: "relative",
-});
-
-const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({
- // display: "flex",
- // flexDirection: "column",
- border: `2px solid ${theme.colors.border.base}`, // border-gray-200
- borderBottom: "none",
- borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`,
-
- position: "fixed",
- bottom: "0",
- left: "0",
- right: "0",
-
- height: "97%", // Take up full height to prevent awkward drawer pop-ups when minimal content
- // maxHeight: "97%",
-
- // overscrollBehavior: "unset",
- // margin: "0 -1px", // mx-[-1px]
-
- background: theme.colors.background.sunk,
-
- border: `0.5px solid ${theme.colors.border.base}`,
- boxShadow: `0px -3px 3px 1px rgba(0, 0, 0, 0.06)`,
-
- overflowX: "hidden",
- // overflowY: "hidden", // Necessary to focus on the drawer content
-
- "&::after": {
- display: "none", // Otherwise seems to visibly block the drawer content
- },
-
- "@media (min-width: 768px)": {
- background: theme.colors.background.top,
- borderRadius: theme.corners.base,
- boxShadow: `-3px 0px 3px 1px rgba(0, 0, 0, 0.03)`,
-
- height: "unset",
- top: "24px",
- right: "24px",
- bottom: "24px",
- left: "unset",
- outline: "none",
- width: sidebarWidth,
- },
-}));
-
-const StyledDrawerHeader = styled("header")({
- // flex justify-between items-center absolute top-0 w-full py-2 px-4 rounded-t-lg
- flex: 1,
-
- position: "sticky",
- top: "0",
- // Create a new stacking context to ensure header content stays above avatar whose rotation transform caused a new stacking context
- zIndex: 1,
- width: "100%",
-
- display: "flex",
- // alignItems: "center",
-
- flexDirection: "row",
- justifyContent: "center",
- alignItems: "center",
- textAlign: "center",
- // padding: "0.5rem 1rem",
-});
-
-const StyledDrawerHeaderInner = styled("div")(({ theme }) => ({
- display: "flex",
- flexDirection: "row",
- justifyContent: "center",
- alignItems: "center",
- width: "100%",
-
- padding: "1rem",
- background: theme.colors.background.sunk,
- borderBottom: `1px solid ${theme.colors.border.base}`,
- boxShadow: `0px 1px 8px 0px ${theme.colors.border.base}`,
- // Ensure header content stays above avatar whose rotation transform causes a new stacking context
- // position: "relative",
- // zIndex: 1,
-
- transform: "translateY(-0.5px)", // Avoid clipping on Retina screens
-
- "@media (min-width: 768px)": {
- background: theme.colors.background.slight,
- transform: "unset",
- },
-}));
-
-const StyledHeaderText = styled("div")(({ theme }) => ({
- display: "flex",
- flexDirection: "column",
- gap: "0.25rem",
- width: "100%",
- padding: "0 2.5rem", // Padding to account for the icon button
-
- "& h3, p": {
- lineHeight: "100%",
- overflow: "hidden",
- whiteSpace: "nowrap",
- display: "block",
- textOverflow: "ellipsis",
- },
- "& h3": {
- fontWeight: "500",
- fontSize: "0.85rem",
- color: theme.colors.text.secondary,
- },
- "& p": {
- fontSize: "0.8rem",
- color: theme.colors.text.tertiary,
- },
-}));
-
-const StyledDrawerInner = styled("div")(({ theme }) => ({
- width: "100%",
- // Normal classes
- padding: "1rem 0", // Commented out X axis to allow overflow for things like photo x-scroll
- paddingTop: "2rem",
- marginTop: "-3.5rem", // To account for sticky header
-
- // Attempts to smooth drawer scroll
- // touchAction: "unset !important",
- // pointerEvents: "unset !important",
- overflowY: "auto",
- overflowX: "hidden", // Prevent horizontal scrolling
-
- // Seems to help with drawer scroll getting stuck, possibly placebo
- // overscrollBehavior: "auto",
- // touchAction: "pan-y", // Prevents zoom gesture which stuffs up general layout, should be revisted for accessibility
-
- // Set same flex properties in ListingRead > Column, given these columns should be invisible when drawer
- display: "flex",
- flexDirection: "column",
- gap: "3rem", // Match in ListingRead
- marginBottom: "1.5rem", // Visual buffer
-}));
-
-const NoListingFound = styled("div")(({ theme }) => ({
- display: "flex",
- flexDirection: "column",
- gap: "2rem",
- padding: "2rem",
- color: theme.colors.text.secondary,
-
- "& > header": {
- display: "flex",
- flexDirection: "column",
- gap: "0.25rem",
-
- "& > *": {
- textAlign: "center",
- textWrap: "balance",
- },
- },
-}));
-
-// export default async function MapPage() {
-export default function MapPageClient({
- user,
- initialListingSlug,
- initialListing,
-}) {
- const t = useTranslations();
- const mapRef = useRef(null);
- const searchInputRef = useRef(null);
- const drawerContentRef = useRef(null);
- const [initialCoordinates, setInitialCoordinates] = useState(null);
- const [countryCode, setCountryCode] = useState(null);
-
- const searchParams = useSearchParams();
- const router = useRouter();
- const supabase = createClient();
-
- const [listings, setListings] = useState([]);
- const [selectedListing, setSelectedListing] = useState(
- initialListing || null
- );
- // Set mapController to set relationship between MapSearch and MapImmersive
- const [mapController, setMapController] = useState(); // https://docs.maptiler.com/react/maplibre-gl-js/geocoding-control/
-
- const [isLoading, setIsLoading] = useState(true);
- // const [isDesktop, setIsDesktop] = useState(false);
- const [snap, setSnap] = useState(snapPoints[0]);
- const [isDrawerOpen, setIsDrawerOpen] = useState(false);
- const [isChatDrawerOpen, setIsChatDrawerOpen] = useState(false);
- const [isDrawerHeaderShown, setIsDrawerHeaderShown] = useState(false);
- const [selectedPinId, setSelectedPinId] = useState(null);
- const [listingSlug, setListingSlug] = useState(null);
- const { isDesktop, hasTouch } = useDeviceContext();
-
- useEffect(() => {
- if (isDesktop) {
- setSnap(snapPoints[1]);
- console.log("Viewport is desktop");
- } else {
- console.log("Viewport is mobile");
- }
- }, [isDesktop]);
-
- useEffect(() => {
- console.log("Managing HTML classes", { hasTouch, snap });
- // const listingSlug = searchParams.get("listing");
- setListingSlug(searchParams.get("listing"));
-
- if (isDesktop) return;
-
- // Always add map class for touch devices
- document.documentElement.classList.add("map");
-
- // Manage drawer-fully-open class based on both snap AND URL state
- if (snap === snapPoints[1] && listingSlug) {
- document.documentElement.classList.add("drawer-fully-open");
- } else {
- document.documentElement.classList.remove("drawer-fully-open");
- }
-
- // Cleanup on unmount
- return () => {
- document.documentElement.classList.remove("map");
- document.documentElement.classList.remove("drawer-fully-open");
- };
- }, [snap, isDesktop, searchParams]); // Include all dependencies
-
- // Load listing from URL param on mount
- useEffect(() => {
- const listingSlug = searchParams.get("listing");
- if (listingSlug) {
- loadListingBySlug(listingSlug);
- // If there is a selected listing upon mount, open the drawerc
- setIsDrawerOpen(true);
- } else {
- // Clear selected listing if no slug in URL
- // setSelectedListing(null);
- // If the user traversed the history back to where there was (possibly) no slug, close the drawer
- setIsDrawerOpen(false);
- setSelectedPinId(null);
- }
- }, [searchParams]); // This will run when the URL changes
-
- // Add this new effect to handle initial location
- useEffect(() => {
- const listingSlug = searchParams.get("listing");
- // Only fetch IP location if there's no listing in URL
- if (!listingSlug) {
- // TODO: see if there is location data already set from local storage, and return that first if so
- // Perhaps do this on the homepage/first page loaded and then use that data for the map
- // And then store that data in local storage for future use in the same session/browser
- async function initializeLocation() {
- console.log("No listing slug. Initializing location");
-
- try {
- // Create a timeout promise
- const timeoutPromise = new Promise((_, reject) => {
- setTimeout(() => reject(new Error("Location timeout")), 3000);
- });
-
- // Race between the geolocation request and timeout
- const response = await Promise.race([
- geolocation.info(),
- timeoutPromise,
- ]);
-
- if (response && response.latitude && response.longitude) {
- setCountryCode(response.country_code); // Used in MapSearch until proximity feature is fixed
- setInitialCoordinates({
- latitude: response.latitude,
- longitude: response.longitude,
- zoom: ZOOM_LEVEL_DEFAULT, // TODO: Increase zoom when more listings are available. Also in MapImmersive
- });
- }
- } catch (error) {
- console.warn(
- "Could not determine location from MapTiler:",
- error.message
- );
- // No need to set fallback coordinates - MapImmersive will handle that
- }
- }
-
- initializeLocation();
- }
- }, []);
-
- const loadListingBySlug = async (slug) => {
- // If the slug matches initial data, use that instead of fetching
- if (slug === initialListingSlug && initialListing) {
- setSelectedListing(initialListing);
- return;
- }
-
- try {
- const { data, error } = await supabase
- .from(user ? "listings_private_data" : "listings_public_data")
- .select()
- .eq("slug", slug)
- .single();
-
- if (error) {
- setSelectedListing({
- error: true,
- message: t("Listings.edit.notFound"),
- });
- return;
- }
-
- setSelectedListing(data);
- } catch (err) {
- console.warn("loadListingBySlug failed:", err);
- setSelectedListing({ error: true, message: t("Listings.edit.notFound") });
- }
- };
-
- const debouncedBoundsChange = useCallback(
- debounce(async (bounds) => {
- setIsLoading(true);
- try {
- const data = await fetchListingsInView(
- bounds._sw.lat,
- bounds._sw.lng,
- bounds._ne.lat,
- bounds._ne.lng
- );
- setListings(data);
- } catch (error) {
- console.error("Error fetching listings:", error);
- } finally {
- setIsLoading(false);
- }
- }, 300), // 300ms delay
- []
- );
-
- const handleBoundsChange = useCallback(
- async (bounds) => {
- debouncedBoundsChange(bounds);
- },
- [debouncedBoundsChange]
- );
-
- const handleSnapChange = () => {
- console.log("Handling snap change", snap, snapPoints[0]);
- if (snap === snapPoints[0]) {
- setSnap(snapPoints[1]);
- } else {
- if (drawerContentRef.current) {
- drawerContentRef.current.scrollTop = 0;
- }
- setSnap(snapPoints[0]);
- }
- };
-
- const handleMarkerClick = async (listingId) => {
- // If the clicked marker is already selected AND the drawer is already open, do nothing and return early
- if (selectedListing?.id === listingId && isDrawerOpen) {
- return;
- }
-
- try {
- const { data, error } = await supabase
- .from(user ? "listings_private_data" : "listings_public_data")
- .select()
- .eq("id", listingId)
- .single();
-
- if (error) {
- setSelectedListing({
- error: true,
- message: t("Listings.edit.notFound"),
- });
- setIsDrawerOpen(true);
- setSnap(snapPoints[0]);
- return;
- }
-
- // Close the chat drawer if it's open
- setIsChatDrawerOpen(false);
- setSelectedListing(data);
- setIsDrawerOpen(true);
- setSnap(snapPoints[0]);
- setIsDrawerHeaderShown(false);
-
- if (drawerContentRef.current) {
- drawerContentRef.current.scrollTop = 0;
- }
-
- router.push(`/map?listing=${data.slug}`, { scroll: false });
- } catch (err) {
- console.warn("handleMarkerClick failed:", err);
- setSelectedListing({ error: true, message: t("Listings.edit.notFound") });
- setIsDrawerOpen(true);
- setSnap(snapPoints[0]);
- }
- };
-
- const handleMapClick = () => {
- console.log("Map clicked without marker click");
- if (selectedListing) {
- handleCloseListing();
- setIsDrawerOpen(false);
- setIsChatDrawerOpen(false);
- }
- };
-
- // Mobile scroll listener
- useEffect(() => {
- if (isDesktop || (!isDesktop && snap !== 1)) {
- setIsDrawerHeaderShown(false);
- return;
- }
-
- console.log("Setting up mobile scroll listener");
-
- const handleScroll = () => {
- if (drawerContentRef.current) {
- const scrollTop = drawerContentRef.current.scrollTop;
- setIsDrawerHeaderShown(scrollTop > 16); // When to show sticky drawer header
- }
- };
-
- if (drawerContentRef.current) {
- console.log("Adding mobile scroll listener");
- drawerContentRef.current.addEventListener("scroll", handleScroll);
- } else {
- console.warn(
- "drawerContentRef.current is null for mobile, cannot add scroll listener."
- );
- }
-
- return () => {
- if (drawerContentRef.current) {
- drawerContentRef.current.removeEventListener("scroll", handleScroll);
- }
- };
- }, [snap]); // Only depends on snap for mobile
-
- // Desktop scroll listener
- useEffect(() => {
- if (isDesktop && isDrawerOpen) {
- console.log("Setting up desktop scroll listener");
-
- const handleScroll = () => {
- if (drawerContentRef.current) {
- const scrollTop = drawerContentRef.current.scrollTop;
- // console.log("Desktop Scroll position:", scrollTop);
- setIsDrawerHeaderShown(scrollTop > 16);
- }
- };
-
- const observer = new MutationObserver(() => {
- const drawerContent = drawerContentRef.current;
- if (drawerContent) {
- drawerContent.addEventListener("scroll", handleScroll);
- observer.disconnect(); // Stop observing once the listener is added
- }
- });
-
- // Start observing the drawer content for changes
- observer.observe(document.body, { childList: true, subtree: true });
-
- return () => {
- observer.disconnect(); // Clean up the observer on unmount
- };
- }
- }, [isDesktop, isDrawerOpen]); // Depends on isDesktop and isDrawerOpen for desktop
-
- const handleSearchPick = useCallback((event) => {
- // console.log("searchInputRef", searchInputRef);
- // Quirk in MapTiler's Geocoding component: they consider tapping close an 'onPick
- // Return early if that's the case
- if (!event.feature?.center) return;
-
- console.log("Search picked", event);
- // Blur the input
- // Not needed because the Geocoding component handles this
- // Delete ref and prop drilling if I don't end up using it for other reasons
- // searchInputRef.current.blur();
-
- // Return those new coordinates
- const nextCoordinates = {
- latitude: event.feature?.center[1],
- longitude: event.feature?.center[0],
- };
-
- console.log("Flying to", nextCoordinates);
- mapRef.current?.flyTo({
- center: [nextCoordinates.longitude, nextCoordinates.latitude],
- duration: 3200, // TODO: Make this dynamic based on distance from current location
- zoom: ZOOM_LEVEL_DEFAULT, // Defaulting to a conservative amount of zoomed-out. TODO: set zoom level dynamically based on how many listings are around the area
- });
- }, []);
-
- const handleCloseListing = useCallback(() => {
- console.log("Closing listing");
- setIsDrawerOpen(false);
- setIsChatDrawerOpen(false);
- setSelectedPinId(null);
- setSnap(snapPoints[0]); // Helps to remove conditional CSS class from html
-
- // setSelectedListing(null); // This is purposefully not set to null, as it clashes with the router, which handles clearing listing state
- router.push("/map", { scroll: false, shallow: true });
- }, [router]);
-
- // Add an effect to handle browser back/forward
- useEffect(() => {
- // Check URL state whenever searchParams changes
- const listingSlug = searchParams.get("listing");
-
- if (!listingSlug) {
- // No listing in URL, ensure drawer-fully-open is removed
- console.log("No listing in URL, removing drawer-fully-open class");
- document.documentElement.classList.remove("drawer-fully-open");
- setSnap(snapPoints[0]);
- }
- }, [searchParams]); // Only depend on searchParams changes
-
- // useEffect(() => {
- // const handlePopstate = () => {
- // // Check if the modal is open on mobile devices
- // // Replace the condition with your modal open check logic
- // const isModalOpenOnMobile = true; // Replace with your own logic
- // if (isModalOpenOnMobile) {
- // // Close the modal when navigating using browser's back/forward buttons
- // // Implement your own modal close logic here
-
- // console.log("Removing drawer-fully-open class");
- // document.documentElement.classList.remove("drawer-fully-open");
- // }
- // };
-
- // window.addEventListener("popstate", handlePopstate);
-
- // return () => {
- // window.removeEventListener("popstate", handlePopstate);
- // };
- // }, []);
-
- return (
-
-
- {
- // console.log("Drawer open change", open);
- }}
- // onDrag={(drag) => console.log("Drawer drag", drag)}
- // onRelease={(release) => {
- // console.log("Drawer release", release);
- // }}
- // scrollLockTimeout={1} // Not sure but seems to make the mobile drawer more responsive
- // onAnimationEnd={(event) => {
- // console.log("Animation ended", event);
- // }}
-
- // data-vaul-delayed-snap-points={false} // Seems to smooth out some of the snapping but I can't call it
- >
-
-
-
-
-
- {t("Map.drawerTitle")}
-
- {t("Map.drawerDescription")}
-
-
-
-
-
-
-
- {getListingDisplayName(selectedListing, user)}
-
- {getListingDisplayType(selectedListing)}
-
-
-
- {!isDesktop ? (
-
-
-
-
- ) : (
-
- )}
-
- {hasTouch && !isDesktop && (
-
-
-
- )}
-
-
- {/* Page content */}
-
- {selectedListing?.error ? (
-
-
- {t("Map.emptyTitle")}
- {t("Map.emptyBody")}
-
-
- {t("Actions.close")}
-
-
- ) : (
-
- )}
-
-
-
-
-
- {isDesktop && }
-
- );
-}
diff --git a/src/components/MapPageClient/index.js b/src/components/MapPageClient/index.js
deleted file mode 100644
index c9439612..00000000
--- a/src/components/MapPageClient/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./MapPageClient";
-export { default } from "./MapPageClient";
diff --git a/src/components/MapPin/MapPin.jsx b/src/components/MapPin/MapPin.tsx
similarity index 72%
rename from src/components/MapPin/MapPin.jsx
rename to src/components/MapPin/MapPin.tsx
index ab9a33bf..459ec45d 100644
--- a/src/components/MapPin/MapPin.jsx
+++ b/src/components/MapPin/MapPin.tsx
@@ -3,7 +3,17 @@ import MapCommunityIcon from "../MapCommunityIcon";
import MapResidentialIcon from "../MapResidentialIcon";
import { styled } from "@pigment-css/react";
-const UnselectedPin = styled("div")(({ theme }) => ({
+import type { ListingType } from "@/types/listing";
+
+type MapPinProps = {
+ selected?: boolean;
+ // Accept any string so callers that hold generic listing types (e.g.
+ // LocationSelect) can still pass them in; unknown values just render the
+ // default pin without a specialised icon.
+ type?: string;
+};
+
+const UnselectedPin = styled("div")({
cursor: "pointer",
width: "48px",
height: "48px",
@@ -11,7 +21,7 @@ const UnselectedPin = styled("div")(({ theme }) => ({
display: "flex",
justifyContent: "center",
alignItems: "center",
-}));
+});
const UnselectedPinInner = styled("div")(({ theme }) => ({
boxShadow: `0 0 0 2.5px ${theme.colors.marker.border}`,
@@ -50,28 +60,6 @@ const UnselectedPinInner = styled("div")(({ theme }) => ({
],
}));
-const SelectedPin = styled("div")(({ theme }) => ({
- display: "flex",
- cursor: "pointer",
-
- // . TODO: is there a way to group these two CSS declarations?
- // I.e. so the transition is only declared once
- [`& ${SelectedPinDot}`]: {
- transition: "transform 75ms ease-in-out",
- },
- [`& ${SelectedPinRing}`]: {
- transition: "transform 75ms ease-in-out",
- },
- "&:hover": {
- [`& ${SelectedPinDot}`]: {
- transform: "scale(1.05)",
- },
- [`& ${SelectedPinRing}`]: {
- transform: "scale(1.25)",
- },
- },
-}));
-
const SelectedPinRing = styled("div")(({ theme }) => ({
width: "80px",
height: "80px",
@@ -90,7 +78,34 @@ const SelectedPinDot = styled("div")(({ theme }) => ({
boxShadow: `0 0 1px 1px ${theme.colors.border.elevated}`,
}));
-const SelectedPinIcon = styled("svg")(({ theme }) => ({
+const SelectedPin = styled("div")({
+ display: "flex",
+ cursor: "pointer",
+
+ [`& ${SelectedPinDot}`]: {
+ transition: "transform 75ms ease-in-out",
+ },
+ [`& ${SelectedPinRing}`]: {
+ transition: "transform 75ms ease-in-out",
+ },
+ "&:hover": {
+ [`& ${SelectedPinDot}`]: {
+ transform: "scale(1.05)",
+ },
+ [`& ${SelectedPinRing}`]: {
+ transform: "scale(1.25)",
+ },
+ },
+});
+
+// Cast to `any` for the styled() call because Pigment's `variants` inference
+// narrows the shared `type` prop to the literal of the first variant, which
+// doesn't reflect what we actually want here (a string union).
+const SelectedPinIcon = (
+ styled("svg") as unknown as (
+ arg: unknown
+ ) => React.ComponentType & { type?: string }>
+)(({ theme }: { theme: any }) => ({
fill: theme.colors.text.ui.emptyState, // Backup fill for when type is not specified
stroke: theme.colors.marker.border,
strokeWidth: "1.5px",
@@ -128,50 +143,26 @@ const SelectedPinIcon = styled("svg")(({ theme }) => ({
],
}));
-const SelectedPinVisual = styled("svg")(({ theme }) => ({
- fill: theme.colors.marker.dot,
-}));
-
const ICON = `M18.149 15.8139C18.2078 15.7251 18.2326 15.6533 18.2915 15.5646C19.3387 13.9878 20 12.0412 20 10C20 4.4 15.5 0 10 0C4.5 0 0 4.5 0 10C0 11.8662 0.522404 13.6453 1.40473 15.0937C1.52799 15.296 1.62851 15.5285 1.79602 15.696C1.79734 15.6974 1.79867 15.6987 1.8 15.7C1.90535 15.8054 1.94349 15.9666 2.02739 16.0897C2.18874 16.3264 2.36323 16.5632 2.6 16.8C4.5396 19.1126 7.70356 22.2044 9.18572 23.6258C9.64236 24.0637 10.3577 24.0638 10.8151 23.6266C12.2976 22.2097 15.4607 19.1376 17.4 16.9C17.5711 16.6433 17.8155 16.3866 18.0078 16.1299C18.07 16.0467 18.0918 15.9006 18.149 15.8139Z`;
-const pinStyleCoarse = {
- backgroundColor: "rgba(0, 0, 255, 0.15)",
- borderRadius: "50%",
- // width: "200px",
- // height: "200px",
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
+// 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 }>,
};
-const iconMap = {
- business: MapBusinessIcon,
- community: MapCommunityIcon,
- residential: MapResidentialIcon,
-};
-
-function MapPin({
- selected = false,
- type,
- zoomLevel = null,
- distanceAcrossMapWidth = 0,
- mapWidth = 0,
-}) {
- // console.log("zoomLevel", zoomLevel);
- const basicSize = 2 ** (zoomLevel * 0.565);
- // const size = 1000 / (1 + Math.exp(-10 * (zoomLevel - 10)));
- // const size = 100 * zoomLevel ** 0.5;
-
- // at 14 zoom level, size is 20
- //at 22 zoom level, size is 100
-
- const km = 0.5;
- const smartSize = (mapWidth / distanceAcrossMapWidth) * km;
-
- // console.log("size", smartSize, "zoomLevel", zoomLevel);
- // console.log(distanceAcrossMapWidth, mapWidth, { smartSize });
+function isListingPinType(value: string | undefined): value is ListingType {
+ // 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)
+ );
+}
- const IconComponent = type && iconMap[type];
+function MapPin({ selected = false, type }: MapPinProps) {
+ const IconComponent = isListingPinType(type) ? iconMap[type] : null;
if (selected) {
return (
@@ -190,10 +181,11 @@ function MapPin({
return (
-
+
{IconComponent && }
);
}
+
export default MapPin;
diff --git a/src/components/MapPin/index.js b/src/components/MapPin/index.ts
similarity index 100%
rename from src/components/MapPin/index.js
rename to src/components/MapPin/index.ts
diff --git a/src/components/MapSearch/MapSearch.jsx b/src/components/MapSearch/MapSearch.jsx
deleted file mode 100644
index 07bb53d5..00000000
--- a/src/components/MapSearch/MapSearch.jsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client";
-import { useCallback, useEffect, useState, useRef } from "react";
-
-import { GeocodingControl } from "@maptiler/geocoding-control/react";
-import "@maptiler/geocoding-control/style.css"; // TODO REMOVE (TURN ON AND OFF TO PREVIEW STYLES)
-import { useTranslations } from "next-intl";
-
-// TODO: Add a 'required' prop for forms that require a location
-function MapSearch({
- onPick,
- mapController,
- searchInputRef,
- countryCode,
- ...props
-}) {
- const t = useTranslations("Map");
-
- return (
-
- {
- onPick(event);
- }}
- // flyToSelected={true}
- />
-
- );
-}
-
-export default MapSearch;
diff --git a/src/components/MapSearch/index.js b/src/components/MapSearch/index.js
deleted file mode 100644
index 6693599d..00000000
--- a/src/components/MapSearch/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./MapSearch";
-export { default } from "./MapSearch";
diff --git a/src/components/MapSidebar/index.js b/src/components/MapSidebar/index.js
deleted file mode 100644
index ed7a5ac1..00000000
--- a/src/components/MapSidebar/index.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export * from "./MapSidebar";
-export { default } from "./MapSidebar";
diff --git a/src/features/map/components/MapListingDrawerPanel.tsx b/src/features/map/components/MapListingDrawerPanel.tsx
new file mode 100644
index 00000000..856ca813
--- /dev/null
+++ b/src/features/map/components/MapListingDrawerPanel.tsx
@@ -0,0 +1,301 @@
+"use client";
+
+import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
+import { Drawer } from "vaul";
+import { styled } from "@pigment-css/react";
+import { useTranslations } from "next-intl";
+import 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 { isListingError, type SelectedListing } from "@/types/listing";
+
+import { SIDEBAR_WIDTH } from "../lib/mapUtils";
+
+type MapListingDrawerPanelProps = {
+ user: User | null;
+ selectedListing: SelectedListing | null;
+ isDesktop: boolean;
+ hasTouch: boolean;
+ isDrawerHeaderShown: boolean;
+ isFullSnap: boolean;
+ isPartialSnap: boolean;
+ onToggleSnap: () => void;
+ onClose: () => void;
+ drawerContentRef: React.MutableRefObject;
+};
+
+const sharedButtonStyles = {
+ pointerEvents: "all" as const,
+};
+
+const StyledIconButtonAbsolute = styled(IconButton)({
+ ...sharedButtonStyles,
+ position: "absolute",
+ right: "0.75rem",
+});
+
+const StyledIconButtonStationary = styled(IconButton)({
+ ...sharedButtonStyles,
+ position: "relative",
+});
+
+const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({
+ borderBottom: "none",
+ borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`,
+
+ position: "fixed",
+ bottom: "0",
+ left: "0",
+ right: "0",
+
+ height: "97%", // Take up full height to prevent awkward drawer pop-ups when minimal content
+
+ background: theme.colors.background.sunk,
+
+ border: `0.5px solid ${theme.colors.border.base}`,
+ boxShadow: `0px -3px 3px 1px rgba(0, 0, 0, 0.06)`,
+
+ overflowX: "hidden",
+
+ "&::after": {
+ display: "none", // Otherwise seems to visibly block the drawer content
+ },
+
+ "@media (min-width: 768px)": {
+ background: theme.colors.background.top,
+ borderRadius: theme.corners.base,
+ boxShadow: `-3px 0px 3px 1px rgba(0, 0, 0, 0.03)`,
+
+ height: "unset",
+ top: "24px",
+ right: "24px",
+ bottom: "24px",
+ left: "unset",
+ outline: "none",
+ width: SIDEBAR_WIDTH,
+ },
+}));
+
+const StyledDrawerHeader = styled("header")({
+ flex: 1,
+
+ position: "sticky",
+ top: "0",
+ // Create a new stacking context to ensure header content stays above
+ // avatar whose rotation transform caused a new stacking context
+ zIndex: 1,
+ width: "100%",
+
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ textAlign: "center",
+});
+
+const StyledDrawerHeaderInner = styled("div")(({ theme }) => ({
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ width: "100%",
+
+ padding: "1rem",
+ background: theme.colors.background.sunk,
+ borderBottom: `1px solid ${theme.colors.border.base}`,
+ boxShadow: `0px 1px 8px 0px ${theme.colors.border.base}`,
+
+ transform: "translateY(-0.5px)", // Avoid clipping on Retina screens
+
+ "@media (min-width: 768px)": {
+ background: theme.colors.background.slight,
+ transform: "unset",
+ },
+}));
+
+const StyledHeaderText = styled("div")(({ theme }) => ({
+ display: "flex",
+ flexDirection: "column",
+ gap: "0.25rem",
+ width: "100%",
+ padding: "0 2.5rem", // Padding to account for the icon button
+
+ "& h3, p": {
+ lineHeight: "100%",
+ overflow: "hidden",
+ whiteSpace: "nowrap",
+ display: "block",
+ textOverflow: "ellipsis",
+ },
+ "& h3": {
+ fontWeight: "500",
+ fontSize: "0.85rem",
+ color: theme.colors.text.secondary,
+ },
+ "& p": {
+ fontSize: "0.8rem",
+ color: theme.colors.text.tertiary,
+ },
+}));
+
+const StyledDrawerInner = styled("div")({
+ width: "100%",
+ padding: "1rem 0", // Commented out X axis to allow overflow for things like photo x-scroll
+ paddingTop: "2rem",
+ marginTop: "-3.5rem", // To account for sticky header
+
+ overflowY: "auto",
+ overflowX: "hidden", // Prevent horizontal scrolling
+
+ display: "flex",
+ flexDirection: "column",
+ gap: "3rem", // Match in ListingRead
+ marginBottom: "1.5rem", // Visual buffer
+});
+
+const DrawerHandleContainer = styled("div")({
+ position: "absolute",
+ top: "0.5rem",
+ left: "50%",
+ transform: "translateX(-50%)",
+});
+
+const ButtonSet = styled("div")({
+ display: "flex",
+ flexDirection: "row",
+ gap: "0.5rem",
+ position: "absolute",
+ right: "0.75rem",
+});
+
+const NoListingFound = styled("div")(({ theme }) => ({
+ display: "flex",
+ flexDirection: "column",
+ gap: "2rem",
+ padding: "2rem",
+ color: theme.colors.text.secondary,
+
+ "& > header": {
+ display: "flex",
+ flexDirection: "column",
+ gap: "0.25rem",
+
+ "& > *": {
+ textAlign: "center",
+ textWrap: "balance",
+ },
+ },
+}));
+
+export default function MapListingDrawerPanel({
+ user,
+ selectedListing,
+ isDesktop,
+ hasTouch,
+ isDrawerHeaderShown,
+ isFullSnap,
+ isPartialSnap,
+ onToggleSnap,
+ onClose,
+ drawerContentRef,
+}: MapListingDrawerPanelProps) {
+ const t = useTranslations();
+
+ const showErrorPanel = isListingError(selectedListing);
+ const listingForDisplay = isListingError(selectedListing)
+ ? null
+ : selectedListing;
+
+ return (
+
+
+
+ {t("Map.drawerTitle")}
+ {t("Map.drawerDescription")}
+
+
+
+
+
+
+ {listingForDisplay
+ ? getListingDisplayName(listingForDisplay, user)
+ : ""}
+
+
+ {listingForDisplay
+ ? getListingDisplayType(listingForDisplay)
+ : ""}
+
+
+
+
+ {!isDesktop ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ {hasTouch && !isDesktop && (
+
+
+
+ )}
+
+
+
+ {showErrorPanel ? (
+
+
+ {t("Map.emptyTitle")}
+ {t("Map.emptyBody")}
+
+
+ {t("Actions.close")}
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/features/map/components/MapPageClient.tsx b/src/features/map/components/MapPageClient.tsx
new file mode 100644
index 00000000..cb6f522d
--- /dev/null
+++ b/src/features/map/components/MapPageClient.tsx
@@ -0,0 +1,148 @@
+"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(
+ (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 (
+
+
+
+
+
+
+
+
+ {isDesktop && }
+
+ );
+}
diff --git a/src/features/map/components/MapPinLayer.tsx b/src/features/map/components/MapPinLayer.tsx
new file mode 100644
index 00000000..df1cce8b
--- /dev/null
+++ b/src/features/map/components/MapPinLayer.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import type { ComponentType } from "react";
+import { Marker } from "react-map-gl/maplibre";
+
+import MapPin from "@/components/MapPin";
+import type { ListingCoordinates, ListingMarker } from "@/types/listing";
+
+import { hasValidCoordinates } from "../lib/mapUtils";
+
+type MapPinLayerProps = {
+ listings: ListingMarker[];
+ selectedListingId: number | null;
+ DrawerTrigger: ComponentType<{ children?: React.ReactNode }>;
+ onMarkerClick: (listing: ListingMarker) => void;
+};
+
+export default function MapPinLayer({
+ listings,
+ selectedListingId,
+ DrawerTrigger,
+ onMarkerClick,
+}: MapPinLayerProps) {
+ return (
+ <>
+ {listings
+ .filter((listing) => hasValidCoordinates(listing))
+ .map((listing) => {
+ const coords = listing.coordinates as ListingCoordinates;
+ const isSelected = selectedListingId === listing.id;
+
+ return (
+
+ {
+ event.originalEvent.stopPropagation();
+ onMarkerClick(listing);
+ }}
+ style={{ zIndex: isSelected ? 1 : 0 }}
+ >
+
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/features/map/components/MapSearch.tsx b/src/features/map/components/MapSearch.tsx
new file mode 100644
index 00000000..0e2202ba
--- /dev/null
+++ b/src/features/map/components/MapSearch.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import type { CSSProperties } from "react";
+
+import { GeocodingControl } from "@maptiler/geocoding-control/react";
+import "@maptiler/geocoding-control/style.css";
+import { useTranslations } from "next-intl";
+
+type GeocodingPickEvent = {
+ feature?: { center?: [number, number] };
+};
+
+type MapSearchProps = {
+ onPick: (event: GeocodingPickEvent) => void;
+ countryCode?: string | null;
+ style?: CSSProperties;
+};
+
+// TODO: Add a 'required' prop for forms that require a location
+export default function MapSearch({
+ onPick,
+ countryCode,
+ style,
+}: MapSearchProps) {
+ const t = useTranslations("Map");
+
+ return (
+
+ {
+ onPick(event);
+ }}
+ />
+
+ );
+}
diff --git a/src/components/MapSidebar/MapSidebar.jsx b/src/features/map/components/MapSidebar.tsx
similarity index 81%
rename from src/components/MapSidebar/MapSidebar.jsx
rename to src/features/map/components/MapSidebar.tsx
index 70f526cb..e043c5d0 100644
--- a/src/components/MapSidebar/MapSidebar.jsx
+++ b/src/features/map/components/MapSidebar.tsx
@@ -1,11 +1,25 @@
"use client";
-import { useState, useEffect } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
import { styled } from "@pigment-css/react";
-import { facts } from "@/data/facts";
import { useTranslations } from "next-intl";
-const sidebarWidth = "clamp(20rem, 30vw, 30rem);";
+
+import { facts } from "@/data/facts";
+
+import type { User } from "@supabase/supabase-js";
+
+import { SIDEBAR_WIDTH } from "../lib/mapUtils";
+
+type MapSidebarProps = {
+ user: User | null | undefined;
+ covered: boolean;
+};
+
+type Fact = {
+ fact: string;
+ source?: string;
+};
const StyledSidebar = styled("div")(({ theme }) => ({
backgroundColor: theme.colors.background.pit,
@@ -18,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}`,
@@ -26,7 +40,7 @@ const StyledSidebar = styled("div")(({ theme }) => ({
overflowY: "hidden",
}));
-const Fact = styled("div")(({ theme }) => ({
+const FactBlock = styled("div")(({ theme }) => ({
display: "flex",
flexDirection: "column",
gap: "1rem",
@@ -106,16 +120,17 @@ const StepList = styled("ol")(({ theme }) => ({
leadingTrim: "both",
fontSize: "1em",
fontWeight: "700",
- // Use same override as StepHeader in PeelsHowItWorks
- // Override the oldstyle numbers just in this case, since that was affecting optical alignment
+ // Override the oldstyle numbers just in this case, since that was
+ // affecting optical alignment
fontVariantNumeric: "lining-nums",
},
},
}));
-export default function MapSidebar({ user, covered }) {
+export default function MapSidebar({ user, covered }: MapSidebarProps) {
const t = useTranslations();
- const [randomFact, setRandomFact] = useState(null);
+ const [randomFact, setRandomFact] = useState(null);
+
const steps = [
{
title: t("Map.steps.find.title"),
@@ -132,7 +147,6 @@ export default function MapSidebar({ user, covered }) {
];
useEffect(() => {
- // Only generate a random fact if there is NO selected listing, not when one is opened
if (!covered) {
setRandomFact(facts[Math.floor(Math.random() * facts.length)]);
}
@@ -140,15 +154,8 @@ export default function MapSidebar({ user, covered }) {
return (
- {/* */}
{user && randomFact && (
- // TODO
- // If user has sent >0 messages, show a fun composting fact
- // Otherwise show the fundamentals (1, 2, 3) of Peels
-
+
{t("Map.didYouKnow")}
{randomFact.fact}
{randomFact.source && (
@@ -160,12 +167,12 @@ export default function MapSidebar({ user, covered }) {
)}
-
+
)}
{!user && (
- {steps.map((step, index) => (
-
+ {steps.map((step) => (
+
{step.title}
{step.description}
diff --git a/src/features/map/components/MapView.tsx b/src/features/map/components/MapView.tsx
new file mode 100644
index 00000000..060455e5
--- /dev/null
+++ b/src/features/map/components/MapView.tsx
@@ -0,0 +1,310 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import type { ComponentType, CSSProperties } from "react";
+
+import Map, {
+ NavigationControl,
+ AttributionControl,
+ GeolocateControl,
+ type MapRef,
+ type ViewStateChangeEvent,
+ type MapLayerMouseEvent,
+} from "react-map-gl/maplibre";
+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 Button from "@/components/Button";
+import type {
+ ListingCoordinates,
+ ListingMarker,
+ SelectedListing,
+} from "@/types/listing";
+
+import MapPinLayer from "./MapPinLayer";
+import MapSearch from "./MapSearch";
+import {
+ DEFAULT_COORDINATES,
+ ZOOM_LEVEL_DEFAULT,
+ ZOOM_LEVEL_SELECTED,
+ getListingCoordinates,
+ hasValidCoordinates,
+} 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;
+ onMapClick: () => void;
+ onMarkerClick: (listing: ListingMarker) => void;
+ DrawerTrigger: ComponentType<{ children?: React.ReactNode }>;
+ isDesktop: boolean;
+ countryCode: string | null;
+};
+
+const MapContainer = styled("div")({
+ position: "relative",
+ width: "100%",
+ height: "100%",
+ backgroundColor: "lightblue",
+});
+
+const ReturnToListingButton = styled(Button)({
+ position: "absolute",
+ top: "20px",
+ left: "50%",
+ transform: "translateX(-50%)",
+ zIndex: 1,
+
+ "@media (min-width: 768px)": {
+ top: "auto",
+ bottom: "20px",
+ },
+});
+
+const LoadingChip = styled("div")(({ theme }) => ({
+ position: "absolute",
+ top: "0.75rem",
+ right: "0.75rem",
+ zIndex: 1,
+ padding: "0.25rem 0.75rem",
+ borderRadius: "999px",
+ background: theme.colors.background.top,
+ boxShadow: "0 1px 6px rgba(0, 0, 0, 0.08)",
+ fontSize: "0.75rem",
+ fontWeight: 500,
+ color: theme.colors.text.secondary,
+ opacity: 0.9,
+ pointerEvents: "none",
+ transition: "opacity 150ms ease",
+}));
+
+const attributionControlMobileStyle: 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: CSSProperties = {
+ opacity: 1,
+ marginRight: "10px",
+ marginBottom: "10px",
+};
+
+const searchStyle: CSSProperties = {
+ position: "absolute",
+ top: "0.75rem",
+ left: "0.75rem",
+ zIndex: 1,
+};
+
+// MapLibre style spec — protomaps tiles with the bundled light theme. Kept as
+// 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"),
+};
+
+function resolveInitialViewState(
+ selectedListing: SelectedListing | null,
+ initialCoordinates: (ListingCoordinates & { zoom: number }) | null
+) {
+ const selectedCoords = getListingCoordinates(selectedListing);
+ const isSelected =
+ hasValidCoordinates(selectedListing) && selectedCoords !== null;
+
+ return {
+ longitude:
+ (isSelected ? selectedCoords!.longitude : undefined) ??
+ initialCoordinates?.longitude ??
+ DEFAULT_COORDINATES.longitude,
+ latitude:
+ (isSelected ? selectedCoords!.latitude : undefined) ??
+ initialCoordinates?.latitude ??
+ DEFAULT_COORDINATES.latitude,
+ zoom: isSelected
+ ? ZOOM_LEVEL_SELECTED
+ : (initialCoordinates?.zoom ?? ZOOM_LEVEL_DEFAULT),
+ };
+}
+
+export default function MapView({
+ selectedListing,
+ selectedListingId,
+ listingSlug,
+ initialCoordinates,
+ onMapClick,
+ onMarkerClick,
+ DrawerTrigger,
+ isDesktop,
+ countryCode,
+}: MapViewProps) {
+ const t = useTranslations("Map");
+ const mapRef = useRef(null);
+
+ const { listings, isFetching, requestBounds } = useListingsInView();
+
+ const {
+ isSelectedInView,
+ handleMapLoad,
+ handleMapMoveEnd,
+ flyToSelected,
+ flyToCoordinate,
+ } = useMapCenter({
+ mapRef,
+ 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 or when skipped), so
+ // this cannot stall indefinitely.
+ const hasInitialPosition =
+ hasValidCoordinates(selectedListing) || Boolean(initialCoordinates);
+
+ useEffect(() => {
+ const protocol = new Protocol();
+ maplibregl.addProtocol("pmtiles", protocol.tile);
+
+ return () => {
+ maplibregl.removeProtocol("pmtiles");
+ };
+ }, []);
+
+ const emitBoundsChange = useCallback(
+ (bounds: LngLatBounds) => {
+ requestBounds(bounds);
+ },
+ [requestBounds]
+ );
+
+ const handleLoad = useCallback(() => {
+ handleMapLoad();
+ const map = mapRef.current?.getMap();
+ if (map) {
+ emitBoundsChange(map.getBounds());
+ }
+ }, [emitBoundsChange, handleMapLoad]);
+
+ const handleMoveEnd = useCallback(
+ (_event: ViewStateChangeEvent) => {
+ const map = mapRef.current?.getMap();
+ if (!map) return;
+ emitBoundsChange(map.getBounds());
+ handleMapMoveEnd();
+ },
+ [emitBoundsChange, handleMapMoveEnd]
+ );
+
+ const handleMapClickInternal = useCallback(
+ (_event: MapLayerMouseEvent) => {
+ if (selectedListingId !== null || listingSlug) {
+ onMapClick();
+ }
+ },
+ [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
+ );
+
+ return (
+
+ {hasInitialPosition && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {isFetching && {t("loadingPins")} }
+
+ {showReturnButton && (
+
+ {t("returnToListing")}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/features/map/hooks/useIpInitialLocation.ts b/src/features/map/hooks/useIpInitialLocation.ts
new file mode 100644
index 00000000..959f5717
--- /dev/null
+++ b/src/features/map/hooks/useIpInitialLocation.ts
@@ -0,0 +1,137 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { config, geolocation } from "@maptiler/client";
+
+import type { ListingCoordinates } from "@/types/listing";
+
+import { DEFAULT_COORDINATES, ZOOM_LEVEL_DEFAULT } from "../lib/mapUtils";
+
+type UseIpInitialLocationArgs = {
+ // Skip when the page already has a listing slug (deep-linked selections
+ // centre on the listing instead of the user's IP location).
+ skip?: boolean;
+};
+
+type UseIpInitialLocationResult = {
+ initialCoordinates: (ListingCoordinates & { zoom: number }) | null;
+ countryCode: string | null;
+};
+
+// 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. 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 {
+ const [initialCoordinates, setInitialCoordinates] = useState<
+ (ListingCoordinates & { zoom: number }) | null
+ >(null);
+ const [countryCode, setCountryCode] = useState(null);
+
+ useEffect(() => {
+ 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();
+
+ 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
+ // 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,
+ ])) as {
+ latitude?: number;
+ longitude?: number;
+ country_code?: string;
+ };
+
+ if (cancelled) return;
+
+ 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: lat,
+ longitude: lng,
+ zoom: ZOOM_LEVEL_DEFAULT,
+ });
+ } else {
+ applyFallback();
+ }
+ } catch (error) {
+ if (cancelled) return;
+ console.warn(
+ "Could not determine location from MapTiler:",
+ (error as Error).message
+ );
+ applyFallback();
+ } finally {
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ timeoutId = null;
+ }
+ }
+ }
+
+ initializeLocation();
+
+ return () => {
+ cancelled = true;
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ timeoutId = null;
+ }
+ };
+ }, [skip]);
+
+ return { initialCoordinates, countryCode };
+}
diff --git a/src/features/map/hooks/useListingsInView.ts b/src/features/map/hooks/useListingsInView.ts
new file mode 100644
index 00000000..bd20337a
--- /dev/null
+++ b/src/features/map/hooks/useListingsInView.ts
@@ -0,0 +1,127 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { LngLatBounds } from "maplibre-gl";
+
+import { fetchListingsInView } from "@/app/actions";
+import type { ListingMarker } from "@/types/listing";
+
+import { padBounds } from "../lib/mapUtils";
+
+const DEBOUNCE_MS = 150;
+const VIEWPORT_PAD_FACTOR = 0.3;
+
+type UseListingsInViewResult = {
+ listings: ListingMarker[];
+ isFetching: boolean;
+ requestBounds: (bounds: LngLatBounds) => void;
+};
+
+// `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>(
+ fn: T,
+ wait: number
+): Debounced {
+ let timer: ReturnType | null = null;
+
+ const debounced = ((...args: Parameters) => {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(() => {
+ timer = null;
+ fn(...args);
+ }, wait);
+ }) as Debounced;
+
+ debounced.cancel = () => {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ return debounced;
+}
+
+// Fetches listings for the current map viewport.
+//
+// - Debounces rapid pan/zoom calls (150 ms).
+// - Each request gets a sequence id; only the latest response is applied, so
+// stale responses from abandoned viewports never overwrite fresh data.
+// - Fetches a padded viewport (30% larger each side) so small pans reuse the
+// already-loaded pins and do not feel like they "pause" to reload.
+// - Never clears `listings` mid-fetch — cached pins stay visible while the
+// next response is in flight.
+export function useListingsInView(): UseListingsInViewResult {
+ const [listings, setListings] = useState([]);
+ const [isFetching, setIsFetching] = useState(false);
+
+ const requestIdRef = useRef(0);
+ const inFlightCountRef = useRef(0);
+
+ const runFetch = useCallback(async (bounds: LngLatBounds) => {
+ // `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 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;
+
+ 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);
+ } finally {
+ inFlightCountRef.current = Math.max(0, inFlightCountRef.current - 1);
+ if (inFlightCountRef.current === 0) {
+ setIsFetching(false);
+ }
+ }
+ }, []);
+
+ const debouncedFetch = useMemo(
+ () => debounce(runFetch, DEBOUNCE_MS),
+ [runFetch]
+ );
+
+ useEffect(() => {
+ return () => {
+ debouncedFetch.cancel();
+ };
+ }, [debouncedFetch]);
+
+ const requestBounds = useCallback(
+ (bounds: LngLatBounds) => {
+ debouncedFetch(bounds);
+ },
+ [debouncedFetch]
+ );
+
+ return { listings, isFetching, requestBounds };
+}
diff --git a/src/features/map/hooks/useMapCenter.ts b/src/features/map/hooks/useMapCenter.ts
new file mode 100644
index 00000000..e454e471
--- /dev/null
+++ b/src/features/map/hooks/useMapCenter.ts
@@ -0,0 +1,153 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { MapRef } from "react-map-gl/maplibre";
+
+import type { ListingCoordinates, SelectedListing } from "@/types/listing";
+
+import {
+ FLY_DURATION,
+ ZOOM_LEVEL_SELECTED,
+ getListingCoordinates,
+ hasValidCoordinates,
+ isCoordinateInBounds,
+} from "../lib/mapUtils";
+
+type UseMapCenterArgs = {
+ mapRef: React.RefObject;
+ selectedListing: SelectedListing | null;
+};
+
+type UseMapCenterResult = {
+ // Whether the selected listing's coordinate is currently inside the map viewport.
+ // Used to decide if the "Return to listing" button should show.
+ isSelectedInView: boolean;
+ // Called from the map's onLoad handler. Performs the single "initial jump"
+ // so the map never flickers from a temporary position to its real one.
+ handleMapLoad: () => void;
+ // Called from the map's onMoveEnd handler.
+ handleMapMoveEnd: () => void;
+ // Explicit fly-to for the "Return to listing" button.
+ flyToSelected: () => void;
+ // Explicit fly-to for a search pick.
+ flyToCoordinate: (coordinate: ListingCoordinates, zoom?: number) => void;
+};
+
+// Single owner of every programmatic map motion, so the four old fly-to rules
+// (initial centre, URL-driven selection, return-to-listing, search pick) no
+// longer race each other.
+//
+// Rules encoded here:
+// 1. Initial mount — caller passes an initial centre via `initialViewState`
+// on the ; this hook only takes over once the map has loaded.
+// 2. URL-driven selection change — if the selected listing's coordinate is
+// already in view, do not move. Otherwise flyTo with a short animation.
+// 3. User taps "Return to listing" — flyTo with a slightly longer animation.
+// 4. Search pick — flyTo the searched coordinate at the provided zoom.
+export function useMapCenter({
+ mapRef,
+ selectedListing,
+}: UseMapCenterArgs): UseMapCenterResult {
+ // Start as `true` so the "Return to listing" button doesn't flash on before
+ // we've had a chance to measure anything.
+ const [isSelectedInView, setIsSelectedInView] = useState(true);
+
+ // Ids we've already "handled" for URL-driven centring. Prevents us from
+ // flying to the same listing more than once as unrelated state changes.
+ const centeredListingIdRef = useRef(null);
+ // Tracks whether onLoad has run, so we don't double-centre on mount.
+ const hasHandledLoadRef = useRef(false);
+
+ const recomputeIsInView = useCallback(() => {
+ const map = mapRef.current?.getMap();
+ if (!map) return;
+
+ if (!hasValidCoordinates(selectedListing)) {
+ setIsSelectedInView(true);
+ return;
+ }
+
+ const coordinates = getListingCoordinates(selectedListing);
+ if (!coordinates) {
+ setIsSelectedInView(true);
+ return;
+ }
+
+ setIsSelectedInView(isCoordinateInBounds(map.getBounds(), coordinates));
+ }, [mapRef, selectedListing]);
+
+ const handleMapLoad = useCallback(() => {
+ hasHandledLoadRef.current = true;
+
+ // If we loaded with a selected listing, "claim" its id so the effect below
+ // doesn't try to fly to it again (the map's initialViewState already did).
+ if (hasValidCoordinates(selectedListing)) {
+ centeredListingIdRef.current = selectedListing.id;
+ }
+
+ recomputeIsInView();
+ }, [recomputeIsInView, selectedListing]);
+
+ const handleMapMoveEnd = useCallback(() => {
+ recomputeIsInView();
+ }, [recomputeIsInView]);
+
+ // URL-driven selection centring.
+ useEffect(() => {
+ const map = mapRef.current?.getMap();
+ if (!map || !hasHandledLoadRef.current) return;
+ if (!hasValidCoordinates(selectedListing)) return;
+
+ const listingId = selectedListing.id;
+ if (centeredListingIdRef.current === listingId) {
+ return;
+ }
+
+ const coordinates = getListingCoordinates(selectedListing);
+ if (!coordinates) return;
+
+ const inView = isCoordinateInBounds(map.getBounds(), coordinates);
+ centeredListingIdRef.current = listingId;
+
+ if (inView) {
+ setIsSelectedInView(true);
+ return;
+ }
+
+ mapRef.current?.flyTo({
+ center: [coordinates.longitude, coordinates.latitude],
+ zoom: ZOOM_LEVEL_SELECTED,
+ duration: FLY_DURATION.urlSelection,
+ });
+ }, [mapRef, selectedListing]);
+
+ const flyToSelected = useCallback(() => {
+ if (!hasValidCoordinates(selectedListing)) return;
+ const coordinates = getListingCoordinates(selectedListing);
+ if (!coordinates) return;
+
+ mapRef.current?.flyTo({
+ center: [coordinates.longitude, coordinates.latitude],
+ duration: FLY_DURATION.returnToListing,
+ });
+ }, [mapRef, selectedListing]);
+
+ const flyToCoordinate = useCallback(
+ (coordinate: ListingCoordinates, zoom?: number) => {
+ mapRef.current?.flyTo({
+ center: [coordinate.longitude, coordinate.latitude],
+ duration: FLY_DURATION.searchPick,
+ ...(typeof zoom === "number" ? { zoom } : {}),
+ });
+ },
+ [mapRef]
+ );
+
+ return {
+ isSelectedInView,
+ handleMapLoad,
+ handleMapMoveEnd,
+ flyToSelected,
+ flyToCoordinate,
+ };
+}
diff --git a/src/features/map/hooks/useMapDrawerState.ts b/src/features/map/hooks/useMapDrawerState.ts
new file mode 100644
index 00000000..d7f69d0a
--- /dev/null
+++ b/src/features/map/hooks/useMapDrawerState.ts
@@ -0,0 +1,179 @@
+"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 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 (attachedTo) {
+ setIsDrawerHeaderShown(attachedTo.scrollTop > SCROLL_THRESHOLD);
+ }
+ };
+
+ const attach = (element: HTMLElement) => {
+ attachedTo = element;
+ element.addEventListener("scroll", handleScroll);
+ };
+
+ 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();
+ attachedTo?.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/features/map/hooks/useMapListingUrl.ts b/src/features/map/hooks/useMapListingUrl.ts
new file mode 100644
index 00000000..60b0ef40
--- /dev/null
+++ b/src/features/map/hooks/useMapListingUrl.ts
@@ -0,0 +1,269 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { createClient } from "@/utils/supabase/client";
+import { isListing, type Listing, type SelectedListing } from "@/types/listing";
+import type { User } from "@supabase/supabase-js";
+
+type UseMapListingUrlArgs = {
+ user: User | null | undefined;
+ initialListingSlug?: string | null;
+ initialListing?: Listing | null;
+};
+
+type UseMapListingUrlResult = {
+ listingSlug: string | null;
+ selectedListing: SelectedListing | null;
+ // The id that should appear "selected" on the map. Combines the optimistic
+ // id from the most recent tap with the resolved listing's id, so the pin
+ // grows immediately on tap and stays grown through the fetch.
+ selectedListingId: number | null;
+ // True whenever there is a listing in the URL. Drives the drawer open state.
+ isListingSelected: boolean;
+ // Marker click handler. Sets the optimistic id synchronously, fetches the
+ // full listing by id, then updates state + URL in one pass. The URL effect
+ // skips the normal fetch because the slug is already resolved.
+ selectListingById: (id: number) => Promise;
+ // Drawer close handler.
+ closeListing: () => void;
+};
+
+// Owns the URL <-> selected-listing relationship for the map page.
+//
+// Goals:
+// - Seed from SSR (`initialListing`) so a deep link doesn't re-fetch.
+// - Fix the "grow -> shrink -> grow" pin flicker by:
+// (a) setting an optimistic pin id synchronously on tap, and
+// (b) only fetching the listing once per selection (removes the original
+// double fetch where handleMarkerClick + loadListingBySlug both ran).
+// - Leave `selectedListing` set while the drawer animates closed, so the
+// last listing stays on-screen instead of flashing empty.
+export function useMapListingUrl({
+ user,
+ initialListingSlug,
+ initialListing,
+}: UseMapListingUrlArgs): UseMapListingUrlResult {
+ const t = useTranslations();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ // `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");
+
+ const [selectedListing, setSelectedListing] =
+ useState(initialListing ?? null);
+ const [optimisticListingId, setOptimisticListingId] = useState(
+ initialListing?.id ?? null
+ );
+
+ // The last slug we've resolved locally. Used to skip the fetch in the URL
+ // sync effect when we were the ones that set the URL.
+ const resolvedSlugRef = useRef(
+ initialListing?.slug ?? initialListingSlug ?? null
+ );
+
+ // 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";
+
+ // 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);
+
+ // 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(() => {
+ if (!listingSlug) return;
+ if (resolvedTableRef.current === null) return;
+ if (resolvedTableRef.current === tableName) return;
+ resolvedTableRef.current = null;
+ }, [listingSlug, tableName]);
+
+ const fetchBySlug = useCallback(
+ async (slug: string) => {
+ const token = ++requestTokenRef.current;
+
+ try {
+ const { data, error } = await supabase
+ .from(tableName)
+ .select()
+ .eq("slug", slug)
+ .single();
+
+ if (token !== requestTokenRef.current) return;
+
+ if (error) {
+ setSelectedListing({
+ error: true,
+ message: t("Listings.edit.notFound"),
+ });
+ setOptimisticListingId(null);
+ return;
+ }
+
+ const listing = data as Listing;
+ setSelectedListing(listing);
+ resolvedSlugRef.current = slug;
+ 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,
+ message: t("Listings.edit.notFound"),
+ });
+ setOptimisticListingId(null);
+ }
+ },
+ [supabase, t, tableName]
+ );
+
+ // Keep state aligned with the URL. Only fetch when the slug has actually
+ // changed from what we resolved locally. We intentionally do *not* clear
+ // `selectedListing` when the slug goes away — the drawer animates out and
+ // should keep showing the last listing until it's fully closed — but we
+ // do clear the pin-selection id so the pin snaps back immediately.
+ useEffect(() => {
+ if (!listingSlug) {
+ // 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);
+ return;
+ }
+
+ // On first mount: if SSR gave us the listing for this slug, use that.
+ if (
+ listingSlug === initialListingSlug &&
+ initialListing &&
+ resolvedSlugRef.current !== listingSlug
+ ) {
+ setSelectedListing(initialListing);
+ resolvedSlugRef.current = listingSlug;
+ resolvedTableRef.current = tableName;
+ setOptimisticListingId(initialListing.id ?? null);
+ return;
+ }
+
+ if (
+ resolvedSlugRef.current === listingSlug &&
+ resolvedTableRef.current === tableName
+ ) {
+ return;
+ }
+
+ fetchBySlug(listingSlug);
+ }, [fetchBySlug, initialListing, initialListingSlug, listingSlug, tableName]);
+
+ 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;
+
+ try {
+ const { data, error } = await supabase
+ .from(tableName)
+ .select()
+ .eq("id", id)
+ .single();
+
+ if (token !== requestTokenRef.current) return;
+
+ if (error || !data) {
+ // 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 to
+ // whatever the drawer is currently showing and leave
+ // `selectedListing` alone.
+ console.warn("Failed to select listing by id:", error);
+ setOptimisticListingId(previousResolvedId);
+ return;
+ }
+
+ const listing = data as Listing;
+ setSelectedListing(listing);
+ const slug = listing.slug;
+
+ 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) {
+ if (token !== requestTokenRef.current) return;
+ console.warn("Failed to select listing by id:", err);
+ setOptimisticListingId(previousResolvedId);
+ }
+ },
+ [router, supabase, tableName]
+ );
+
+ 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);
+ router.push("/map", { scroll: false });
+ }, [router]);
+
+ // Pin selection is driven entirely by the optimistic id, which is set on
+ // tap and cleared whenever the URL loses its listing slug (including on
+ // browser back/forward). `selectedListing` may stick around during the
+ // drawer close animation but the pin snaps back immediately, matching the
+ // prior behaviour.
+ const selectedListingId = optimisticListingId;
+
+ return {
+ listingSlug,
+ selectedListing,
+ selectedListingId,
+ isListingSelected: Boolean(listingSlug),
+ selectListingById,
+ closeListing,
+ };
+}
diff --git a/src/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/features/map/lib/mapUtils.ts b/src/features/map/lib/mapUtils.ts
new file mode 100644
index 00000000..66d5e08a
--- /dev/null
+++ b/src/features/map/lib/mapUtils.ts
@@ -0,0 +1,137 @@
+import type { LngLatBounds } from "maplibre-gl";
+
+import {
+ isListingError,
+ type Listing,
+ type ListingCoordinates,
+ type ListingMarker,
+ type SelectedListing,
+} from "@/types/listing";
+
+export type BoundingBox = {
+ south: number;
+ west: number;
+ north: number;
+ east: 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,
+};
+
+export const ZOOM_LEVEL_DEFAULT = 11;
+export const ZOOM_LEVEL_SELECTED = 14;
+
+// Durations (ms) for the different fly-to flows
+export const FLY_DURATION = {
+ jump: 0,
+ urlSelection: 900,
+ returnToListing: 1500,
+ searchPick: 3200,
+} as const;
+
+// 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;
+
+// `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: CoordinateBearing | SelectedListing | null | undefined
+): ListingCoordinates | null {
+ if (!hasCoordinateField(listing)) return null;
+ return listing.coordinates ?? null;
+}
+
+export function hasValidCoordinates(
+ listing: CoordinateBearing | SelectedListing | null | undefined
+): listing is CoordinateBearing & { coordinates: ListingCoordinates } {
+ const coordinates = getListingCoordinates(listing);
+ return Boolean(
+ coordinates &&
+ typeof coordinates.latitude === "number" &&
+ typeof coordinates.longitude === "number" &&
+ Number.isFinite(coordinates.latitude) &&
+ Number.isFinite(coordinates.longitude)
+ );
+}
+
+// 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.
+//
+// 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();
+
+ const latSpan = ne.lat - sw.lat;
+ const lngSpan = ne.lng - sw.lng;
+
+ const latPad = latSpan * factor;
+ const lngPad = lngSpan * factor;
+
+ 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(
+ bounds: LngLatBounds,
+ coordinates: ListingCoordinates
+): boolean {
+ return bounds.contains([coordinates.longitude, coordinates.latitude]);
+}
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 };
+}