Skip to content

Commit f266745

Browse files
Refactor HelpMenu/hooks.ts for improved maintainability
Splits the monolithic hooks.ts file into three focused modules following SOLID principles, particularly Single Responsibility Principle. **New Module Structure:** 1. **fieldMapping.ts** - Handles all Salesforce field mapping logic - Maps application fields to Salesforce hidden/visible fields - Parses user names into first/last name components - Determines field editability based on data source - Extensively commented to explain mapping decisions 2. **businessHours.ts** - Manages business hours tracking and formatting - Tracks current business hours with automatic timeout management - Formats hours for display using Intl.DateTimeFormat - Handles grace periods for better UX - Comments explain timeout logic and state management 3. **chatController.ts** - Controls chat popup window and messaging - Opens/positions popup window at bottom-right - Manages postMessage communication with security checks - Polls for popup closure and handles cleanup - Comments explain security considerations and lifecycle **Benefits:** - Each module has a single, clear responsibility - Easier to test individual components - Better code organization and navigation - Comprehensive comments explain WHY decisions were made - hooks.ts now serves as a clean re-export layer for backward compatibility Fixes CORE-1450 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent 25e92d1 commit f266745

File tree

4 files changed

+531
-221
lines changed

4 files changed

+531
-221
lines changed
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import React from "react";
2+
3+
/**
4+
* Business Hours Management
5+
*
6+
* This module handles business hours calculation, tracking, and formatting
7+
* for the help/support chat system. It determines when chat support is
8+
* available and formats the hours for display to users.
9+
*/
10+
11+
export interface BusinessHours {
12+
startTime: number; // Unix timestamp in milliseconds
13+
endTime: number; // Unix timestamp in milliseconds
14+
}
15+
16+
export interface BusinessHoursResponse {
17+
businessHoursInfo: {
18+
businessHours: BusinessHours[];
19+
};
20+
timestamp?: number;
21+
}
22+
23+
/**
24+
* Finds the currently active business hours window, if any.
25+
*
26+
* The grace period extends the hours window slightly before/after to handle
27+
* edge cases and provide a better user experience.
28+
*
29+
* @param hoursResponse - Business hours data from the server
30+
* @param gracePeriod - Milliseconds to extend the hours window (default: 5 seconds)
31+
* @returns The current business hours window, or undefined if outside hours
32+
*/
33+
const findCurrentBusinessHours = (
34+
hoursResponse: BusinessHoursResponse | undefined,
35+
gracePeriod: number,
36+
): BusinessHours | undefined => {
37+
if (!hoursResponse) return undefined;
38+
39+
const now = Date.now();
40+
const { businessHoursInfo: { businessHours } } = hoursResponse;
41+
42+
// Find a hours window that encompasses the current time (with grace period)
43+
return businessHours.find(
44+
(h) => h.startTime - gracePeriod <= now && now < h.endTime + gracePeriod,
45+
);
46+
};
47+
48+
/**
49+
* React hook that tracks current business hours and automatically updates
50+
* when the hours window ends.
51+
*
52+
* This hook:
53+
* 1. Determines if we're currently within business hours
54+
* 2. Sets a timeout to clear the hours when they end
55+
* 3. Uses smart comparison to avoid unnecessary re-renders
56+
*
57+
* @param hoursResponse - Business hours data from the server
58+
* @param gracePeriod - Milliseconds to extend the hours window (default: 5 seconds)
59+
* @returns The current business hours window, or undefined if outside hours
60+
*/
61+
export const useBusinessHours = (
62+
hoursResponse: BusinessHoursResponse | undefined,
63+
gracePeriod = 5_000,
64+
): BusinessHours | undefined => {
65+
const timeoutRef = React.useRef<NodeJS.Timeout>();
66+
const [hours, setHours] = React.useState<BusinessHours | undefined>();
67+
68+
React.useEffect(() => {
69+
const nextState = findCurrentBusinessHours(hoursResponse, gracePeriod);
70+
71+
// Clear any existing timeout
72+
clearTimeout(timeoutRef.current);
73+
74+
// If we're in business hours, set a timeout to unset them when they end
75+
if (nextState !== undefined) {
76+
// Schedule the update for end time, or at least 1 second in the future
77+
const dT = Math.max(nextState.endTime - Date.now(), 1000);
78+
timeoutRef.current = setTimeout(() => {
79+
setHours(undefined);
80+
}, dT);
81+
}
82+
83+
// Only update state if the hours actually changed
84+
// This prevents unnecessary re-renders when the effect runs
85+
setHours((prev) =>
86+
prev !== undefined &&
87+
prev.startTime === nextState?.startTime &&
88+
prev.endTime === nextState?.endTime
89+
? prev // Keep the same object reference if times haven't changed
90+
: nextState,
91+
);
92+
93+
// Cleanup: clear timeout when component unmounts or effect re-runs
94+
return () => {
95+
clearTimeout(timeoutRef.current);
96+
};
97+
}, [hoursResponse, gracePeriod]);
98+
99+
return hours;
100+
};
101+
102+
/**
103+
* Formats a business hours time range for display to users.
104+
*
105+
* Uses the Intl.DateTimeFormat API for proper localization.
106+
* Falls back to simple hour display if Intl is not available.
107+
*
108+
* @param startTime - Unix timestamp in milliseconds
109+
* @param endTime - Unix timestamp in milliseconds
110+
* @returns Formatted string like "9 AM - 5 PM CDT" or empty string if invalid
111+
*
112+
* @example
113+
* formatBusinessHoursRange(1609502400000, 1609531200000)
114+
* // Returns: "9 AM - 5 PM CST"
115+
*/
116+
export const formatBusinessHoursRange = (startTime: number, endTime: number): string => {
117+
const startDate = new Date(startTime);
118+
const endDate = new Date(endTime);
119+
120+
// Validate that we have real timestamps
121+
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
122+
return "";
123+
}
124+
125+
try {
126+
// Use Intl.DateTimeFormat for proper localized formatting
127+
const baseOptions: Intl.DateTimeFormatOptions = {
128+
hour: "numeric",
129+
hour12: true,
130+
};
131+
132+
const start = new Intl.DateTimeFormat(undefined, baseOptions).format(startDate);
133+
const end = new Intl.DateTimeFormat(undefined, {
134+
...baseOptions,
135+
timeZoneName: "short", // Include timezone in end time
136+
}).format(endDate);
137+
138+
// Example output: "9 AM - 5 PM CDT"
139+
return `${start} - ${end}`;
140+
} catch (e) {
141+
// Fallback for environments without Intl support
142+
console.warn("Intl.DateTimeFormat not available, falling back to simple hours.", e);
143+
// Example output: "9 - 17"
144+
return `${startDate.getHours()} - ${endDate.getHours()}`;
145+
}
146+
};
147+
148+
/**
149+
* React hook that provides a formatted hours range string.
150+
*
151+
* Combines useBusinessHours with formatBusinessHoursRange to provide
152+
* a ready-to-display string for the UI.
153+
*
154+
* @param businessHours - Business hours data from the server
155+
* @param gracePeriod - Optional grace period for hours window
156+
* @returns Formatted hours string, or undefined if outside business hours
157+
*
158+
* @example
159+
* const hoursDisplay = useHoursRange(businessHoursData);
160+
* // Returns: "9 AM - 5 PM CDT" or undefined
161+
*/
162+
export const useHoursRange = (
163+
businessHours: BusinessHoursResponse | undefined,
164+
gracePeriod?: number,
165+
): string | undefined => {
166+
const hours = useBusinessHours(businessHours, gracePeriod);
167+
168+
// Memoize the formatted string to avoid recalculating on every render
169+
return React.useMemo(
170+
() => (hours ? formatBusinessHoursRange(hours.startTime, hours.endTime) : undefined),
171+
[hours],
172+
);
173+
};
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import React from "react";
2+
import { getPreChatFields } from "./fieldMapping";
3+
4+
/**
5+
* Chat Controller
6+
*
7+
* This module manages the popup window for the chat system and handles
8+
* communication between the parent window and the chat popup using postMessage.
9+
*
10+
* The chat system opens in a popup window positioned at the bottom-right of
11+
* the screen, similar to common chat widgets.
12+
*/
13+
14+
/**
15+
* Configuration for the chat popup window dimensions and positioning.
16+
*/
17+
const POPUP_CONFIG = {
18+
width: 500,
19+
height: 800,
20+
} as const;
21+
22+
/**
23+
* Calculates the position for a popup window at the bottom-right of the screen.
24+
*
25+
* @returns Object with top and left pixel coordinates
26+
*/
27+
const calculateBottomRightPosition = () => {
28+
// Get the current window's position and size
29+
// screenX/screenY are more reliable than screenLeft/screenTop
30+
const rightX = (window.screenX || window.screenLeft) + window.outerWidth;
31+
const bottomY = (window.screenY || window.screenTop) + window.outerHeight;
32+
33+
// Position popup at bottom-right
34+
const top = bottomY - POPUP_CONFIG.height;
35+
const left = rightX - POPUP_CONFIG.width;
36+
37+
return { top, left };
38+
};
39+
40+
/**
41+
* Generates the options string for window.open() to create a popup.
42+
*
43+
* @returns Formatted options string like "popup=true,width=500,height=800,top=100,left=200"
44+
*/
45+
const createPopupOptions = (): string => {
46+
const position = calculateBottomRightPosition();
47+
48+
const options = {
49+
popup: true,
50+
width: POPUP_CONFIG.width,
51+
height: POPUP_CONFIG.height,
52+
...position,
53+
};
54+
55+
return Object.entries(options)
56+
.map(([k, v]) => `${k}=${v}`)
57+
.join(",");
58+
};
59+
60+
/**
61+
* Hook that manages postMessage communication with the chat popup.
62+
*
63+
* Provides a safe way to send messages to the popup window with
64+
* origin validation.
65+
*
66+
* @param popup - Ref to the popup window
67+
* @param path - Full URL of the chat embed (used to determine origin)
68+
* @returns Function to send messages to the popup
69+
*/
70+
const usePostMessageChannel = (
71+
popup: React.MutableRefObject<Window | null>,
72+
path: string | undefined,
73+
) => {
74+
// Extract and memoize the origin from the chat embed path
75+
// This is used to validate messages and restrict postMessage target
76+
const popupOrigin = React.useMemo(
77+
() => (path ? new URL(path).origin : undefined),
78+
[path],
79+
);
80+
81+
// Create a memoized function to send messages to the popup
82+
const sendMessage = React.useCallback(
83+
<T,>(message: { type: string; data?: T }) => {
84+
// Safety checks: popup must exist and be open, and we must have an origin
85+
if (!popup.current || !popupOrigin) return;
86+
87+
// Send the message with origin restriction for security
88+
popup.current.postMessage(message, popupOrigin);
89+
},
90+
[popup, popupOrigin],
91+
);
92+
93+
return { sendMessage, popupOrigin };
94+
};
95+
96+
/**
97+
* Hook that manages the chat popup window lifecycle and communication.
98+
*
99+
* Responsibilities:
100+
* - Opens the popup window
101+
* - Manages the popup lifecycle (creation, messaging, cleanup)
102+
* - Handles bidirectional communication via postMessage
103+
* - Sends pre-chat fields when the popup is ready
104+
* - Polls for popup closure and cleans up event listeners
105+
*
106+
* @param path - URL to the chat embed page
107+
* @param preChatFields - Pre-populated form fields to send to the chat
108+
* @returns Object with openChat function, or empty object if path is not provided
109+
*/
110+
export const useChatController = (
111+
path: string | undefined,
112+
preChatFields: ReturnType<typeof getPreChatFields>,
113+
) => {
114+
// Store reference to the popup window
115+
const popup = React.useRef<Window | null>(null);
116+
117+
const { sendMessage, popupOrigin } = usePostMessageChannel(popup, path);
118+
119+
/**
120+
* Sends the pre-chat fields to the popup.
121+
* This is called when the popup signals it's ready.
122+
*/
123+
const sendPreChatFields = React.useCallback(() => {
124+
sendMessage({ type: "preChatFields", data: preChatFields });
125+
}, [sendMessage, preChatFields]);
126+
127+
/**
128+
* Initializes the chat popup with fields and opens the chat interface.
129+
* Called when the popup sends a "ready" message.
130+
*/
131+
const init = React.useCallback(() => {
132+
sendPreChatFields();
133+
sendMessage({ type: "open" });
134+
}, [sendMessage, sendPreChatFields]);
135+
136+
/**
137+
* Opens the chat popup window.
138+
*
139+
* This function:
140+
* 1. Checks if a popup is already open (prevents duplicates)
141+
* 2. Creates a new popup window positioned at bottom-right
142+
* 3. Sets up message listener for popup communication
143+
* 4. Polls for popup closure to clean up
144+
*/
145+
const openChat = React.useCallback(() => {
146+
// Prevent opening multiple popups or opening without a path
147+
if (popup.current || !path) return;
148+
149+
// Open the popup window with calculated position
150+
const options = createPopupOptions();
151+
popup.current = window.open(path, "_blank", options);
152+
153+
// If popup was blocked by browser, bail out
154+
if (!popup.current) return;
155+
156+
/**
157+
* Handles messages from the popup window.
158+
* Currently listens for "ready" message to initialize the chat.
159+
*/
160+
const handleMessage = (e: MessageEvent) => {
161+
const { source, data: { type } } = e;
162+
163+
// Security: only process messages from our popup
164+
if (source !== popup.current) return;
165+
166+
// Initialize chat when popup signals ready
167+
if (type === "ready") init();
168+
};
169+
170+
/**
171+
* Polls to detect when the popup is closed by the user.
172+
* Cleans up event listeners when closed.
173+
*/
174+
const checkClosed = setInterval(() => {
175+
if (popup.current?.closed) {
176+
// Cleanup: remove message listener
177+
window.removeEventListener("message", handleMessage, false);
178+
popup.current = null;
179+
clearInterval(checkClosed);
180+
}
181+
}, 500); // Check every 500ms
182+
183+
// Set up the message listener
184+
window.addEventListener("message", handleMessage, false);
185+
}, [path, init]);
186+
187+
/**
188+
* Effect: Re-send pre-chat fields if they change while popup is open.
189+
* This ensures the popup always has the latest field values.
190+
*/
191+
React.useEffect(() => {
192+
sendPreChatFields();
193+
}, [sendPreChatFields]);
194+
195+
// Only return the openChat function if we have a valid path
196+
// This makes it easy for consumers to check if chat is available
197+
return path ? { openChat } : {};
198+
};

0 commit comments

Comments
 (0)