@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,11 +1,11 @@
1
1
  /**
2
- * ZENLEASE-style listing card: image carousel area, name + address, price by beds, amenities, Apply button.
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, useState } from "react";
6
- import { Button } from "./ui/button";
7
- import type { SearchResultRecordData } from "../features/global-search/types/search/searchResults";
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 area with carousel affordances + Virtual Tours / Videos overlays */}
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
- {/* Left/right carousel arrows (visual only for now) */}
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
- <div className="p-3">
120
- {/* Name + address row with favorite */}
121
- <div className="mb-2 flex items-start justify-between gap-2">
122
- <div className="min-w-0">
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
- {/* Price by bedssingle price shown as main */}
139
- <div className="mb-2 flex flex-wrap gap-3 text-sm">
140
- {price != null && (
141
- <span className="font-medium text-foreground">
142
- {formatPrice(price)} <span className="font-normal text-muted-foreground">2 Beds</span>
143
- </span>
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
- {/* Amenities line */}
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
  }}
@@ -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
- // Custom pin icon so each property shows a visible pin (no dependency on external marker images)
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: `<span class="property-pin-shape" aria-hidden="true"></span>`,
25
- iconSize: [28, 40],
26
- iconAnchor: [14, 40],
27
- popupAnchor: [0, -40],
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
- const sum = markers.reduce((acc, m) => [acc[0] + m.lat, acc[1] + m.lng], [0, 0]);
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 &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
171
+ attribution='&copy; <a href="https://leafletjs.com/">Leaflet</a> | Data by &copy; <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 key={`${m.lat}-${m.lng}-${i}`} position={[m.lat, m.lng]} icon={pinIcon}>
105
- <Popup>{m.label ?? "Property"}</Popup>
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
+ }