@salesforce/webapp-template-app-react-sample-b2x-experimental 1.84.1 → 1.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/README.md +24 -0
  3. package/dist/force-app/main/default/data/Property_Image__c.json +1 -1
  4. package/dist/force-app/main/default/data/Property_Listing__c.json +1 -1
  5. package/dist/force-app/main/default/data/prepare-import-unique-fields.js +85 -0
  6. package/dist/force-app/main/default/permissionsets/Property_Management_Access.permissionset-meta.xml +0 -7
  7. package/dist/force-app/main/default/webapplications/appreactsampleb2x/index.html +6 -0
  8. package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +3 -3
  9. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/applicationApi.ts +9 -9
  10. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphql-operations-types.ts +296 -0
  11. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphqlClient.ts +12 -7
  12. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/maintenanceRequestApi.ts +50 -38
  13. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyDetailGraphQL.ts +50 -102
  14. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyListingGraphQL.ts +211 -43
  15. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/userApi.ts +43 -0
  16. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +9 -208
  17. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/appliances.svg +13 -0
  18. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/electrical.svg +39 -0
  19. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/hvac.svg +78 -0
  20. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/pest.svg +5 -0
  21. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/plumbing.svg +7 -0
  22. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/zen-logo.svg +5 -0
  23. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/MaintenanceRequestIcon.tsx +46 -0
  24. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/NavMenu.tsx +53 -0
  25. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyListingCard.tsx +55 -58
  26. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx +93 -11
  27. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertySearchFilters.tsx +315 -0
  28. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/StatusBadge.tsx +36 -0
  29. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/TopBar.tsx +107 -0
  30. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyAddresses.ts +2 -2
  31. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingAmenities.ts +55 -0
  32. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingPriceRange.ts +64 -0
  33. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingSearch.ts +14 -5
  34. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyMapMarkers.ts +54 -11
  35. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyPrimaryImages.ts +1 -1
  36. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx +42 -39
  37. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +10 -10
  38. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Dashboard.tsx +64 -91
  39. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/HelpCenter.tsx +1 -1
  40. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Home.tsx +19 -9
  41. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Maintenance.tsx +79 -100
  42. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/NotFound.tsx +1 -1
  43. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx +62 -47
  44. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx +3 -3
  45. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx +230 -34
  46. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +10 -1
  47. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/styles/global.css +64 -0
  48. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/geocode.ts +30 -5
  49. package/dist/package.json +1 -1
  50. package/dist/setup-cli.mjs +271 -0
  51. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ import { Check } from "lucide-react";
