@salesforce/webapp-template-app-react-sample-b2x-experimental 1.84.0 → 1.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/SKILL.md +5 -3
- package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/header-footer.md +8 -0
- package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/page.md +8 -7
- package/dist/.a4drules/skills/webapp-ui-ux/SKILL.md +11 -8
- package/dist/.a4drules/webapp-react.md +54 -0
- package/dist/CHANGELOG.md +16 -0
- package/dist/README.md +24 -0
- package/dist/force-app/main/default/data/Property_Image__c.json +1 -1
- package/dist/force-app/main/default/data/Property_Listing__c.json +1 -1
- package/dist/force-app/main/default/data/prepare-import-unique-fields.js +85 -0
- package/dist/force-app/main/default/permissionsets/Property_Management_Access.permissionset-meta.xml +0 -7
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/index.html +6 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/package.json +3 -3
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/applicationApi.ts +9 -9
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphql-operations-types.ts +296 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/graphqlClient.ts +12 -7
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/maintenanceRequestApi.ts +50 -38
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyDetailGraphQL.ts +50 -102
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/propertyListingGraphQL.ts +211 -43
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/api/userApi.ts +43 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/appLayout.tsx +9 -208
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/appliances.svg +13 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/electrical.svg +39 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/hvac.svg +78 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/pest.svg +5 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/plumbing.svg +7 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/assets/icons/zen-logo.svg +5 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/MaintenanceRequestIcon.tsx +46 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/NavMenu.tsx +53 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyListingCard.tsx +55 -58
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertyMap.tsx +93 -11
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/PropertySearchFilters.tsx +315 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/StatusBadge.tsx +36 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/TopBar.tsx +107 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyAddresses.ts +2 -2
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingAmenities.ts +55 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingPriceRange.ts +64 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyListingSearch.ts +14 -5
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyMapMarkers.ts +54 -11
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/hooks/usePropertyPrimaryImages.ts +1 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx +42 -39
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Contact.tsx +10 -10
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Dashboard.tsx +64 -91
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/HelpCenter.tsx +1 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Home.tsx +19 -9
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Maintenance.tsx +79 -100
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/NotFound.tsx +1 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyDetails.tsx +62 -47
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertyListings.tsx +3 -3
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/PropertySearch.tsx +230 -34
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/routes.tsx +10 -1
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/styles/global.css +64 -0
- package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/utils/geocode.ts +30 -5
- package/dist/package.json +1 -1
- package/dist/setup-cli.mjs +271 -0
- package/package.json +1 -1
- /package/dist/.a4drules/skills/{webapp-react-add-component → webapp-react}/implementation/component.md +0 -0
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/StatusBadge.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
interface StatusBadgeProps {
|
|
4
|
+
status: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function StatusBadge({ status }: StatusBadgeProps) {
|
|
8
|
+
const statusLower = status.toLowerCase();
|
|
9
|
+
|
|
10
|
+
const getStyle = () => {
|
|
11
|
+
if (statusLower === "new") return "bg-pink-100 text-pink-700";
|
|
12
|
+
if (statusLower === "in progress") return "bg-yellow-100 text-yellow-700";
|
|
13
|
+
if (statusLower === "resolved") return "bg-green-100 text-green-700";
|
|
14
|
+
return "bg-gray-100 text-gray-700";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const getLabel = () => {
|
|
18
|
+
if (statusLower === "new") return "Needs Action";
|
|
19
|
+
if (statusLower === "in progress") return "In Progress";
|
|
20
|
+
if (statusLower === "resolved") return "Resolved";
|
|
21
|
+
return status;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const showCheckmark = statusLower === "resolved";
|
|
25
|
+
const showDot = statusLower === "new" || statusLower === "in progress";
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium ${getStyle()}`}
|
|
30
|
+
>
|
|
31
|
+
{showCheckmark && <Check className="size-4" aria-hidden />}
|
|
32
|
+
{showDot && <span className="size-2 rounded-full bg-current" aria-hidden />}
|
|
33
|
+
{getLabel()}
|
|
34
|
+
</span>
|
|
35
|
+
);
|
|
36
|
+
}
|
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/components/TopBar.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Search, Bell, ChevronDown, Menu } from "lucide-react";
|
|
3
|
+
import { getUserInfo } from "@/api/userApi";
|
|
4
|
+
import zenLogo from "@/assets/icons/zen-logo.svg";
|
|
5
|
+
|
|
6
|
+
export interface TopBarProps {
|
|
7
|
+
onMenuClick?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TopBar({ onMenuClick }: TopBarProps) {
|
|
11
|
+
const [userName, setUserName] = useState<string>("User");
|
|
12
|
+
const [showNotifications, setShowNotifications] = useState(false);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const loadUserInfo = async () => {
|
|
16
|
+
const userInfo = await getUserInfo();
|
|
17
|
+
if (userInfo) {
|
|
18
|
+
setUserName(userInfo.name);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
loadUserInfo();
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const handleNotificationClick = () => {
|
|
25
|
+
setShowNotifications(!showNotifications);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleCloseNotifications = () => {
|
|
29
|
+
setShowNotifications(false);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<header
|
|
34
|
+
className="flex h-16 items-center justify-between bg-teal-700 px-6 text-white"
|
|
35
|
+
role="banner"
|
|
36
|
+
>
|
|
37
|
+
<div className="flex items-center gap-4">
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={onMenuClick}
|
|
41
|
+
className="rounded-md p-2 transition-colors hover:bg-teal-600 md:hidden"
|
|
42
|
+
aria-label="Toggle menu"
|
|
43
|
+
>
|
|
44
|
+
<Menu className="size-6" aria-hidden />
|
|
45
|
+
</button>
|
|
46
|
+
<div className="flex items-center gap-2">
|
|
47
|
+
<img src={zenLogo} alt="Zenlease Logo" className="size-8" />
|
|
48
|
+
<span className="text-xl tracking-wide">
|
|
49
|
+
<span className="font-light">ZEN</span>
|
|
50
|
+
<span className="font-semibold">LEASE</span>
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="flex items-center gap-4">
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className="rounded-md p-2 transition-colors hover:bg-teal-600 md:hidden"
|
|
59
|
+
aria-label="Search"
|
|
60
|
+
>
|
|
61
|
+
<Search className="size-5" aria-hidden />
|
|
62
|
+
</button>
|
|
63
|
+
|
|
64
|
+
<div className="relative">
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={handleNotificationClick}
|
|
68
|
+
className="relative rounded-md p-2 transition-colors hover:bg-teal-600"
|
|
69
|
+
aria-label="Notifications"
|
|
70
|
+
>
|
|
71
|
+
<Bell className="size-5" aria-hidden />
|
|
72
|
+
</button>
|
|
73
|
+
|
|
74
|
+
{showNotifications && (
|
|
75
|
+
<>
|
|
76
|
+
<div className="fixed inset-0 z-40" onClick={handleCloseNotifications} aria-hidden />
|
|
77
|
+
<div className="absolute right-0 top-full z-50 mt-2 w-80 overflow-hidden rounded-lg bg-white shadow-xl">
|
|
78
|
+
<div className="border-b border-gray-200 p-4">
|
|
79
|
+
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
|
|
80
|
+
</div>
|
|
81
|
+
<div className="p-8 text-center">
|
|
82
|
+
<Bell className="mx-auto mb-3 size-12 text-gray-300" aria-hidden />
|
|
83
|
+
<p className="text-sm text-gray-500">No new notifications</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className="flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-teal-600"
|
|
93
|
+
aria-label="User menu"
|
|
94
|
+
>
|
|
95
|
+
<div
|
|
96
|
+
className="flex size-8 items-center justify-center rounded-full bg-teal-300 font-semibold text-teal-900"
|
|
97
|
+
aria-hidden
|
|
98
|
+
>
|
|
99
|
+
{userName.charAt(0).toUpperCase()}
|
|
100
|
+
</div>
|
|
101
|
+
<span className="hidden font-medium md:inline">{userName.toUpperCase()}</span>
|
|
102
|
+
<ChevronDown className="hidden size-4 md:inline" aria-hidden />
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</header>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useEffect } from "react";
|
|
6
6
|
import { fetchPropertyAddresses } from "@/api/propertyDetailGraphQL";
|
|
7
|
-
import { getPropertyIdFromRecord } from "
|
|
8
|
-
import type { SearchResultRecord } from "
|
|
7
|
+
import { getPropertyIdFromRecord } from "@/hooks/usePropertyPrimaryImages";
|
|
8
|
+
import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
|
|
9
9
|
|
|
10
10
|
export function usePropertyAddresses(results: SearchResultRecord[]): Record<string, string> {
|
|
11
11
|
const [map, setMap] = useState<Record<string, string>>({});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches amenities (feature descriptions) for each property in search results.
|
|
3
|
+
* Returns a map of propertyId -> "Amenity 1 | Amenity 2 | ..." for use in listing cards.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useEffect } from "react";
|
|
6
|
+
import { fetchFeaturesByPropertyId } from "@/api/propertyDetailGraphQL";
|
|
7
|
+
import { getPropertyIdFromRecord } from "@/hooks/usePropertyPrimaryImages";
|
|
8
|
+
import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
|
|
9
|
+
|
|
10
|
+
const AMENITIES_SEPARATOR = " | ";
|
|
11
|
+
|
|
12
|
+
export function usePropertyListingAmenities(
|
|
13
|
+
results: SearchResultRecord[],
|
|
14
|
+
): Record<string, string> & { loading: boolean } {
|
|
15
|
+
const [map, setMap] = useState<Record<string, string>>({});
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
|
|
18
|
+
const propertyIds = results
|
|
19
|
+
.map((r) => r?.record && getPropertyIdFromRecord(r.record))
|
|
20
|
+
.filter((id): id is string => Boolean(id));
|
|
21
|
+
const uniqueIds = [...new Set(propertyIds)];
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (uniqueIds.length === 0) {
|
|
25
|
+
setMap({});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
setLoading(true);
|
|
30
|
+
Promise.all(uniqueIds.map((id) => fetchFeaturesByPropertyId(id)))
|
|
31
|
+
.then((featuresPerProperty) => {
|
|
32
|
+
if (cancelled) return;
|
|
33
|
+
const next: Record<string, string> = {};
|
|
34
|
+
uniqueIds.forEach((id, i) => {
|
|
35
|
+
const features = featuresPerProperty[i] ?? [];
|
|
36
|
+
const descriptions = features
|
|
37
|
+
.map((f) => f.description)
|
|
38
|
+
.filter((d): d is string => d != null && d.trim() !== "");
|
|
39
|
+
next[id] = descriptions.join(AMENITIES_SEPARATOR);
|
|
40
|
+
});
|
|
41
|
+
setMap(next);
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {
|
|
44
|
+
if (!cancelled) setMap({});
|
|
45
|
+
})
|
|
46
|
+
.finally(() => {
|
|
47
|
+
if (!cancelled) setLoading(false);
|
|
48
|
+
});
|
|
49
|
+
return () => {
|
|
50
|
+
cancelled = true;
|
|
51
|
+
};
|
|
52
|
+
}, [uniqueIds.join(",")]);
|
|
53
|
+
|
|
54
|
+
return Object.assign(map, { loading });
|
|
55
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches the available min/max listing price for the current search (no price/bedroom filters).
|
|
3
|
+
* Use to render the filter bar only after knowing the available price range.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useEffect, useRef } from "react";
|
|
6
|
+
import { queryPropertyListingPriceRange } from "@/api/propertyListingGraphQL";
|
|
7
|
+
|
|
8
|
+
/** Cap for slider max when dataset has outliers; UI never sees a higher max. */
|
|
9
|
+
const SLIDER_PRICE_CAP = 50_000;
|
|
10
|
+
|
|
11
|
+
export interface PropertyListingPriceRange {
|
|
12
|
+
priceMin: number;
|
|
13
|
+
priceMax: number;
|
|
14
|
+
/** True when raw max was > cap and we capped for the slider (show "50,000+"). */
|
|
15
|
+
maxCapped?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Fallback when the price-range API call fails. */
|
|
19
|
+
const DEFAULT_PRICE_RANGE: PropertyListingPriceRange = { priceMin: 0, priceMax: 100_000 };
|
|
20
|
+
|
|
21
|
+
function capPriceRange(range: { priceMin: number; priceMax: number }): PropertyListingPriceRange {
|
|
22
|
+
if (range.priceMax <= SLIDER_PRICE_CAP)
|
|
23
|
+
return { priceMin: range.priceMin, priceMax: range.priceMax };
|
|
24
|
+
return {
|
|
25
|
+
priceMin: range.priceMin,
|
|
26
|
+
priceMax: SLIDER_PRICE_CAP,
|
|
27
|
+
maxCapped: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function usePropertyListingPriceRange(searchQuery: string): {
|
|
32
|
+
priceRange: PropertyListingPriceRange | null;
|
|
33
|
+
loading: boolean;
|
|
34
|
+
error: string | null;
|
|
35
|
+
} {
|
|
36
|
+
const [priceRange, setPriceRange] = useState<PropertyListingPriceRange | null>(null);
|
|
37
|
+
const [loading, setLoading] = useState(true);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
const cancelledRef = useRef(false);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
cancelledRef.current = false;
|
|
43
|
+
setLoading(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
queryPropertyListingPriceRange(searchQuery)
|
|
46
|
+
.then((range) => {
|
|
47
|
+
if (!cancelledRef.current) setPriceRange(range ? capPriceRange(range) : null);
|
|
48
|
+
})
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
if (!cancelledRef.current) {
|
|
51
|
+
setError(err instanceof Error ? err.message : "Failed to load price range");
|
|
52
|
+
setPriceRange(capPriceRange(DEFAULT_PRICE_RANGE));
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.finally(() => {
|
|
56
|
+
if (!cancelledRef.current) setLoading(false);
|
|
57
|
+
});
|
|
58
|
+
return () => {
|
|
59
|
+
cancelledRef.current = true;
|
|
60
|
+
};
|
|
61
|
+
}, [searchQuery]);
|
|
62
|
+
|
|
63
|
+
return { priceRange, loading, error };
|
|
64
|
+
}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Property Listing search via GraphQL.
|
|
2
|
+
* Property Listing search via GraphQL. Optional text, price range, and bedrooms filters.
|
|
3
3
|
*/
|
|
4
4
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
queryPropertyListingsGraphQL,
|
|
7
|
+
type PropertyListingFilters,
|
|
8
|
+
} from "@/api/propertyListingGraphQL";
|
|
9
|
+
import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
|
|
7
10
|
|
|
8
|
-
export function usePropertyListingSearch(
|
|
11
|
+
export function usePropertyListingSearch(
|
|
12
|
+
searchQuery: string,
|
|
13
|
+
pageSize: number,
|
|
14
|
+
pageToken: string,
|
|
15
|
+
filters?: PropertyListingFilters,
|
|
16
|
+
) {
|
|
9
17
|
const [results, setResults] = useState<SearchResultRecord[]>([]);
|
|
10
18
|
const [nextPageToken, setNextPageToken] = useState<string | null>(null);
|
|
11
19
|
const [previousPageToken, setPreviousPageToken] = useState<string | null>(null);
|
|
@@ -27,6 +35,7 @@ export function usePropertyListingSearch(searchQuery: string, pageSize: number,
|
|
|
27
35
|
pageSize,
|
|
28
36
|
afterCursor,
|
|
29
37
|
ac.signal,
|
|
38
|
+
filters,
|
|
30
39
|
);
|
|
31
40
|
if (ac.signal.aborted) return;
|
|
32
41
|
setResults(result.records);
|
|
@@ -41,7 +50,7 @@ export function usePropertyListingSearch(searchQuery: string, pageSize: number,
|
|
|
41
50
|
} finally {
|
|
42
51
|
if (!ac.signal.aborted) setLoading(false);
|
|
43
52
|
}
|
|
44
|
-
}, [searchQuery, pageSize, pageToken]);
|
|
53
|
+
}, [searchQuery, pageSize, pageToken, filters]);
|
|
45
54
|
|
|
46
55
|
useEffect(() => {
|
|
47
56
|
if (debounceRef.current) {
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useEffect } from "react";
|
|
6
6
|
import { fetchPropertyAddresses } from "@/api/propertyDetailGraphQL";
|
|
7
|
-
import { geocodeAddress } from "@/utils/geocode";
|
|
8
|
-
import { getPropertyIdFromRecord } from "
|
|
9
|
-
import type { SearchResultRecord } from "
|
|
7
|
+
import { geocodeAddress, getStateZipFromAddress } from "@/utils/geocode";
|
|
8
|
+
import { getPropertyIdFromRecord } from "@/hooks/usePropertyPrimaryImages";
|
|
9
|
+
import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
|
|
10
10
|
import type { MapMarker } from "@/components/PropertyMap";
|
|
11
11
|
|
|
12
12
|
function getListingName(record: {
|
|
@@ -19,6 +19,42 @@ function getListingName(record: {
|
|
|
19
19
|
return "Property";
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Round to 5 decimals (~1 m) so near-duplicate coords group together */
|
|
23
|
+
function key(lat: number, lng: number): string {
|
|
24
|
+
return `${lat.toFixed(5)},${lng.toFixed(5)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* When multiple markers share the same lat/lng, offset them in a small circle so they appear
|
|
29
|
+
* close together but are all visible (no stacking).
|
|
30
|
+
*/
|
|
31
|
+
function spreadDuplicateMarkers(markers: MapMarker[]): MapMarker[] {
|
|
32
|
+
const groups = new Map<string, MapMarker[]>();
|
|
33
|
+
for (const m of markers) {
|
|
34
|
+
const k = key(m.lat, m.lng);
|
|
35
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
36
|
+
groups.get(k)!.push(m);
|
|
37
|
+
}
|
|
38
|
+
const result: MapMarker[] = [];
|
|
39
|
+
const radiusDeg = 0.0004; // ~40–50 m so pins sit close but visible
|
|
40
|
+
for (const group of groups.values()) {
|
|
41
|
+
if (group.length === 1) {
|
|
42
|
+
result.push(group[0]);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
for (let i = 0; i < group.length; i++) {
|
|
46
|
+
const m = group[i];
|
|
47
|
+
const angle = (i / group.length) * 2 * Math.PI;
|
|
48
|
+
result.push({
|
|
49
|
+
...m,
|
|
50
|
+
lat: m.lat + radiusDeg * Math.cos(angle),
|
|
51
|
+
lng: m.lng + radiusDeg * Math.sin(angle),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
22
58
|
export function usePropertyMapMarkers(results: SearchResultRecord[]): {
|
|
23
59
|
markers: MapMarker[];
|
|
24
60
|
loading: boolean;
|
|
@@ -59,24 +95,31 @@ export function usePropertyMapMarkers(results: SearchResultRecord[]): {
|
|
|
59
95
|
setLoading(false);
|
|
60
96
|
return;
|
|
61
97
|
}
|
|
62
|
-
// Geocode all addresses in parallel
|
|
98
|
+
// Geocode all addresses in parallel; fallback to City, State Zip if full address fails
|
|
63
99
|
Promise.all(
|
|
64
|
-
toGeocode.map(([id, address]) =>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
100
|
+
toGeocode.map(async ([id, address]) => {
|
|
101
|
+
const normalized = address.replace(/\n/g, ", ").trim();
|
|
102
|
+
let coords = await geocodeAddress(normalized);
|
|
103
|
+
if (!coords) {
|
|
104
|
+
const stateZip = getStateZipFromAddress(normalized);
|
|
105
|
+
if (stateZip !== normalized) {
|
|
106
|
+
coords = await geocodeAddress(stateZip);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return coords ? { id, coords } : null;
|
|
110
|
+
}),
|
|
69
111
|
)
|
|
70
112
|
.then((resolved) => {
|
|
71
113
|
if (cancelled) return;
|
|
72
|
-
const
|
|
114
|
+
const raw: MapMarker[] = resolved
|
|
73
115
|
.filter((r): r is { id: string; coords: { lat: number; lng: number } } => r != null)
|
|
74
116
|
.map(({ id, coords }) => ({
|
|
75
117
|
lat: coords.lat,
|
|
76
118
|
lng: coords.lng,
|
|
77
119
|
label: propertyIdToLabel.get(id) ?? "Property",
|
|
120
|
+
propertyId: id,
|
|
78
121
|
}));
|
|
79
|
-
setMarkers(
|
|
122
|
+
setMarkers(spreadDuplicateMarkers(raw));
|
|
80
123
|
})
|
|
81
124
|
.catch(() => {
|
|
82
125
|
if (!cancelled) setMarkers([]);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useEffect } from "react";
|
|
6
6
|
import { fetchPrimaryImagesByPropertyIds } from "@/api/propertyDetailGraphQL";
|
|
7
|
-
import type { SearchResultRecord } from "
|
|
7
|
+
import type { SearchResultRecord } from "@/features/global-search/types/search/searchResults.js";
|
|
8
8
|
|
|
9
9
|
export function getPropertyIdFromRecord(record: {
|
|
10
10
|
fields?: Record<string, { value?: unknown }>;
|
package/dist/force-app/main/default/webapplications/appreactsampleb2x/src/pages/Application.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useSearchParams, Link } from "react-router";
|
|
2
|
-
import { useCallback, useEffect, useState } from "react";
|
|
3
|
-
import { Button } from "
|
|
4
|
-
import { Input } from "
|
|
5
|
-
import { Label } from "
|
|
6
|
-
import { Card, CardContent } from "
|
|
2
|
+
import { useCallback, useEffect, useState, type ChangeEvent } from "react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
7
7
|
import {
|
|
8
8
|
fetchListingById,
|
|
9
9
|
fetchPropertyById,
|
|
@@ -94,7 +94,7 @@ export default function Application() {
|
|
|
94
94
|
Phone__c: phone.trim() || null,
|
|
95
95
|
Start_Date__c: moveInDate.trim() || null,
|
|
96
96
|
Preferred_Term__c: preferredTerm.trim() || null,
|
|
97
|
-
|
|
97
|
+
Employment__c: employmentInfo.trim() || null,
|
|
98
98
|
References__c: references.trim() || null,
|
|
99
99
|
});
|
|
100
100
|
setSubmittedId(id.id);
|
|
@@ -119,26 +119,30 @@ export default function Application() {
|
|
|
119
119
|
|
|
120
120
|
if (loading) {
|
|
121
121
|
return (
|
|
122
|
-
<div className="mx-auto max-w-[
|
|
123
|
-
<div className="mb-6 h-48 animate-pulse rounded-
|
|
124
|
-
<div className="h-64 animate-pulse rounded-
|
|
122
|
+
<div className="mx-auto max-w-[900px]">
|
|
123
|
+
<div className="mb-6 h-48 animate-pulse rounded-2xl bg-muted" />
|
|
124
|
+
<div className="h-64 animate-pulse rounded-2xl bg-muted" />
|
|
125
125
|
</div>
|
|
126
126
|
);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
if (submittedId) {
|
|
130
130
|
return (
|
|
131
|
-
<div className="mx-auto max-w-[
|
|
132
|
-
<Card className="mb-6 p-6">
|
|
133
|
-
<h2 className="mb-2 text-
|
|
131
|
+
<div className="mx-auto max-w-[900px]">
|
|
132
|
+
<Card className="mb-6 rounded-2xl border border-border p-6 shadow-sm">
|
|
133
|
+
<h2 className="mb-2 text-2xl font-semibold text-foreground">Application submitted</h2>
|
|
134
134
|
<p className="text-sm text-muted-foreground">
|
|
135
135
|
Your application has been saved. Reference: {submittedId}
|
|
136
136
|
</p>
|
|
137
137
|
<div className="mt-4 flex gap-2">
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
</
|
|
141
|
-
<Button
|
|
138
|
+
<Link to="/properties" className="text-sm text-primary no-underline hover:underline">
|
|
139
|
+
Back to search
|
|
140
|
+
</Link>
|
|
141
|
+
<Button
|
|
142
|
+
asChild
|
|
143
|
+
size="sm"
|
|
144
|
+
className="rounded-xl bg-primary px-5 py-5 text-lg font-medium transition-colors duration-200 hover:bg-primary/90"
|
|
145
|
+
>
|
|
142
146
|
<Link to="/application">Submit another</Link>
|
|
143
147
|
</Button>
|
|
144
148
|
</div>
|
|
@@ -148,8 +152,16 @@ export default function Application() {
|
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
return (
|
|
151
|
-
<div className="mx-auto max-w-[
|
|
152
|
-
<
|
|
155
|
+
<div className="mx-auto max-w-[900px]">
|
|
156
|
+
<div className="mb-4">
|
|
157
|
+
<Link
|
|
158
|
+
to={listingId ? `/property/${listingId}` : "/properties"}
|
|
159
|
+
className="text-sm text-primary no-underline hover:underline"
|
|
160
|
+
>
|
|
161
|
+
{listingId ? "← Back to listing" : "← Back to search"}
|
|
162
|
+
</Link>
|
|
163
|
+
</div>
|
|
164
|
+
<Card className="mb-6 flex gap-4 rounded-2xl border border-border p-6 shadow-sm">
|
|
153
165
|
<div className="relative size-[200px] shrink-0 overflow-hidden rounded-xl bg-muted">
|
|
154
166
|
{propertyImageUrl ? (
|
|
155
167
|
<img src={propertyImageUrl} alt="" className="h-full w-full object-cover" />
|
|
@@ -158,7 +170,7 @@ export default function Application() {
|
|
|
158
170
|
)}
|
|
159
171
|
</div>
|
|
160
172
|
<div className="min-w-0 flex-1">
|
|
161
|
-
<h2 className="mb-1 text-
|
|
173
|
+
<h2 className="mb-1.5 text-2xl font-semibold text-foreground">
|
|
162
174
|
{listingName ?? "Apply for a property"}
|
|
163
175
|
</h2>
|
|
164
176
|
<p className="text-sm text-muted-foreground">
|
|
@@ -169,19 +181,12 @@ export default function Application() {
|
|
|
169
181
|
</p>
|
|
170
182
|
{loadError && <p className="mt-2 text-sm text-destructive">{loadError}</p>}
|
|
171
183
|
</div>
|
|
172
|
-
<div className="flex gap-2">
|
|
173
|
-
<Button asChild variant="outline" size="sm">
|
|
174
|
-
<Link to={listingId ? `/property/${listingId}` : "/properties"}>Back to listing</Link>
|
|
175
|
-
</Button>
|
|
176
|
-
</div>
|
|
177
184
|
</Card>
|
|
178
185
|
|
|
179
|
-
<Card className="mb-6">
|
|
180
|
-
<CardContent className="pt-
|
|
186
|
+
<Card className="mb-6 rounded-2xl border border-border shadow-sm">
|
|
187
|
+
<CardContent className="pt-3">
|
|
181
188
|
<form onSubmit={handleSubmit}>
|
|
182
|
-
<h3 className="mb-4 text-
|
|
183
|
-
YOUR INFO
|
|
184
|
-
</h3>
|
|
189
|
+
<h3 className="mb-4 text-base font-semibold text-foreground">Your info</h3>
|
|
185
190
|
<div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
186
191
|
<div className="space-y-2">
|
|
187
192
|
<Label htmlFor="app-first-name">First Name *</Label>
|
|
@@ -189,7 +194,7 @@ export default function Application() {
|
|
|
189
194
|
id="app-first-name"
|
|
190
195
|
type="text"
|
|
191
196
|
value={firstName}
|
|
192
|
-
onChange={(e) => setFirstName(e.target.value)}
|
|
197
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setFirstName(e.target.value)}
|
|
193
198
|
/>
|
|
194
199
|
</div>
|
|
195
200
|
<div className="space-y-2">
|
|
@@ -198,7 +203,7 @@ export default function Application() {
|
|
|
198
203
|
id="app-last-name"
|
|
199
204
|
type="text"
|
|
200
205
|
value={lastName}
|
|
201
|
-
onChange={(e) => setLastName(e.target.value)}
|
|
206
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setLastName(e.target.value)}
|
|
202
207
|
/>
|
|
203
208
|
</div>
|
|
204
209
|
</div>
|
|
@@ -208,7 +213,7 @@ export default function Application() {
|
|
|
208
213
|
id="app-email"
|
|
209
214
|
type="email"
|
|
210
215
|
value={email}
|
|
211
|
-
onChange={(e) => setEmail(e.target.value)}
|
|
216
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
|
212
217
|
/>
|
|
213
218
|
</div>
|
|
214
219
|
<div className="mb-4 space-y-2">
|
|
@@ -217,12 +222,10 @@ export default function Application() {
|
|
|
217
222
|
id="app-phone"
|
|
218
223
|
type="tel"
|
|
219
224
|
value={phone}
|
|
220
|
-
onChange={(e) => setPhone(e.target.value)}
|
|
225
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
|
|
221
226
|
/>
|
|
222
227
|
</div>
|
|
223
|
-
<h3 className="mb-4 mt-6 text-
|
|
224
|
-
MOVE IN
|
|
225
|
-
</h3>
|
|
228
|
+
<h3 className="mb-4 mt-6 text-base font-semibold text-foreground">Move in</h3>
|
|
226
229
|
<div className="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
227
230
|
<div className="space-y-2">
|
|
228
231
|
<Label htmlFor="app-move-in">Move in date</Label>
|
|
@@ -230,7 +233,7 @@ export default function Application() {
|
|
|
230
233
|
id="app-move-in"
|
|
231
234
|
type="date"
|
|
232
235
|
value={moveInDate}
|
|
233
|
-
onChange={(e) => setMoveInDate(e.target.value)}
|
|
236
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setMoveInDate(e.target.value)}
|
|
234
237
|
/>
|
|
235
238
|
</div>
|
|
236
239
|
<div className="space-y-2">
|
|
@@ -239,7 +242,7 @@ export default function Application() {
|
|
|
239
242
|
id="app-term"
|
|
240
243
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
241
244
|
value={preferredTerm}
|
|
242
|
-
onChange={(e) => setPreferredTerm(e.target.value)}
|
|
245
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => setPreferredTerm(e.target.value)}
|
|
243
246
|
>
|
|
244
247
|
{PREFERRED_TERM_OPTIONS.map((opt) => (
|
|
245
248
|
<option key={opt || "empty"} value={opt}>
|
|
@@ -274,7 +277,7 @@ export default function Application() {
|
|
|
274
277
|
<Button
|
|
275
278
|
type="submit"
|
|
276
279
|
size="sm"
|
|
277
|
-
className="bg-
|
|
280
|
+
className="w-full cursor-pointer rounded-xl bg-primary px-5 py-5 text-lg font-medium transition-colors duration-200 hover:bg-primary/90 disabled:opacity-50"
|
|
278
281
|
disabled={submitting}
|
|
279
282
|
>
|
|
280
283
|
{submitting ? "Submitting…" : "Submit application"}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, type SubmitEvent } from "react";
|
|
2
2
|
import { Link } from "react-router";
|
|
3
|
-
import { Button } from "
|
|
4
|
-
import { Input } from "
|
|
5
|
-
import { Label } from "
|
|
6
|
-
import { Card, CardContent, CardHeader, CardTitle } from "
|
|
7
|
-
import { CenteredPageLayout } from "
|
|
8
|
-
import { Skeleton } from "
|
|
9
|
-
import { createContactUsLead } from "
|
|
10
|
-
import { useAuth } from "
|
|
11
|
-
import { fetchUserProfile } from "
|
|
12
|
-
import type { UserInfo } from "
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { CenteredPageLayout } from "@/features/authentication/layout/centered-page-layout";
|
|
8
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
9
|
+
import { createContactUsLead } from "@/api/leadApi";
|
|
10
|
+
import { useAuth } from "@/features/authentication/context/AuthContext";
|
|
11
|
+
import { fetchUserProfile } from "@/features/authentication/api/userProfileApi";
|
|
12
|
+
import type { UserInfo } from "@/api/leadApi";
|
|
13
13
|
|
|
14
14
|
function LoadingCard() {
|
|
15
15
|
return (
|