Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions src/components/HelpMenu/businessHours.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import React from "react";

/**
* Business Hours Management
*
* This module handles business hours calculation, tracking, and formatting
* for the help/support chat system. It determines when chat support is
* available and formats the hours for display to users.
*/

export interface BusinessHours {
startTime: number; // Unix timestamp in milliseconds
endTime: number; // Unix timestamp in milliseconds
}

export interface BusinessHoursResponse {
businessHoursInfo: {
businessHours: BusinessHours[];
};
timestamp?: number;
}

/**
* Finds the currently active business hours window, if any.
*
* The grace period extends the hours window slightly before/after to handle
* edge cases and provide a better user experience.
*
* @param hoursResponse - Business hours data from the server
* @param gracePeriod - Milliseconds to extend the hours window (default: 5 seconds)
* @returns The current business hours window, or undefined if outside hours
*/
const findCurrentBusinessHours = (
hoursResponse: BusinessHoursResponse | undefined,
gracePeriod: number,
): BusinessHours | undefined => {
if (!hoursResponse) return undefined;

const now = Date.now();
const { businessHoursInfo: { businessHours } } = hoursResponse;

// Find a hours window that encompasses the current time (with grace period)
return businessHours.find(
(h) => h.startTime - gracePeriod <= now && now < h.endTime + gracePeriod,
);
};

/**
* React hook that tracks current business hours and automatically updates
* when the hours window ends.
*
* This hook:
* 1. Determines if we're currently within business hours
* 2. Sets a timeout to clear the hours when they end
* 3. Uses smart comparison to avoid unnecessary re-renders
*
* @param hoursResponse - Business hours data from the server
* @param gracePeriod - Milliseconds to extend the hours window (default: 5 seconds)
* @returns The current business hours window, or undefined if outside hours
*/
export const useBusinessHours = (
hoursResponse: BusinessHoursResponse | undefined,
gracePeriod = 5_000,
): BusinessHours | undefined => {
const timeoutRef = React.useRef<NodeJS.Timeout>();
const [hours, setHours] = React.useState<BusinessHours | undefined>();

React.useEffect(() => {
const nextState = findCurrentBusinessHours(hoursResponse, gracePeriod);

// Clear any existing timeout
clearTimeout(timeoutRef.current);

// If we're in business hours, set a timeout to unset them when they end
if (nextState !== undefined) {
// Schedule the update for end time, or at least 1 second in the future
const dT = Math.max(nextState.endTime - Date.now(), 1000);
timeoutRef.current = setTimeout(() => {
setHours(undefined);
}, dT);
}

// Only update state if the hours actually changed
// This prevents unnecessary re-renders when the effect runs
setHours((prev) =>
prev !== undefined &&
nextState !== undefined &&
prev.startTime === nextState.startTime &&
prev.endTime === nextState.endTime
? prev // Keep the same object reference if times haven't changed
: nextState,
);

// Cleanup: clear timeout when component unmounts or effect re-runs
return () => {
clearTimeout(timeoutRef.current);
};
}, [hoursResponse, gracePeriod]);

return hours;
};

/**
* Formats a business hours time range for display to users.
*
* Uses the Intl.DateTimeFormat API for proper localization.
* Falls back to simple hour display if Intl is not available.
*
* @param startTime - Unix timestamp in milliseconds
* @param endTime - Unix timestamp in milliseconds
* @returns Formatted string like "9 AM - 5 PM CDT" or empty string if invalid
*
* @example
* formatBusinessHoursRange(1609502400000, 1609531200000)
* // Returns: "9 AM - 5 PM CST"
*/
export const formatBusinessHoursRange = (startTime: number, endTime: number): string => {
const startDate = new Date(startTime);
const endDate = new Date(endTime);

// Validate that we have real timestamps
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return "";
}

try {
// Use Intl.DateTimeFormat for proper localized formatting
const baseOptions: Intl.DateTimeFormatOptions = {
hour: "numeric",
hour12: true,
};

const start = new Intl.DateTimeFormat(undefined, baseOptions).format(startDate);
const end = new Intl.DateTimeFormat(undefined, {
...baseOptions,
timeZoneName: "short", // Include timezone in end time
}).format(endDate);

// Example output: "9 AM - 5 PM CDT"
return `${start} - ${end}`;
} catch (e) {
// Fallback for environments without Intl support
console.warn("Intl.DateTimeFormat not available, falling back to simple hours.", e);
// Example output: "9 - 17"
return `${startDate.getHours()} - ${endDate.getHours()}`;
}
};

/**
* React hook that provides a formatted hours range string.
*
* Combines useBusinessHours with formatBusinessHoursRange to provide
* a ready-to-display string for the UI.
*
* @param businessHours - Business hours data from the server
* @param gracePeriod - Optional grace period for hours window
* @returns Formatted hours string, or undefined if outside business hours
*
* @example
* const hoursDisplay = useHoursRange(businessHoursData);
* // Returns: "9 AM - 5 PM CDT" or undefined
*/
export const useHoursRange = (
businessHours: BusinessHoursResponse | undefined,
gracePeriod?: number,
): string | undefined => {
const hours = useBusinessHours(businessHours, gracePeriod);

// Memoize the formatted string to avoid recalculating on every render
return React.useMemo(
() => (hours ? formatBusinessHoursRange(hours.startTime, hours.endTime) : undefined),
[hours],
);
};
198 changes: 198 additions & 0 deletions src/components/HelpMenu/chatController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import React from "react";
import { getPreChatFields } from "./fieldMapping";

