Skip to content

Commit ba4f8bc

Browse files
dnywhcursoragent
andauthored
refactor map page to TSX and split into focused modules (#47)
* refactor map page to TSX and split into focused modules - Extract map concerns into hooks: useMapListingUrl (URL sync + optimistic pin selection to fix tap flicker), useListingsInView (debounced, cancellable, padded viewport fetch that preserves prior pins), useMapCenter (centralised fly-to rules), useIpInitialLocation, and useMapDrawerScroll. - Split MapPageClient into MapPageClient + MapListingDrawer and MapImmersive into MapImmersive + MapPinLayer; move shared types and helpers into src/utils/mapUtils.ts. - Convert MapPageClient, MapImmersive, MapPin, MapSearch, and MapSidebar (plus their barrels) from JSX to TSX with typed props. - Strip extraneous console logs and add i18n strings for the return-to- listing button and the pin-loading chip. Made-with: Cursor * refactor * fix styling * fix(map): address Copilot PR feedback on map hooks and MapView - Always render Map so DEFAULT_COORDINATES applies when IP lookup is null - Accept latitude/longitude 0 in MapTiler geolocation response - Refetch listing when public/private view changes after auth resolves - Loosen debounce helper typings (document why any[] is needed) Co-authored-by: Danny White <dnywh@users.noreply.github.com> * fix(map): address second-pass PR feedback - useIpInitialLocation: track setTimeout id and clear it in finally + effect cleanup so the losing race branch can't fire after the network call wins. - useMapListingUrl: add a monotonic requestTokenRef; fetchBySlug and selectListingById ignore stale responses (and close invalidates any in-flight fetch) so older requests can't overwrite newer selections. - MapPageClient: handleDrawerOpenChange now accepts the open arg and only calls closeListing when the drawer is actually closing. - mapUtils: narrow getListingCoordinates / hasValidCoordinates via an explicit isListingError check instead of casting through isListing. Made-with: Cursor * fix(map): attach desktop drawer scroll listener when ref is already set Previously, the desktop branch of useMapDrawerState only attached the scroll listener inside the MutationObserver callback. If drawerContentRef.current was already populated when the effect ran (common on subsequent listing changes), no DOM mutation fired, the listener never attached, and the sticky drawer header stayed hidden. Now we attach immediately when the ref is set and only fall back to the observer when it isn't. Made-with: Cursor * fix(map): keep URL/UI in sync on tap failure; handle antimeridian in padBounds - selectListingById no longer sets the error sentinel when the fetch fails. Tap-driven fetches happen before the URL is pushed, so the drawer would either stay closed (swallowing the error) or desync UI and URL while pointing at the previous listing. The optimistic pin id is reverted and `selectedListing` is left untouched, so UI and URL stay consistent. - padBounds now wraps longitudes into [-180, 180] and splits the envelope into two when the padded viewport crosses the antimeridian. Callers iterate all returned boxes. - useListingsInView fetches all returned boxes in parallel and merges responses, deduping by id. Made-with: Cursor * fix(map): restore optimistic pin to resolved listing on tap failure When selectListingById's fetch fails while a previously resolved listing is still visible in the drawer, the pin selection was being cleared to null — leaving the drawer showing listing A with no pin highlighted. Now we capture the currently-resolved listing id (via a ref mirroring selectedListing.id) before the optimistic change, and revert to that id on failure so the pin and the drawer stay in sync. Made-with: Cursor * fix(map): cancel in-flight fetch when URL loses listingSlug; cleanups - useMapListingUrl: bump requestTokenRef when listingSlug becomes null so a late fetchBySlug response can't re-select the listing after browser back. - ListingRead: memoize the Supabase client so the thread-loading effect doesn't re-run on every render; drop unused mapZoomLevel state + effect (MapThumbnail already uses initialZoomLevel directly). - ListingChatDrawer: drop the dead listingDisplayName prop (was renamed to a discarded local); simplify isNested check to !isNested so an omitted prop is treated as "not nested". Made-with: Cursor * fix: narrow thread effect deps; share ListingType with MapPin - ListingRead: the thread-loading effect depended on the whole realListing object, so any parent re-render with a new listing identity (even with the same id/owner_id) refetched the thread. Depend on the specific fields the effect reads instead. - MapPin: drop the duplicated ListingPinType union and key the icon map on the shared ListingType. Adding a new listing type now surfaces as a compile error in MapPin, keeping the two from drifting out of sync. Made-with: Cursor * fix(map): restore IP-based initial centre; assorted review tweaks - MapView: restore the hasInitialPosition gate. MapLibre's initialViewState is consumed once at mount, so mounting before the IP lookup resolves locks the map to DEFAULT_COORDINATES (Brisbane) even after IP data arrives. The gate waits for either a selected listing or the IP fallback to resolve before mounting. - useIpInitialLocation: on timeout or error, fall back to DEFAULT_COORDINATES so the gate always eventually lifts (addresses the earlier concern about the map never rendering on IP failure). - mapUtils: drop the unused DEFAULT_COORDINATES.zoom; ZOOM_LEVEL_DEFAULT is already the canonical default zoom. - useMapListingUrl: memoize the Supabase client so fetchBySlug / selectListingById don't churn on every render. - MapPin: tighten isListingPinType with hasOwnProperty.call so inherited prototype keys like "toString" can't incorrectly narrow to ListingType. Made-with: Cursor * fix(map): resolve initialCoordinates even when IP lookup is skipped Deep links (skip: true) would leave initialCoordinates null forever; combined with a listing that has no coordinates or resolves to an error sentinel, the <Map> never mounted. Fall back to DEFAULT_COORDINATES on skip so MapView can always render. Made-with: Cursor --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Danny White <dnywh@users.noreply.github.com>
1 parent 2411aa1 commit ba4f8bc

32 files changed

Lines changed: 2308 additions & 1562 deletions

messages/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@
378378
"searchPlaceholder": "Suchen",
379379
"searchError": "Etwas ist schiefgelaufen. Erneut versuchen?",
380380
"searchNoResults": "Keine Ergebnisse. Tippe weiter oder verfeinere deine Suche",
381+
"returnToListing": "Zurück zum Eintrag",
382+
"loadingPins": "Wird geladen…",
381383
"didYouKnow": "Wusstest du schon?",
382384
"steps": {
383385
"find": {

messages/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@
378378
"searchPlaceholder": "Search",
379379
"searchError": "Something went wrong. Try again?",
380380
"searchNoResults": "No results. Keep typing or refine your search",
381+
"returnToListing": "Return to listing",
382+
"loadingPins": "Loading…",
381383
"didYouKnow": "Did you know?",
382384
"steps": {
383385
"find": {

messages/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@
378378
"searchPlaceholder": "Buscar",
379379
"searchError": "Algo salió mal. ¿Intentarlo de nuevo?",
380380
"searchNoResults": "Sin resultados. Sigue escribiendo o ajusta tu búsqueda",
381+
"returnToListing": "Volver al anuncio",
382+
"loadingPins": "Cargando…",
381383
"didYouKnow": "¿Sabías que?",
382384
"steps": {
383385
"find": {

src/app/(core)/(interact)/(stretched)/map/page.js renamed to src/app/(core)/(interact)/(stretched)/map/page.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1+
import { cache } from "react";
2+
import type { Metadata } from "next/types";
3+
14
import { createClient } from "@/utils/supabase/server";
25
import { siteConfig } from "@/config/site";
36
import { generateListingMetadata } from "@/utils/listingUtils";
4-
import MapPageClient from "@/components/MapPageClient";
5-
import { cache } from "react";
7+
import MapPageClient from "@/features/map";
8+
import type { Listing } from "@/types/listing";
9+
10+
type MapPageSearchParams = {
11+
listing?: string;
12+
};
13+
14+
type MapPageProps = {
15+
searchParams: Promise<MapPageSearchParams>;
16+
};
617

7-
// Fetch data only once and use across metadata and page
8-
const getInitialData = cache(async (listingSlug) => {
18+
const getInitialData = cache(async (listingSlug: string | undefined) => {
919
const supabase = await createClient();
1020

11-
// Get user first
1221
const {
1322
data: { user },
1423
} = await supabase.auth.getUser();
1524

16-
// Then get listing data if slug exists
1725
const listingResponse = listingSlug
1826
? await supabase
1927
.from(user ? "listings_private_data" : "listings_public_data")
@@ -24,11 +32,13 @@ const getInitialData = cache(async (listingSlug) => {
2432

2533
return {
2634
user,
27-
listing: listingResponse?.data,
35+
listing: (listingResponse?.data ?? null) as Listing | null,
2836
};
2937
});
3038

31-
export async function generateMetadata({ searchParams }) {
39+
export async function generateMetadata({
40+
searchParams,
41+
}: MapPageProps): Promise<Metadata> {
3242
const listingSlug = (await searchParams)?.listing;
3343

3444
if (!listingSlug) {
@@ -42,18 +52,17 @@ export async function generateMetadata({ searchParams }) {
4252

4353
const { user, listing } = await getInitialData(listingSlug);
4454

45-
// Use shared utility to generate metadata
4655
return generateListingMetadata(listing, user);
4756
}
4857

49-
export default async function Page({ searchParams }) {
58+
export default async function Page({ searchParams }: MapPageProps) {
5059
const listingSlug = (await searchParams)?.listing;
5160
const { user, listing } = await getInitialData(listingSlug);
5261

5362
return (
5463
<MapPageClient
5564
user={user}
56-
initialListingSlug={listingSlug}
65+
initialListingSlug={listingSlug ?? null}
5766
initialListing={listing}
5867
/>
5968
);

src/app/actions.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,6 @@ export async function fetchListingsInView(
494494
return [];
495495
}
496496

497-
console.log(`Successfully fetched ${data?.length || 0} listings`);
498497
return data || [];
499498
} catch (error) {
500499
console.error("Fatal error in fetchListingsInView:", {

src/components/ListingChatDrawer/ListingChatDrawer.jsx renamed to src/components/ListingChatDrawer/ListingChatDrawer.tsx

Lines changed: 48 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"use client";
2+
import type { ReactNode } from "react";
3+
import type { User } from "@supabase/supabase-js";
24
import { useDeviceContext } from "@/hooks/useDeviceContext";
35
import { Drawer } from "vaul";
46
import Button from "@/components/Button";
57
import ChatWindow from "@/components/ChatWindow";
68
import ListingCta from "@/components/ListingCta";
79
import { styled } from "@pigment-css/react";
810

11+
import type { Listing } from "@/types/listing";
12+
913
const sidebarWidth = "clamp(20rem, 30vw, 30rem)";
1014

1115
const StyledDrawerOverlay = styled(Drawer.Overlay)({
@@ -15,7 +19,7 @@ const StyledDrawerOverlay = styled(Drawer.Overlay)({
1519
});
1620

1721
const ListingCtaContainer = styled("div")({
18-
padding: "0 1rem", // Match padding from other parts of ListingRead
22+
padding: "0 1rem",
1923

2024
"& > *": {
2125
width: "100%",
@@ -24,17 +28,16 @@ const ListingCtaContainer = styled("div")({
2428

2529
const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({
2630
background: theme.colors.background.top,
27-
borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`, // Match over drawer content
31+
borderRadius: `${theme.corners.base} ${theme.corners.base} 0 0`,
2832

2933
overflowX: "hidden",
3034

3135
"&::after": {
32-
display: "none", // Otherwise seems to include side scroll, even when overflowX hidden
36+
display: "none",
3337
},
3438

3539
marginTop: "24px",
36-
// maxHeight: "95%",
37-
height: "95%", // Take up full height even if the message contents aren't overflowing yet
40+
height: "95%",
3841
position: "fixed",
3942
bottom: "0",
4043
left: "0",
@@ -43,7 +46,7 @@ const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({
4346
flexDirection: "column",
4447

4548
"@media (min-width: 768px)": {
46-
borderRadius: theme.corners.base, // Match over drawer content
49+
borderRadius: theme.corners.base,
4750
height: "unset",
4851
marginTop: "unset",
4952
top: "24px",
@@ -52,53 +55,53 @@ const StyledDrawerContent = styled(Drawer.Content)(({ theme }) => ({
5255
left: "unset",
5356
outline: "none",
5457
width: sidebarWidth,
55-
// height: "100%",
5658
},
5759
}));
5860

59-
// We need to define two different drawer components, because depending on the 'modal' prop, a different number of hooks will be rendered
60-
// React doesn't like when we conditionally change the number of hooks. It's better to just render a separate component for each case
61-
// Shared drawer props to reduce repetition
62-
const getDrawerProps = ({
63-
isNested,
64-
isChatDrawerOpen,
65-
setIsChatDrawerOpen,
66-
isDesktop,
67-
...rest
68-
}) => ({
69-
isNested,
70-
direction: isDesktop ? "right" : undefined,
71-
open: isChatDrawerOpen,
72-
onOpenChange: setIsChatDrawerOpen,
73-
...rest,
74-
});
75-
76-
const ModalDrawer = (props) => {
77-
const DrawerComponent = props.isNested ? Drawer.NestedRoot : Drawer.Root;
78-
return <DrawerComponent modal={true} {...getDrawerProps(props)} />;
61+
type ListingChatDrawerProps = {
62+
isNested?: boolean;
63+
user: User | null;
64+
listing: Listing;
65+
isChatDrawerOpen: boolean;
66+
setIsChatDrawerOpen: (open: boolean) => void;
67+
existingThread: unknown;
7968
};
8069

81-
const NonModalDrawer = (props) => {
82-
const DrawerComponent = props.isNested ? Drawer.NestedRoot : Drawer.Root;
83-
return <DrawerComponent modal={false} {...getDrawerProps(props)} />;
70+
type SharedDrawerProps = {
71+
isNested?: boolean;
72+
open: boolean;
73+
onOpenChange: (open: boolean) => void;
74+
direction?: "right";
75+
children?: ReactNode;
8476
};
8577

78+
// We need two drawer variants because `modal` changes which hooks vaul renders.
79+
function ModalDrawer({ isNested, ...rest }: SharedDrawerProps) {
80+
const DrawerComponent = isNested ? Drawer.NestedRoot : Drawer.Root;
81+
return <DrawerComponent modal={true} {...rest} />;
82+
}
83+
84+
function NonModalDrawer({ isNested, ...rest }: SharedDrawerProps) {
85+
const DrawerComponent = isNested ? Drawer.NestedRoot : Drawer.Root;
86+
return <DrawerComponent modal={false} {...rest} />;
87+
}
88+
8689
export default function ListingChatDrawer({
8790
isNested,
8891
user,
8992
listing,
9093
isChatDrawerOpen,
9194
setIsChatDrawerOpen,
9295
existingThread,
93-
listingDisplayName,
94-
...props
95-
}) {
96+
}: ListingChatDrawerProps) {
9697
const { isDesktop, hasTouch } = useDeviceContext();
9798

98-
// We can infer modal behavior based on presentation
99-
// If it's a mobile breakpoint, always use a model
100-
// If it's a desktop breakpoint, only use a modal if it's NOT a nested drawer
101-
const shouldUseModal = !isDesktop || isNested === false;
99+
// Mobile: always modal. Desktop: modal only if NOT a nested drawer.
100+
// (`!isNested` treats an omitted prop the same as `false`.)
101+
const shouldUseModal = !isDesktop || !isNested;
102+
103+
const visibility = listing.visibility ?? undefined;
104+
const isStub = listing.is_stub ?? undefined;
102105

103106
const drawerContent = (
104107
<>
@@ -108,14 +111,14 @@ export default function ListingChatDrawer({
108111
<ListingCta
109112
viewer="owner"
110113
slug={listing.slug}
111-
visibility={listing.visibility}
112-
isStub={listing.is_stub}
114+
visibility={visibility}
115+
isStub={isStub}
113116
/>
114117
) : listing.is_stub ? (
115118
<ListingCta
116119
viewer="guest"
117120
slug={listing.slug}
118-
visibility={listing.visibility}
121+
visibility={visibility}
119122
isStub={true}
120123
/>
121124
) : (
@@ -129,8 +132,8 @@ export default function ListingChatDrawer({
129132
<ListingCta
130133
viewer="guest"
131134
slug={listing.slug}
132-
visibility={listing.visibility}
133-
isStub={listing.is_stub}
135+
visibility={visibility}
136+
isStub={isStub}
134137
/>
135138
)}
136139
</ListingCtaContainer>
@@ -154,10 +157,9 @@ export default function ListingChatDrawer({
154157
return (
155158
<DrawerComponent
156159
isNested={isNested}
157-
isChatDrawerOpen={isChatDrawerOpen}
158-
setIsChatDrawerOpen={setIsChatDrawerOpen}
159-
isDesktop={isDesktop}
160-
{...props}
160+
open={isChatDrawerOpen}
161+
onOpenChange={setIsChatDrawerOpen}
162+
direction={isDesktop ? "right" : undefined}
161163
>
162164
{drawerContent}
163165
</DrawerComponent>

0 commit comments

Comments
 (0)