@salesforce/webapp-template-app-react-sample-b2x-experimental 1.84.0 → 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 (57) hide show
  1. package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/SKILL.md +5 -3
  2. package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/header-footer.md +8 -0
  3. package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/page.md +8 -7
  4. package/dist/.a4drules/skills/webapp-ui-ux/SKILL.md +11 -8
  5. package/dist/.a4drules/webapp-react.md +54 -0
  6. package/dist/CHANGELOG.md +16 -0
  7. package/dist/README.md +24 -0
  8. package/dist/force-app/main/default/data/Property_Image__c.json +1 -1
  9. package/dist/force-app/main/default/data/Property_Listing__c.json +1 -1
  10. package/dist/force-app/main/default/data/prepare-import-unique-fields.js +85 -0
  11. package/dist/force-app/main/default/permissionsets/Property_Management_Access.permissionset-meta.xml +0 -7
  12. package/dist/force-app/main/default/webapplications/appreactsampleb2x/index.html +6 -0
  13. package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +3 -3
  14. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/applicationApi.ts +9 -9
  15. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphql-operations-types.ts +296 -0
  16. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphqlClient.ts +12 -7
  17. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/maintenanceRequestApi.ts +50 -38
  18. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyDetailGraphQL.ts +50 -102
  19. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyListingGraphQL.ts +211 -43
  20. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/userApi.ts +43 -0
  21. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +9 -208
  22. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/appliances.svg +13 -0
  23. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/electrical.svg +39 -0
  24. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/hvac.svg +78 -0
  25. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/pest.svg +5 -0
  26. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/plumbing.svg +7 -0
  27. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/zen-logo.svg +5 -0
  28. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/MaintenanceRequestIcon.tsx +46 -0
  29. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/NavMenu.tsx +53 -0
  30. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyListingCard.tsx +55 -58
  31. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx +93 -11
  32. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertySearchFilters.tsx +315 -0
  33. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/StatusBadge.tsx +36 -0
  34. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/TopBar.tsx +107 -0
  35. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyAddresses.ts +2 -2
  36. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingAmenities.ts +55 -0
  37. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingPriceRange.ts +64 -0
  38. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingSearch.ts +14 -5
  39. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyMapMarkers.ts +54 -11
  40. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyPrimaryImages.ts +1 -1
  41. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx +42 -39
  42. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +10 -10
  43. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Dashboard.tsx +64 -91
  44. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/HelpCenter.tsx +1 -1
  45. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Home.tsx +19 -9
  46. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Maintenance.tsx +79 -100
  47. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/NotFound.tsx +1 -1
  48. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx +62 -47
  49. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx +3 -3
  50. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx +230 -34
  51. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +10 -1
  52. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/styles/global.css +64 -0
  53. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/geocode.ts +30 -5
  54. package/dist/package.json +1 -1
  55. package/dist/setup-cli.mjs +271 -0
  56. package/package.json +1 -1
  57. /package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/component.md +0 -0
@@ -1,6 +1,6 @@
1
1
  import { useParams, Link } from "react-router";
2
- import { Button } from "../components/ui/button";
3
- import { Card, CardHeader, CardTitle, CardContent } from "../components/ui/card";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
4
4
  import PropertyMap from "@/components/PropertyMap";
5
5
  import { usePropertyDetail } from "@/hooks/usePropertyDetail";
6
6
  import { useGeocode } from "@/hooks/useGeocode";
@@ -13,6 +13,18 @@ function formatCurrency(val: number | string | null): string {
13
13
  : new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
14
14
  }
15
15
 
