@salesforce/webapp-template-app-react-sample-b2x-experimental 1.117.0 → 1.117.1

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 (26) hide show
  1. package/dist/CHANGELOG.md +8 -0
  2. package/dist/force-app/main/default/webapplications/propertyrentalapp/package.json +3 -3
  3. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +15 -6
  4. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/FilterContext.tsx +1 -32
  5. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/BooleanFilter.tsx +9 -5
  6. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/DateFilter.tsx +15 -8
  7. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/DateRangeFilter.tsx +8 -7
  8. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/FilterFieldWrapper.tsx +33 -0
  9. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/MultiSelectFilter.tsx +4 -5
  10. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +113 -82
  11. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/SearchFilter.tsx +24 -11
  12. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/SelectFilter.tsx +9 -5
  13. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/components/filters/TextFilter.tsx +29 -12
  14. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/hooks/useDebouncedCallback.ts +34 -0
  15. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/hooks/useObjectSearchParams.ts +5 -86
  16. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/utils/debounce.ts +4 -1
  17. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/features/object-search/utils/filterUtils.ts +24 -1
  18. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Application.tsx +10 -2
  19. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Contact.tsx +5 -1
  20. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Home.tsx +1 -1
  21. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/Maintenance.tsx +1 -1
  22. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/PropertyDetails.tsx +19 -2
  23. package/dist/force-app/main/default/webapplications/propertyrentalapp/src/pages/PropertySearch.tsx +1 -3
  24. package/dist/package-lock.json +2 -2
  25. package/dist/package.json +1 -1
  26. package/package.json +1 -1
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [1.117.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.117.0...v1.117.1) (2026-03-28)
7
+
8
+ **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
9
+
10
+
11
+
12
+
13
+
6
14
  # [1.117.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v1.116.13...v1.117.0) (2026-03-27)
7
15
 
8
16
  **Note:** Version bump only for package @salesforce/webapp-template-base-sfdx-project-experimental
@@ -15,8 +15,8 @@
15
15
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@salesforce/sdk-data": "^1.117.0",
19
- "@salesforce/webapp-experimental": "^1.117.0",
18
+ "@salesforce/sdk-data": "^1.117.1",
19
+ "@salesforce/webapp-experimental": "^1.117.1",
20
20
  "@tailwindcss/vite": "^4.1.17",
21
21
  "class-variance-authority": "^0.7.1",
22
22
  "clsx": "^2.1.1",
@@ -47,7 +47,7 @@
47
47
  "@graphql-eslint/eslint-plugin": "^4.1.0",
48
48
  "@graphql-tools/utils": "^11.0.0",
49
49
  "@playwright/test": "^1.49.0",
50
- "@salesforce/vite-plugin-webapp-experimental": "^1.117.0",
50
+ "@salesforce/vite-plugin-webapp-experimental": "^1.117.1",
51
51
  "@testing-library/jest-dom": "^6.6.3",
52
52
  "@testing-library/react": "^16.1.0",
53
53
  "@testing-library/user-event": "^14.5.2",
@@ -62,8 +62,8 @@ const FILTER_CONFIGS: FilterFieldConfig[] = [
62
62
  { field: "Industry", label: "Industry", type: "picklist" },
63
63
  { field: "Type", label: "Type", type: "multipicklist" },
64
64
  { field: "AnnualRevenue", label: "Annual Revenue", type: "numeric" },
65
- { field: "CreatedDate", label: "Created Date", type: "date" },
66
- { field: "LastModifiedDate", label: "Last Modified Date", type: "daterange" },
65
+ { field: "CreatedDate", label: "Created Date", type: "datetime" },
66
+ { field: "LastModifiedDate", label: "Last Modified Date", type: "datetimerange" },
67
67
  ];
68
68
 
