@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.
Files changed (51) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/README.md +24 -0
  3. package/dist/force-app/main/default/data/Property_Image__c.json +1 -1
  4. package/dist/force-app/main/default/data/Property_Listing__c.json +1 -1
  5. package/dist/force-app/main/default/data/prepare-import-unique-fields.js +85 -0
  6. package/dist/force-app/main/default/permissionsets/Property_Management_Access.permissionset-meta.xml +0 -7
  7. package/dist/force-app/main/default/webapplications/appreactsampleb2x/index.html +6 -0
  8. package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +3 -3
  9. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/applicationApi.ts +9 -9
  10. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphql-operations-types.ts +296 -0
  11. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphqlClient.ts +12 -7
  12. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/maintenanceRequestApi.ts +50 -38
  13. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyDetailGraphQL.ts +50 -102
  14. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyListingGraphQL.ts +211 -43
  15. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/userApi.ts +43 -0
  16. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +9 -208
  17. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/appliances.svg +13 -0
  18. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/electrical.svg +39 -0
  19. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/hvac.svg +78 -0
  20. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/pest.svg +5 -0
  21. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/plumbing.svg +7 -0
  22. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/zen-logo.svg +5 -0
  23. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/MaintenanceRequestIcon.tsx +46 -0
  24. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/NavMenu.tsx +53 -0
  25. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyListingCard.tsx +55 -58
  26. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx +93 -11
  27. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertySearchFilters.tsx +315 -0
  28. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/StatusBadge.tsx +36 -0
  29. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/TopBar.tsx +107 -0
  30. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyAddresses.ts +2 -2
  31. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingAmenities.ts +55 -0
  32. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingPriceRange.ts +64 -0
  33. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingSearch.ts +14 -5
  34. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyMapMarkers.ts +54 -11
  35. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyPrimaryImages.ts +1 -1
  36. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx +42 -39
  37. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +10 -10
  38. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Dashboard.tsx +64 -91
  39. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/HelpCenter.tsx +1 -1
  40. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Home.tsx +19 -9
  41. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Maintenance.tsx +79 -100
  42. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/NotFound.tsx +1 -1
  43. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx +62 -47
  44. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx +3 -3
  45. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx +230 -34
  46. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +10 -1
  47. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/styles/global.css +64 -0
  48. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/geocode.ts +30 -5
  49. package/dist/package.json +1 -1
  50. package/dist/setup-cli.mjs +271 -0
  51. package/package.json +1 -1
@@ -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 "../features/global-search/constants";
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 { Input } from "@/components/ui/input";
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 PropertySearchPlaceholder from "./PropertySearchPlaceholder";
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
- const MAP_CENTER_SF: [number, number] = [37.7749, -122.4194];
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(searchQuery, searchPageSize, searchPageToken);
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
- {/* Search bar */}
69
- <div className="flex shrink-0 flex-wrap items-center gap-2 border-b bg-background px-4 py-3">
70
- <Input
71
- type="text"
72
- value={searchQuery}
73
- onChange={(e) => setSearchQuery(e.target.value)}
74
- onKeyDown={(e) => e.key === "Enter" && handleSearchSubmit()}
75
- placeholder="Add area or search"
76
- className="h-9 w-48 max-w-[200px]"
77
- aria-label="Search property listings"
78
- />
79
- </div>
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 left (2/3), list right (1/3) */}
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 – takes ~2/3 on desktop */}
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={MAP_CENTER_SF}
87
- zoom={mapMarkers.length > 0 ? 12 : 11}
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, ~1/3 */}
94
- <aside className="flex w-full flex-col border-t border-border bg-background lg:w-1/3 lg:border-l lg:border-t-0">
95
- <div className="shrink-0 border-b bg-muted/30 px-4 py-3">
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
- <p className="text-sm text-muted-foreground">
101
- {apiUnavailable
102
- ? "Placeholder (API unavailable)"
103
- : resultsLoading
104
- ? "Loading…"
105
- : `${results.length} result(s)`}
106
- </p>
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
- ) : results.length === 0 ? (
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
- {validResults.map((record, index) => {
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 "./appLayout";
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. Allows limited parallel in-flight requests (maxConcurrent).
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 CACHE = new Map<string, { lat: number; lng: number }>();
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) return null;
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)) return null;
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;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
3
- "version": "1.84.1",
3
+ "version": "1.85.0",
4
4
  "description": "Base SFDX project template",
5
5
  "private": true,
6
6
  "files": [