diff --git a/src/app/components/chat/chat.scss b/src/app/components/chat/chat.scss new file mode 100644 index 000000000..da9f75688 --- /dev/null +++ b/src/app/components/chat/chat.scss @@ -0,0 +1,3 @@ +// Salesforce chat widget styling +// The chat widget is injected by Salesforce and appears as a fixed overlay +// Add custom styling overrides here if needed diff --git a/src/app/components/chat/chat.tsx b/src/app/components/chat/chat.tsx new file mode 100644 index 000000000..cee1eb5c3 --- /dev/null +++ b/src/app/components/chat/chat.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import useUserContext from '~/contexts/user'; +import './chat.scss'; + +declare global { + interface Window { + embeddedservice_bootstrap?: { + settings: { + language: string; + }; + init: ( + orgId: string, + deploymentName: string, + baseUrl: string, + options: {scrt2URL: string} + ) => void; + prechatAPI?: { + setHiddenPrechatFields: (fields: Record) => void; + }; + }; + __salesforceChatInitialized?: boolean; + } +} + +const SALESFORCE_CONFIG = { + orgId: '00DU0000000Kwch', + deploymentName: 'Web_Messaging_Deployment', + baseUrl: 'https://openstax.my.site.com/ESWWebMessagingDeployme1716235390398', + scrt2URL: 'https://openstax.my.salesforce-scrt.com', + bootstrapScript: 'https://openstax.my.site.com/ESWWebMessagingDeployme1716235390398/assets/js/bootstrap.min.js' +}; + +function initEmbeddedMessaging(): boolean { + try { + if (window.embeddedservice_bootstrap) { + window.embeddedservice_bootstrap.settings.language = 'en_US'; + window.embeddedservice_bootstrap.init( + SALESFORCE_CONFIG.orgId, + SALESFORCE_CONFIG.deploymentName, + SALESFORCE_CONFIG.baseUrl, + {scrt2URL: SALESFORCE_CONFIG.scrt2URL} + ); + return true; + } + return false; + } catch (err) { + console.error('Error initializing Salesforce chat:', err); + return false; + } +} + +// eslint-disable-next-line complexity +export default function Chat() { + const userContext = useUserContext(); + const [scriptLoaded, setScriptLoaded] = React.useState(false); + + // Derive stable user primitives from userStatus (which is always available) + // with fallback to userModel when available + const userStatus = userContext?.userStatus; + const userModel = userContext?.userModel; + const uuid = userStatus?.uuid || userModel?.uuid; + const firstName = userStatus?.firstName || userModel?.first_name; + const lastName = userStatus?.lastName || userModel?.last_name; + const email = userStatus?.email || userModel?.email; + const school = userStatus?.school || userModel?.accountsModel?.school_name; + + // Load Salesforce script once, or short-circuit if already loaded + React.useEffect(() => { + let script: HTMLScriptElement | null = null; + + // Short-circuit if bootstrap is already available from a previous mount + if (window.embeddedservice_bootstrap) { + setScriptLoaded(true); + } else { + script = document.createElement('script'); + + script.src = SALESFORCE_CONFIG.bootstrapScript; + script.type = 'text/javascript'; + script.async = true; + + script.onload = () => { + setScriptLoaded(true); + }; + + script.onerror = () => { + console.error('Failed to load Salesforce chat script'); + }; + + document.body.appendChild(script); + } + + // Always return cleanup function to hide widget on unmount + return () => { + if (script && document.body.contains(script)) { + // Clear handlers to prevent setState on unmounted component + script.onload = null; + script.onerror = null; + document.body.removeChild(script); + } + // Hide the chat widget when component unmounts + // The Salesforce widget injects elements with these selectors + const chatElement = document.getElementById('embedded-messaging'); + + if (chatElement) { + chatElement.style.display = 'none'; + } + // Note: Don't delete window.embeddedservice_bootstrap or __salesforceChatInitialized + // to maintain conversation state across component remounts + }; + }, []); + + // Show chat widget when component mounts (if it was previously hidden) + React.useEffect(() => { + if (scriptLoaded && window.__salesforceChatInitialized) { + const chatElement = document.getElementById('embedded-messaging'); + + if (chatElement) { + chatElement.style.removeProperty('display'); + } + } + }, [scriptLoaded]); + + // Initialize chat widget once (on first mount or after refresh) + React.useEffect(() => { + if (!scriptLoaded || !window.embeddedservice_bootstrap) { + return; + } + + // Only initialize if not already initialized (persists across component remounts) + if (!window.__salesforceChatInitialized) { + const success = initEmbeddedMessaging(); + + // Only set the flag if initialization succeeded + if (success) { + window.__salesforceChatInitialized = true; + } + } + }, [scriptLoaded]); + + // Update pre-chat fields whenever user information changes + // This allows fields to update when a user logs in after chat is initialized + // eslint-disable-next-line complexity + React.useEffect(() => { + if (!scriptLoaded || !window.embeddedservice_bootstrap?.prechatAPI) { + return; + } + + // Set sProduct for all users (logged in or not) + const hiddenFields: Record = { + sProduct: 'Website' + }; + + // Add user information if available + if (uuid) { + hiddenFields.OpenStax_UUID__c = uuid; // eslint-disable-line camelcase + } + if (firstName) { + hiddenFields.FirstName = firstName; + } + if (lastName) { + hiddenFields.LastName = lastName; + } + if (email) { + hiddenFields.Email = email; + } + if (school) { + hiddenFields.School = school; + } + + window.embeddedservice_bootstrap.prechatAPI.setHiddenPrechatFields(hiddenFields); + }, [scriptLoaded, uuid, firstName, lastName, email, school]); + + return null; +} diff --git a/src/app/components/form-header/form-header.tsx b/src/app/components/form-header/form-header.tsx index 032375293..154d7b611 100644 --- a/src/app/components/form-header/form-header.tsx +++ b/src/app/components/form-header/form-header.tsx @@ -42,8 +42,7 @@ function FormHeader({ data: Record; prefix: string; }) { - const {userStatus} = useUserContext(); - const isLoggedIn = Boolean(userStatus?.userInfo?.last_name); + const {userStatus, isLoggedIn} = useUserContext(); const heading = pickField(data, prefix, 'IntroHeading', isLoggedIn); const description = pickField(data, prefix, 'IntroDescription', isLoggedIn); const replacements = React.useMemo(() => buildReplacements(userStatus), [userStatus]); diff --git a/src/app/components/shell/router.tsx b/src/app/components/shell/router.tsx index 335be629e..95a29e250 100644 --- a/src/app/components/shell/router.tsx +++ b/src/app/components/shell/router.tsx @@ -16,6 +16,9 @@ import { generateFooterPageRoutes } from './router-helpers/page-routes'; import {NonPortalRouteWrapper} from './router-helpers/non-portal-route-wrapper'; +import useSharedDataContext from '~/contexts/shared-data'; +import useUserContext from '~/contexts/user'; +import Chat from '~/components/chat/chat'; import './skip-to-content.scss'; function doSkipToContent(event: React.MouseEvent) { @@ -75,8 +78,42 @@ export default function Router() { ); } +// eslint-disable-next-line complexity function MainRoutes() { const {Layout} = useLayoutContext(); + const {pathname} = useLocation(); + const {flags} = useSharedDataContext(); + const {isLoggedIn} = useUserContext(); + + // Determine if chat should be shown based on current route and feature flags + // eslint-disable-next-line complexity + const showChat = React.useMemo(() => { + if (!flags) { + return false; + } + + // If chat_logged_in_only is enabled, return false unless logged in + if (flags.chat_logged_in_only && !isLoggedIn) { + return false; + } + + // Check if we're on a book details page + if (pathname.startsWith('/details/') && flags.chat_book_details) { + return true; + } + + // Check if we're on a subjects page + if (pathname.startsWith('/subjects') && flags.chat_subjects) { + return true; + } + + // Check if we're on the contact page + if (pathname.startsWith('/contact') && flags.chat_contact) { + return true; + } + + return false; + }, [pathname, flags, isLoggedIn]); return ( @@ -91,6 +128,7 @@ function MainRoutes() { } /> } /> + {showChat && } ); } diff --git a/src/app/contexts/shared-data.ts b/src/app/contexts/shared-data.ts index 29f228034..1c7dd6ce0 100644 --- a/src/app/contexts/shared-data.ts +++ b/src/app/contexts/shared-data.ts @@ -2,7 +2,14 @@ import buildContext from '~/components/jsx-helpers/build-context'; import cmsFetch from '~/helpers/cms-fetch'; import {usePromise} from '~/helpers/use-data'; -type FlagName = 'myox_pardot' | 'my_openstax' | 'new_subjects'; +type FlagName = + | 'myox_pardot' + | 'my_openstax' + | 'new_subjects' + | 'chat_book_details' + | 'chat_subjects' + | 'chat_contact' + | 'chat_logged_in_only'; type Flag = { name: FlagName; diff --git a/src/app/contexts/user.ts b/src/app/contexts/user.ts index 214c1107c..067aa8798 100644 --- a/src/app/contexts/user.ts +++ b/src/app/contexts/user.ts @@ -47,6 +47,9 @@ function useContextValue() { model?.accountsModel?.faculty_status === 'confirmed_faculty'; const [fetchTime, updateMyOpenStaxUser] = useRefreshable(() => Date.now()); const myOpenStaxUser = useMyOpenStaxUser(isVerified, fetchTime); + // Derive login state from userStatus (always available) with fallback to model + // This is the canonical way to check if a user is logged in + const isLoggedIn = Boolean(userStatus?.uuid || model?.uuid); const value = React.useMemo( () => model.last_name @@ -59,12 +62,13 @@ function useContextValue() { userModel: model, uuid: model.uuid, isVerified, + isLoggedIn, userStatus, myOpenStaxUser, updateMyOpenStaxUser } - : {userStatus, myOpenStaxUser}, - [model, userStatus, isVerified, myOpenStaxUser, updateMyOpenStaxUser] + : {isLoggedIn, userStatus, myOpenStaxUser, uuid: userStatus?.uuid}, + [model, userStatus, isVerified, isLoggedIn, myOpenStaxUser, updateMyOpenStaxUser] ); React.useEffect(() => { diff --git a/src/app/pages/adoption/adoption.tsx b/src/app/pages/adoption/adoption.tsx index 59f369e87..9e8026bbc 100644 --- a/src/app/pages/adoption/adoption.tsx +++ b/src/app/pages/adoption/adoption.tsx @@ -176,8 +176,7 @@ function FacultyForm({ const afterSubmit = useAfterSubmit(selectedBooksRef); const {onSubmit, submitting, FormTarget} = useFormTarget(afterSubmit); const {adoptionUrl} = useSalesforceContext(); - const {userModel, uuid} = useUserContext(); - const isLoggedIn = Boolean(userModel?.last_name); + const {uuid, isLoggedIn} = useUserContext(); const adoptions = useAdoptions(uuid); const preselectedValues = React.useMemo( () => adoptions?.Books.map((b) => b.name), @@ -297,8 +296,7 @@ export default function AdoptionForm() { const [selectedRole, setSelectedRole] = useState(''); const [hideRoleSelector, setHideRoleSelector] = useState(false); const ref = useRef(null); - const {userModel} = useUserContext(); - const isLoggedIn = Boolean(userModel?.last_name); + const {userModel, isLoggedIn} = useUserContext(); const initialRender = useRef(true); const onPageChange = React.useCallback((page: number) => { setHideRoleSelector(page > 1); diff --git a/test/src/components/chat.test.tsx b/test/src/components/chat.test.tsx new file mode 100644 index 000000000..7794c33ea --- /dev/null +++ b/test/src/components/chat.test.tsx @@ -0,0 +1,297 @@ +import React from 'react'; +import {render, waitFor} from '@testing-library/preact'; +import Chat from '~/components/chat/chat'; +import * as UserContext from '~/contexts/user'; + +// Mock the user context and scss +jest.mock('~/contexts/user'); +jest.mock('~/components/chat/chat.scss', () => ({})); + +describe('Chat', () => { + let mockEmbeddedService: any; + + beforeEach(() => { + // Clean up any existing scripts and global objects + document.querySelectorAll('script[src*="bootstrap.min.js"]').forEach((el) => el.remove()); + delete (window as any).embeddedservice_bootstrap; + delete (window as any).__salesforceChatInitialized; + + // Create mock embeddedservice_bootstrap + mockEmbeddedService = { + settings: { + language: '' + }, + init: jest.fn(), + prechatAPI: { + setHiddenPrechatFields: jest.fn() + } + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('appends Salesforce bootstrap script once', () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + render(); + + const scripts = document.querySelectorAll('script[src*="bootstrap.min.js"]'); + expect(scripts.length).toBe(1); + expect(scripts[0].getAttribute('src')).toContain('bootstrap.min.js'); + expect(scripts[0].getAttribute('type')).toBe('text/javascript'); + }); + + it('initializes embeddedservice_bootstrap when script loads', async () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.init).toHaveBeenCalledWith( + '00DU0000000Kwch', + 'Web_Messaging_Deployment', + 'https://openstax.my.site.com/ESWWebMessagingDeployme1716235390398', + {scrt2URL: 'https://openstax.my.salesforce-scrt.com'} + ); + }); + }); + + it('sets sProduct for anonymous users', async () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website' + }); + }); + }); + + it('sets user information for logged-in users', async () => { + const mockUserContext = { + userModel: { + uuid: 'test-uuid-123', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + accountsModel: { + school_name: 'Test University' + } + } + }; + + (UserContext.default as jest.Mock).mockReturnValue(mockUserContext); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website', + OpenStax_UUID__c: 'test-uuid-123', + FirstName: 'John', + LastName: 'Doe', + Email: 'john.doe@example.com', + School: 'Test University' + }); + }); + }); + + it('sets partial user information when some fields are missing', async () => { + const mockUserContext = { + userModel: { + uuid: 'test-uuid-123', + first_name: 'John', + // Missing last_name, email, school + } + }; + + (UserContext.default as jest.Mock).mockReturnValue(mockUserContext); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website', + OpenStax_UUID__c: 'test-uuid-123', + FirstName: 'John' + }); + }); + }); + + it('removes script on unmount', () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + const {unmount} = render(); + + expect(document.querySelectorAll('script[src*="bootstrap.min.js"]').length).toBe(1); + + unmount(); + + expect(document.querySelectorAll('script[src*="bootstrap.min.js"]').length).toBe(0); + }); + + it('handles script load error gracefully', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + (UserContext.default as jest.Mock).mockReturnValue({}); + + render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + script.onerror?.(new Event('error')); + + expect(consoleError).toHaveBeenCalledWith('Failed to load Salesforce chat script'); + + consoleError.mockRestore(); + }); + + it('only initializes once even if user context changes', async () => { + const mockUserContext = { + userModel: { + uuid: 'test-uuid-123', + first_name: 'John' + } + }; + + (UserContext.default as jest.Mock).mockReturnValue(mockUserContext); + + const {rerender} = render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + // Update user context + (UserContext.default as jest.Mock).mockReturnValue({ + userModel: { + uuid: 'test-uuid-123', + first_name: 'Jane' + } + }); + + rerender(); + + // Should not initialize again + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + it('updates pre-chat fields when user logs in after initialization', async () => { + // Start with anonymous user + (UserContext.default as jest.Mock).mockReturnValue({}); + + const {rerender} = render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + // Verify initial fields (anonymous user) + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website' + }); + + // Clear mock calls + mockEmbeddedService.prechatAPI.setHiddenPrechatFields.mockClear(); + + // Simulate user logging in + (UserContext.default as jest.Mock).mockReturnValue({ + userModel: { + uuid: 'test-uuid-456', + first_name: 'Jane', + last_name: 'Doe', + email: 'jane.doe@example.com', + accountsModel: { + school_name: 'Example University' + } + } + }); + + rerender(); + + // Verify fields updated with user information + await waitFor(() => { + expect(mockEmbeddedService.prechatAPI.setHiddenPrechatFields).toHaveBeenCalledWith({ + sProduct: 'Website', + OpenStax_UUID__c: 'test-uuid-456', + FirstName: 'Jane', + LastName: 'Doe', + Email: 'jane.doe@example.com', + School: 'Example University' + }); + }); + + // Init should still only have been called once + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + it('preserves initialization state across component remounts', async () => { + (UserContext.default as jest.Mock).mockReturnValue({}); + + const {unmount} = render(); + + const script = document.querySelector('script[src*="bootstrap.min.js"]') as HTMLScriptElement; + + // Simulate script load and initialization + (window as any).embeddedservice_bootstrap = mockEmbeddedService; + script.onload?.(new Event('load')); + + await waitFor(() => { + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + }); + + // Unmount component + unmount(); + + // Verify script was removed from DOM + expect(document.querySelectorAll('script[src*="bootstrap.min.js"]').length).toBe(0); + + // Remount component - bootstrap object still exists in window, so no new script is created + render(); + + // Because window.embeddedservice_bootstrap still exists, the component short-circuits + // and doesn't inject a new script. scriptLoaded is set immediately. + // The initialization effect sees __salesforceChatInitialized is already true and doesn't re-init. + expect(mockEmbeddedService.init).toHaveBeenCalledTimes(1); + + // Verify no new script was added (short-circuit logic worked) + expect(document.querySelectorAll('script[src*="bootstrap.min.js"]').length).toBe(0); + }); +});