/**
* Chat Controller
*
* This module manages the popup window for the chat system and handles
* communication between the parent window and the chat popup using postMessage.
*
* The chat system opens in a popup window positioned at the bottom-right of
* the screen, similar to common chat widgets.
*/

/**
* Configuration for the chat popup window dimensions and positioning.
*/
const POPUP_CONFIG = {
width: 500,
height: 800,
} as const;

/**
* Calculates the position for a popup window at the bottom-right of the screen.
*
* @returns Object with top and left pixel coordinates
*/
const calculateBottomRightPosition = () => {
// Get the current window's position and size
// screenX/screenY are more reliable than screenLeft/screenTop
const rightX = (window.screenX || window.screenLeft) + window.outerWidth;
const bottomY = (window.screenY || window.screenTop) + window.outerHeight;

// Position popup at bottom-right
const top = bottomY - POPUP_CONFIG.height;
const left = rightX - POPUP_CONFIG.width;

return { top, left };
};

/**
* Generates the options string for window.open() to create a popup.
*
* @returns Formatted options string like "popup=true,width=500,height=800,top=100,left=200"
*/
const createPopupOptions = (): string => {
const position = calculateBottomRightPosition();

const options = {
popup: true,
width: POPUP_CONFIG.width,
height: POPUP_CONFIG.height,
...position,
};

return Object.entries(options)
.map(([k, v]) => `${k}=${v}`)
.join(",");
};

/**
* Hook that manages postMessage communication with the chat popup.
*
* Provides a safe way to send messages to the popup window with
* origin validation.
*
* @param popup - Ref to the popup window
* @param path - Full URL of the chat embed (used to determine origin)
* @returns Function to send messages to the popup
*/
const usePostMessageChannel = (
popup: React.MutableRefObject<Window | null>,
path: string | undefined,
) => {
// Extract and memoize the origin from the chat embed path
// This is used to validate messages and restrict postMessage target
const popupOrigin = React.useMemo(
() => (path ? new URL(path).origin : undefined),
[path],
);

// Create a memoized function to send messages to the popup
const sendMessage = React.useCallback(
<T,>(message: { type: string; data?: T }) => {
// Safety checks: popup must exist and be open, and we must have an origin
if (!popup.current || !popupOrigin) return;

// Send the message with origin restriction for security
popup.current.postMessage(message, popupOrigin);
},
[popup, popupOrigin],
);

return { sendMessage, popupOrigin };
};

/**
* Hook that manages the chat popup window lifecycle and communication.
*
* Responsibilities:
* - Opens the popup window
* - Manages the popup lifecycle (creation, messaging, cleanup)
* - Handles bidirectional communication via postMessage
* - Sends pre-chat fields when the popup is ready
* - Polls for popup closure and cleans up event listeners
*
* @param path - URL to the chat embed page
* @param preChatFields - Pre-populated form fields to send to the chat
* @returns Object with openChat function, or empty object if path is not provided
*/
export const useChatController = (
path: string | undefined,
preChatFields: ReturnType<typeof getPreChatFields>,
) => {
// Store reference to the popup window
const popup = React.useRef<Window | null>(null);

const { sendMessage } = usePostMessageChannel(popup, path);

/**
* Sends the pre-chat fields to the popup.
* This is called when the popup signals it's ready.
*/
const sendPreChatFields = React.useCallback(() => {
sendMessage({ type: "preChatFields", data: preChatFields });
}, [sendMessage, preChatFields]);

/**
* Initializes the chat popup with fields and opens the chat interface.
* Called when the popup sends a "ready" message.
*/
const init = React.useCallback(() => {
sendPreChatFields();
sendMessage({ type: "open" });
}, [sendMessage, sendPreChatFields]);

/**
* Opens the chat popup window.
*
* This function:
* 1. Checks if a popup is already open (prevents duplicates)
* 2. Creates a new popup window positioned at bottom-right
* 3. Sets up message listener for popup communication
* 4. Polls for popup closure to clean up
*/
const openChat = React.useCallback(() => {
// Prevent opening multiple popups or opening without a path
if (popup.current || !path) return;

// Open the popup window with calculated position
const options = createPopupOptions();
popup.current = window.open(path, "_blank", options);

// If popup was blocked by browser, bail out
if (!popup.current) return;

/**
* Handles messages from the popup window.
* Currently listens for "ready" message to initialize the chat.
*/
const handleMessage = (e: MessageEvent) => {
const { source, data: { type } } = e;

// Security: only process messages from our popup
if (source !== popup.current) return;

// Initialize chat when popup signals ready
if (type === "ready") init();
};

/**
* Polls to detect when the popup is closed by the user.
* Cleans up event listeners when closed.
*/
const checkClosed = setInterval(() => {
if ((popup.current as Window).closed) {
// Cleanup: remove message listener
window.removeEventListener("message", handleMessage, false);
popup.current = null;
clearInterval(checkClosed);
}
}, 500); // Check every 500ms

// Set up the message listener
window.addEventListener("message", handleMessage, false);
}, [path, init]);

/**
* Effect: Re-send pre-chat fields if they change while popup is open.
* This ensures the popup always has the latest field values.
*/
React.useEffect(() => {
sendPreChatFields();
}, [sendPreChatFields]);

// Only return the openChat function if we have a valid path
// This makes it easy for consumers to check if chat is available
return path ? { openChat } : {};
};
Loading
Loading