@salesforce/webapp-template-app-react-sample-b2x-experimental 1.84.1 → 1.85.0
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.
- package/dist/CHANGELOG.md +8 -0
- package/dist/README.md +24 -0
- package/dist/force-app/main/default/data/Property_Image__c.json +1 -1
- package/dist/force-app/main/default/data/Property_Listing__c.json +1 -1
- package/dist/force-app/main/default/data/prepare-import-unique-fields.js +85 -0
- package/dist/force-app/main/default/permissionsets/Property_Management_Access.permissionset-meta.xml +0 -7
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/index.html +6 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +3 -3
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/applicationApi.ts +9 -9
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphql-operations-types.ts +296 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphqlClient.ts +12 -7
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/maintenanceRequestApi.ts +50 -38
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyDetailGraphQL.ts +50 -102
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyListingGraphQL.ts +211 -43
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/userApi.ts +43 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +9 -208
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/appliances.svg +13 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/electrical.svg +39 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/hvac.svg +78 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/pest.svg +5 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/plumbing.svg +7 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/zen-logo.svg +5 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/MaintenanceRequestIcon.tsx +46 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/NavMenu.tsx +53 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyListingCard.tsx +55 -58
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx +93 -11
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertySearchFilters.tsx +315 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/StatusBadge.tsx +36 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/TopBar.tsx +107 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyAddresses.ts +2 -2
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingAmenities.ts +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingPriceRange.ts +64 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingSearch.ts +14 -5
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyMapMarkers.ts +54 -11
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyPrimaryImages.ts +1 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx +42 -39
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +10 -10
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Dashboard.tsx +64 -91
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/HelpCenter.tsx +1 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Home.tsx +19 -9
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Maintenance.tsx +79 -100
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/NotFound.tsx +1 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx +62 -47
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx +3 -3
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx +230 -34
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +10 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/styles/global.css +64 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/geocode.ts +30 -5
- package/dist/package.json +1 -1
- package/dist/setup-cli.mjs +271 -0
- package/package.json +1 -1
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx
CHANGED
|
@@ -4,21 +4,33 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
|
6
6
|
import { useSearchParams } from "react-router";
|
|
7
|
-
import { DEFAULT_PAGE_SIZE } from "
|
|
7
|
+
import { DEFAULT_PAGE_SIZE } from "@/features/global-search/constants";
|
|
8
8
|
import { usePropertyListingSearch } from "@/hooks/usePropertyListingSearch";
|
|
9
9
|
import {
|
|
10
10
|
usePropertyPrimaryImages,
|
|
11
11
|
getPropertyIdFromRecord,
|
|
12
12
|
} from "@/hooks/usePropertyPrimaryImages";
|
|
13
13
|
import { usePropertyAddresses } from "@/hooks/usePropertyAddresses";
|
|
14
|
+
import { usePropertyListingAmenities } from "@/hooks/usePropertyListingAmenities";
|
|
14
15
|
import { usePropertyMapMarkers } from "@/hooks/usePropertyMapMarkers";
|
|
15
|
-
import
|
|
16
|
-
import SearchPagination from "../features/global-search/components/search/SearchPagination";
|
|
16
|
+
import SearchPagination from "@/features/global-search/components/search/SearchPagination";
|
|
17
17
|
import PropertyListingCard from "@/components/PropertyListingCard";
|
|
18
|
+
import PropertySearchFilters, {
|
|
19
|
+
type BedroomFilter,
|
|
20
|
+
type SortBy,
|
|
21
|
+
} from "@/components/PropertySearchFilters";
|
|
18
22
|
import PropertyMap from "@/components/PropertyMap";
|
|
19
|
-
import
|
|
23
|
+
import type { MapMarker, MapBounds } from "@/components/PropertyMap";
|
|
24
|
+
import PropertySearchPlaceholder from "@/pages/PropertySearchPlaceholder";
|
|
25
|
+
import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
/** Fallback map center when there are no geocoded markers yet. Zoom 7 ≈ 100-mile radius view. */
|
|
28
|
+
const MAP_CENTER_FALLBACK: [number, number] = [37.7897484, -122.3998086];
|
|
29
|
+
const MAP_ZOOM_DEFAULT = 10;
|
|
30
|
+
const MAP_ZOOM_WITH_MARKERS = 12;
|
|
31
|
+
|
|
32
|
+
/** Delay before applying any filter change to the search (avoids refetch on every keystroke/slider tick). */
|
|
33
|
+
const SEARCH_FILTER_DEBOUNCE_MS = 400;
|
|
22
34
|
|
|
23
35
|
export default function PropertySearch() {
|
|
24
36
|
const [searchParams] = useSearchParams();
|
|
@@ -26,14 +38,72 @@ export default function PropertySearch() {
|
|
|
26
38
|
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
|
27
39
|
const [searchPageSize, setSearchPageSize] = useState(DEFAULT_PAGE_SIZE);
|
|
28
40
|
const [searchPageToken, setSearchPageToken] = useState("0");
|
|
41
|
+
const [priceMin, setPriceMin] = useState<string>("");
|
|
42
|
+
const [priceMax, setPriceMax] = useState<string>("");
|
|
43
|
+
const [bedrooms, setBedrooms] = useState<BedroomFilter>(null);
|
|
44
|
+
const [committedSearchQuery, setCommittedSearchQuery] = useState(initialSearch);
|
|
45
|
+
const [committedPriceMin, setCommittedPriceMin] = useState<string>("");
|
|
46
|
+
const [committedPriceMax, setCommittedPriceMax] = useState<string>("");
|
|
47
|
+
const [committedBedrooms, setCommittedBedrooms] = useState<BedroomFilter>(null);
|
|
48
|
+
const [stagedSortBy, setStagedSortBy] = useState<SortBy>(null);
|
|
49
|
+
const [committedSortBy, setCommittedSortBy] = useState<SortBy>("price_asc");
|
|
29
50
|
|
|
30
51
|
// Sync from URL when navigating with ?search=... (e.g. from Home "Find Home")
|
|
31
52
|
useEffect(() => {
|
|
32
53
|
const q = searchParams.get("search") ?? "";
|
|
33
54
|
setSearchQuery(q);
|
|
55
|
+
setCommittedSearchQuery(q);
|
|
34
56
|
setSearchPageToken("0");
|
|
35
57
|
}, [searchParams]);
|
|
36
58
|
|
|
59
|
+
// Debounce search query only; price and bedrooms commit only when user clicks Save in the popover.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const t = setTimeout(() => {
|
|
62
|
+
setCommittedSearchQuery(searchQuery);
|
|
63
|
+
}, SEARCH_FILTER_DEBOUNCE_MS);
|
|
64
|
+
return () => clearTimeout(t);
|
|
65
|
+
}, [searchQuery]);
|
|
66
|
+
|
|
67
|
+
const handlePriceSave = useCallback((min: string, max: string) => {
|
|
68
|
+
setCommittedPriceMin(min);
|
|
69
|
+
setCommittedPriceMax(max);
|
|
70
|
+
setSearchPageToken("0");
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const handleBedsSave = useCallback((value: BedroomFilter) => {
|
|
74
|
+
setCommittedBedrooms(value);
|
|
75
|
+
setSearchPageToken("0");
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const handleSortSave = useCallback((value: SortBy) => {
|
|
79
|
+
setCommittedSortBy(value);
|
|
80
|
+
setSearchPageToken("0");
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const filters = useMemo(() => {
|
|
84
|
+
const out: {
|
|
85
|
+
priceMin?: number;
|
|
86
|
+
priceMax?: number;
|
|
87
|
+
bedroomsMin?: number;
|
|
88
|
+
bedroomsMax?: number;
|
|
89
|
+
sortBy?: SortBy;
|
|
90
|
+
} = {};
|
|
91
|
+
const min = committedPriceMin.trim() ? Number(committedPriceMin.replace(/[^0-9.]/g, "")) : NaN;
|
|
92
|
+
const max = committedPriceMax.trim() ? Number(committedPriceMax.replace(/[^0-9.]/g, "")) : NaN;
|
|
93
|
+
if (Number.isFinite(min) && min >= 0) out.priceMin = min;
|
|
94
|
+
if (Number.isFinite(max) && max >= 0) out.priceMax = max;
|
|
95
|
+
if (committedBedrooms === "le2") {
|
|
96
|
+
out.bedroomsMax = 2;
|
|
97
|
+
} else if (committedBedrooms === "3") {
|
|
98
|
+
out.bedroomsMin = 3;
|
|
99
|
+
out.bedroomsMax = 3;
|
|
100
|
+
} else if (committedBedrooms === "ge4") {
|
|
101
|
+
out.bedroomsMin = 4;
|
|
102
|
+
}
|
|
103
|
+
if (committedSortBy != null) out.sortBy = committedSortBy;
|
|
104
|
+
return out;
|
|
105
|
+
}, [committedPriceMin, committedPriceMax, committedBedrooms, committedSortBy]);
|
|
106
|
+
|
|
37
107
|
const {
|
|
38
108
|
results,
|
|
39
109
|
nextPageToken,
|
|
@@ -41,15 +111,80 @@ export default function PropertySearch() {
|
|
|
41
111
|
currentPageToken,
|
|
42
112
|
resultsLoading,
|
|
43
113
|
resultsError,
|
|
44
|
-
} = usePropertyListingSearch(
|
|
114
|
+
} = usePropertyListingSearch(committedSearchQuery, searchPageSize, searchPageToken, filters);
|
|
45
115
|
|
|
46
116
|
const primaryImagesMap = usePropertyPrimaryImages(results);
|
|
47
117
|
const propertyAddressMap = usePropertyAddresses(results);
|
|
118
|
+
const amenitiesMap = usePropertyListingAmenities(results);
|
|
48
119
|
const { markers: mapMarkers } = usePropertyMapMarkers(results);
|
|
49
120
|
const apiUnavailable = Boolean(resultsError);
|
|
50
121
|
|
|
122
|
+
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
|
123
|
+
|
|
51
124
|
const validResults = useMemo(() => results.filter((r) => r?.record?.id), [results]);
|
|
52
125
|
|
|
126
|
+
function getSortPrice(r: SearchResultRecord): number {
|
|
127
|
+
const raw = r?.record?.fields?.["Listing_Price__c"];
|
|
128
|
+
if (raw == null || typeof raw !== "object") return NaN;
|
|
129
|
+
const v = (raw as { value?: unknown }).value;
|
|
130
|
+
return typeof v === "number" ? v : Number(v);
|
|
131
|
+
}
|
|
132
|
+
function getSortBeds(r: SearchResultRecord): number {
|
|
133
|
+
const raw = r?.record?.fields?.["Property__r.Bedrooms__c"];
|
|
134
|
+
if (raw == null || typeof raw !== "object") return NaN;
|
|
135
|
+
const v = (raw as { value?: unknown }).value;
|
|
136
|
+
return typeof v === "number" ? v : Number(v);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Order applied only after user clicks Save (committedSortBy); server may also apply orderBy.
|
|
140
|
+
const sortedResults = useMemo(() => {
|
|
141
|
+
if (!committedSortBy) return validResults;
|
|
142
|
+
const list = [...validResults];
|
|
143
|
+
if (committedSortBy === "price_asc" || committedSortBy === "price_desc") {
|
|
144
|
+
const dir = committedSortBy === "price_asc" ? 1 : -1;
|
|
145
|
+
list.sort((a, b) => {
|
|
146
|
+
const pa = getSortPrice(a);
|
|
147
|
+
const pb = getSortPrice(b);
|
|
148
|
+
if (Number.isNaN(pa) && Number.isNaN(pb)) return 0;
|
|
149
|
+
if (Number.isNaN(pa)) return 1;
|
|
150
|
+
if (Number.isNaN(pb)) return -1;
|
|
151
|
+
return dir * (pa - pb);
|
|
152
|
+
});
|
|
153
|
+
} else {
|
|
154
|
+
const dir = committedSortBy === "beds_asc" ? 1 : -1;
|
|
155
|
+
list.sort((a, b) => {
|
|
156
|
+
const ba = getSortBeds(a);
|
|
157
|
+
const bb = getSortBeds(b);
|
|
158
|
+
if (Number.isNaN(ba) && Number.isNaN(bb)) return 0;
|
|
159
|
+
if (Number.isNaN(ba)) return 1;
|
|
160
|
+
if (Number.isNaN(bb)) return -1;
|
|
161
|
+
return dir * (ba - bb);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return list;
|
|
165
|
+
}, [validResults, committedSortBy]);
|
|
166
|
+
|
|
167
|
+
// When user pans/zooms, filter list to properties whose pin is visible on the map
|
|
168
|
+
const visibleResults = useMemo(() => {
|
|
169
|
+
if (!mapBounds || mapMarkers.length === 0) return sortedResults;
|
|
170
|
+
const visiblePropertyIds = new Set(
|
|
171
|
+
mapMarkers
|
|
172
|
+
.filter(
|
|
173
|
+
(m) =>
|
|
174
|
+
m.propertyId &&
|
|
175
|
+
m.lat >= mapBounds.south &&
|
|
176
|
+
m.lat <= mapBounds.north &&
|
|
177
|
+
m.lng >= mapBounds.west &&
|
|
178
|
+
m.lng <= mapBounds.east,
|
|
179
|
+
)
|
|
180
|
+
.map((m) => m.propertyId as string),
|
|
181
|
+
);
|
|
182
|
+
return sortedResults.filter((r) => {
|
|
183
|
+
const id = getPropertyIdFromRecord(r.record);
|
|
184
|
+
return id != null && visiblePropertyIds.has(id);
|
|
185
|
+
});
|
|
186
|
+
}, [sortedResults, mapMarkers, mapBounds]);
|
|
187
|
+
|
|
53
188
|
const handlePageChange = useCallback((newPageToken: string) => {
|
|
54
189
|
setSearchPageToken(newPageToken);
|
|
55
190
|
}, []);
|
|
@@ -63,47 +198,92 @@ export default function PropertySearch() {
|
|
|
63
198
|
setSearchPageToken("0");
|
|
64
199
|
}, []);
|
|
65
200
|
|
|
201
|
+
const popupContent = useCallback(
|
|
202
|
+
(marker: MapMarker) => {
|
|
203
|
+
if (!marker.propertyId) return marker.label ?? "Property";
|
|
204
|
+
const result = results.find(
|
|
205
|
+
(r) => r?.record && getPropertyIdFromRecord(r.record) === marker.propertyId,
|
|
206
|
+
);
|
|
207
|
+
if (!result?.record) return marker.label ?? "Property";
|
|
208
|
+
const propertyId = getPropertyIdFromRecord(result.record);
|
|
209
|
+
const imageUrl = propertyId ? (primaryImagesMap[propertyId] ?? null) : null;
|
|
210
|
+
const address = propertyId ? (propertyAddressMap[propertyId] ?? null) : null;
|
|
211
|
+
const amenities = propertyId ? (amenitiesMap[propertyId] ?? null) : null;
|
|
212
|
+
return (
|
|
213
|
+
<div className="w-[280px] min-w-0">
|
|
214
|
+
<PropertyListingCard
|
|
215
|
+
record={result.record}
|
|
216
|
+
imageUrl={imageUrl}
|
|
217
|
+
address={address}
|
|
218
|
+
amenities={amenities || undefined}
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
[results, primaryImagesMap, propertyAddressMap, amenitiesMap],
|
|
224
|
+
);
|
|
225
|
+
|
|
66
226
|
return (
|
|
67
227
|
<div className="flex h-[calc(100vh-4rem)] min-h-[500px] flex-col">
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
228
|
+
<PropertySearchFilters
|
|
229
|
+
searchQuery={searchQuery}
|
|
230
|
+
onSearchQueryChange={setSearchQuery}
|
|
231
|
+
priceMin={priceMin}
|
|
232
|
+
onPriceMinChange={setPriceMin}
|
|
233
|
+
priceMax={priceMax}
|
|
234
|
+
onPriceMaxChange={setPriceMax}
|
|
235
|
+
onPriceSave={handlePriceSave}
|
|
236
|
+
bedrooms={bedrooms}
|
|
237
|
+
onBedroomsChange={setBedrooms}
|
|
238
|
+
onBedsSave={handleBedsSave}
|
|
239
|
+
sortBy={stagedSortBy ?? committedSortBy}
|
|
240
|
+
onSortChange={setStagedSortBy}
|
|
241
|
+
onSortSave={handleSortSave}
|
|
242
|
+
appliedSortBy={committedSortBy}
|
|
243
|
+
onSubmit={handleSearchSubmit}
|
|
244
|
+
/>
|
|
80
245
|
|
|
81
|
-
{/* Main: map
|
|
246
|
+
{/* Main: map 2/3, list 1/3 */}
|
|
82
247
|
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
83
|
-
{/* Map –
|
|
248
|
+
{/* Map – 2/3 on desktop */}
|
|
84
249
|
<div className="h-64 shrink-0 lg:h-full lg:min-h-0 lg:w-2/3" aria-label="Map">
|
|
85
250
|
<PropertyMap
|
|
86
|
-
center={
|
|
87
|
-
zoom={mapMarkers.length > 0 ?
|
|
251
|
+
center={MAP_CENTER_FALLBACK}
|
|
252
|
+
zoom={mapMarkers.length > 0 ? MAP_ZOOM_WITH_MARKERS : MAP_ZOOM_DEFAULT}
|
|
88
253
|
markers={mapMarkers}
|
|
254
|
+
popupContent={popupContent}
|
|
255
|
+
onBoundsChange={setMapBounds}
|
|
89
256
|
className="h-full w-full"
|
|
90
257
|
/>
|
|
91
258
|
</div>
|
|
92
259
|
|
|
93
|
-
{/* Listings – scrollable,
|
|
94
|
-
<aside className="flex w-full flex-col border-t border-border
|
|
95
|
-
<div className="shrink-0 border-b
|
|
260
|
+
{/* Listings – scrollable, 1/3 */}
|
|
261
|
+
<aside className="flex w-full flex-col border-t border-border lg:w-1/3 lg:border-l lg:border-t-0">
|
|
262
|
+
<div className="shrink-0 border-b border-border px-4 py-3">
|
|
96
263
|
<h2 className="text-base font-semibold text-foreground">
|
|
97
264
|
Property Listings
|
|
98
265
|
{searchQuery.trim() ? ` matching "${searchQuery.trim()}"` : ""}
|
|
99
266
|
</h2>
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
267
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
268
|
+
<p className="text-sm text-muted-foreground">
|
|
269
|
+
{apiUnavailable
|
|
270
|
+
? "Placeholder (API unavailable)"
|
|
271
|
+
: resultsLoading
|
|
272
|
+
? "Loading…"
|
|
273
|
+
: mapBounds != null && mapMarkers.length > 0
|
|
274
|
+
? `${visibleResults.length} of ${sortedResults.length} in map view`
|
|
275
|
+
: `${sortedResults.length} result(s)`}
|
|
276
|
+
</p>
|
|
277
|
+
{mapBounds != null && sortedResults.length > 0 && !resultsLoading && (
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
onClick={() => setMapBounds(null)}
|
|
281
|
+
className="text-sm font-medium text-primary hover:underline"
|
|
282
|
+
>
|
|
283
|
+
Show all
|
|
284
|
+
</button>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
107
287
|
</div>
|
|
108
288
|
<div className="flex-1 overflow-y-auto p-4">
|
|
109
289
|
{apiUnavailable ? (
|
|
@@ -122,24 +302,40 @@ export default function PropertySearch() {
|
|
|
122
302
|
</div>
|
|
123
303
|
))}
|
|
124
304
|
</div>
|
|
125
|
-
) :
|
|
305
|
+
) : sortedResults.length === 0 ? (
|
|
126
306
|
<div className="py-12 text-center">
|
|
127
307
|
<p className="mb-2 font-medium">No results found</p>
|
|
128
308
|
<p className="text-sm text-muted-foreground">Try adjusting search or filters</p>
|
|
129
309
|
</div>
|
|
310
|
+
) : visibleResults.length === 0 && mapBounds != null ? (
|
|
311
|
+
<div className="py-12 text-center">
|
|
312
|
+
<p className="mb-2 font-medium">No listings in this map area</p>
|
|
313
|
+
<p className="text-sm text-muted-foreground">
|
|
314
|
+
Pan or zoom to see results, or clear the map filter
|
|
315
|
+
</p>
|
|
316
|
+
<button
|
|
317
|
+
type="button"
|
|
318
|
+
onClick={() => setMapBounds(null)}
|
|
319
|
+
className="mt-3 text-sm font-medium text-primary hover:underline"
|
|
320
|
+
>
|
|
321
|
+
Show all {sortedResults.length} result(s)
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
130
324
|
) : (
|
|
131
325
|
<>
|
|
132
326
|
<ul className="space-y-4" role="list" aria-label="Search results">
|
|
133
|
-
{
|
|
327
|
+
{visibleResults.map((record, index) => {
|
|
134
328
|
const propertyId = getPropertyIdFromRecord(record.record);
|
|
135
329
|
const imageUrl = propertyId ? (primaryImagesMap[propertyId] ?? null) : null;
|
|
136
330
|
const address = propertyId ? (propertyAddressMap[propertyId] ?? null) : null;
|
|
331
|
+
const amenities = propertyId ? (amenitiesMap[propertyId] ?? null) : null;
|
|
137
332
|
return (
|
|
138
333
|
<li key={record.record.id ?? index}>
|
|
139
334
|
<PropertyListingCard
|
|
140
335
|
record={record.record}
|
|
141
336
|
imageUrl={imageUrl}
|
|
142
337
|
address={address}
|
|
338
|
+
amenities={amenities || undefined}
|
|
143
339
|
/>
|
|
144
340
|
</li>
|
|
145
341
|
);
|
|
@@ -14,7 +14,7 @@ import ChangePassword from "./features/authentication/pages/ChangePassword";
|
|
|
14
14
|
import AuthenticationRoute from "./features/authentication/layouts/authenticationRouteLayout";
|
|
15
15
|
import PrivateRoute from "./features/authentication/layouts/privateRouteLayout";
|
|
16
16
|
import { ROUTES } from "./features/authentication/authenticationConfig";
|
|
17
|
-
import AppLayout from "
|
|
17
|
+
import AppLayout from "@/appLayout";
|
|
18
18
|
import Dashboard from "@/pages/Dashboard";
|
|
19
19
|
import Maintenance from "@/pages/Maintenance";
|
|
20
20
|
import PropertySearch from "@/pages/PropertySearch";
|
|
@@ -107,6 +107,15 @@ export const routes: RouteObject[] = [
|
|
|
107
107
|
path: "object/Property_Listing__c/:id",
|
|
108
108
|
element: <PropertyDetails />
|
|
109
109
|
},
|
|
110
|
+
{
|
|
111
|
+
path: "maintenance/requests",
|
|
112
|
+
element: <Maintenance />,
|
|
113
|
+
handle: { showInNavigation: true, label: "Maintenance" }
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
path: "application",
|
|
117
|
+
element: <Application />
|
|
118
|
+
},
|
|
110
119
|
{
|
|
111
120
|
path: "contact",
|
|
112
121
|
element: <Contact />,
|
|
@@ -160,3 +160,67 @@
|
|
|
160
160
|
body {
|
|
161
161
|
@apply bg-background text-foreground;
|
|
162
162
|
}
|
|
163
|
+
|
|
164
|
+
@layer base {
|
|
165
|
+
* {
|
|
166
|
+
font-family: "Jost", sans-serif !important;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
html,
|
|
170
|
+
body,
|
|
171
|
+
#root {
|
|
172
|
+
font-family: "Jost", sans-serif !important;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
h1,
|
|
176
|
+
h2,
|
|
177
|
+
h3,
|
|
178
|
+
h4,
|
|
179
|
+
h5,
|
|
180
|
+
h6,
|
|
181
|
+
p,
|
|
182
|
+
span,
|
|
183
|
+
div,
|
|
184
|
+
a,
|
|
185
|
+
button,
|
|
186
|
+
input,
|
|
187
|
+
select,
|
|
188
|
+
textarea {
|
|
189
|
+
font-family: "Jost", sans-serif !important;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@layer components {
|
|
194
|
+
.text-primary-teal {
|
|
195
|
+
color: var(--primary);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Leaflet map pin (PropertyMap): Lucide-style MapPin, teal stroke */
|
|
200
|
+
.property-map-pin {
|
|
201
|
+
border: none !important;
|
|
202
|
+
background: transparent !important;
|
|
203
|
+
color: var(--primary);
|
|
204
|
+
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25));
|
|
205
|
+
}
|
|
206
|
+
.property-map-pin svg {
|
|
207
|
+
display: block;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* Leaflet popup: no extra white box; close button is drawn inside content */
|
|
211
|
+
.leaflet-popup-content-wrapper {
|
|
212
|
+
background: transparent !important;
|
|
213
|
+
box-shadow: none !important;
|
|
214
|
+
border: none !important;
|
|
215
|
+
padding: 0 !important;
|
|
216
|
+
}
|
|
217
|
+
.leaflet-popup-content {
|
|
218
|
+
margin: 0 !important;
|
|
219
|
+
min-width: 0 !important;
|
|
220
|
+
}
|
|
221
|
+
.leaflet-popup-close-button {
|
|
222
|
+
display: none !important;
|
|
223
|
+
}
|
|
224
|
+
.leaflet-popup-tip {
|
|
225
|
+
display: none !important;
|
|
226
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Geocode an address to lat/lng using OpenStreetMap Nominatim (free, no API key).
|
|
3
|
-
* Results are cached in memory
|
|
3
|
+
* Results are cached in memory (including 200 responses with empty results).
|
|
4
|
+
* Allows limited parallel in-flight requests (maxConcurrent).
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
+
const CACHED_EMPTY = Symbol("cached empty geocode");
|
|
8
|
+
type CacheEntry = { lat: number; lng: number } | typeof CACHED_EMPTY;
|
|
9
|
+
|
|
10
|
+
const CACHE = new Map<string, CacheEntry>();
|
|
7
11
|
const MAX_CONCURRENT = 6;
|
|
8
12
|
let inFlight = 0;
|
|
9
13
|
const queue: Array<() => void> = [];
|
|
@@ -32,11 +36,26 @@ export interface GeocodeResult {
|
|
|
32
36
|
lng: number;
|
|
33
37
|
}
|
|
34
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Extracts "State Zip" from a US-style full address (e.g. "123 Main St, Unit 4B, Los Angeles, CA 90028" → "CA 90028").
|
|
41
|
+
* Used as a fallback when geocoding the full address fails.
|
|
42
|
+
*/
|
|
43
|
+
export function getStateZipFromAddress(address: string): string {
|
|
44
|
+
const parts = address
|
|
45
|
+
.split(",")
|
|
46
|
+
.map((p) => p.trim())
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
if (parts.length >= 1) {
|
|
49
|
+
return parts[parts.length - 1];
|
|
50
|
+
}
|
|
51
|
+
return address.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
export async function geocodeAddress(address: string): Promise<GeocodeResult | null> {
|
|
36
55
|
const key = address.trim().replace(/\s+/g, " ").toLowerCase();
|
|
37
56
|
if (!key) return null;
|
|
38
57
|
const cached = CACHE.get(key);
|
|
39
|
-
if (cached) return cached;
|
|
58
|
+
if (cached !== undefined) return cached === CACHED_EMPTY ? null : cached;
|
|
40
59
|
|
|
41
60
|
await acquire();
|
|
42
61
|
try {
|
|
@@ -50,10 +69,16 @@ export async function geocodeAddress(address: string): Promise<GeocodeResult | n
|
|
|
50
69
|
if (!res.ok) return null;
|
|
51
70
|
const data = (await res.json()) as Array<{ lat?: string; lon?: string }>;
|
|
52
71
|
const first = data?.[0];
|
|
53
|
-
if (!first?.lat || !first?.lon)
|
|
72
|
+
if (!first?.lat || !first?.lon) {
|
|
73
|
+
CACHE.set(key, CACHED_EMPTY);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
54
76
|
const lat = Number(first.lat);
|
|
55
77
|
const lng = Number(first.lon);
|
|
56
|
-
if (Number.isNaN(lat) || Number.isNaN(lng))
|
|
78
|
+
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
|
79
|
+
CACHE.set(key, CACHED_EMPTY);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
57
82
|
const result = { lat, lng };
|
|
58
83
|
CACHE.set(key, result);
|
|
59
84
|
return result;
|