16
+ /** Currency, no decimals. Used on detail page (no "+" suffix; card uses "+" for "and up"). */
17
+ function formatListingPrice(val: number | string | null): string {
18
+ if (val == null) return "—";
19
+ const n = typeof val === "number" ? val : Number(val);
20
+ if (Number.isNaN(n)) return String(val);
21
+ return new Intl.NumberFormat("en-US", {
22
+ style: "currency",
23
+ currency: "USD",
24
+ maximumFractionDigits: 0,
25
+ }).format(n);
26
+ }
27
+
16
28
  function formatDate(val: string | null): string {
17
29
  if (!val) return "—";
18
30
  try {
@@ -52,7 +64,7 @@ export default function PropertyDetails() {
52
64
  ← Back to listings
53
65
  </Link>
54
66
  </div>
55
- <Card className="rounded-2xl shadow-md">
67
+ <Card className="rounded-2xl border border-border shadow-sm">
56
68
  <CardContent className="pt-6">
57
69
  <p className="text-destructive">{error ?? "Listing not found."}</p>
58
70
  </CardContent>
@@ -120,51 +132,54 @@ export default function PropertyDetails() {
120
132
  </div>
121
133
  )}
122
134
 
123
- {/* Listing + address */}
124
- <Card className="mb-4 rounded-2xl shadow-md">
125
- <CardContent className="pt-6">
135
+ {/* Name, address, price (same order and price format as PropertyListingCard) */}
136
+ <Card className="mb-4 rounded-2xl border border-border shadow-sm">
137
+ <CardContent className="pt-3">
138
+ <h1 className="mb-1.5 text-2xl font-semibold text-foreground">
139
+ {listing?.name ?? property?.name ?? "Untitled"}
140
+ </h1>
126
141
  {property?.address && (
127
- <p className="mb-2 text-sm text-muted-foreground">
142
+ <p className="mb-1.5 text-sm text-muted-foreground">
128
143
  {property.address.replace(/\n/g, ", ")}
129
144
  </p>
130
145
  )}
131
- <p className="mb-1 text-2xl font-bold text-foreground">
146
+ <p className="mb-4 text-2xl font-semibold text-primary">
132
147
  {listing?.listingPrice != null
133
- ? formatCurrency(listing.listingPrice)
148
+ ? formatListingPrice(listing.listingPrice)
134
149
  : property?.monthlyRent != null
135
- ? formatCurrency(property.monthlyRent) + " / Month"
150
+ ? formatListingPrice(property.monthlyRent) + " / Month"
136
151
  : "—"}
137
152
  </p>
138
- <p className="mb-4 text-sm text-muted-foreground">
139
- {listing?.name ?? property?.name ?? "Untitled"}
140
- </p>
141
- <div className="flex flex-wrap gap-3">
142
- {property?.bedrooms != null && (
143
- <span className="rounded-lg bg-primary/10 px-3 py-1.5 text-sm text-primary">
144
- {property.bedrooms} Bedroom{Number(property.bedrooms) !== 1 ? "s" : ""}
145
- </span>
146
- )}
147
- {property?.bathrooms != null && (
148
- <span className="rounded-lg bg-primary/10 px-3 py-1.5 text-sm text-primary">
149
- {property.bathrooms} Bath{Number(property.bathrooms) !== 1 ? "s" : ""}
153
+ {/* Stat cards: value on top, label below, rounded panels (same order as reference) */}
154
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
155
+ <div className="flex flex-col items-center justify-center rounded-xl bg-primary px-4 py-3 text-center">
156
+ <span className="text-xl font-semibold text-primary-foreground">
157
+ {property?.bedrooms ?? "—"}
150
158
  </span>
151
- )}
152
- {property?.squareFootage != null && (
153
- <span className="rounded-lg bg-primary/10 px-3 py-1.5 text-sm text-primary">
154
- {property.squareFootage} sq ft
159
+ <span className="text-xs text-primary-foreground/90">Bedrooms</span>
160
+ </div>
161
+ <div className="flex flex-col items-center justify-center rounded-xl bg-primary px-4 py-3 text-center">
162
+ <span className="text-xl font-semibold text-primary-foreground">
163
+ {property?.bathrooms ?? "—"}
155
164
  </span>
156
- )}
157
- {listing?.listingStatus && (
158
- <span className="rounded-lg bg-primary/10 px-3 py-1.5 text-sm text-primary">
159
- {listing.listingStatus}
165
+ <span className="text-xs text-primary-foreground/90">Baths</span>
166
+ </div>
167
+ <div className="flex flex-col items-center justify-center rounded-xl bg-primary px-4 py-3 text-center">
168
+ <span className="text-xl font-semibold text-primary-foreground">
169
+ {property?.squareFootage ?? "—"}
160
170
  </span>
161
- )}
162
- {property?.propertyType && (
163
- <span className="rounded-lg bg-primary/10 px-3 py-1.5 text-sm text-primary">
164
- {property.propertyType}
171
+ <span className="text-xs text-primary-foreground/90">Square Feet</span>
172
+ </div>
173
+ <div className="flex flex-col items-center justify-center rounded-xl bg-primary px-4 py-3 text-center">
174
+ <span className="text-xl font-semibold text-primary-foreground">
175
+ {listing?.listingStatus ?? "Now"}
165
176
  </span>
166
- )}
177
+ <span className="text-xs text-primary-foreground/90">Available</span>
178
+ </div>
167
179
  </div>
180
+ {property?.propertyType && (
181
+ <p className="mt-3 text-sm text-muted-foreground">{property.propertyType}</p>
182
+ )}
168
183
  {property?.description && (
169
184
  <p className="mt-4 text-sm text-foreground">{property.description}</p>
170
185
  )}
@@ -173,9 +188,9 @@ export default function PropertyDetails() {
173
188
 
174
189
  {/* Related: Costs */}
175
190
  {costs.length > 0 && (
176
- <Card className="mb-4 rounded-2xl shadow-md">
191
+ <Card className="mb-4 rounded-2xl border border-border shadow-sm">
177
192
  <CardHeader>
178
- <CardTitle className="text-base">Related costs</CardTitle>
193
+ <CardTitle className="text-base font-semibold">Related costs</CardTitle>
179
194
  </CardHeader>
180
195
  <CardContent>
181
196
  <ul className="space-y-2">
@@ -204,22 +219,22 @@ export default function PropertyDetails() {
204
219
 
205
220
  {/* Related: Features */}
206
221
  {features.length > 0 && (
207
- <Card className="mb-4 rounded-2xl shadow-md">
222
+ <Card className="mb-4 rounded-2xl border border-border shadow-sm">
208
223
  <CardHeader>
209
- <CardTitle className="text-base">Features & amenities</CardTitle>
224
+ <CardTitle className="text-base font-semibold">Features & amenities</CardTitle>
210
225
  </CardHeader>
211
226
  <CardContent>
212
- <ul className="flex flex-wrap gap-2">
227
+ <div className="flex flex-wrap gap-1.5">
213
228
  {features.map((f) => (
214
- <li
229
+ <span
215
230
  key={f.id}
216
- className="rounded-md border border-border bg-muted/50 px-3 py-1.5 text-sm"
231
+ className="rounded-full border border-border bg-muted/60 px-2.5 py-0.5 text-xs font-medium text-muted-foreground"
217
232
  >
218
- {f.category && <span className="text-muted-foreground">{f.category}: </span>}
233
+ {f.category ? `${f.category}: ` : ""}
219
234
  {f.description ?? f.name ?? "—"}
220
- </li>
235
+ </span>
221
236
  ))}
222
- </ul>
237
+ </div>
223
238
  </CardContent>
224
239
  </Card>
225
240
  )}
@@ -227,8 +242,8 @@ export default function PropertyDetails() {
227
242
  <div className="mb-4">
228
243
  <Button
229
244
  asChild
230
- size="lg"
231
- className="w-full cursor-pointer rounded-xl bg-violet-600 text-base font-semibold transition-colors duration-200 hover:bg-violet-700"
245
+ size="sm"
246
+ className="w-full cursor-pointer rounded-xl bg-primary px-5 py-5 text-lg font-medium transition-colors duration-200 hover:bg-primary/90"
232
247
  >
233
248
  <Link to={`/application?listingId=${encodeURIComponent(id ?? "")}`}>
234
249
  Fill out an application
@@ -1,7 +1,7 @@
1
1
  import { Link } from "react-router";
2
- import { Button } from "../components/ui/button";
3
- import { Input } from "../components/ui/input";
4
- import { Card, CardContent } from "../components/ui/card";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Card, CardContent } from "@/components/ui/card";
5
5
 
6
6
  const listings = [
7
7
  {
@@ -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
+ }