@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.
- package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/SKILL.md +5 -3
- package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/header-footer.md +8 -0
- package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/page.md +8 -7
- package/dist/.a4drules/skills/webapp-ui-ux/SKILL.md +11 -8
- package/dist/.a4drules/webapp-react.md +54 -0
- package/dist/CHANGELOG.md +16 -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/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/component.md +0 -0
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useParams, Link } from "react-router";
|
|
2
|
-
import { Button } from "
|
|
3
|
-
import { Card, CardHeader, CardTitle, CardContent } from "
|
|
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-
|
|
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
|
-
{/*
|
|
124
|
-
<Card className="mb-4 rounded-2xl shadow-
|
|
125
|
-
<CardContent className="pt-
|
|
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-
|
|
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-
|
|
146
|
+
<p className="mb-4 text-2xl font-semibold text-primary">
|
|
132
147
|
{listing?.listingPrice != null
|
|
133
|
-
?
|
|
148
|
+
? formatListingPrice(listing.listingPrice)
|
|
134
149
|
: property?.monthlyRent != null
|
|
135
|
-
?
|
|
150
|
+
? formatListingPrice(property.monthlyRent) + " / Month"
|
|
136
151
|
: "—"}
|
|
137
152
|
</p>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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-
|
|
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-
|
|
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
|
-
<
|
|
227
|
+
<div className="flex flex-wrap gap-1.5">
|
|
213
228
|
{features.map((f) => (
|
|
214
|
-
<
|
|
229
|
+
<span
|
|
215
230
|
key={f.id}
|
|
216
|
-
className="rounded-
|
|
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
|
|
233
|
+
{f.category ? `${f.category}: ` : ""}
|
|
219
234
|
{f.description ?? f.name ?? "—"}
|
|
220
|
-
</
|
|
235
|
+
</span>
|
|
221
236
|
))}
|
|
222
|
-
</
|
|
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="
|
|
231
|
-
className="w-full cursor-pointer rounded-xl bg-
|
|
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
|
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Link } from "react-router";
|
|
2
|
-
import { Button } from "
|
|
3
|
-
import { Input } from "
|
|
4
|
-
import { Card, CardContent } from "
|
|
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
|
{
|
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
|
+
}
|