69
69
  const ACCOUNT_SORT_CONFIGS: SortFieldConfig<keyof Account_OrderBy>[] = [
@@ -152,7 +152,7 @@ export default function AccountSearch() {
152
152
  </div>
153
153
  </CardHeader>
154
154
  <CollapsibleContent>
155
- <CardContent className="space-y-4 pt-0">
155
+ <CardContent className="space-y-1 pt-0">
156
156
  <SearchFilter
157
157
  field="search"
158
158
  label="Search"
@@ -165,9 +165,18 @@ export default function AccountSearch() {
165
165
  options={industryOptions ?? []}
166
166
  />
167
167
  <MultiSelectFilter field="Type" label="Type" options={typeOptions ?? []} />
168
- <NumericRangeFilter field="AnnualRevenue" label="Annual Revenue" />
169
- <DateFilter field="CreatedDate" label="Created Date" />
170
- <DateRangeFilter field="LastModifiedDate" label="Last Modified Date" />
168
+ <NumericRangeFilter
169
+ field="AnnualRevenue"
170
+ label="Annual Revenue"
171
+ min={0}
172
+ max={1_000_000_000_000}
173
+ />
174
+ <DateFilter field="CreatedDate" label="Created Date" filterType="datetime" />
175
+ <DateRangeFilter
176
+ field="LastModifiedDate"
177
+ label="Last Modified Date"
178
+ filterType="datetimerange"
179
+ />
171
180
  </CardContent>
172
181
  </CollapsibleContent>
173
182
  </Collapsible>
@@ -7,9 +7,6 @@ interface FilterContextValue {
7
7
  onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
8
8
  onFilterRemove: (field: string) => void;
9
9
  onReset: () => void;
10
- onApply: () => void;
11
- hasPendingChanges: boolean;
12
- hasValidationError: boolean;
13
10
  }
14
11
 
15
12
  const FilterContext = createContext<FilterContextValue | null>(null);
@@ -19,9 +16,6 @@ interface FilterProviderProps {
19
16
  onFilterChange: (field: string, value: ActiveFilterValue | undefined) => void;
20
17
  onFilterRemove: (field: string) => void;
21
18
  onReset: () => void;
22
- onApply?: () => void;
23
- hasPendingChanges?: boolean;
24
- hasValidationError?: boolean;
25
19
  children: ReactNode;
26
20
  }
27
21
 
@@ -30,9 +24,6 @@ export function FilterProvider({
30
24
  onFilterChange,
31
25
  onFilterRemove,
32
26
  onReset,
33
- onApply,
34
- hasPendingChanges = false,
35
- hasValidationError = false,
36
27
  children,
37
28
  }: FilterProviderProps) {
38
29
  return (
@@ -42,9 +33,6 @@ export function FilterProvider({
42
33
  onFilterChange,
43
34
  onFilterRemove,
44
35
  onReset,
45
- onApply: onApply ?? (() => {}),
46
- hasPendingChanges,
47
- hasValidationError,
48
36
  }}
49
37
  >
50
38
  {children}
@@ -75,13 +63,10 @@ export function useFilterField(field: string) {
75
63
  }
76
64
 
77
65
  export function useFilterPanel() {
78
- const { filters, onReset, onApply, hasPendingChanges, hasValidationError } = useFilterContext();
66
+ const { filters, onReset } = useFilterContext();
79
67
  return {
80
68
  hasActiveFilters: filters.length > 0,
81
- hasPendingChanges,
82
- hasValidationError,
83
69
  resetAll: onReset,
84
- apply: onApply,
85
70
  };
86
71
  }
87
72
 
@@ -96,19 +81,3 @@ export function FilterResetButton({ children, ...props }: FilterResetButtonProps
96
81
  </Button>
97
82
  );
98
83
  }
99
-
100
- type FilterApplyButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "disabled">;
101
-
102
- export function FilterApplyButton({ children, ...props }: FilterApplyButtonProps) {
103
- const { apply, hasPendingChanges, hasValidationError } = useFilterPanel();
104
- return (
105
- <Button
106
- onClick={apply}
107
- disabled={!hasPendingChanges || hasValidationError}
108
- aria-label="Apply filters"
109
- {...props}
110
- >
111
- {children ?? "Apply"}
112
- </Button>
113
- );
114
- }
@@ -5,9 +5,9 @@ import {
5
5
  SelectTrigger,
6
6
  SelectValue,
7
7
  } from "../../../../components/ui/select";
8
- import { Label } from "../../../../components/ui/label";
9
8
  import { cn } from "../../../../lib/utils";
10
9
  import { useFilterField } from "../FilterContext";
10
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
11
11
  import type { ActiveFilterValue } from "../../utils/filterUtils";
12
12
 
13
13
  const ALL_VALUE = "__all__";
@@ -21,11 +21,15 @@ interface BooleanFilterProps extends Omit<React.ComponentProps<"div">, "onChange
21
21
  export function BooleanFilter({ field, label, helpText, className, ...props }: BooleanFilterProps) {
22
22
  const { value, onChange } = useFilterField(field);
23
23
  return (
24
- <div className={cn("space-y-1.5", className)} {...props}>
25
- <Label htmlFor={`filter-${field}`}>{label}</Label>
24
+ <FilterFieldWrapper
25
+ label={label}
26
+ htmlFor={`filter-${field}`}
27
+ helpText={helpText}
28
+ className={className}
29
+ {...props}
30
+ >
26
31
  <BooleanFilterSelect field={field} label={label} value={value} onChange={onChange} />
27
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
28
- </div>
32
+ </FilterFieldWrapper>
29
33
  );
30
34
  }
31
35
 
@@ -1,14 +1,15 @@
1
1
  import { useState } from "react";
2
2
  import { parseISO } from "date-fns";
3
- import { Label } from "../../../../components/ui/label";
4
3
  import {
5
4
  DatePicker,
6
5
  DatePickerTrigger,
7
6
  DatePickerContent,
8
7
  DatePickerCalendar,
9
8
  } from "../../../../components/ui/datePicker";
10
- import { cn } from "../../../../lib/utils";
9
+
11
10
  import { useFilterField } from "../FilterContext";
11
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
12
+ import type { FilterFieldType } from "../../utils/filterUtils";
12
13
  import {
13
14
  Select,
14
15
  SelectContent,
@@ -32,9 +33,17 @@ interface DateFilterProps extends Omit<React.ComponentProps<"div">, "onChange">
32
33
  field: string;
33
34
  label: string;
34
35
  helpText?: string;
36
+ filterType?: FilterFieldType;
35
37
  }
36
38
 
37
- export function DateFilter({ field, label, helpText, className, ...props }: DateFilterProps) {
39
+ export function DateFilter({
40
+ field,
41
+ label,
42
+ helpText,
43
+ filterType = "date",
44
+ className,
45
+ ...props
46
+ }: DateFilterProps) {
38
47
  const { value, onChange } = useFilterField(field);
39
48
 
40
49
  const initialOp: DateOperator = value?.min ? "gt" : "lt";
@@ -63,7 +72,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
63
72
  onChange({
64
73
  field,
65
74
  label,
66
- type: "date",
75
+ type: filterType,
67
76
  value: op,
68
77
  min: f === "min" ? dateStr : undefined,
69
78
  max: f === "max" ? dateStr : undefined,
@@ -71,8 +80,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
71
80
  }
72
81
 
73
82
  return (
74
- <div className={cn("space-y-1.5", className)} {...props}>
75
- <Label>{label}</Label>
83
+ <FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
76
84
  <div className="flex gap-2">
77
85
  <Select value={operator} onValueChange={(v) => handleOperatorChange(v as DateOperator)}>
78
86
  <SelectTrigger className="w-full flex-1">
@@ -104,8 +112,7 @@ export function DateFilter({ field, label, helpText, className, ...props }: Date
104
112
  </DatePickerContent>
105
113
  </DatePicker>
106
114
  </div>
107
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
108
- </div>
115
+ </FilterFieldWrapper>
109
116
  );
110
117
  }
111
118
 
@@ -1,25 +1,28 @@
1
1
  import type { DateRange } from "react-day-picker";
2
- import { Label } from "../../../../components/ui/label";
3
2
  import {
4
3
  DatePicker,
5
4
  DatePickerRangeTrigger,
6
5
  DatePickerContent,
7
6
  DatePickerCalendar,
8
7
  } from "../../../../components/ui/datePicker";
9
- import { cn } from "../../../../lib/utils";
8
+
10
9
  import { useFilterField } from "../FilterContext";
10
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
11
+ import type { FilterFieldType } from "../../utils/filterUtils";
11
12
  import { toDate, toDateString } from "./DateFilter";
12
13
 
13
14
  interface DateRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
14
15
  field: string;
15
16
  label: string;
16
17
  helpText?: string;
18
+ filterType?: FilterFieldType;
17
19
  }
18
20
 
19
21
  export function DateRangeFilter({
20
22
  field,
21
23
  label,
22
24
  helpText,
25
+ filterType = "daterange",
23
26
  className,
24
27
  ...props
25
28
  }: DateRangeFilterProps) {
@@ -35,7 +38,7 @@ export function DateRangeFilter({
35
38
  onChange({
36
39
  field,
37
40
  label,
38
- type: "daterange",
41
+ type: filterType,
39
42
  min: toDateString(range?.from),
40
43
  max: toDateString(range?.to),
41
44
  });
@@ -43,8 +46,7 @@ export function DateRangeFilter({
43
46
  }
44
47
 
45
48
  return (
46
- <div className={cn("space-y-1.5", className)} {...props}>
47
- <Label>{label}</Label>
49
+ <FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
48
50
  <DatePicker>
49
51
  <DatePickerRangeTrigger
50
52
  className="w-full"
@@ -63,7 +65,6 @@ export function DateRangeFilter({
63
65
  />
64
66
  </DatePickerContent>
65
67
  </DatePicker>
66
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
67
- </div>
68
+ </FilterFieldWrapper>
68
69
  );
69
70
  }
@@ -0,0 +1,33 @@
1
+ import { Label } from "../../../../components/ui/label";
2
+ import { cn } from "../../../../lib/utils";
3
+
4
+ interface FilterFieldWrapperProps extends React.ComponentProps<"div"> {
5
+ label: string;
6
+ htmlFor?: string;
7
+ helpText?: string;
8
+ error?: string;
9
+ }
10
+
11
+ export function FilterFieldWrapper({
12
+ label,
13
+ htmlFor,
14
+ helpText,
15
+ error,
16
+ className,
17
+ children,
18
+ ...props
19
+ }: FilterFieldWrapperProps) {
20
+ return (
21
+ <div className={cn("space-y-1", className)} {...props}>
22
+ <Label htmlFor={htmlFor}>{label}</Label>
23
+ {children}
24
+ <div className="min-h-4">
25
+ {error ? (
26
+ <p className="text-xs text-destructive">{error}</p>
27
+ ) : (
28
+ helpText && <p className="text-xs text-muted-foreground">{helpText}</p>
29
+ )}
30
+ </div>
31
+ </div>
32
+ );
33
+ }
@@ -4,11 +4,12 @@ import {
4
4
  PopoverTrigger,
5
5
  } from "../../../../components/ui/popover";
6
6
  import { Checkbox } from "../../../../components/ui/checkbox";
7
- import { Label } from "../../../../components/ui/label";
8
7
  import { Button } from "../../../../components/ui/button";
9
8
  import { cn } from "../../../../lib/utils";
9
+ import { Label } from "../../../../components/ui/label";
10
10
  import { ChevronDown } from "lucide-react";
11
11
  import { useFilterField } from "../FilterContext";
12
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
12
13
 
13
14
  interface MultiSelectFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
14
15
  field: string;
@@ -53,8 +54,7 @@ export function MultiSelectFilter({
53
54
  }
54
55
 
55
56
  return (
56
- <div className={cn("space-y-1.5", className)} {...props}>
57
- <Label>{label}</Label>
57
+ <FilterFieldWrapper label={label} helpText={helpText} className={className} {...props}>
58
58
  <Popover>
59
59
  <PopoverTrigger asChild>
60
60
  <Button
@@ -92,7 +92,6 @@ export function MultiSelectFilter({
92
92
  </div>
93
93
  </PopoverContent>
94
94
  </Popover>
95
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
96
- </div>
95
+ </FilterFieldWrapper>
97
96
  );
98
97
  }
@@ -1,49 +1,52 @@
1
+ import { useEffect, useState } from "react";
1
2
  import { Input } from "../../../../components/ui/input";
2
- import { Label } from "../../../../components/ui/label";
3
- import { toast } from "sonner";
4
- import { cn } from "../../../../lib/utils";
3
+
5
4
  import { useFilterField } from "../FilterContext";
5
+ import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
6
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
6
7
  import type { ActiveFilterValue } from "../../utils/filterUtils";
7
8
 
8
9
  interface NumericRangeFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
9
10
  field: string;
10
11
  label: string;
11
12
  helpText?: string;
12
- minInputProps?: React.ComponentProps<typeof Input>;
13
- maxInputProps?: React.ComponentProps<typeof Input>;
13
+ min?: number;
14
+ max?: number;
14
15
  }
15
16
 
16
17
  export function NumericRangeFilter({
17
18
  field,
18
19
  label,
19
20
  helpText,
21
+ min,
22
+ max,
20
23
  className,
21
- minInputProps,
22
- maxInputProps,
23
24
  ...props
24
25
  }: NumericRangeFilterProps) {
25
26
  const { value, onChange } = useFilterField(field);
26
27
  return (
27
- <div className={cn("space-y-1.5", className)} {...props}>
28
- <Label>{label}</Label>
29
- <NumericRangeFilterInputs
30
- field={field}
31
- label={label}
32
- value={value}
33
- onChange={onChange}
34
- minInputProps={minInputProps}
35
- maxInputProps={maxInputProps}
36
- />
37
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
38
- </div>
28
+ <NumericRangeFilterInputs
29
+ field={field}
30
+ label={label}
31
+ helpText={helpText}
32
+ value={value}
33
+ onChange={onChange}
34
+ min={min}
35
+ max={max}
36
+ className={className}
37
+ {...props}
38
+ />
39
39
  );
40
40
  }
41
41
 
42
42
  interface NumericRangeFilterInputsProps extends Omit<React.ComponentProps<"div">, "onChange"> {
43
43
  field: string;
44
44
  label: string;
45
+ helpText?: string;
45
46
  value: ActiveFilterValue | undefined;
46
47
  onChange: (value: ActiveFilterValue | undefined) => void;
48
+ min?: number;
49
+ max?: number;
47
50
  minInputProps?: React.ComponentProps<typeof Input>;
48
51
  maxInputProps?: React.ComponentProps<typeof Input>;
49
52
  }
@@ -51,82 +54,110 @@ interface NumericRangeFilterInputsProps extends Omit<React.ComponentProps<"div">
51
54
  export function NumericRangeFilterInputs({
52
55
  field,
53
56
  label,
57
+ helpText,
54
58
  value,
55
59
  onChange,
60
+ min: boundMin,
61
+ max: boundMax,
56
62
  className,
57
- minInputProps,
58
- maxInputProps,
59
63
  ...props
60
64
  }: NumericRangeFilterInputsProps) {
61
- const validateNumericRangeFilter = (filter: ActiveFilterValue) => {
62
- if (filter.type !== "numeric") return null;
63
-
64
- const min = filter.min?.trim();
65
- const max = filter.max?.trim();
66
- const filterLabel = filter.label || filter.field;
65
+ const [localMin, setLocalMin] = useState(value?.min ?? "");
66
+ const [localMax, setLocalMax] = useState(value?.max ?? "");
67
67
 
68
- if (!min || !max) return null;
68
+ const externalMin = value?.min ?? "";
69
+ const externalMax = value?.max ?? "";
70
+ useEffect(() => {
71
+ setLocalMin(externalMin);
72
+ }, [externalMin]);
73
+ useEffect(() => {
74
+ setLocalMax(externalMax);
75
+ }, [externalMax]);
69
76
 
70
- const minValue = Number(min);
71
- const maxValue = Number(max);
72
- if (!Number.isNaN(minValue) && !Number.isNaN(maxValue) && minValue >= maxValue) {
73
- return `${filterLabel}: minimum value must be less than maximum value.`;
74
- }
75
-
76
- return null;
77
+ const isOutOfBounds = (v: string) => {
78
+ if (v === "") return false;
79
+ const n = Number(v);
80
+ return (boundMin != null && n < boundMin) || (boundMax != null && n > boundMax);
77
81
  };
82
+ const minOutOfBounds = isOutOfBounds(localMin);
83
+ const maxOutOfBounds = isOutOfBounds(localMax);
84
+ const isRangeInverted = localMin !== "" && localMax !== "" && Number(localMin) > Number(localMax);
85
+ const hasError = minOutOfBounds || maxOutOfBounds || isRangeInverted;
78
86
 
79
- const handleChange = (bound: "min" | "max", v: string) => {
80
- const next = {
81
- field,
82
- label,
83
- type: "numeric" as const,
84
- min: value?.min ?? "",
85
- max: value?.max ?? "",
86
- [bound]: v,
87
- };
88
-
89
- if (!next.min && !next.max) {
87
+ const debouncedOnChange = useDebouncedCallback((min: string, max: string) => {
88
+ if (!min && !max) {
90
89
  onChange(undefined);
91
- } else {
92
- onChange(next);
90
+ return;
93
91
  }
94
- };
92
+ const minNum = min !== "" ? Number(min) : null;
93
+ const maxNum = max !== "" ? Number(max) : null;
94
+ if (minNum != null && maxNum != null && minNum > maxNum) return;
95
+ if (
96
+ minNum != null &&
97
+ ((boundMin != null && minNum < boundMin) || (boundMax != null && minNum > boundMax))
98
+ )
99
+ return;
100
+ if (
101
+ maxNum != null &&
102
+ ((boundMin != null && maxNum < boundMin) || (boundMax != null && maxNum > boundMax))
103
+ )
104
+ return;
105
+ onChange({ field, label, type: "numeric" as const, min, max });
106
+ });
95
107
 
96
- const handleBlur = (bound: "min" | "max", currentValue: string) => {
97
- const next = {
98
- field,
99
- label,
100
- type: "numeric" as const,
101
- min: bound === "min" ? currentValue : (value?.min ?? ""),
102
- max: bound === "max" ? currentValue : (value?.max ?? ""),
103
- };
104
- const validationError = validateNumericRangeFilter(next);
105
- if (validationError) {
106
- toast.error("Invalid range filter", { description: validationError });
107
- }
108
- };
108
+ const boundsLabel =
109
+ boundMin != null && boundMax != null
110
+ ? `${boundMin}–${boundMax}`
111
+ : boundMin != null
112
+ ? `${boundMin} or more`
113
+ : boundMax != null
114
+ ? `${boundMax} or less`
115
+ : null;
116
+
117
+ const errorMessage = isRangeInverted
118
+ ? "Min must not exceed max"
119
+ : (minOutOfBounds || maxOutOfBounds) && boundsLabel
120
+ ? `Value must be between ${boundsLabel}`
121
+ : undefined;
109
122
 
110
123
  return (
111
- <div className={cn("flex gap-2", className)} {...props}>
112
- <Input
113
- type="number"
114
- placeholder="Min"
115
- value={value?.min ?? ""}
116
- onChange={(e) => handleChange("min", e.target.value)}
117
- onBlur={(e) => handleBlur("min", e.target.value)}
118
- aria-label={`${label} minimum`}
119
- {...minInputProps}
120
- />
121
- <Input
122
- type="number"
123
- placeholder="Max"
124
- value={value?.max ?? ""}
125
- onChange={(e) => handleChange("max", e.target.value)}
126
- onBlur={(e) => handleBlur("max", e.target.value)}
127
- aria-label={`${label} maximum`}
128
- {...maxInputProps}
129
- />
130
- </div>
124
+ <FilterFieldWrapper
125
+ label={label}
126
+ helpText={helpText}
127
+ error={errorMessage}
128
+ className={className}
129
+ {...props}
130
+ >
131
+ <div className="flex gap-2">
132
+ <Input
133
+ type="number"
134
+ placeholder="Min"
135
+ value={localMin}
136
+ min={boundMin}
137
+ max={boundMax}
138
+ onChange={(e) => {
139
+ const v = e.target.value;
140
+ setLocalMin(v);
141
+ debouncedOnChange(v, localMax);
142
+ }}
143
+ aria-label={`${label} minimum`}
144
+ aria-invalid={hasError || undefined}
145
+ />
146
+ <Input
147
+ type="number"
148
+ placeholder="Max"
149
+ value={localMax}
150
+ min={boundMin}
151
+ max={boundMax}
152
+ onChange={(e) => {
153
+ const v = e.target.value;
154
+ setLocalMax(v);
155
+ debouncedOnChange(localMin, v);
156
+ }}
157
+ aria-label={`${label} maximum`}
158
+ aria-invalid={hasError || undefined}
159
+ />
160
+ </div>
161
+ </FilterFieldWrapper>
131
162
  );
132
163
  }
@@ -1,7 +1,9 @@
1
- import { Label } from "../../../../components/ui/label";
2
- import { cn } from "../../../../lib/utils";
1
+ import { useEffect, useState } from "react";
2
+
3
3
  import { SearchBar } from "../SearchBar";
4
4
  import { useFilterField } from "../FilterContext";
5
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
6
+ import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
5
7
 
6
8
  interface SearchFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
7
9
  field: string;
@@ -17,21 +19,32 @@ export function SearchFilter({
17
19
  ...props
18
20
  }: SearchFilterProps) {
19
21
  const { value, onChange } = useFilterField(field);
22
+ const [localValue, setLocalValue] = useState(value?.value ?? "");
23
+
24
+ const externalValue = value?.value ?? "";
25
+ useEffect(() => {
26
+ setLocalValue(externalValue);
27
+ }, [externalValue]);
28
+
29
+ const debouncedOnChange = useDebouncedCallback((v: string) => {
30
+ if (v) {
31
+ onChange({ field, label, type: "search", value: v });
32
+ } else {
33
+ onChange(undefined);
34
+ }
35
+ });
36
+
20
37
  return (
21
- <div className={cn("space-y-1.5", className)} {...props}>
22
- <Label htmlFor={`filter-${field}`}>{label}</Label>
38
+ <FilterFieldWrapper label={label} htmlFor={`filter-${field}`} className={className} {...props}>
23
39
  <SearchBar
24
- value={value?.value ?? ""}
40
+ value={localValue}
25
41
  handleChange={(v) => {
26
- if (v) {
27
- onChange({ field, label, type: "search", value: v });
28
- } else {
29
- onChange(undefined);
30
- }
42
+ setLocalValue(v);
43
+ debouncedOnChange(v);
31
44
  }}
32
45
  placeholder={placeholder}
33
46
  inputProps={{ id: `filter-${field}` }}
34
47
  />
35
- </div>
48
+ </FilterFieldWrapper>
36
49
  );
37
50
  }
@@ -5,9 +5,9 @@ import {
5
5
  SelectTrigger,
6
6
  SelectValue,
7
7
  } from "../../../../components/ui/select";
8
- import { Label } from "../../../../components/ui/label";
9
8
  import { cn } from "../../../../lib/utils";
10
9
  import { useFilterField } from "../FilterContext";
10
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
11
11
  import type { ActiveFilterValue } from "../../utils/filterUtils";
12
12
 
13
13
  const ALL_VALUE = "__all__";
@@ -29,8 +29,13 @@ export function SelectFilter({
29
29
  }: SelectFilterProps) {
30
30
  const { value, onChange } = useFilterField(field);
31
31
  return (
32
- <div className={cn("space-y-1.5", className)} {...props}>
33
- <Label htmlFor={`filter-${field}`}>{label}</Label>
32
+ <FilterFieldWrapper
33
+ label={label}
34
+ htmlFor={`filter-${field}`}
35
+ helpText={helpText}
36
+ className={className}
37
+ {...props}
38
+ >
34
39
  <SelectFilterControl
35
40
  field={field}
36
41
  label={label}
@@ -38,8 +43,7 @@ export function SelectFilter({
38
43
  value={value}
39
44
  onChange={onChange}
40
45
  />
41
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
42
- </div>
46
+ </FilterFieldWrapper>
43
47
  );
44
48
  }
45
49
 
@@ -1,7 +1,9 @@
1
+ import { useEffect, useState } from "react";
1
2
  import { Input } from "../../../../components/ui/input";
2
- import { Label } from "../../../../components/ui/label";
3
3
  import { cn } from "../../../../lib/utils";
4
4
  import { useFilterField } from "../FilterContext";
5
+ import { FilterFieldWrapper } from "./FilterFieldWrapper";
6
+ import { useDebouncedCallback } from "../../hooks/useDebouncedCallback";
5
7
  import type { ActiveFilterValue } from "../../utils/filterUtils";
6
8
 
7
9
  interface TextFilterProps extends Omit<React.ComponentProps<"div">, "onChange"> {
@@ -21,8 +23,13 @@ export function TextFilter({
21
23
  }: TextFilterProps) {
22
24
  const { value, onChange } = useFilterField(field);
23
25
  return (
24
- <div className={cn("space-y-1.5", className)} {...props}>
25
- <Label htmlFor={`filter-${field}`}>{label}</Label>
26
+ <FilterFieldWrapper
27
+ label={label}
28
+ htmlFor={`filter-${field}`}
29
+ helpText={helpText}
30
+ className={className}
31
+ {...props}
32
+ >
26
33
  <TextFilterInput
27
34
  field={field}
28
35
  label={label}
@@ -30,8 +37,7 @@ export function TextFilter({
30
37
  value={value}
31
38
  onChange={onChange}
32
39
  />
33
- {helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
34
- </div>
40
+ </FilterFieldWrapper>
35
41
  );
36
42
  }
37
43
 
@@ -53,19 +59,30 @@ export function TextFilterInput({
53
59
  className,
54
60
  ...props
55
61
  }: TextFilterInputProps) {
62
+ const [localValue, setLocalValue] = useState(value?.value ?? "");
63
+
64
+ const externalValue = value?.value ?? "";
65
+ useEffect(() => {
66
+ setLocalValue(externalValue);
67
+ }, [externalValue]);
68
+
69
+ const debouncedOnChange = useDebouncedCallback((v: string) => {
70
+ if (v) {
71
+ onChange({ field, label, type: "text", value: v });
72
+ } else {
73
+ onChange(undefined);
74
+ }
75
+ });
76
+
56
77
  return (
57
78
  <Input
58
79
  id={`filter-${field}`}
59
80
  type="text"
60
81
  placeholder={props.placeholder ?? `Filter by ${label.toLowerCase()}...`}
61
- value={value?.value ?? ""}
82
+ value={localValue}
62
83
  onChange={(e) => {
63
- const v = e.target.value;
64
- if (v) {
65
- onChange({ field, label, type: "text", value: v });
66
- } else {
67
- onChange(undefined);
68
- }
84
+ setLocalValue(e.target.value);
85
+ debouncedOnChange(e.target.value);
69
86
  }}
70
87
  className={cn(className)}
71
88
  {...props}
@@ -0,0 +1,34 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import { debounce, FILTER_DEBOUNCE_MS } from "../utils/debounce";
3
+
4
+ /**
5
+ * Returns a stable debounced wrapper around the provided callback.
6
+ *
7
+ * The wrapper always invokes the *latest* version of `fn` (via a ref),
8
+ * so the debounce timer is never reset when `fn` changes — only when
9
+ * the caller invokes the returned function again.
10
+ *
11
+ * @param fn - The callback to debounce.
12
+ * @param delay - Debounce delay in ms. Defaults to `FILTER_DEBOUNCE_MS`.
13
+ */
14
+ export function useDebouncedCallback<T extends (...args: any[]) => void>(
15
+ fn: T,
16
+ delay: number = FILTER_DEBOUNCE_MS,
17
+ ): (...args: Parameters<T>) => void {
18
+ const fnRef = useRef(fn);
19
+ const debouncedRef = useRef<((...args: any[]) => void) | null>(null);
20
+
21
+ useEffect(() => {
22
+ fnRef.current = fn;
23
+ });
24
+
25
+ useEffect(() => {
26
+ debouncedRef.current = debounce((...args: any[]) => {
27
+ fnRef.current(...(args as Parameters<T>));
28
+ }, delay);
29
+ }, [delay]);
30
+
31
+ return useCallback((...args: Parameters<T>) => {
32
+ debouncedRef.current?.(...args);
33
+ }, []);
34
+ }
@@ -20,11 +20,6 @@ export interface UseObjectSearchParamsReturn<TFilter, TOrderBy> {
20
20
  set: (field: string, value: ActiveFilterValue | undefined) => void;
21
21
  remove: (field: string) => void;
22
22
  };
23
- filterState: {
24
- apply: () => void;
25
- hasPendingChanges: boolean;
26
- hasValidationError: boolean;
27
- };
28
23
  sort: {
29
24
  current: SortState | null;
30
25
  set: (sort: SortState | null) => void;
@@ -41,40 +36,6 @@ export interface UseObjectSearchParamsReturn<TFilter, TOrderBy> {
41
36
  resetAll: () => void;
42
37
  }
43
38
 
44
- export interface UseObjectSearchParamsOptions {
45
- filterSyncMode?: "immediate" | "manual";
46
- }
47
-
48
- function areFiltersEqual(left: ActiveFilterValue[], right: ActiveFilterValue[]) {
49
- if (left.length !== right.length) return false;
50
- const normalize = (filters: ActiveFilterValue[]) =>
51
- [...filters]
52
- .sort((a, b) => a.field.localeCompare(b.field))
53
- .map((filter) => ({
54
- field: filter.field,
55
- type: filter.type,
56
- value: filter.value ?? "",
57
- min: filter.min ?? "",
58
- max: filter.max ?? "",
59
- }));
60
- return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
61
- }
62
-
63
- function hasFilterValidationError(filters: ActiveFilterValue[]) {
64
- for (const filter of filters) {
65
- if (filter.type !== "numeric") continue;
66
- const min = filter.min?.trim();
67
- const max = filter.max?.trim();
68
- if (!min || !max) continue;
69
- const minValue = Number(min);
70
- const maxValue = Number(max);
71
- if (!Number.isNaN(minValue) && !Number.isNaN(maxValue) && minValue >= maxValue) {
72
- return true;
73
- }
74
- }
75
- return false;
76
- }
77
-
78
39
  /**
79
40
  * Manages filter, sort, and cursor-based pagination state for an object search page.
80
41
  *
@@ -98,11 +59,7 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
98
59
  filterConfigs: FilterFieldConfig[],
99
60
  _sortConfigs?: SortFieldConfig[],
100
61
  paginationConfig?: PaginationConfig,
101
- options?: UseObjectSearchParamsOptions,
102
62
  ) {
103
- const filterSyncMode = options?.filterSyncMode ?? "immediate";
104
- const isManualFilterSync = filterSyncMode === "manual";
105
-
106
63
  const defaultPageSize = paginationConfig?.defaultPageSize ?? 10;
107
64
  const validPageSizes = useMemo(
108
65
  () => paginationConfig?.validPageSizes ?? [defaultPageSize],
@@ -119,7 +76,6 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
119
76
  );
120
77
 
121
78
  const [filters, setFilters] = useState<ActiveFilterValue[]>(initial.filters);
122
- const [appliedFilters, setAppliedFilters] = useState<ActiveFilterValue[]>(initial.filters);
123
79
  const [sort, setLocalSort] = useState<SortState | null>(initial.sort);
124
80
 
125
81
  // Pagination — cursor-based with a stack to support "previous page" navigation.
@@ -170,15 +126,6 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
170
126
 
171
127
  const setFilter = useCallback(
172
128
  (field: string, value: ActiveFilterValue | undefined) => {
173
- if (isManualFilterSync) {
174
- setFilters((prev) => {
175
- const next = prev.filter((f) => f.field !== field);
176
- if (value) next.push(value);
177
- return next;
178
- });
179
- return;
180
- }
181
-
182
129
  const { sort: s, pageSize: ps } = stateRef.current;
183
130
  setFilters((prev) => {
184
131
  const next = prev.filter((f) => f.field !== field);
@@ -188,16 +135,11 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
188
135
  });
189
136
  resetPagination();
190
137
  },
191
- [isManualFilterSync, resetPagination],
138
+ [resetPagination],
192
139
  );
193
140
 
194
141
  const removeFilter = useCallback(
195
142
  (field: string) => {
196
- if (isManualFilterSync) {
197
- setFilters((prev) => prev.filter((f) => f.field !== field));
198
- return;
199
- }
200
-
201
143
  const { sort: s, pageSize: ps } = stateRef.current;
202
144
  setFilters((prev) => {
203
145
  const next = prev.filter((f) => f.field !== field);
@@ -206,17 +148,9 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
206
148
  });
207
149
  resetPagination();
208
150
  },
209
- [isManualFilterSync, resetPagination],
151
+ [resetPagination],
210
152
  );
211
153
 
212
- const applyFilters = useCallback(() => {
213
- if (!isManualFilterSync) return;
214
- const { filters: nextFilters, sort: s, pageSize: ps } = stateRef.current;
215
- setAppliedFilters(nextFilters);
216
- resetPagination();
217
- syncToUrl(nextFilters, s, ps);
218
- }, [isManualFilterSync, resetPagination, syncToUrl]);
219
-
220
154
  // -- Sort callback ----------------------------------------------------------
221
155
 
222
156
  const setSort = useCallback(
@@ -233,7 +167,6 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
233
167
 
234
168
  const resetAll = useCallback(() => {
235
169
  setFilters([]);
236
- setAppliedFilters([]);
237
170
  setLocalSort(null);
238
171
  resetPagination();
239
172
  syncToUrl([], null, defaultPageSize, 0);
@@ -283,8 +216,8 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
283
216
  // Translate local filter/sort state into API-ready `where` and `orderBy`.
284
217
 
285
218
  const where = useMemo(
286
- () => buildFilter<TFilter>(isManualFilterSync ? appliedFilters : filters, filterConfigs),
287
- [appliedFilters, filters, filterConfigs, isManualFilterSync],
219
+ () => buildFilter<TFilter>(filters, filterConfigs),
220
+ [filters, filterConfigs],
288
221
  );
289
222
 
290
223
  const orderBy = useMemo(() => buildOrderBy<TOrderBy>(sort), [sort]);
@@ -296,23 +229,10 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
296
229
  // causing unnecessary re-renders.
297
230
 
298
231
  const filtersGroup = useMemo(
299
- () => ({
300
- active: filters,
301
- set: setFilter,
302
- remove: removeFilter,
303
- }),
232
+ () => ({ active: filters, set: setFilter, remove: removeFilter }),
304
233
  [filters, setFilter, removeFilter],
305
234
  );
306
235
 
307
- const filterState = useMemo(
308
- () => ({
309
- apply: applyFilters,
310
- hasPendingChanges: isManualFilterSync ? !areFiltersEqual(filters, appliedFilters) : false,
311
- hasValidationError: hasFilterValidationError(filters),
312
- }),
313
- [applyFilters, isManualFilterSync, filters, appliedFilters],
314
- );
315
-
316
236
  const sortGroup = useMemo(() => ({ current: sort, set: setSort }), [sort, setSort]);
317
237
 
318
238
  const query = useMemo(() => ({ where, orderBy }), [where, orderBy]);
@@ -324,7 +244,6 @@ export function useObjectSearchParams<TFilter, TOrderBy>(
324
244
 
325
245
  return {
326
246
  filters: filtersGroup,
327
- filterState,
328
247
  sort: sortGroup,
329
248
  query,
330
249
  pagination,
@@ -1,3 +1,6 @@
1
+ /** Default debounce delay for keystroke-driven filter inputs (search, text, numeric). */
2
+ export const FILTER_DEBOUNCE_MS = 300;
3
+
1
4
  /**
2
5
  * Creates a debounced version of the provided function.
3
6
  *
@@ -10,7 +13,7 @@
10
13
  * @param ms - The debounce delay in milliseconds.
11
14
  * @returns A new function with the same signature that delays execution.
12
15
  */
13
- export function debounce<T extends (...args: never[]) => void>(
16
+ export function debounce<T extends (...args: any[]) => void>(
14
17
  fn: T,
15
18
  ms: number,
16
19
  ): (...args: Parameters<T>) => void {
@@ -25,6 +25,8 @@ export type FilterFieldType =
25
25
  | "boolean"
26
26
  | "date"
27
27
  | "daterange"
28
+ | "datetime"
29
+ | "datetimerange"
28
30
  | "multipicklist"
29
31
  | "search";
30
32
 
@@ -337,13 +339,34 @@ function buildSingleFilter<TFilter>(
337
339
  return { [field]: { in: values } } as TFilter;
338
340
  }
339
341
  case "date": {
342
+ if (!min && !max) return null;
343
+ const op = value ?? (min ? "gte" : "lte");
344
+ const dateStr = min ?? max;
345
+ return { [field]: { [op]: { value: dateStr } } } as TFilter;
346
+ }
347
+ case "daterange": {
348
+ if (!min && !max) return null;
349
+ const clauses: TFilter[] = [];
350
+ if (min) {
351
+ clauses.push({
352
+ [field]: { gte: { value: min } },
353
+ } as TFilter);
354
+ }
355
+ if (max) {
356
+ clauses.push({
357
+ [field]: { lte: { value: max } },
358
+ } as TFilter);
359
+ }
360
+ return clauses.length === 1 ? clauses[0] : ({ and: clauses } as TFilter);
361
+ }
362
+ case "datetime": {
340
363
  if (!min && !max) return null;
341
364
  const op = value ?? (min ? "gte" : "lte");
342
365
  const dateStr = min ?? max;
343
366
  const isoStr = op === "gte" || op === "gt" ? toStartOfDay(dateStr!) : toEndOfDay(dateStr!);
344
367
  return { [field]: { [op]: { value: isoStr } } } as TFilter;
345
368
  }
346
- case "daterange": {
369
+ case "datetimerange": {
347
370
  if (!min && !max) return null;
348
371
  const clauses: TFilter[] = [];
349
372
  if (min) {
@@ -170,7 +170,11 @@ export default function Application() {
170
170
  "Select a property from search or listing detail to apply."
171
171
  ))}
172
172
  </p>
173
- {loadError && <p className="mt-2 text-sm text-destructive">{loadError}</p>}
173
+ {loadError && (
174
+ <p className="mt-2 text-sm text-destructive">
175
+ Something went wrong. Please try again later.
176
+ </p>
177
+ )}
174
178
  </div>
175
179
  </Card>
176
180
 
@@ -208,7 +212,11 @@ export default function Application() {
208
212
  onChange={(e) => setReferences(e.target.value)}
209
213
  />
210
214
  </div>
211
- {submitError && <p className="mb-4 text-sm text-destructive">{submitError}</p>}
215
+ {submitError && (
216
+ <p className="mb-4 text-sm text-destructive">
217
+ Something went wrong. Please try again later.
218
+ </p>
219
+ )}
212
220
  <div className="flex gap-2">
213
221
  <Button
214
222
  type="submit"
@@ -147,7 +147,11 @@ function ContactForm({
147
147
  className="min-h-[120px] w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
148
148
  />
149
149
  </div>
150
- {submitError && <p className="text-sm text-destructive">{submitError}</p>}
150
+ {submitError && (
151
+ <p className="text-sm text-destructive">
152
+ Something went wrong. Please try again later.
153
+ </p>
154
+ )}
151
155
  <Button type="submit" disabled={submitting} className="bg-teal-600 hover:bg-teal-700">
152
156
  {submitting ? "Sending…" : "Send message"}
153
157
  </Button>
@@ -147,7 +147,7 @@ export default function Home() {
147
147
  const handleFindHome = (e: React.FormEvent) => {
148
148
  e.preventDefault();
149
149
  const q = searchInputRef.current?.value?.trim() ?? "";
150
- navigate(q ? `/properties?search=${encodeURIComponent(q)}` : "/properties");
150
+ navigate(q ? `/properties?q=${encodeURIComponent(q)}` : "/properties");
151
151
  };
152
152
 
153
153
  const handleFooterSubmit = async (e: React.FormEvent) => {
@@ -274,7 +274,7 @@ export default function Maintenance() {
274
274
  </div>
275
275
  {submitError && (
276
276
  <p className="text-sm text-destructive" role="alert">
277
- {submitError}
277
+ Something went wrong. Please try again later.
278
278
  </p>
279
279
  )}
280
280
  {submitSuccess && (
@@ -138,7 +138,7 @@ export default function PropertyDetails() {
138
138
  return <PropertyDetailsSkeleton />;
139
139
  }
140
140
 
141
- if (error || (!property && id)) {
141
+ if (error) {
142
142
  return (
143
143
  <div className="mx-auto max-w-[900px]">
144
144
  <div className="mb-4">
@@ -148,7 +148,24 @@ export default function PropertyDetails() {
148
148
  </div>
149
149
  <Card className="rounded-2xl border border-border shadow-sm">
150
150
  <CardContent className="pt-6">
151
- <p className="text-destructive">{error ?? "Listing not found."}</p>
151
+ <p className="text-destructive">Something went wrong. Please try again later.</p>
152
+ </CardContent>
153
+ </Card>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ if (!property && id) {
159
+ return (
160
+ <div className="mx-auto max-w-[900px]">
161
+ <div className="mb-4">
162
+ <Link to="/properties" className="text-sm text-primary no-underline hover:underline">
163
+ ← Back to listings
164
+ </Link>
165
+ </div>
166
+ <Card className="rounded-2xl border border-border shadow-sm">
167
+ <CardContent className="pt-6">
168
+ <p className="text-destructive">Listing not found.</p>
152
169
  </CardContent>
153
170
  </Card>
154
171
  </div>
@@ -401,9 +401,7 @@ export default function PropertySearch() {
401
401
  </div>
402
402
  <div className="flex-1 overflow-y-auto p-4">
403
403
  {apiUnavailable ? (
404
- <PropertySearchPlaceholder
405
- message={resultsError ?? "Search is temporarily unavailable."}
406
- />
404
+ <PropertySearchPlaceholder message="Search is temporarily unavailable." />
407
405
  ) : resultsLoading ? (
408
406
  <div className="space-y-4">
409
407
  {[1, 2, 3].map((i) => (
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
3
- "version": "1.117.0",
3
+ "version": "1.117.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
9
- "version": "1.117.0",
9
+ "version": "1.117.1",
10
10
  "license": "SEE LICENSE IN LICENSE.txt",
11
11
  "devDependencies": {
12
12
  "@lwc/eslint-plugin-lwc": "^3.3.0",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-base-sfdx-project-experimental",
3
- "version": "1.117.0",
3
+ "version": "1.117.1",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/webapp-template-app-react-sample-b2x-experimental",
3
- "version": "1.117.0",
3
+ "version": "1.117.1",
4
4
  "description": "Salesforce sample property rental React app",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "author": "",