@salesforce/webapp-template-app-react-sample-b2x-experimental 1.68.0 → 1.69.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 (131) hide show
  1. package/dist/CHANGELOG.md +16 -0
  2. package/dist/force-app/main/default/data/Lease__c.json +13 -0
  3. package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +13 -8
  4. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/applicationApi.ts +78 -0
  5. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphqlClient.ts +17 -0
  6. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/index.ts +19 -0
  7. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/leadApi.ts +69 -0
  8. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/maintenanceRequestApi.ts +177 -0
  9. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectDetailService.ts +125 -0
  10. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectInfoGraphQLService.ts +194 -0
  11. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectInfoService.ts +199 -0
  12. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyDetailGraphQL.ts +497 -0
  13. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyListingGraphQL.ts +190 -0
  14. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/recordListGraphQLService.ts +365 -0
  15. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +20 -30
  16. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/FiltersPanel.tsx +375 -0
  17. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/LoadingFallback.tsx +61 -0
  18. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyListingCard.tsx +164 -0
  19. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx +113 -0
  20. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/SearchResultCard.tsx +131 -0
  21. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/alerts/status-alert.tsx +45 -0
  22. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailFields.tsx +55 -0
  23. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailForm.tsx +146 -0
  24. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailHeader.tsx +34 -0
  25. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailLayoutSections.tsx +80 -0
  26. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/Section.tsx +108 -0
  27. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/SectionRow.tsx +20 -0
  28. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/UiApiDetailForm.tsx +140 -0
  29. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FieldValueDisplay.tsx +73 -0
  30. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedAddress.tsx +29 -0
  31. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedEmail.tsx +17 -0
  32. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedPhone.tsx +24 -0
  33. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedText.tsx +11 -0
  34. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedUrl.tsx +29 -0
  35. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/index.ts +6 -0
  36. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterField.tsx +54 -0
  37. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterInput.tsx +55 -0
  38. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterSelect.tsx +72 -0
  39. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/forms/filters-form.tsx +114 -0
  40. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/forms/submit-button.tsx +47 -0
  41. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/layout/card-layout.tsx +19 -0
  42. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/ResultCardFields.tsx +71 -0
  43. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchHeader.tsx +31 -0
  44. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchPagination.tsx +144 -0
  45. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchResultsPanel.tsx +197 -0
  46. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/shared/GlobalSearchInput.tsx +114 -0
  47. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/constants.ts +39 -0
  48. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/global-search/index.ts +33 -0
  49. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/form.tsx +204 -0
  50. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/index.ts +22 -0
  51. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useGeocode.ts +35 -0
  52. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useMaintenanceRequests.ts +39 -0
  53. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useObjectInfoBatch.ts +65 -0
  54. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useObjectSearchData.ts +395 -0
  55. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyAddresses.ts +36 -0
  56. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyDetail.ts +99 -0
  57. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingSearch.ts +75 -0
  58. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyMapMarkers.ts +100 -0
  59. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyPrimaryImages.ts +51 -0
  60. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useRecordDetailLayout.ts +156 -0
  61. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useRecordListGraphQL.ts +135 -0
  62. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useWeather.ts +173 -0
  63. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx +263 -76
  64. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +158 -0
  65. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Dashboard.tsx +137 -65
  66. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/DetailPage.tsx +109 -0
  67. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/GlobalSearch.tsx +229 -0
  68. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Home.tsx +469 -21
  69. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Maintenance.tsx +244 -95
  70. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx +211 -39
  71. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx +26 -10
  72. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx +165 -0
  73. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearchPlaceholder.tsx +49 -0
  74. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-01.jpg +0 -0
  75. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-02.jpg +0 -0
  76. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-03.jpg +0 -0
  77. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-04.jpg +0 -0
  78. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-05.jpg +0 -0
  79. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-06.jpg +0 -0
  80. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-07.jpg +0 -0
  81. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-08.jpg +0 -0
  82. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-09.jpg +0 -0
  83. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-10.jpg +0 -0
  84. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-11.jpg +0 -0
  85. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-12.jpg +0 -0
  86. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-13.jpg +0 -0
  87. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-14.jpg +0 -0
  88. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-15.jpg +0 -0
  89. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-16.jpg +0 -0
  90. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-17.jpg +0 -0
  91. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-18.jpg +0 -0
  92. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-19.jpg +0 -0
  93. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-20.jpg +0 -0
  94. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-21.jpg +0 -0
  95. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-22.jpg +0 -0
  96. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-23.jpg +0 -0
  97. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-24.jpg +0 -0
  98. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-25.jpg +0 -0
  99. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +32 -6
  100. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/styles/global.css +23 -63
  101. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/filters/filters.ts +120 -0
  102. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/filters/picklist.ts +32 -0
  103. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/index.ts +4 -0
  104. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/leaflet.d.ts +17 -0
  105. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/objectInfo/objectInfo.ts +166 -0
  106. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/recordDetail/recordDetail.ts +61 -0
  107. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/search/searchResults.ts +229 -0
  108. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/apiUtils.ts +125 -0
  109. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/cacheUtils.ts +76 -0
  110. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/debounce.ts +89 -0
  111. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/fieldUtils.ts +354 -0
  112. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/fieldValueExtractor.ts +67 -0
  113. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/filterUtils.ts +32 -0
  114. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/formDataTransformUtils.ts +260 -0
  115. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/formUtils.ts +142 -0
  116. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/geocode.ts +65 -0
  117. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLNodeFieldUtils.ts +186 -0
  118. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLObjectInfoAdapter.ts +319 -0
  119. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLRecordAdapter.ts +90 -0
  120. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/index.ts +59 -0
  121. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/layoutTransformUtils.ts +236 -0
  122. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/linkUtils.ts +14 -0
  123. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/paginationUtils.ts +49 -0
  124. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/recordUtils.ts +159 -0
  125. package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/sanitizationUtils.ts +49 -0
  126. package/dist/package.json +1 -1
  127. package/package.json +2 -2
  128. package/dist/force-app/main/default/classes/MaintenanceRequestListAction.cls +0 -111
  129. package/dist/force-app/main/default/classes/MaintenanceRequestListAction.cls-meta.xml +0 -6
  130. package/dist/force-app/main/default/classes/MaintenanceRequestUpdatePriorityAction.cls +0 -93
  131. package/dist/force-app/main/default/classes/MaintenanceRequestUpdatePriorityAction.cls-meta.xml +0 -6
