@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.
- package/dist/CHANGELOG.md +16 -0
- package/dist/force-app/main/default/data/Lease__c.json +13 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +13 -8
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/applicationApi.ts +78 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphqlClient.ts +17 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/index.ts +19 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/leadApi.ts +69 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/maintenanceRequestApi.ts +177 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectDetailService.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectInfoGraphQLService.ts +194 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/objectInfoService.ts +199 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyDetailGraphQL.ts +497 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyListingGraphQL.ts +190 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/recordListGraphQLService.ts +365 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +20 -30
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/FiltersPanel.tsx +375 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/LoadingFallback.tsx +61 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyListingCard.tsx +164 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx +113 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/SearchResultCard.tsx +131 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/alerts/status-alert.tsx +45 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailFields.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailForm.tsx +146 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailHeader.tsx +34 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/DetailLayoutSections.tsx +80 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/Section.tsx +108 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/SectionRow.tsx +20 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/UiApiDetailForm.tsx +140 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FieldValueDisplay.tsx +73 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedAddress.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedEmail.tsx +17 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedPhone.tsx +24 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedText.tsx +11 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/FormattedUrl.tsx +29 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/detail/formatted/index.ts +6 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterField.tsx +54 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterInput.tsx +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/filters/FilterSelect.tsx +72 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/forms/filters-form.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/forms/submit-button.tsx +47 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/layout/card-layout.tsx +19 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/ResultCardFields.tsx +71 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchHeader.tsx +31 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchPagination.tsx +144 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/search/SearchResultsPanel.tsx +197 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/shared/GlobalSearchInput.tsx +114 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/constants.ts +39 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/features/global-search/index.ts +33 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/form.tsx +204 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/index.ts +22 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useGeocode.ts +35 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useMaintenanceRequests.ts +39 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useObjectInfoBatch.ts +65 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useObjectSearchData.ts +395 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyAddresses.ts +36 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyDetail.ts +99 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingSearch.ts +75 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyMapMarkers.ts +100 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyPrimaryImages.ts +51 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useRecordDetailLayout.ts +156 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useRecordListGraphQL.ts +135 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/useWeather.ts +173 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx +263 -76
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +158 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Dashboard.tsx +137 -65
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/DetailPage.tsx +109 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/GlobalSearch.tsx +229 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Home.tsx +469 -21
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Maintenance.tsx +244 -95
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx +211 -39
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx +26 -10
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx +165 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearchPlaceholder.tsx +49 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-01.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-02.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-03.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-04.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-05.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-06.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-07.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-08.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-09.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-10.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-11.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-12.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-13.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-14.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-15.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-16.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-17.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-18.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-19.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-20.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-21.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-22.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-23.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-24.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/public/property-25.jpg +0 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +32 -6
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/styles/global.css +23 -63
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/filters/filters.ts +120 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/filters/picklist.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/index.ts +4 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/leaflet.d.ts +17 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/objectInfo/objectInfo.ts +166 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/recordDetail/recordDetail.ts +61 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/types/search/searchResults.ts +229 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/apiUtils.ts +125 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/cacheUtils.ts +76 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/debounce.ts +89 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/fieldUtils.ts +354 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/fieldValueExtractor.ts +67 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/filterUtils.ts +32 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/formDataTransformUtils.ts +260 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/formUtils.ts +142 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/geocode.ts +65 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLNodeFieldUtils.ts +186 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLObjectInfoAdapter.ts +319 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/graphQLRecordAdapter.ts +90 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/index.ts +59 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/layoutTransformUtils.ts +236 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/linkUtils.ts +14 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/paginationUtils.ts +49 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/recordUtils.ts +159 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/sanitizationUtils.ts +49 -0
- package/dist/package.json +1 -1
- package/package.json +2 -2
- package/dist/force-app/main/default/classes/MaintenanceRequestListAction.cls +0 -111
- package/dist/force-app/main/default/classes/MaintenanceRequestListAction.cls-meta.xml +0 -6
- package/dist/force-app/main/default/classes/MaintenanceRequestUpdatePriorityAction.cls +0 -93
- 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
|
+
}
|