@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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Stacked property listing card: image on top, details below. Single price + bedrooms, amenity list, Apply button.
|
|
3
|
+
* No phone or secondary price. Virtual Tours / Videos pills on image.
|
|
3
4
|
*/
|
|
4
5
|
import { useNavigate } from "react-router";
|
|
5
|
-
import { useCallback,
|
|
6
|
-
import { Button } from "
|
|
7
|
-
import type { SearchResultRecordData } from "
|
|
8
|
-
import { Heart } from "lucide-react";
|
|
6
|
+
import { useCallback, type MouseEvent } from "react";
|
|
7
|
+
import { Button } from "@/components/ui/button";
|
|
8
|
+
import type { SearchResultRecordData } from "@/features/global-search/types/search/searchResults.js";
|
|
9
9
|
|
|
10
10
|
function fieldDisplay(
|
|
11
11
|
fields: Record<string, { value?: unknown; displayValue?: string | null }> | undefined,
|
|
@@ -36,18 +36,26 @@ interface PropertyListingCardProps {
|
|
|
36
36
|
imageUrl: string | null;
|
|
37
37
|
/** Property address (Address__c from Property__c) when available */
|
|
38
38
|
address?: string | null;
|
|
39
|
+
/** Amenities string (e.g. "In-unit washer | Pool | Gym"), separated by | */
|
|
40
|
+
amenities?: string | null;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export default function PropertyListingCard({
|
|
42
44
|
record,
|
|
43
45
|
imageUrl,
|
|
44
46
|
address,
|
|
47
|
+
amenities,
|
|
45
48
|
}: PropertyListingCardProps) {
|
|
46
49
|
const navigate = useNavigate();
|
|
47
|
-
const [favorited, setFavorited] = useState(false);
|
|
48
50
|
const name = fieldDisplay(record.fields, "Name") ?? "Untitled";
|
|
49
51
|
const price = fieldDisplay(record.fields, "Listing_Price__c");
|
|
50
52
|
const propertyRef = fieldDisplay(record.fields, "Property__c");
|
|
53
|
+
const bedroomsRaw = fieldDisplay(record.fields, "Property__r.Bedrooms__c");
|
|
54
|
+
const bedroomsNum = bedroomsRaw != null && bedroomsRaw !== "" ? Number(bedroomsRaw) : NaN;
|
|
55
|
+
const bedroomsLabel =
|
|
56
|
+
!Number.isNaN(bedroomsNum) && bedroomsNum >= 0
|
|
57
|
+
? `${bedroomsNum} Bedroom${bedroomsNum !== 1 ? "s" : ""}`
|
|
58
|
+
: null;
|
|
51
59
|
const detailPath = `/property/${record.id}`;
|
|
52
60
|
const displayAddress = (address ?? propertyRef ?? "").trim().replace(/\n/g, ", ") || null;
|
|
53
61
|
|
|
@@ -65,21 +73,16 @@ export default function PropertyListingCard({
|
|
|
65
73
|
[handleClick],
|
|
66
74
|
);
|
|
67
75
|
|
|
68
|
-
const toggleFavorite = useCallback((e: React.MouseEvent) => {
|
|
69
|
-
e.stopPropagation();
|
|
70
|
-
setFavorited((v) => !v);
|
|
71
|
-
}, []);
|
|
72
|
-
|
|
73
76
|
return (
|
|
74
77
|
<article
|
|
75
|
-
className="cursor-pointer overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all duration-200 hover:shadow-md focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
|
|
78
|
+
className="flex cursor-pointer flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-all duration-200 hover:shadow-md focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2"
|
|
76
79
|
onClick={handleClick}
|
|
77
80
|
onKeyDown={handleKeyDown}
|
|
78
81
|
role="button"
|
|
79
82
|
tabIndex={0}
|
|
80
83
|
aria-label={`View details for ${name}`}
|
|
81
84
|
>
|
|
82
|
-
{/* Image
|
|
85
|
+
{/* Image on top, full width */}
|
|
83
86
|
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-muted">
|
|
84
87
|
{imageUrl ? (
|
|
85
88
|
<img src={imageUrl} alt="" className="h-full w-full object-cover" />
|
|
@@ -88,25 +91,7 @@ export default function PropertyListingCard({
|
|
|
88
91
|
No image
|
|
89
92
|
</div>
|
|
90
93
|
)}
|
|
91
|
-
|
|
92
|
-
<button
|
|
93
|
-
type="button"
|
|
94
|
-
className="absolute left-2 top-1/2 flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/40 text-white transition-colors duration-200 hover:bg-black/60"
|
|
95
|
-
aria-label="Previous image"
|
|
96
|
-
onClick={(e) => e.stopPropagation()}
|
|
97
|
-
>
|
|
98
|
-
←
|
|
99
|
-
</button>
|
|
100
|
-
<button
|
|
101
|
-
type="button"
|
|
102
|
-
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-black/40 text-white transition-colors duration-200 hover:bg-black/60"
|
|
103
|
-
aria-label="Next image"
|
|
104
|
-
onClick={(e) => e.stopPropagation()}
|
|
105
|
-
>
|
|
106
|
-
→
|
|
107
|
-
</button>
|
|
108
|
-
{/* Virtual Tours / Videos pills – purple per ZENLEASE screenshots */}
|
|
109
|
-
<div className="absolute left-2 top-2 flex flex-col gap-1">
|
|
94
|
+
<div className="absolute left-1.5 top-1.5 flex flex-col gap-0.5">
|
|
110
95
|
<span className="rounded-full bg-violet-600 px-2 py-0.5 text-xs font-medium text-white">
|
|
111
96
|
Virtual Tours
|
|
112
97
|
</span>
|
|
@@ -116,42 +101,54 @@ export default function PropertyListingCard({
|
|
|
116
101
|
</div>
|
|
117
102
|
</div>
|
|
118
103
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<div
|
|
122
|
-
<div className="
|
|
123
|
-
<h3 className="font-semibold text-foreground">{name}</h3>
|
|
104
|
+
{/* Content below: name, address, price+beds, amenities, Apply */}
|
|
105
|
+
<div className="flex flex-col justify-between p-3">
|
|
106
|
+
<div>
|
|
107
|
+
<div className="mb-1.5">
|
|
108
|
+
<h3 className="text-2xl font-semibold text-foreground">{name}</h3>
|
|
124
109
|
{displayAddress && (
|
|
125
110
|
<p className="truncate text-sm text-muted-foreground">{displayAddress}</p>
|
|
126
111
|
)}
|
|
127
112
|
</div>
|
|
128
|
-
<button
|
|
129
|
-
type="button"
|
|
130
|
-
className="shrink-0 cursor-pointer rounded-xl p-1 text-muted-foreground transition-colors duration-200 hover:bg-muted hover:text-foreground"
|
|
131
|
-
aria-label={favorited ? "Remove from favorites" : "Add to favorites"}
|
|
132
|
-
onClick={toggleFavorite}
|
|
133
|
-
>
|
|
134
|
-
<Heart className={`h-5 w-5 ${favorited ? "fill-primary text-primary" : ""}`} />
|
|
135
|
-
</button>
|
|
136
|
-
</div>
|
|
137
113
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
114
|
+
{/* Single price + bedrooms – price bold, teal, 2x size */}
|
|
115
|
+
<div className="mb-1.5 flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-base">
|
|
116
|
+
{price != null && (
|
|
117
|
+
<span className="text-2xl font-semibold text-primary">
|
|
118
|
+
{formatPrice(price)}
|
|
119
|
+
{bedroomsLabel != null ? (
|
|
120
|
+
<span className="ml-1 text-base font-normal text-muted-foreground">
|
|
121
|
+
{bedroomsLabel}
|
|
122
|
+
</span>
|
|
123
|
+
) : null}
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Amenity pills (fetched per property, string separated by |) */}
|
|
129
|
+
{amenities != null && amenities.trim() !== "" && (
|
|
130
|
+
<div className="mb-2 flex flex-wrap gap-1.5">
|
|
131
|
+
{amenities
|
|
132
|
+
.split(/\s*\|\s*/)
|
|
133
|
+
.map((label) => label.trim())
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.map((label) => (
|
|
136
|
+
<span
|
|
137
|
+
key={label}
|
|
138
|
+
className="rounded-full border border-border bg-muted/60 px-2.5 py-0.5 text-xs font-medium text-muted-foreground"
|
|
139
|
+
>
|
|
140
|
+
{label}
|
|
141
|
+
</span>
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
144
|
)}
|
|
145
145
|
</div>
|
|
146
146
|
|
|
147
|
-
{/*
|
|
148
|
-
<p className="mb-3 text-xs text-muted-foreground">View details for amenities</p>
|
|
149
|
-
|
|
150
|
-
{/* Apply – teal primary button */}
|
|
147
|
+
{/* Apply button – no phone, no email */}
|
|
151
148
|
<Button
|
|
152
149
|
size="sm"
|
|
153
|
-
className="w-full cursor-pointer rounded-xl bg-primary transition-colors duration-200 hover:bg-primary/90"
|
|
154
|
-
onClick={(e) => {
|
|
150
|
+
className="mt-4 w-full cursor-pointer rounded-xl bg-primary px-5 py-5 text-lg font-medium transition-colors duration-200 hover:bg-primary/90"
|
|
151
|
+
onClick={(e: MouseEvent<HTMLButtonElement>) => {
|
|
155
152
|
e.stopPropagation();
|
|
156
153
|
navigate(`/application?listingId=${encodeURIComponent(record.id)}`);
|
|
157
154
|
}}
|
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Leaflet map for property search and detail. Uses OpenStreetMap tiles (no API key).
|
|
3
3
|
* Renders one pin per property (each marker in the markers array).
|
|
4
4
|
*/
|
|
5
|
-
import { useMemo, useState, useEffect } from "react";
|
|
5
|
+
import { useMemo, useState, useEffect, type ReactNode } from "react";
|
|
6
6
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
|
7
7
|
import L from "leaflet";
|
|
8
8
|
import "leaflet/dist/leaflet.css";
|
|
@@ -18,19 +18,32 @@ const Leaflet = L as {
|
|
|
18
18
|
}) => unknown;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
//
|
|
21
|
+
// Lucide-style map pin (teardrop + circle), filled, 1.5x size
|
|
22
|
+
const PIN_SVG =
|
|
23
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="42" height="60" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" fill="currentColor"/><circle cx="12" cy="10" r="3" fill="white" stroke="none"/></svg>';
|
|
24
|
+
|
|
22
25
|
const pinIcon = Leaflet.divIcon({
|
|
23
26
|
className: "property-map-pin",
|
|
24
|
-
html:
|
|
25
|
-
iconSize: [
|
|
26
|
-
iconAnchor: [
|
|
27
|
-
popupAnchor: [0, -
|
|
27
|
+
html: PIN_SVG,
|
|
28
|
+
iconSize: [42, 60],
|
|
29
|
+
iconAnchor: [21, 60],
|
|
30
|
+
popupAnchor: [0, -60],
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
export interface MapMarker {
|
|
31
34
|
lat: number;
|
|
32
35
|
lng: number;
|
|
33
36
|
label?: string;
|
|
37
|
+
/** Property__c id; used to look up listing record for popup card */
|
|
38
|
+
propertyId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Bounding box in lat/lng (Leaflet getBounds()). */
|
|
42
|
+
export interface MapBounds {
|
|
43
|
+
north: number;
|
|
44
|
+
south: number;
|
|
45
|
+
east: number;
|
|
46
|
+
west: number;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
interface PropertyMapProps {
|
|
@@ -40,6 +53,10 @@ interface PropertyMapProps {
|
|
|
40
53
|
zoom?: number;
|
|
41
54
|
/** Optional markers */
|
|
42
55
|
markers?: MapMarker[];
|
|
56
|
+
/** Optional: render custom content in each marker popup (e.g. PropertyListingCard) */
|
|
57
|
+
popupContent?: (marker: MapMarker) => ReactNode;
|
|
58
|
+
/** Called when the user pans or zooms; use to filter list by visible pins */
|
|
59
|
+
onBoundsChange?: (bounds: MapBounds | null) => void;
|
|
43
60
|
/** CSS class for the container (e.g. height) */
|
|
44
61
|
className?: string;
|
|
45
62
|
}
|
|
@@ -52,10 +69,65 @@ function MapCenterUpdater({ center, zoom = 13 }: { center: [number, number]; zoo
|
|
|
52
69
|
return null;
|
|
53
70
|
}
|
|
54
71
|
|
|
72
|
+
function MapBoundsReporter({
|
|
73
|
+
onBoundsChange,
|
|
74
|
+
}: {
|
|
75
|
+
onBoundsChange?: (bounds: MapBounds | null) => void;
|
|
76
|
+
}) {
|
|
77
|
+
const map = useMap() as {
|
|
78
|
+
getBounds?: () => {
|
|
79
|
+
getNorth: () => number;
|
|
80
|
+
getSouth: () => number;
|
|
81
|
+
getEast: () => number;
|
|
82
|
+
getWest: () => number;
|
|
83
|
+
};
|
|
84
|
+
on?: (event: string, fn: () => void) => void;
|
|
85
|
+
off?: (event: string, fn: () => void) => void;
|
|
86
|
+
};
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!onBoundsChange || !map.getBounds) return;
|
|
89
|
+
const report = () => {
|
|
90
|
+
const b = map.getBounds!();
|
|
91
|
+
onBoundsChange({
|
|
92
|
+
north: b.getNorth(),
|
|
93
|
+
south: b.getSouth(),
|
|
94
|
+
east: b.getEast(),
|
|
95
|
+
west: b.getWest(),
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
// Only report on moveend (user pan/zoom). Reporting on mount caused setState -> re-render -> map setView -> moveend -> loop.
|
|
99
|
+
map.on?.("moveend", report);
|
|
100
|
+
return () => {
|
|
101
|
+
map.off?.("moveend", report);
|
|
102
|
+
};
|
|
103
|
+
}, [map, onBoundsChange]);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Wraps popup content with an in-card close button and shadow; removes need for Leaflet's default chrome */
|
|
108
|
+
function PopupContentWrapper({ children }: { children: ReactNode }) {
|
|
109
|
+
const map = useMap() as { closePopup?: () => void };
|
|
110
|
+
return (
|
|
111
|
+
<div className="relative min-w-0">
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => map.closePopup?.()}
|
|
115
|
+
className="absolute right-2 top-2 z-10 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80"
|
|
116
|
+
aria-label="Close"
|
|
117
|
+
>
|
|
118
|
+
×
|
|
119
|
+
</button>
|
|
120
|
+
<div className="rounded-2xl shadow-lg overflow-hidden">{children}</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
55
125
|
export default function PropertyMap({
|
|
56
126
|
center,
|
|
57
127
|
zoom = 13,
|
|
58
128
|
markers = [],
|
|
129
|
+
popupContent,
|
|
130
|
+
onBoundsChange,
|
|
59
131
|
className = "h-[400px] w-full rounded-xl overflow-hidden",
|
|
60
132
|
}: PropertyMapProps) {
|
|
61
133
|
const [mounted, setMounted] = useState(false);
|
|
@@ -66,8 +138,7 @@ export default function PropertyMap({
|
|
|
66
138
|
const hasMarkers = markers.length > 0;
|
|
67
139
|
const effectiveCenter = useMemo((): [number, number] => {
|
|
68
140
|
if (hasMarkers) {
|
|
69
|
-
|
|
70
|
-
return [sum[0] / markers.length, sum[1] / markers.length];
|
|
141
|
+
return [markers[0].lat, markers[0].lng];
|
|
71
142
|
}
|
|
72
143
|
return center;
|
|
73
144
|
}, [center, hasMarkers, markers]);
|
|
@@ -95,14 +166,25 @@ export default function PropertyMap({
|
|
|
95
166
|
style={{ minHeight: 200 }}
|
|
96
167
|
>
|
|
97
168
|
<MapCenterUpdater center={effectiveCenter} zoom={zoom} />
|
|
169
|
+
<MapBoundsReporter onBoundsChange={onBoundsChange} />
|
|
98
170
|
<TileLayer
|
|
99
|
-
attribution='Data by © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
171
|
+
attribution='© <a href="https://leafletjs.com/">Leaflet</a> | Data by © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
100
172
|
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
101
173
|
maxZoom={19}
|
|
102
174
|
/>
|
|
103
175
|
{markers.map((m, i) => (
|
|
104
|
-
<Marker
|
|
105
|
-
|
|
176
|
+
<Marker
|
|
177
|
+
key={`${m.lat}-${m.lng}-${m.propertyId ?? i}`}
|
|
178
|
+
position={[m.lat, m.lng]}
|
|
179
|
+
icon={pinIcon}
|
|
180
|
+
>
|
|
181
|
+
<Popup>
|
|
182
|
+
{popupContent ? (
|
|
183
|
+
<PopupContentWrapper>{popupContent(m)}</PopupContentWrapper>
|
|
184
|
+
) : (
|
|
185
|
+
(m.label ?? "Property")
|
|
186
|
+
)}
|
|
187
|
+
</Popup>
|
|
106
188
|
</Marker>
|
|
107
189
|
))}
|
|
108
190
|
</MapContainer>
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search and filter bar for property search: area search, price range, number of bedrooms.
|
|
3
|
+
*/
|
|
4
|
+
import { Popover } from "radix-ui";
|
|
5
|
+
import { Search, ChevronDown } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
const SEARCH_INPUT_CLASS =
|
|
8
|
+
"h-10 rounded-full border-2 border-primary/50 bg-background px-4 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20";
|
|
9
|
+
|
|
10
|
+
const FILTER_PILL_CLASS =
|
|
11
|
+
"inline-flex h-10 items-center gap-1.5 rounded-full border-2 border-primary/50 bg-background px-4 text-sm font-medium text-foreground outline-none transition-colors hover:border-primary/70 focus:ring-2 focus:ring-primary/20";
|
|
12
|
+
|
|
13
|
+
const SAVE_BUTTON_CLASS =
|
|
14
|
+
"mt-3 w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/20";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PRICE_MIN = 0;
|
|
17
|
+
const DEFAULT_PRICE_MAX = 100_000;
|
|
18
|
+
|
|
19
|
+
/** Bedroom filter buckets: ≤2, exactly 3, or ≥4 */
|
|
20
|
+
export type BedroomFilter = "le2" | "3" | "ge4" | null;
|
|
21
|
+
|
|
22
|
+
const BEDROOM_OPTIONS: { value: BedroomFilter; label: string }[] = [
|
|
23
|
+
{ value: "le2", label: "≤2" },
|
|
24
|
+
{ value: "3", label: "3" },
|
|
25
|
+
{ value: "ge4", label: "≥4" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Sort by price or number of bedrooms, ascending or descending */
|
|
29
|
+
export type SortBy = "price_asc" | "price_desc" | "beds_asc" | "beds_desc" | null;
|
|
30
|
+
|
|
31
|
+
const SORT_OPTIONS: { value: NonNullable<SortBy>; label: string }[] = [
|
|
32
|
+
{ value: "price_asc", label: "Price (low to high)" },
|
|
33
|
+
{ value: "price_desc", label: "Price (high to low)" },
|
|
34
|
+
{ value: "beds_asc", label: "Number of bedrooms (low to high)" },
|
|
35
|
+
{ value: "beds_desc", label: "Number of bedrooms (high to low)" },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export interface PropertySearchFiltersProps {
|
|
39
|
+
searchQuery: string;
|
|
40
|
+
onSearchQueryChange: (value: string) => void;
|
|
41
|
+
priceMin: string;
|
|
42
|
+
onPriceMinChange: (value: string) => void;
|
|
43
|
+
priceMax: string;
|
|
44
|
+
onPriceMaxChange: (value: string) => void;
|
|
45
|
+
/** Called when user clicks Save in the Price popover; use this to commit filters and run search. */
|
|
46
|
+
onPriceSave?: (min: string, max: string) => void;
|
|
47
|
+
bedrooms: BedroomFilter;
|
|
48
|
+
onBedroomsChange: (value: BedroomFilter) => void;
|
|
49
|
+
/** Called when user clicks Save in the Beds popover; use this to commit filters and run search. */
|
|
50
|
+
onBedsSave?: (value: BedroomFilter) => void;
|
|
51
|
+
sortBy: SortBy;
|
|
52
|
+
onSortChange: (value: SortBy) => void;
|
|
53
|
+
/** Called when user clicks Save in the Sort popover; use this to apply sort. */
|
|
54
|
+
onSortSave?: (value: SortBy) => void;
|
|
55
|
+
/** When set, pill shows this as the applied sort (e.g. after Save); otherwise pill shows sortBy. */
|
|
56
|
+
appliedSortBy?: SortBy | null;
|
|
57
|
+
onSubmit?: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function PropertySearchFilters({
|
|
61
|
+
searchQuery,
|
|
62
|
+
onSearchQueryChange,
|
|
63
|
+
priceMin,
|
|
64
|
+
onPriceMinChange,
|
|
65
|
+
priceMax,
|
|
66
|
+
onPriceMaxChange,
|
|
67
|
+
onPriceSave,
|
|
68
|
+
bedrooms,
|
|
69
|
+
onBedroomsChange,
|
|
70
|
+
onBedsSave,
|
|
71
|
+
sortBy,
|
|
72
|
+
onSortChange,
|
|
73
|
+
onSortSave,
|
|
74
|
+
appliedSortBy,
|
|
75
|
+
onSubmit,
|
|
76
|
+
}: PropertySearchFiltersProps) {
|
|
77
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
78
|
+
if (e.key === "Enter") onSubmit?.();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const rangeMin = DEFAULT_PRICE_MIN;
|
|
82
|
+
const rangeMax = DEFAULT_PRICE_MAX;
|
|
83
|
+
|
|
84
|
+
const minNum = priceMin.trim() ? Number(priceMin.replace(/[^0-9.]/g, "")) : NaN;
|
|
85
|
+
const maxNum = priceMax.trim() ? Number(priceMax.replace(/[^0-9.]/g, "")) : NaN;
|
|
86
|
+
const hasMin = Number.isFinite(minNum) && minNum > 0;
|
|
87
|
+
const hasMax = Number.isFinite(maxNum) && maxNum > 0;
|
|
88
|
+
const formatPrice = (n: number) =>
|
|
89
|
+
new Intl.NumberFormat("en-US", {
|
|
90
|
+
style: "currency",
|
|
91
|
+
currency: "USD",
|
|
92
|
+
maximumFractionDigits: 0,
|
|
93
|
+
}).format(n);
|
|
94
|
+
const priceValueLabel =
|
|
95
|
+
hasMin && hasMax
|
|
96
|
+
? `${formatPrice(minNum)} – ${formatPrice(maxNum)}`
|
|
97
|
+
: hasMin
|
|
98
|
+
? `${formatPrice(minNum)}+`
|
|
99
|
+
: hasMax
|
|
100
|
+
? `– ${formatPrice(maxNum)}`
|
|
101
|
+
: null;
|
|
102
|
+
const priceLabel = priceValueLabel != null ? `Price: ${priceValueLabel}` : "Price";
|
|
103
|
+
|
|
104
|
+
const bedsLabel = bedrooms
|
|
105
|
+
? `Number of bedrooms: ${BEDROOM_OPTIONS.find((o) => o.value === bedrooms)?.label ?? ""}`
|
|
106
|
+
: "Number of bedrooms";
|
|
107
|
+
|
|
108
|
+
const sortForPill = appliedSortBy ?? sortBy;
|
|
109
|
+
const sortOptionLabel = sortForPill
|
|
110
|
+
? (SORT_OPTIONS.find((o) => o.value === sortForPill)?.label ?? null)
|
|
111
|
+
: null;
|
|
112
|
+
const sortLabel = sortOptionLabel != null ? `Sort By: ${sortOptionLabel}` : "Sort By";
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex w-full shrink-0 flex-col items-stretch gap-3 border-b border-border px-4 py-4 lg:flex-row lg:items-center">
|
|
116
|
+
<div className="relative min-w-0 w-full lg:w-1/2 lg:shrink-0">
|
|
117
|
+
<Search
|
|
118
|
+
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
|
|
119
|
+
aria-hidden
|
|
120
|
+
/>
|
|
121
|
+
<input
|
|
122
|
+
type="text"
|
|
123
|
+
value={searchQuery}
|
|
124
|
+
onChange={(e) => onSearchQueryChange(e.target.value)}
|
|
125
|
+
onKeyDown={handleKeyDown}
|
|
126
|
+
placeholder="Area or search"
|
|
127
|
+
className={`${SEARCH_INPUT_CLASS} w-full pl-10`}
|
|
128
|
+
aria-label="Search property listings"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="flex flex-wrap items-center gap-3 lg:flex-1 lg:min-w-0 lg:flex-nowrap">
|
|
132
|
+
<div className="min-w-0 lg:flex-1">
|
|
133
|
+
<Popover.Root>
|
|
134
|
+
<Popover.Trigger asChild>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className={`${FILTER_PILL_CLASS} w-full justify-center`}
|
|
138
|
+
aria-label={priceValueLabel == null ? "Price filter" : priceLabel}
|
|
139
|
+
>
|
|
140
|
+
{priceLabel}
|
|
141
|
+
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
142
|
+
</button>
|
|
143
|
+
</Popover.Trigger>
|
|
144
|
+
<Popover.Portal>
|
|
145
|
+
<Popover.Content
|
|
146
|
+
sideOffset={8}
|
|
147
|
+
align="start"
|
|
148
|
+
className="z-[1100] w-[min(20rem,90vw)] rounded-xl border border-border bg-background p-4 shadow-lg outline-none"
|
|
149
|
+
>
|
|
150
|
+
<div className="space-y-3">
|
|
151
|
+
<p className="text-sm font-medium text-foreground">Price range</p>
|
|
152
|
+
<div className="flex items-center gap-2">
|
|
153
|
+
<div className="flex flex-1 items-center rounded-full border-2 border-primary/50 bg-background focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20">
|
|
154
|
+
<span className="pl-4 text-sm text-muted-foreground">$</span>
|
|
155
|
+
<input
|
|
156
|
+
type="number"
|
|
157
|
+
min={rangeMin}
|
|
158
|
+
max={rangeMax}
|
|
159
|
+
step={1000}
|
|
160
|
+
value={priceMin}
|
|
161
|
+
onChange={(e) => onPriceMinChange(e.target.value)}
|
|
162
|
+
onKeyDown={handleKeyDown}
|
|
163
|
+
className="h-10 flex-1 border-0 bg-transparent px-2 py-0 text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
164
|
+
aria-label="Minimum price"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
<span className="text-muted-foreground">–</span>
|
|
168
|
+
<div className="flex flex-1 items-center rounded-full border-2 border-primary/50 bg-background focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20">
|
|
169
|
+
<span className="pl-4 text-sm text-muted-foreground">$</span>
|
|
170
|
+
<input
|
|
171
|
+
type="number"
|
|
172
|
+
min={rangeMin}
|
|
173
|
+
max={rangeMax}
|
|
174
|
+
step={1000}
|
|
175
|
+
value={priceMax}
|
|
176
|
+
onChange={(e) => onPriceMaxChange(e.target.value)}
|
|
177
|
+
onKeyDown={handleKeyDown}
|
|
178
|
+
className="h-10 flex-1 border-0 bg-transparent px-2 py-0 text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
179
|
+
aria-label="Maximum price"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
<Popover.Close asChild>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
className={SAVE_BUTTON_CLASS}
|
|
187
|
+
onClick={() => onPriceSave?.(priceMin, priceMax)}
|
|
188
|
+
>
|
|
189
|
+
Save
|
|
190
|
+
</button>
|
|
191
|
+
</Popover.Close>
|
|
192
|
+
</div>
|
|
193
|
+
</Popover.Content>
|
|
194
|
+
</Popover.Portal>
|
|
195
|
+
</Popover.Root>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div className="min-w-0 lg:flex-1">
|
|
199
|
+
<Popover.Root>
|
|
200
|
+
<Popover.Trigger asChild>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className={`${FILTER_PILL_CLASS} w-full justify-center`}
|
|
204
|
+
aria-label={bedrooms ? bedsLabel : "Number of bedrooms filter"}
|
|
205
|
+
>
|
|
206
|
+
{bedsLabel}
|
|
207
|
+
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
208
|
+
</button>
|
|
209
|
+
</Popover.Trigger>
|
|
210
|
+
<Popover.Portal>
|
|
211
|
+
<Popover.Content
|
|
212
|
+
sideOffset={8}
|
|
213
|
+
align="start"
|
|
214
|
+
className="z-[1100] w-[min(20rem,90vw)] rounded-xl border border-border bg-background p-4 shadow-lg outline-none"
|
|
215
|
+
>
|
|
216
|
+
<div className="space-y-3">
|
|
217
|
+
<p className="text-sm font-medium text-foreground">Number of bedrooms</p>
|
|
218
|
+
<div
|
|
219
|
+
className="flex overflow-hidden rounded-full border-2 border-primary/40"
|
|
220
|
+
role="group"
|
|
221
|
+
aria-label="Number of bedrooms"
|
|
222
|
+
>
|
|
223
|
+
{BEDROOM_OPTIONS.map(({ value, label }) => (
|
|
224
|
+
<button
|
|
225
|
+
key={value}
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={() => onBedroomsChange(bedrooms === value ? null : value)}
|
|
228
|
+
className={`min-w-[4rem] flex-1 border-r border-primary/40 px-4 py-2 text-sm font-medium transition-colors last:border-r-0 ${
|
|
229
|
+
bedrooms === value
|
|
230
|
+
? "bg-primary text-primary-foreground"
|
|
231
|
+
: "bg-background text-primary hover:bg-primary/10"
|
|
232
|
+
}`}
|
|
233
|
+
aria-pressed={bedrooms === value}
|
|
234
|
+
aria-label={
|
|
235
|
+
value === "le2"
|
|
236
|
+
? "2 or fewer bedrooms"
|
|
237
|
+
: value === "3"
|
|
238
|
+
? "3 bedrooms"
|
|
239
|
+
: "4 or more bedrooms"
|
|
240
|
+
}
|
|
241
|
+
>
|
|
242
|
+
{label}
|
|
243
|
+
</button>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
<Popover.Close asChild>
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
className={SAVE_BUTTON_CLASS}
|
|
250
|
+
onClick={() => onBedsSave?.(bedrooms)}
|
|
251
|
+
>
|
|
252
|
+
Save
|
|
253
|
+
</button>
|
|
254
|
+
</Popover.Close>
|
|
255
|
+
</div>
|
|
256
|
+
</Popover.Content>
|
|
257
|
+
</Popover.Portal>
|
|
258
|
+
</Popover.Root>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div className="min-w-0 lg:flex-1">
|
|
262
|
+
<Popover.Root>
|
|
263
|
+
<Popover.Trigger asChild>
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
className={`${FILTER_PILL_CLASS} w-full justify-center`}
|
|
267
|
+
aria-label={sortBy ? sortLabel : "Sort By"}
|
|
268
|
+
>
|
|
269
|
+
{sortLabel}
|
|
270
|
+
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
271
|
+
</button>
|
|
272
|
+
</Popover.Trigger>
|
|
273
|
+
<Popover.Portal>
|
|
274
|
+
<Popover.Content
|
|
275
|
+
sideOffset={8}
|
|
276
|
+
align="start"
|
|
277
|
+
className="z-[1100] w-[min(20rem,90vw)] rounded-xl border border-border bg-background p-4 shadow-lg outline-none"
|
|
278
|
+
>
|
|
279
|
+
<div className="space-y-3">
|
|
280
|
+
<p className="text-sm font-medium text-foreground">Sort by</p>
|
|
281
|
+
<div className="flex flex-col gap-1">
|
|
282
|
+
{SORT_OPTIONS.map(({ value, label }) => (
|
|
283
|
+
<button
|
|
284
|
+
key={value}
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={() => onSortChange(value)}
|
|
287
|
+
className={`rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${
|
|
288
|
+
sortBy === value
|
|
289
|
+
? "bg-primary text-primary-foreground"
|
|
290
|
+
: "bg-background text-foreground hover:bg-muted"
|
|
291
|
+
}`}
|
|
292
|
+
aria-pressed={sortBy === value}
|
|
293
|
+
>
|
|
294
|
+
{label}
|
|
295
|
+
</button>
|
|
296
|
+
))}
|
|
297
|
+
</div>
|
|
298
|
+
<Popover.Close asChild>
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
className={SAVE_BUTTON_CLASS}
|
|
302
|
+
onClick={() => onSortSave?.(sortBy)}
|
|
303
|
+
>
|
|
304
|
+
Save
|
|
305
|
+
</button>
|
|
306
|
+
</Popover.Close>
|
|
307
|
+
</div>
|
|
308
|
+
</Popover.Content>
|
|
309
|
+
</Popover.Portal>
|
|
310
|
+
</Popover.Root>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|