@@ -0,0 +1,375 @@
1
+ /**
2
+ * FiltersPanel Component
3
+ *
4
+ * Displays a panel of filter inputs for refining search results.
5
+ * Supports both text inputs and select dropdowns based on filter affordance.
6
+ *
7
+ * @param filters - Array of filter definitions to display
8
+ * @param picklistValues - Record of picklist values keyed by field path
9
+ * @param loading - Whether filters are currently loading
10
+ * @param onApplyFilters - Callback when filters are applied, receives filter values object
11
+ *
12
+ * @remarks
13
+ * - Automatically initializes filter values from defaultValues
14
+ * - Shows loading skeleton while filters are being fetched
15
+ * - Supports "Apply Filters" and "Reset" actions
16
+ * - Uses TanStack Form for form state management (similar to Login page)
17
+ * - Uses FiltersForm wrapper for consistent UX/UI (similar to AuthForm pattern)
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <FiltersPanel
22
+ * filters={filters}
23
+ * picklistValues={picklistValues}
24
+ * loading={false}
25
+ * onApplyFilters={(values) => applyFilters(values)}
26
+ * />
27
+ * ```
28
+ */
29
+ import { useState, useMemo, useCallback, useEffect, useRef } from "react";
30
+ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
31
+ import { Skeleton } from "./ui/skeleton";
32
+ import { FiltersForm } from "./forms/filters-form";
33
+ import { Field, FieldLabel, FieldDescription } from "./ui/field";
34
+ import { useAppForm, validateRangeValues } from "../hooks/form";
35
+ import type { Filter, FilterCriteria } from "../types/filters/filters";
36
+ import type { PicklistValue } from "../types/filters/picklist";
37
+ import { parseFilterValue } from "../utils/filterUtils";
38
+ import { sanitizeFilterValue } from "../utils/sanitizationUtils";
39
+ import { getFormValueByPath } from "../utils/formUtils";
40
+
41
+ interface FiltersPanelProps {
42
+ filters: Filter[];
43
+ picklistValues: Record<string, PicklistValue[]>;
44
+ loading: boolean;
45
+ objectApiName: string;
46
+ onApplyFilters: (filterCriteria: FilterCriteria[]) => void;
47
+ }
48
+
49
+ export default function FiltersPanel({
50
+ filters,
51
+ picklistValues,
52
+ loading,
53
+ objectApiName,
54
+ onApplyFilters,
55
+ }: FiltersPanelProps) {
56
+ const [submitError, setSubmitError] = useState<string | null>(null);
57
+ const [submitSuccess, setSubmitSuccess] = useState<string | null>(null);
58
+
59
+ const defaultValues = useMemo(() => {
60
+ if (!filters || !Array.isArray(filters)) {
61
+ return {};
62
+ }
63
+
64
+ const values: Record<string, string> = {};
65
+ filters.forEach((filter) => {
66
+ if (filter && filter.targetFieldPath) {
67
+ const affordance = filter.affordance?.toLowerCase() || "";
68
+
69
+ if (affordance === "range") {
70
+ const minFieldName = `${filter.targetFieldPath}_min`;
71
+ const maxFieldName = `${filter.targetFieldPath}_max`;
72
+
73
+ if (filter.defaultValues && filter.defaultValues.length >= 2) {
74
+ values[minFieldName] = filter.defaultValues[0] || "";
75
+ values[maxFieldName] = filter.defaultValues[1] || "";
76
+ } else {
77
+ values[minFieldName] = "";
78
+ values[maxFieldName] = "";
79
+ }
80
+ } else {
81
+ if (filter.defaultValues && filter.defaultValues.length > 0) {
82
+ values[filter.targetFieldPath] = filter.defaultValues[0];
83
+ } else {
84
+ values[filter.targetFieldPath] = "";
85
+ }
86
+ }
87
+ }
88
+ });
89
+ return values;
90
+ }, [filters]);
91
+
92
+ const form = useAppForm({
93
+ defaultValues,
94
+ onSubmit: async ({ value }) => {
95
+ setSubmitError(null);
96
+ setSubmitSuccess(null);
97
+ try {
98
+ const filterCriteria: FilterCriteria[] = [];
99
+
100
+ for (const filter of filters) {
101
+ if (!filter || !filter.targetFieldPath) {
102
+ continue;
103
+ }
104
+
105
+ const affordance = filter.affordance?.toLowerCase() || "";
106
+
107
+ if (affordance === "range") {
108
+ const minFieldName = `${filter.targetFieldPath}_min`;
109
+ const maxFieldName = `${filter.targetFieldPath}_max`;
110
+ const minValueRaw = value[minFieldName] || "";
111
+ const maxValueRaw = value[maxFieldName] || "";
112
+
113
+ const minValue = sanitizeFilterValue(minValueRaw);
114
+ const maxValue = sanitizeFilterValue(maxValueRaw);
115
+
116
+ if (minValue && maxValue) {
117
+ const rangeError = validateRangeValues(minValue, maxValue);
118
+ if (rangeError) {
119
+ setSubmitError(rangeError);
120
+ return;
121
+ }
122
+ }
123
+
124
+ if (minValue) {
125
+ const parsedMin = parseFilterValue(minValue);
126
+ if (parsedMin !== "") {
127
+ filterCriteria.push({
128
+ objectApiName,
129
+ fieldPath: filter.targetFieldPath,
130
+ operator: "gte",
131
+ values: [parsedMin],
132
+ });
133
+ }
134
+ }
135
+
136
+ if (maxValue) {
137
+ const parsedMax = parseFilterValue(maxValue);
138
+ if (parsedMax !== "") {
139
+ filterCriteria.push({
140
+ objectApiName,
141
+ fieldPath: filter.targetFieldPath,
142
+ operator: "lte",
143
+ values: [parsedMax],
144
+ });
145
+ }
146
+ }
147
+ } else {
148
+ const fieldValueRaw =
149
+ getFormValueByPath(value as Record<string, unknown>, filter.targetFieldPath) || "";
150
+ const fieldValue = sanitizeFilterValue(fieldValueRaw);
151
+
152
+ if (fieldValue) {
153
+ if (affordance === "select") {
154
+ filterCriteria.push({
155
+ objectApiName,
156
+ fieldPath: filter.targetFieldPath,
157
+ operator: "eq",
158
+ values: [fieldValue],
159
+ });
160
+ } else {
161
+ const likeValue = `%${fieldValue}%`;
162
+ filterCriteria.push({
163
+ objectApiName,
164
+ fieldPath: filter.targetFieldPath,
165
+ operator: "like",
166
+ values: [likeValue],
167
+ });
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ if (filterCriteria.length === 0) {
174
+ setSubmitSuccess("No filters applied. Showing all results.");
175
+ } else {
176
+ setSubmitSuccess("Filters applied successfully");
177
+ }
178
+
179
+ onApplyFilters(filterCriteria);
180
+ } catch (err) {
181
+ const errorMessage = err instanceof Error ? err.message : "Failed to apply filters";
182
+ setSubmitError(errorMessage);
183
+ }
184
+ },
185
+ onSubmitInvalid: () => {},
186
+ });
187
+
188
+ const previousDefaultValuesRef = useRef<Record<string, string>>({});
189
+ const previousLoadingRef = useRef<boolean>(true);
190
+
191
+ useEffect(() => {
192
+ const loadingJustCompleted = previousLoadingRef.current && !loading;
193
+ const defaultValuesChanged =
194
+ JSON.stringify(previousDefaultValuesRef.current) !== JSON.stringify(defaultValues);
195
+
196
+ if (loadingJustCompleted && defaultValues && Object.keys(defaultValues).length > 0) {
197
+ form.reset(defaultValues);
198
+ previousDefaultValuesRef.current = defaultValues;
199
+ } else if (defaultValuesChanged && !loading && Object.keys(defaultValues).length > 0) {
200
+ form.reset(defaultValues);
201
+ previousDefaultValuesRef.current = defaultValues;
202
+ }
203
+
204
+ previousLoadingRef.current = loading;
205
+ }, [loading, defaultValues]);
206
+
207
+ const handleSuccessDismiss = useCallback(() => {
208
+ setSubmitSuccess(null);
209
+ }, []);
210
+
211
+ const handleReset = useCallback(() => {
212
+ if (!filters || !Array.isArray(filters)) {
213
+ form.reset();
214
+ onApplyFilters([]);
215
+ setSubmitError(null);
216
+ setSubmitSuccess(null);
217
+ return;
218
+ }
219
+
220
+ const resetValues: Record<string, string> = {};
221
+ filters.forEach((filter) => {
222
+ if (filter && filter.targetFieldPath) {
223
+ const affordance = filter.affordance?.toLowerCase() || "";
224
+
225
+ if (affordance === "range") {
226
+ resetValues[`${filter.targetFieldPath}_min`] = "";
227
+ resetValues[`${filter.targetFieldPath}_max`] = "";
228
+ } else {
229
+ resetValues[filter.targetFieldPath] = "";
230
+ }
231
+ }
232
+ });
233
+ form.reset(resetValues);
234
+ onApplyFilters([]);
235
+ setSubmitError(null);
236
+ setSubmitSuccess(null);
237
+ }, [filters, onApplyFilters, form]);
238
+
239
+ if (loading) {
240
+ return (
241
+ <Card className="w-full" role="region" aria-label="Filters panel">
242
+ <CardHeader>
243
+ <CardTitle>Filters</CardTitle>
244
+ </CardHeader>
245
+ <CardContent
246
+ className="space-y-4"
247
+ role="status"
248
+ aria-live="polite"
249
+ aria-label="Loading filters"
250
+ >
251
+ <span className="sr-only">Loading filters</span>
252
+ {[1, 2, 3].map((i) => (
253
+ <div key={i} className="space-y-2" aria-hidden="true">
254
+ <Skeleton className="h-4 w-24" />
255
+ <Skeleton className="h-9 w-full" />
256
+ </div>
257
+ ))}
258
+ </CardContent>
259
+ </Card>
260
+ );
261
+ }
262
+
263
+ if (!filters || !Array.isArray(filters) || filters.length === 0) {
264
+ return (
265
+ <Card className="w-full" role="region" aria-label="Filters panel">
266
+ <CardHeader>
267
+ <CardTitle>Filters</CardTitle>
268
+ </CardHeader>
269
+ <CardContent>
270
+ <p className="text-sm text-muted-foreground">No filters available</p>
271
+ </CardContent>
272
+ </Card>
273
+ );
274
+ }
275
+
276
+ return (
277
+ <form.AppForm>
278
+ <FiltersForm
279
+ title="Filters"
280
+ description="Refine your search results by applying filters"
281
+ error={submitError}
282
+ success={submitSuccess}
283
+ onSuccessDismiss={handleSuccessDismiss}
284
+ submit={{
285
+ text: "Apply Filters",
286
+ loadingText: "Applying filters…",
287
+ }}
288
+ reset={{
289
+ text: "Reset",
290
+ onReset: handleReset,
291
+ }}
292
+ >
293
+ {filters.map((filter) => {
294
+ if (!filter || !filter.targetFieldPath) {
295
+ return null;
296
+ }
297
+
298
+ const fieldPicklistValues = picklistValues[filter.targetFieldPath] || [];
299
+ const affordance = filter.affordance?.toLowerCase() || "";
300
+
301
+ if (affordance === "range") {
302
+ const minFieldName = `${filter.targetFieldPath}_min`;
303
+ const maxFieldName = `${filter.targetFieldPath}_max`;
304
+ const inputType = "text";
305
+ const placeholder =
306
+ filter.attributes?.placeholder === "null"
307
+ ? undefined
308
+ : filter.attributes?.placeholder;
309
+
310
+ return (
311
+ <Field key={filter.targetFieldPath}>
312
+ <FieldLabel>{filter.label || filter.targetFieldPath}</FieldLabel>
313
+ {filter.helpMessage && <FieldDescription>{filter.helpMessage}</FieldDescription>}
314
+ <div
315
+ className="grid grid-cols-2 gap-3"
316
+ role="group"
317
+ aria-label={`${filter.label || filter.targetFieldPath} range filter`}
318
+ >
319
+ <form.AppField name={minFieldName}>
320
+ {(field) => (
321
+ <field.FilterRangeMinField
322
+ placeholder={placeholder || "Min"}
323
+ type={inputType}
324
+ aria-label={`${filter.label || filter.targetFieldPath} - Minimum`}
325
+ />
326
+ )}
327
+ </form.AppField>
328
+ <form.AppField name={maxFieldName}>
329
+ {(field) => (
330
+ <field.FilterRangeMaxField
331
+ placeholder={placeholder || "Max"}
332
+ type={inputType}
333
+ aria-label={`${filter.label || filter.targetFieldPath} - Maximum`}
334
+ />
335
+ )}
336
+ </form.AppField>
337
+ </div>
338
+ </Field>
339
+ );
340
+ }
341
+
342
+ if (affordance === "select") {
343
+ return (
344
+ <form.AppField key={filter.targetFieldPath} name={filter.targetFieldPath}>
345
+ {(field) => (
346
+ <field.FilterSelectField
347
+ label={filter.label || filter.targetFieldPath}
348
+ description={filter.helpMessage || undefined}
349
+ placeholder={filter.attributes?.placeholder || "Select..."}
350
+ options={fieldPicklistValues}
351
+ />
352
+ )}
353
+ </form.AppField>
354
+ );
355
+ }
356
+
357
+ return (
358
+ <form.AppField key={filter.targetFieldPath} name={filter.targetFieldPath}>
359
+ {(field) => (
360
+ <field.FilterTextField
361
+ label={filter.label || filter.targetFieldPath}
362
+ description={filter.helpMessage || undefined}
363
+ placeholder={
364
+ filter.attributes?.placeholder ||
365
+ `Enter ${(filter.label || filter.targetFieldPath).toLowerCase()}`
366
+ }
367
+ />
368
+ )}
369
+ </form.AppField>
370
+ );
371
+ })}
372
+ </FiltersForm>
373
+ </form.AppForm>
374
+ );
375
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * LoadingFallback Component
3
+ *
4
+ * Loading fallback component for Suspense boundaries.
5
+ * Displays a centered spinner while lazy-loaded components are being fetched.
6
+ *
7
+ * @remarks
8
+ * - Used with React Suspense for code splitting
9
+ * - Simple centered spinner design
10
+ * - Responsive and accessible
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * <Suspense fallback={<LoadingFallback />}>
15
+ * <LazyComponent />
16
+ * </Suspense>
17
+ * ```
18
+ */
19
+ import { cva, type VariantProps } from "class-variance-authority";
20
+ import { Spinner } from "./ui/spinner";
21
+
22
+ /**
23
+ * Spinner size variants based on content width.
24
+ */
25
+ const spinnerVariants = cva("", {
26
+ variants: {
27
+ contentMaxWidth: {
28
+ sm: "size-6",
29
+ md: "size-8",
30
+ lg: "size-10",
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ contentMaxWidth: "sm",
35
+ },
36
+ });
37
+
38
+ interface LoadingFallbackProps extends VariantProps<typeof spinnerVariants> {
39
+ /**
40
+ * Maximum width of the content container. Also scales the spinner size.
41
+ * @default "sm"
42
+ */
43
+ contentMaxWidth?: "sm" | "md" | "lg";
44
+ /**
45
+ * Accessible label for screen readers.
46
+ * @default "Loading…"
47
+ */
48
+ loadingText?: string;
49
+ }
50
+
51
+ export default function LoadingFallback({
52
+ contentMaxWidth = "sm",
53
+ loadingText = "Loading…",
54
+ }: LoadingFallbackProps) {
55
+ return (
56
+ <div className="flex justify-center" role="status" aria-live="polite">
57
+ <Spinner className={spinnerVariants({ contentMaxWidth })} aria-hidden="true" />
58
+ <span className="sr-only">{loadingText}</span>
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * ZENLEASE-style listing card: image carousel area, name + address, price by beds, amenities, Apply button.
3
+ */
4
+ import { useNavigate } from "react-router";
5
+ import { useCallback, useState } from "react";
6
+ import { Button } from "@/components/ui/button";
7
+ import type { SearchResultRecordData } from "@/types/search/searchResults";
8
+ import { Heart } from "lucide-react";
9
+
10
+ function fieldDisplay(
11
+ fields: Record<string, { value?: unknown; displayValue?: string | null }> | undefined,
12
+ apiName: string,
13
+ ): string | null {
14
+ const f = fields?.[apiName];
15
+ if (!f || typeof f !== "object") return null;
16
+ if (f.displayValue != null && f.displayValue !== "") return String(f.displayValue);
17
+ if (f.value != null) return typeof f.value === "object" ? null : String(f.value);
18
+ return null;
19
+ }
20
+
21
+ function formatPrice(val: string | number | null): string {
22
+ if (val == null) return "—";
23
+ const n = typeof val === "number" ? val : Number(val);
24
+ if (Number.isNaN(n)) return String(val);
25
+ return (
26
+ new Intl.NumberFormat("en-US", {
27
+ style: "currency",
28
+ currency: "USD",
29
+ maximumFractionDigits: 0,
30
+ }).format(n) + "+"
31
+ );
32
+ }
33
+
34
+ interface PropertyListingCardProps {
35
+ record: SearchResultRecordData;
36
+ imageUrl: string | null;
37
+ /** Property address (Address__c from Property__c) when available */
38
+ address?: string | null;
39
+ }
40
+
41
+ export default function PropertyListingCard({
42
+ record,
43
+ imageUrl,
44
+ address,
45
+ }: PropertyListingCardProps) {
46
+ const navigate = useNavigate();
47
+ const [favorited, setFavorited] = useState(false);
48
+ const name = fieldDisplay(record.fields, "Name") ?? "Untitled";
49
+ const price = fieldDisplay(record.fields, "Listing_Price__c");
50
+ const propertyRef = fieldDisplay(record.fields, "Property__c");
51
+ const detailPath = `/property/${record.id}`;
52
+ const displayAddress = (address ?? propertyRef ?? "").trim().replace(/\n/g, ", ") || null;
53
+
54
+ const handleClick = useCallback(() => {
55
+ navigate(detailPath);
56
+ }, [navigate, detailPath]);
57
+
58
+ const handleKeyDown = useCallback(
59
+ (e: React.KeyboardEvent) => {
60
+ if (e.key === "Enter" || e.key === " ") {
61
+ e.preventDefault();
62
+ handleClick();
63
+ }
64
+ },
65
+ [handleClick],
66
+ );
67
+
68
+ const toggleFavorite = useCallback((e: React.MouseEvent) => {
69
+ e.stopPropagation();
70
+ setFavorited((v) => !v);
71
+ }, []);
72
+
73
+ return (
74
+ <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"
76
+ onClick={handleClick}
77
+ onKeyDown={handleKeyDown}
78
+ role="button"
79
+ tabIndex={0}
80
+ aria-label={`View details for ${name}`}
81
+ >
82
+ {/* Image area with carousel affordances + Virtual Tours / Videos overlays */}
83
+ <div className="relative aspect-[16/10] w-full overflow-hidden rounded-t-2xl bg-muted">
84
+ {imageUrl ? (
85
+ <img src={imageUrl} alt="" className="h-full w-full object-cover" />
86
+ ) : (
87
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
88
+ No image
89
+ </div>
90
+ )}
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">
110
+ <span className="rounded-full bg-violet-600 px-2 py-0.5 text-xs font-medium text-white">
111
+ Virtual Tours
112
+ </span>
113
+ <span className="rounded-full bg-violet-600/90 px-2 py-0.5 text-xs font-medium text-white">
114
+ Videos
115
+ </span>
116
+ </div>
117
+ </div>
118
+
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>
124
+ {displayAddress && (
125
+ <p className="truncate text-sm text-muted-foreground">{displayAddress}</p>
126
+ )}
127
+ </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
+
138
+ {/* Price by beds – single 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>
144
+ )}
145
+ </div>
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 */}
151
+ <Button
152
+ size="sm"
153
+ className="w-full cursor-pointer rounded-xl bg-primary transition-colors duration-200 hover:bg-primary/90"
154
+ onClick={(e) => {
155
+ e.stopPropagation();
156
+ navigate(`/application?listingId=${encodeURIComponent(record.id)}`);
157
+ }}
158
+ >
159
+ Apply
160
+ </Button>
161
+ </div>
162
+ </article>
163
+ );
164
+ }