@salesforce/webapp-template-app-react-sample-b2x-experimental 1.116.7 → 1.116.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/webapplications/propertyrentalapp/eslint.config.js +13 -2
  3. package/dist/force-app/main/default/webapplications/propertyrentalapp/package.json +3 -3
  4. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/graphql-operations-types.ts +24594 -7234
  5. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/maintenanceRequests/maintenanceRequestApi.ts +21 -157
  6. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/maintenanceRequests/query/maintenanceRequests.graphql +60 -0
  7. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/propertyDetailGraphQL.ts +45 -444
  8. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/propertyNodeUtils.ts +29 -0
  9. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/propertySearchService.ts +56 -0
  10. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/query/distinctPropertyStatus.graphql +19 -0
  11. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/query/distinctPropertyType.graphql +19 -0
  12. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/query/listingById.graphql +29 -0
  13. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/query/propertyAddressesByIds.graphql +17 -0
  14. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/query/propertyDetailById.graphql +124 -0
  15. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/query/searchProperties.graphql +85 -0
  16. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/appLayout.tsx +1 -1
  17. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/components/SkeletonPrimitives.tsx +9 -6
  18. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/components/dashboard/WeatherWidget.tsx +35 -19
  19. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/components/maintenanceRequests/MaintenanceRequestList.tsx +7 -5
  20. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/components/maintenanceRequests/MaintenanceRequestListItem.tsx +11 -10
  21. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/components/maintenanceRequests/MaintenanceSummaryDetailsModal.tsx +20 -15
  22. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/components/properties/PropertyListingCard.tsx +11 -24
  23. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/components/properties/PropertyMap.tsx +2 -1
  24. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/authentication/context/AuthContext.tsx +1 -1
  25. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/authentication/hooks/useCountdownTimer.ts +1 -1
  26. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/authentication/pages/Profile.tsx +3 -3
  27. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/authentication/pages/Register.tsx +1 -1
  28. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/authentication/sessionTimeout/SessionTimeoutValidator.tsx +12 -18
  29. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/FilterContext.tsx +1 -1
  30. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/hooks/useObjectSearchParams.ts +10 -5
  31. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/useGeocode.ts +23 -38
  32. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/useMaintenanceRequests.ts +29 -25
  33. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/usePropertyDetail.ts +42 -78
  34. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/usePropertyMapMarkers.ts +34 -41
  35. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/useWeather.ts +14 -30
  36. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Application.tsx +41 -74
  37. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Contact.tsx +44 -55
  38. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Dashboard.tsx +1 -0
  39. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Home.tsx +63 -32
  40. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Maintenance.tsx +97 -8
  41. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/PropertyDetails.tsx +67 -45
  42. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/PropertySearch.tsx +299 -191
  43. package/dist/package-lock.json +2 -2
  44. package/dist/package.json +1 -1
  45. package/package.json +4 -1
  46. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/properties/propertyListingGraphQL.ts +0 -380
  47. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/components/properties/PropertyListingSearchPagination.tsx +0 -136
  48. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/constants/propertyListing.ts +0 -4
  49. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/usePropertyAddresses.ts +0 -45
  50. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/usePropertyListingAmenities.ts +0 -57
  51. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/usePropertyListingSearch.ts +0 -84
  52. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/hooks/usePropertyPrimaryImages.ts +0 -53
  53. /package/dist/force-app/main/default/webapplications/propertyrentalapp/src/api/{leadApi.ts → leads/leadApi.ts} +0 -0
