@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.
- package/dist/CHANGELOG.md +8 -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
|
@@ -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
|
+
}
|
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/StatusBadge.tsx
ADDED
|
@@ -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
|
+
}
|
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/TopBar.tsx
ADDED
|
@@ -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 "
|
|
8
|
-
import type { SearchResultRecord } from "
|
|
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.
|
|
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 {
|
|
6
|
-
|
|
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(
|
|
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) {
|