2
+
3
+ interface StatusBadgeProps {
4
+ status: string;
5
+ }
6
+
7
+ export function StatusBadge({ status }: StatusBadgeProps) {
8
+ const statusLower = status.toLowerCase();
9
+
10
+ const getStyle = () => {
11
+ if (statusLower === "new") return "bg-pink-100 text-pink-700";
12
+ if (statusLower === "in progress") return "bg-yellow-100 text-yellow-700";
13
+ if (statusLower === "resolved") return "bg-green-100 text-green-700";
14
+ return "bg-gray-100 text-gray-700";
15
+ };
16
+
17
+ const getLabel = () => {
18
+ if (statusLower === "new") return "Needs Action";
19
+ if (statusLower === "in progress") return "In Progress";
20
+ if (statusLower === "resolved") return "Resolved";
21
+ return status;
22
+ };
23
+
24
+ const showCheckmark = statusLower === "resolved";
25
+ const showDot = statusLower === "new" || statusLower === "in progress";
26
+
27
+ return (
28
+ <span
29
+ className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ${getStyle()}`}
30
+ >
31
+ {showCheckmark && <Check className="size-4" aria-hidden />}
32
+ {showDot && <span className="size-2 rounded-full bg-current" aria-hidden />}
33
+ {getLabel()}
34
+ </span>
35
+ );
36
+ }
@@ -0,0 +1,107 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Search, Bell, ChevronDown, Menu } from "lucide-react";
3
+ import { getUserInfo } from "@/api/userApi";
4
+ import zenLogo from "@/assets/icons/zen-logo.svg";
5
+
6
+ export interface TopBarProps {
7
+ onMenuClick?: () => void;
8
+ }
9
+
10
+ export function TopBar({ onMenuClick }: TopBarProps) {
11
+ const [userName, setUserName] = useState<string>("User");
12
+ const [showNotifications, setShowNotifications] = useState(false);
13
+
14
+ useEffect(() => {
15
+ const loadUserInfo = async () => {
16
+ const userInfo = await getUserInfo();
17
+ if (userInfo) {
18
+ setUserName(userInfo.name);
19
+ }
20
+ };
21
+ loadUserInfo();
22
+ }, []);
23
+
24
+ const handleNotificationClick = () => {
25
+ setShowNotifications(!showNotifications);
26
+ };
27
+
28
+ const handleCloseNotifications = () => {
29
+ setShowNotifications(false);
30
+ };
31
+
32
+ return (
33
+ <header
34
+ className="flex h-16 items-center justify-between bg-teal-700 px-6 text-white"
35
+ role="banner"
36
+ >
37
+ <div className="flex items-center gap-4">
38
+ <button
39
+ type="button"
40
+ onClick={onMenuClick}
41
+ className="rounded-md p-2 transition-colors hover:bg-teal-600 md:hidden"
42
+ aria-label="Toggle menu"
43
+ >
44
+ <Menu className="size-6" aria-hidden />
45
+ </button>
46
+ <div className="flex items-center gap-2">
47
+ <img src={zenLogo} alt="Zenlease Logo" className="size-8" />
48
+ <span className="text-xl tracking-wide">
49
+ <span className="font-light">ZEN</span>
50
+ <span className="font-semibold">LEASE</span>
51
+ </span>
52
+ </div>
53
+ </div>
54
+
55
+ <div className="flex items-center gap-4">
56
+ <button
57
+ type="button"
58
+ className="rounded-md p-2 transition-colors hover:bg-teal-600 md:hidden"
59
+ aria-label="Search"
60
+ >
61
+ <Search className="size-5" aria-hidden />
62
+ </button>
63
+
64
+ <div className="relative">
65
+ <button
66
+ type="button"
67
+ onClick={handleNotificationClick}
68
+ className="relative rounded-md p-2 transition-colors hover:bg-teal-600"
69
+ aria-label="Notifications"
70
+ >
71
+ <Bell className="size-5" aria-hidden />
72
+ </button>
73
+
74
+ {showNotifications && (
75
+ <>
76
+ <div className="fixed inset-0 z-40" onClick={handleCloseNotifications} aria-hidden />
77
+ <div className="absolute right-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-lg bg-white shadow-xl">
78
+ <div className="border-b border-gray-200 p-4">
79
+ <h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
80
+ </div>
81
+ <div className="p-8 text-center">
82
+ <Bell className="mx-auto mb-3 size-12 text-gray-300" aria-hidden />
83
+ <p className="text-sm text-gray-500">No new notifications</p>
84
+ </div>
85
+ </div>
86
+ </>
87
+ )}
88
+ </div>
89
+
90
+ <button
91
+ type="button"
92
+ className="flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-teal-600"
93
+ aria-label="User menu"
94
+ >
95
+ <div
96
+ className="flex size-8 items-center justify-center rounded-full bg-teal-300 font-semibold text-teal-900"
97
+ aria-hidden
98
+ >
99
+ {userName.charAt(0).toUpperCase()}
100
+ </div>
101
+ <span className="hidden font-medium md:inline">{userName.toUpperCase()}</span>
102
+ <ChevronDown className="hidden size-4 md:inline" aria-hidden />
103
+ </button>
104
+ </div>
105
+ </header>
106
+ );
107
+ }
@@ -4,8 +4,8 @@
4
4
  */
5
5
  import { useState, useEffect } from "react";
6
6
  import { fetchPropertyAddresses } from "@/api/propertyDetailGraphQL";
7
- import { getPropertyIdFromRecord } from "./usePropertyPrimaryImages";
8
- import type { SearchResultRecord } from "../features/global-search/types/search/searchResults";
7
+ import { getPropertyIdFromRecord } from "@/hooks/usePropertyPrimaryImages";
8
+ import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
9
9
 
10
10
  export function usePropertyAddresses(results: SearchResultRecord[]): Record<string, string> {
11
11
  const [map, setMap] = useState<Record<string, string>>({});
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Fetches amenities (feature descriptions) for each property in search results.
3
+ * Returns a map of propertyId -> "Amenity 1 | Amenity 2 | ..." for use in listing cards.
4
+ */
5
+ import { useState, useEffect } from "react";
6
+ import { fetchFeaturesByPropertyId } from "@/api/propertyDetailGraphQL";
7
+ import { getPropertyIdFromRecord } from "@/hooks/usePropertyPrimaryImages";
8
+ import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
9
+
10
+ const AMENITIES_SEPARATOR = " | ";
11
+
12
+ export function usePropertyListingAmenities(
13
+ results: SearchResultRecord[],
14
+ ): Record<string, string> & { loading: boolean } {
15
+ const [map, setMap] = useState<Record<string, string>>({});
16
+ const [loading, setLoading] = useState(false);
17
+
18
+ const propertyIds = results
19
+ .map((r) => r?.record && getPropertyIdFromRecord(r.record))
20
+ .filter((id): id is string => Boolean(id));
21
+ const uniqueIds = [...new Set(propertyIds)];
22
+
23
+ useEffect(() => {
24
+ if (uniqueIds.length === 0) {
25
+ setMap({});
26
+ return;
27
+ }
28
+ let cancelled = false;
29
+ setLoading(true);
30
+ Promise.all(uniqueIds.map((id) => fetchFeaturesByPropertyId(id)))
31
+ .then((featuresPerProperty) => {
32
+ if (cancelled) return;
33
+ const next: Record<string, string> = {};
34
+ uniqueIds.forEach((id, i) => {
35
+ const features = featuresPerProperty[i] ?? [];
36
+ const descriptions = features
37
+ .map((f) => f.description)
38
+ .filter((d): d is string => d != null && d.trim() !== "");
39
+ next[id] = descriptions.join(AMENITIES_SEPARATOR);
40
+ });
41
+ setMap(next);
42
+ })
43
+ .catch(() => {
44
+ if (!cancelled) setMap({});
45
+ })
46
+ .finally(() => {
47
+ if (!cancelled) setLoading(false);
48
+ });
49
+ return () => {
50
+ cancelled = true;
51
+ };
52
+ }, [uniqueIds.join(",")]);
53
+
54
+ return Object.assign(map, { loading });
55
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Fetches the available min/max listing price for the current search (no price/bedroom filters).
3
+ * Use to render the filter bar only after knowing the available price range.
4
+ */
5
+ import { useState, useEffect, useRef } from "react";
6
+ import { queryPropertyListingPriceRange } from "@/api/propertyListingGraphQL";
7
+
8
+ /** Cap for slider max when dataset has outliers; UI never sees a higher max. */
9
+ const SLIDER_PRICE_CAP = 50_000;
10
+
11
+ export interface PropertyListingPriceRange {
12
+ priceMin: number;
13
+ priceMax: number;
14
+ /** True when raw max was > cap and we capped for the slider (show "50,000+"). */
15
+ maxCapped?: boolean;
16
+ }
17
+
18
+ /** Fallback when the price-range API call fails. */
19
+ const DEFAULT_PRICE_RANGE: PropertyListingPriceRange = { priceMin: 0, priceMax: 100_000 };
20
+
21
+ function capPriceRange(range: { priceMin: number; priceMax: number }): PropertyListingPriceRange {
22
+ if (range.priceMax <= SLIDER_PRICE_CAP)
23
+ return { priceMin: range.priceMin, priceMax: range.priceMax };
24
+ return {
25
+ priceMin: range.priceMin,
26
+ priceMax: SLIDER_PRICE_CAP,
27
+ maxCapped: true,
28
+ };
29
+ }
30
+
31
+ export function usePropertyListingPriceRange(searchQuery: string): {
32
+ priceRange: PropertyListingPriceRange | null;
33
+ loading: boolean;
34
+ error: string | null;
35
+ } {
36
+ const [priceRange, setPriceRange] = useState<PropertyListingPriceRange | null>(null);
37
+ const [loading, setLoading] = useState(true);
38
+ const [error, setError] = useState<string | null>(null);
39
+ const cancelledRef = useRef(false);
40
+
41
+ useEffect(() => {
42
+ cancelledRef.current = false;
43
+ setLoading(true);
44
+ setError(null);
45
+ queryPropertyListingPriceRange(searchQuery)
46
+ .then((range) => {
47
+ if (!cancelledRef.current) setPriceRange(range ? capPriceRange(range) : null);
48
+ })
49
+ .catch((err) => {
50
+ if (!cancelledRef.current) {
51
+ setError(err instanceof Error ? err.message : "Failed to load price range");
52
+ setPriceRange(capPriceRange(DEFAULT_PRICE_RANGE));
53
+ }
54
+ })
55
+ .finally(() => {
56
+ if (!cancelledRef.current) setLoading(false);
57
+ });
58
+ return () => {
59
+ cancelledRef.current = true;
60
+ };
61
+ }, [searchQuery]);
62
+
63
+ return { priceRange, loading, error };
64
+ }
@@ -1,11 +1,19 @@
1
1
  /**
2
- * Property Listing search via GraphQL. Shows all when no search term.
2
+ * Property Listing search via GraphQL. Optional text, price range, and bedrooms filters.
3
3
  */
4
4
  import { useState, useEffect, useRef, useCallback } from "react";
5
- import { queryPropertyListingsGraphQL } from "@/api/propertyListingGraphQL";
6
- import type { SearchResultRecord } from "../features/global-search/types/search/searchResults";
5
+ import {
6
+ queryPropertyListingsGraphQL,
7
+ type PropertyListingFilters,
8
+ } from "@/api/propertyListingGraphQL";
9
+ import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
7
10
 
8
- export function usePropertyListingSearch(searchQuery: string, pageSize: number, pageToken: string) {
11
+ export function usePropertyListingSearch(
12
+ searchQuery: string,
13
+ pageSize: number,
14
+ pageToken: string,
15
+ filters?: PropertyListingFilters,
16
+ ) {
9
17
  const [results, setResults] = useState<SearchResultRecord[]>([]);
10
18
  const [nextPageToken, setNextPageToken] = useState<string | null>(null);
11
19
  const [previousPageToken, setPreviousPageToken] = useState<string | null>(null);
@@ -27,6 +35,7 @@ export function usePropertyListingSearch(searchQuery: string, pageSize: number,
27
35
  pageSize,
28
36
  afterCursor,
29
37
  ac.signal,
38
+ filters,
30
39
  );
31
40
  if (ac.signal.aborted) return;
32
41
  setResults(result.records);
@@ -41,7 +50,7 @@ export function usePropertyListingSearch(searchQuery: string, pageSize: number,
41
50
  } finally {
42
51
  if (!ac.signal.aborted) setLoading(false);
43
52
  }
44
- }, [searchQuery, pageSize, pageToken]);
53
+ }, [searchQuery, pageSize, pageToken, filters]);
45
54
 
46
55
  useEffect(() => {
47
56
  if (debounceRef.current) {