@@ -496,8 +496,18 @@ export default function SessionTimeoutValidator({
496
496
  // Get current location from React Router
497
497
  const location = useLocation();
498
498
 
499
- // State for session expired alert
500
- const [showExpiredAlert, setShowExpiredAlert] = useState(false);
499
+ // Session expired alert checked once at mount via lazy initializer.
500
+ // The session timeout handler triggers a hard navigation (window.location.replace),
501
+ // so the component always mounts fresh on the login page after expiry.
502
+ const [showExpiredAlert, setShowExpiredAlert] = useState(() => {
503
+ const isLoginPage = location.pathname === ROUTES.LOGIN.PATH;
504
+ const shouldShow =
505
+ isLoginPage && sessionStorage.getItem(STORAGE_KEYS.SHOW_SESSION_MESSAGE) === "true";
506
+ if (shouldShow) {
507
+ sessionStorage.removeItem(STORAGE_KEYS.SHOW_SESSION_MESSAGE);
508
+ }
509
+ return shouldShow;
510
+ });
501
511
 
502
512
  // Session timeout monitoring hook
503
513
  const sessionTimeout = useSessionTimeout({
@@ -505,22 +515,6 @@ export default function SessionTimeoutValidator({
505
515
  isGuest,
506
516
  });
507
517
 
508
- /**
509
- * Check if we should show expired session message
510
- * Called on mount and whenever pathname changes
511
- */
512
- useEffect(() => {
513
- // Check if we're on the login page and should show expired message
514
- const isLoginPage = location.pathname === ROUTES.LOGIN.PATH;
515
- const shouldShowMessage = sessionStorage.getItem(STORAGE_KEYS.SHOW_SESSION_MESSAGE) === "true";
516
-
517
- if (isLoginPage && shouldShowMessage) {
518
- setShowExpiredAlert(true);
519
- // Clear the flag immediately after reading
520
- sessionStorage.removeItem(STORAGE_KEYS.SHOW_SESSION_MESSAGE);
521
- }
522
- }, [location.pathname]);
523
-
524
518
  /**
525
519
  * Handle session extension
526
520
  * Called when user clicks "Continue Working" in warning modal
@@ -60,7 +60,7 @@ export function useFilterPanel() {
60
60
  return { hasActiveFilters: filters.length > 0, resetAll: onReset };
61
61
  }
62
62
 
63
- interface FilterResetButtonProps extends Omit<React.ComponentProps<typeof Button>, "onClick"> {}
63
+ type FilterResetButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick">;
64
64
 
65
65
  export function FilterResetButton({ children, ...props }: FilterResetButtonProps) {
66
66
  const { hasActiveFilters, resetAll } = useFilterPanel();
@@ -61,7 +61,10 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
61
61
  paginationConfig?: PaginationConfig,
62
62
  ) {
63
63
  const defaultPageSize = paginationConfig?.defaultPageSize ?? 10;
64
- const validPageSizes = paginationConfig?.validPageSizes ?? [defaultPageSize];
64
+ const validPageSizes = useMemo(
65
+ () => paginationConfig?.validPageSizes ?? [defaultPageSize],
66
+ [paginationConfig?.validPageSizes, defaultPageSize],
67
+ );
65
68
  const [searchParams, setSearchParams] = useSearchParams();
66
69
 
67
70
  // Seed local state from URL on initial load
@@ -76,8 +79,10 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
76
79
  const [sort, setLocalSort] = useState<SortState | null>(initial.sort);
77
80
 
78
81
  // Pagination — cursor-based with a stack to support "previous page" navigation.
79
- const getValidPageSize = (size: number) =>
80
- validPageSizes.includes(size) ? size : defaultPageSize;
82
+ const getValidPageSize = useCallback(
83
+ (size: number) => (validPageSizes.includes(size) ? size : defaultPageSize),
84
+ [validPageSizes, defaultPageSize],
85
+ );
81
86
 
82
87
  const [pageSize, setPageSizeState] = useState<number>(
83
88
  getValidPageSize(initial.pageSize ?? defaultPageSize),
@@ -166,7 +171,7 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
166
171
  resetPagination();
167
172
  syncToUrl([], null, defaultPageSize, 0);
168
173
  setPageSizeState(defaultPageSize);
169
- }, [syncToUrl, resetPagination]);
174
+ }, [syncToUrl, resetPagination, defaultPageSize]);
170
175
 
171
176
  // -- Pagination callbacks ---------------------------------------------------
172
177
  // Uses a cursor stack to track visited pages. "Next" pushes the current
@@ -204,7 +209,7 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
204
209
  resetPagination();
205
210
  debouncedSyncRef.current(f, s, validated);
206
211
  },
207
- [resetPagination],
212
+ [resetPagination, getValidPageSize],
208
213
  );
209
214
 
210
215
  // -- Derived query objects ---------------------------------------------------
@@ -1,48 +1,33 @@
1
- import { useState, useEffect } from "react";
2
1
  import { geocodeAddress, getStateZipFromAddress, type GeocodeResult } from "@/utils/geocode";
2
+ import { useCachedAsyncData } from "@/features/object-search/hooks/useCachedAsyncData";
3
+
4
+ async function geocodeWithFallback(address: string): Promise<GeocodeResult | null> {
5
+ const normalized = address.replace(/\n/g, ", ").trim();
6
+ const result = await geocodeAddress(normalized);
7
+ if (result != null) return result;
8
+
9
+ // Fallback: try state + zip if full address failed
10
+ const stateZip = getStateZipFromAddress(normalized);
11
+ if (stateZip !== normalized) {
12
+ return geocodeAddress(stateZip);
13
+ }
14
+ return null;
15
+ }
3
16
 
4
17
  export function useGeocode(address: string | null | undefined): {
5
18
  coords: GeocodeResult | null;
6
19
  loading: boolean;
7
20
  } {
8
- const [coords, setCoords] = useState<GeocodeResult | null>(null);
9
- const [loading, setLoading] = useState(false);
10
-
11
- useEffect(() => {
12
- if (!address?.trim()) {
13
- setCoords(null);
14
- setLoading(false);
15
- return;
16
- }
17
- let cancelled = false;
18
- setLoading(true);
19
- const normalized = address.replace(/\n/g, ", ").trim();
20
-
21
- (async () => {
22
- try {
23
- let result = await geocodeAddress(normalized);
24
- if (cancelled) return;
25
- if (result != null) {
26
- setCoords(result);
27
- return;
28
- }
29
- // Fallback: same as property search – try state + zip if full address failed
30
- const stateZip = getStateZipFromAddress(normalized);
31
- if (stateZip !== normalized) {
32
- result = await geocodeAddress(stateZip);
33
- if (!cancelled) setCoords(result);
34
- }
35
- } catch {
36
- if (!cancelled) setCoords(null);
37
- } finally {
38
- if (!cancelled) setLoading(false);
39
- }
40
- })();
21
+ const trimmed = address?.trim() ?? "";
41
22
 
42
- return () => {
43
- cancelled = true;
44
- };
45
- }, [address?.trim() ?? ""]);
23
+ const { data: coords, loading } = useCachedAsyncData(
24
+ () => {
25
+ if (!trimmed) return Promise.resolve(null);
26
+ return geocodeWithFallback(trimmed);
27
+ },
28
+ [trimmed],
29
+ { key: `geocode:${trimmed}`, ttl: 600_000 },
30
+ );
46
31
 
47
32
  return { coords, loading };
48
33
  }
@@ -1,39 +1,43 @@
1
1
  /**
2
2
  * Fetches Maintenance_Request__c list and exposes refetch for after create.
3
3
  */
4
- import { useState, useEffect, useCallback } from "react";
4
+ import { useState, useCallback } from "react";
5
5
  import {
6
- queryMaintenanceRequests,
7
- type MaintenanceRequestSummary,
6
+ searchMaintenanceRequests,
7
+ type MaintenanceRequestNode,
8
8
  } from "@/api/maintenanceRequests/maintenanceRequestApi";
9
+ import { ResultOrder } from "@/api/graphql-operations-types";
10
+ import {
11
+ useCachedAsyncData,
12
+ clearCacheEntry,
13
+ } from "@/features/object-search/hooks/useCachedAsyncData";
14
+
15
+ const CACHE_KEY = "maintenance-requests";
16
+
17
+ async function fetchMaintenanceNodes(): Promise<MaintenanceRequestNode[]> {
18
+ const result = await searchMaintenanceRequests({
19
+ first: 50,
20
+ orderBy: { Scheduled__c: { order: ResultOrder.Desc } },
21
+ });
22
+ return (result.edges ?? []).flatMap((e) => (e?.node ? [e.node] : []));
23
+ }
9
24
 
10
25
  export function useMaintenanceRequests(): {
11
- requests: MaintenanceRequestSummary[];
26
+ requests: MaintenanceRequestNode[];
12
27
  loading: boolean;
13
28
  error: string | null;
14
- refetch: () => Promise<void>;
29
+ refetch: () => void;
15
30
  } {
16
- const [requests, setRequests] = useState<MaintenanceRequestSummary[]>([]);
17
- const [loading, setLoading] = useState(true);
18
- const [error, setError] = useState<string | null>(null);
31
+ const [generation, setGeneration] = useState(0);
19
32
 
20
- const fetchList = useCallback(async () => {
21
- setLoading(true);
22
- setError(null);
23
- try {
24
- const list = await queryMaintenanceRequests(50, null);
25
- setRequests(list);
26
- } catch (e) {
27
- setError(e instanceof Error ? e.message : "Failed to load maintenance requests");
28
- setRequests([]);
29
- } finally {
30
- setLoading(false);
31
- }
32
- }, []);
33
+ const { data, loading, error } = useCachedAsyncData(fetchMaintenanceNodes, [generation], {
34
+ key: `${CACHE_KEY}:${generation}`,
35
+ });
33
36
 
34
- useEffect(() => {
35
- fetchList();
36
- }, [fetchList]);
37
+ const refetch = useCallback(() => {
38
+ clearCacheEntry(`${CACHE_KEY}:${generation}`);
39
+ setGeneration((g) => g + 1);
40
+ }, [generation]);
37
41
 
38
- return { requests, loading, error, refetch: fetchList };
42
+ return { requests: data ?? [], loading, error, refetch };
39
43
  }
@@ -1,99 +1,63 @@
1
1
  /**
2
- * Fetches Property_Listing__c by id, then related Property__c, images, costs, and features.
2
+ * Fetches Property__c by id with all related data (images, costs, features, listings)
3
+ * in a single GraphQL query.
3
4
  */
4
- import { useState, useEffect, useCallback } from "react";
5
+ import { useState, useCallback } from "react";
5
6
  import {
7
+ fetchPropertyDetailById,
6
8
  fetchListingById,
7
- fetchPropertyById,
8
- fetchImagesByPropertyId,
9
- fetchCostsByPropertyId,
10
- fetchFeaturesByPropertyId,
11
- type ListingDetail,
12
- type PropertyDetail,
13
- type PropertyImageRecord,
14
- type PropertyCostRecord,
15
- type PropertyFeatureRecord,
9
+ type PropertyDetailNode,
16
10
  } from "@/api/properties/propertyDetailGraphQL";
11
+ import {
12
+ useCachedAsyncData,
13
+ clearCacheEntry,
14
+ } from "@/features/object-search/hooks/useCachedAsyncData";
17
15
 
18
16
  export interface PropertyDetailState {
19
- listing: ListingDetail | null;
20
- property: PropertyDetail | null;
21
- images: PropertyImageRecord[];
22
- costs: PropertyCostRecord[];
23
- features: PropertyFeatureRecord[];
17
+ property: PropertyDetailNode | null;
24
18
  loading: boolean;
25
19
  error: string | null;
26
20
  }
27
21
 
22
+ const CACHE_KEY_PREFIX = "property-detail";
23
+
24
+ async function fetchDetail(id: string): Promise<PropertyDetailNode | null> {
25
+ // First try directly as a Property__c ID (common path).
26
+ const detail = await fetchPropertyDetailById(id);
27
+ if (detail) return detail;
28
+
29
+ // Fall back: treat as a Property_Listing__c ID and resolve to its Property__c.
30
+ const listing = await fetchListingById(id);
31
+ const propertyId = listing?.Property__c?.value ?? null;
32
+ if (!propertyId) return null;
33
+ return fetchPropertyDetailById(propertyId);
34
+ }
35
+
28
36
  export function usePropertyDetail(
29
- listingId: string | undefined,
37
+ id: string | undefined,
30
38
  ): PropertyDetailState & { refetch: () => void } {
31
- const [listing, setListing] = useState<ListingDetail | null>(null);
32
- const [property, setProperty] = useState<PropertyDetail | null>(null);
33
- const [images, setImages] = useState<PropertyImageRecord[]>([]);
34
- const [costs, setCosts] = useState<PropertyCostRecord[]>([]);
35
- const [features, setFeatures] = useState<PropertyFeatureRecord[]>([]);
36
- const [loading, setLoading] = useState(true);
37
- const [error, setError] = useState<string | null>(null);
39
+ const [generation, setGeneration] = useState(0);
40
+ const trimmedId = id?.trim() ?? "";
41
+ const cacheKey = `${CACHE_KEY_PREFIX}:${trimmedId}:${generation}`;
38
42
 
39
- const load = useCallback(async () => {
40
- if (!listingId?.trim()) {
41
- setListing(null);
42
- setProperty(null);
43
- setImages([]);
44
- setCosts([]);
45
- setFeatures([]);
46
- setLoading(false);
47
- setError(null);
48
- return;
49
- }
50
- setLoading(true);
51
- setError(null);
52
- try {
53
- const listingData = await fetchListingById(listingId);
54
- setListing(listingData ?? null);
55
- if (!listingData?.propertyId) {
56
- setProperty(null);
57
- setImages([]);
58
- setCosts([]);
59
- setFeatures([]);
60
- setLoading(false);
61
- return;
62
- }
63
- const [propertyData, imagesData, costsData, featuresData] = await Promise.all([
64
- fetchPropertyById(listingData.propertyId),
65
- fetchImagesByPropertyId(listingData.propertyId),
66
- fetchCostsByPropertyId(listingData.propertyId),
67
- fetchFeaturesByPropertyId(listingData.propertyId),
68
- ]);
69
- setProperty(propertyData ?? null);
70
- setImages(imagesData ?? []);
71
- setCosts(costsData ?? []);
72
- setFeatures(featuresData ?? []);
73
- } catch (e) {
74
- setError(e instanceof Error ? e.message : String(e));
75
- setListing(null);
76
- setProperty(null);
77
- setImages([]);
78
- setCosts([]);
79
- setFeatures([]);
80
- } finally {
81
- setLoading(false);
82
- }
83
- }, [listingId]);
43
+ const { data, loading, error } = useCachedAsyncData(
44
+ () => {
45
+ if (!trimmedId) return Promise.resolve(null);
46
+ return fetchDetail(trimmedId);
47
+ },
48
+ [trimmedId, generation],
49
+ { key: cacheKey },
50
+ );
84
51
 
85
- useEffect(() => {
86
- load();
87
- }, [load]);
52
+ const refetch = useCallback(() => {
53
+ clearCacheEntry(cacheKey);
54
+ setGeneration((g) => g + 1);
55
+ }, [cacheKey]);
88
56
 
89
57
  return {
90
- listing,
91
- property,
92
- images,
93
- costs,
94
- features,
58
+ property: data ?? null,
95
59
  loading,
96
60
  error,
97
- refetch: load,
61
+ refetch,
98
62
  };
99
63
  }
@@ -1,21 +1,17 @@
1
1
  /**
2
- * Fetches property addresses for the current page of results only, geocodes them in parallel,
3
- * and returns map markers (one pin per property in the current window).
2
+ * Builds map markers from search result nodes. Uses coordinates when available,
3
+ * falls back to geocoding addresses for properties missing coordinates.
4
4
  */
5
- import { useState, useEffect } from "react";
5
+ import { useState, useEffect, useRef } from "react";
6
6
  import { fetchPropertyAddresses } from "@/api/properties/propertyDetailGraphQL";
7
7
  import { geocodeAddress, getStateZipFromAddress } from "@/utils/geocode";
8
- import { getPropertyIdFromRecord } from "@/hooks/usePropertyPrimaryImages";
9
- import type { SearchResultRecord } from "@/types/searchResults.js";
8
+ import type { PropertySearchNode } from "@/api/properties/propertySearchService";
10
9
  import type { MapMarker } from "@/components/properties/PropertyMap";
11
10
 
12
- function getListingName(record: {
13
- fields?: Record<string, { value?: unknown; displayValue?: string | null }>;
14
- }): string {
15
- const f = record.fields?.Name;
16
- if (!f || typeof f !== "object") return "Property";
17
- if (f.displayValue != null && f.displayValue !== "") return String(f.displayValue);
18
- if (f.value != null && typeof f.value === "string") return f.value;
11
+ function getListingName(node: PropertySearchNode): string {
12
+ if (node.Name?.displayValue != null && node.Name.displayValue !== "")
13
+ return node.Name.displayValue;
14
+ if (node.Name?.value != null && node.Name.value !== "") return node.Name.value;
19
15
  return "Property";
20
16
  }
21
17
 
@@ -28,13 +24,9 @@ function toFiniteNumber(value: unknown): number | null {
28
24
  return null;
29
25
  }
30
26
 
31
- function getCoordinatesFromRecord(record: {
32
- fields?: Record<string, { value?: unknown }>;
33
- }): { lat: number; lng: number } | null {
34
- const latRaw = record.fields?.["Property__r.Coordinates__Latitude__s"]?.value;
35
- const lngRaw = record.fields?.["Property__r.Coordinates__Longitude__s"]?.value;
36
- const lat = toFiniteNumber(latRaw);
37
- const lng = toFiniteNumber(lngRaw);
27
+ function getCoordinatesFromNode(node: PropertySearchNode): { lat: number; lng: number } | null {
28
+ const lat = toFiniteNumber(node.Coordinates__Latitude__s?.value);
29
+ const lng = toFiniteNumber(node.Coordinates__Longitude__s?.value);
38
30
  if (lat == null || lng == null) return null;
39
31
  return { lat, lng };
40
32
  }
@@ -75,48 +67,50 @@ function spreadDuplicateMarkers(markers: MapMarker[]): MapMarker[] {
75
67
  return result;
76
68
  }
77
69
 
78
- export function usePropertyMapMarkers(results: SearchResultRecord[]): {
70
+ export function usePropertyMapMarkers(results: PropertySearchNode[]): {
79
71
  markers: MapMarker[];
80
72
  loading: boolean;
81
73
  } {
82
74
  const [markers, setMarkers] = useState<MapMarker[]>([]);
83
75
  const [loading, setLoading] = useState(false);
84
76
 
85
- // Only the current page / current window of results
86
- const propertyIds = results
87
- .map((r) => r?.record && getPropertyIdFromRecord(r.record))
88
- .filter((id): id is string => Boolean(id));
77
+ const propertyIds = results.map((r) => r.Id).filter(Boolean);
89
78
  const propertyIdToLabel = new Map<string, string>();
90
- for (const r of results) {
91
- if (!r?.record) continue;
92
- const id = getPropertyIdFromRecord(r.record);
93
- if (id && !propertyIdToLabel.has(id)) {
94
- propertyIdToLabel.set(id, getListingName(r.record));
79
+ for (const node of results) {
80
+ if (!propertyIdToLabel.has(node.Id)) {
81
+ propertyIdToLabel.set(node.Id, getListingName(node));
95
82
  }
96
83
  }
84
+ const idsKey = [...new Set(propertyIds)].join(",");
97
85
 
86
+ const resultsRef = useRef(results);
87
+ const labelMapRef = useRef(propertyIdToLabel);
98
88
  useEffect(() => {
99
- if (propertyIds.length === 0) {
89
+ resultsRef.current = results;
90
+ labelMapRef.current = propertyIdToLabel;
91
+ });
92
+
93
+ useEffect(() => {
94
+ const uniqIds = idsKey === "" ? [] : idsKey.split(",");
95
+ if (uniqIds.length === 0) {
100
96
  setMarkers([]);
101
97
  setLoading(false);
102
98
  return;
103
99
  }
104
100
  let cancelled = false;
105
101
  setLoading(true);
106
- const uniqIds = [...new Set(propertyIds)];
102
+ const currentLabels = labelMapRef.current;
107
103
  const directMarkers: MapMarker[] = [];
108
104
  const missingIds: string[] = [];
109
- for (const r of results) {
110
- if (!r?.record) continue;
111
- const id = getPropertyIdFromRecord(r.record);
112
- if (!id || !uniqIds.includes(id)) continue;
113
- const coords = getCoordinatesFromRecord(r.record);
105
+ for (const node of results) {
106
+ if (!uniqIds.includes(node.Id)) continue;
107
+ const coords = getCoordinatesFromNode(node);
114
108
  if (coords) {
115
109
  directMarkers.push({
116
110
  lat: coords.lat,
117
111
  lng: coords.lng,
118
- label: propertyIdToLabel.get(id) ?? "Property",
119
- propertyId: id,
112
+ label: propertyIdToLabel.get(node.Id) ?? "Property",
113
+ propertyId: node.Id,
120
114
  });
121
115
  }
122
116
  }
@@ -140,7 +134,6 @@ export function usePropertyMapMarkers(results: SearchResultRecord[]): {
140
134
  setLoading(false);
141
135
  return;
142
136
  }
143
- // Geocode all addresses in parallel; fallback to City, State Zip if full address fails
144
137
  Promise.all(
145
138
  toGeocode.map(async ([id, address]) => {
146
139
  const normalized = address.replace(/\n/g, ", ").trim();
@@ -161,7 +154,7 @@ export function usePropertyMapMarkers(results: SearchResultRecord[]): {
161
154
  .map(({ id, coords }) => ({
162
155
  lat: coords.lat,
163
156
  lng: coords.lng,
164
- label: propertyIdToLabel.get(id) ?? "Property",
157
+ label: currentLabels.get(id) ?? "Property",
165
158
  propertyId: id,
166
159
  }));
167
160
  setMarkers(spreadDuplicateMarkers([...directMarkers, ...geocoded]));
@@ -182,7 +175,7 @@ export function usePropertyMapMarkers(results: SearchResultRecord[]): {
182
175
  return () => {
183
176
  cancelled = true;
184
177
  };
185
- }, [propertyIds.join(",")]);
178
+ }, [idsKey]);
186
179
 
187
180
  return { markers, loading };
188
181
  }
@@ -3,6 +3,7 @@
3
3
  * Detects user location via Geolocation API; falls back to San Francisco.
4
4
  */
5
5
  import { useState, useEffect } from "react";
6
+ import { useCachedAsyncData } from "@/features/object-search/hooks/useCachedAsyncData";
6
7
 
7
8
  const FALLBACK = {
8
9
  LAT: 37.7749,
@@ -268,17 +269,14 @@ interface GeoPosition {
268
269
  }
269
270
 
270
271
  function useGeolocation(): GeoPosition {
271
- const [position, setPosition] = useState<GeoPosition>({
272
+ const [position, setPosition] = useState<GeoPosition>(() => ({
272
273
  latitude: FALLBACK.LAT,
273
274
  longitude: FALLBACK.LNG,
274
- resolved: false,
275
- });
275
+ resolved: typeof navigator === "undefined" || !navigator.geolocation,
276
+ }));
276
277
 
277
278
  useEffect(() => {
278
- if (!navigator.geolocation) {
279
- setPosition((prev) => ({ ...prev, resolved: true }));
280
- return;
281
- }
279
+ if (!navigator.geolocation) return;
282
280
  navigator.geolocation.getCurrentPosition(
283
281
  (pos) =>
284
282
  setPosition({
@@ -299,29 +297,15 @@ export function useWeather(lat?: number | null, lng?: number | null) {
299
297
  const longitude = lng ?? geo.longitude;
300
298
  const canFetch = lat != null || geo.resolved;
301
299
 
302
- const [data, setData] = useState<WeatherData | null>(null);
303
- const [loading, setLoading] = useState(true);
304
- const [error, setError] = useState<string | null>(null);
300
+ const cached = useCachedAsyncData(
301
+ () => fetchWeather(latitude, longitude),
302
+ [latitude, longitude, canFetch],
303
+ { key: `weather:${latitude},${longitude}:${canFetch}`, ttl: 300_000 },
304
+ );
305
305
 
306
- useEffect(() => {
307
- if (!canFetch) return;
308
- let cancelled = false;
309
- setLoading(true);
310
- setError(null);
311
- fetchWeather(latitude, longitude)
312
- .then((result) => {
313
- if (!cancelled) setData(result);
314
- })
315
- .catch((err) => {
316
- if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load weather");
317
- })
318
- .finally(() => {
319
- if (!cancelled) setLoading(false);
320
- });
321
- return () => {
322
- cancelled = true;
323
- };
324
- }, [latitude, longitude, canFetch]);
306
+ if (!canFetch) {
307
+ return { data: null, loading: true, error: null };
308
+ }
325
309
 
326
- return { data, loading, error };
310
+ return { data: cached.data, loading: cached.loading, error: cached.error };
327
311
  }