Skip to content

Commit 98523d8

Browse files
Move stuff; polish things
1 parent 1fa80c7 commit 98523d8

File tree

6 files changed

+255
-171
lines changed

6 files changed

+255
-171
lines changed

src/components/HelpMenu.spec.tsx

Lines changed: 0 additions & 30 deletions
This file was deleted.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { BodyPortalSlotsContext } from '../BodyPortalSlotsContext';
3+
import { HelpMenu, HelpMenuItem } from '.';
4+
import { NavBar } from '../NavBar';
5+
6+
describe('HelpMenu', () => {
7+
let root: HTMLElement;
8+
9+
beforeEach(() => {
10+
root = document.createElement('main');
11+
root.id = 'root';
12+
document.body.append(root);
13+
});
14+
15+
it('matches snapshot', () => {
16+
render(
17+
<BodyPortalSlotsContext.Provider value={['nav', 'root']}>
18+
<NavBar logo>
19+
<HelpMenu contactFormParams={[{key: 'userId', value: 'test'}]}>
20+
<HelpMenuItem onAction={() => window.alert('Ran HelpMenu callback function')}>
21+
Test Callback
22+
</HelpMenuItem>
23+
</HelpMenu>
24+
</NavBar>
25+
</BodyPortalSlotsContext.Provider>
26+
);
27+
28+
expect(document.body).toMatchSnapshot();
29+
});
30+
31+
it('shows loading icon while SDK is loading', () => {
32+
render(<HelpMenu contactFormParams={[{key: 'userId', value: 'test'}]} />);
33+
expect(screen.getByRole('button')).toBeDisabled();
34+
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
35+
});
36+
37+
it('replaces button when SDK is ready', async () => {
38+
// mock the global embeddedservice_bootstrap object
39+
const mockSvc = {
40+
init: jest.fn(),
41+
utilAPI: { launchChat: jest.fn() },
42+
};
43+
(window as any).embeddedservice_bootstrap = mockSvc;
44+
const addEventListenerCalls: Parameters<typeof window.addEventListener>[] = [];
45+
window.addEventListener = jest.fn().mockImplementation((...args: Parameters<typeof window.addEventListener>) => {
46+
addEventListenerCalls.push(args);
47+
});
48+
49+
render(<HelpMenu contactFormParams={[{key: 'userId', value: 'test'}]} />);
50+
const btn = await screen.findByRole('button', { name: /chat with us/i });
51+
expect(btn).not.toBeDisabled();
52+
53+
fireEvent.click(btn);
54+
expect(mockSvc.utilAPI.launchChat).toHaveBeenCalled();
55+
});
56+
});
57+

src/components/HelpMenu.stories.tsx renamed to src/components/HelpMenu/HelpMenu.stories.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createGlobalStyle } from 'styled-components';
2-
import { BodyPortalSlotsContext } from './BodyPortalSlotsContext';
3-
import { HelpMenu, HelpMenuItem } from './HelpMenu';
4-
import { NavBar } from './NavBar';
2+
import { BodyPortalSlotsContext } from '../BodyPortalSlotsContext';
3+
import { HelpMenu, HelpMenuItem } from '.';
4+
import { NavBar } from '../NavBar';
55

66
const BodyPortalGlobalStyle = createGlobalStyle`
77
[data-portal-slot="nav"] {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export const orgId = '00DU0000000Kwch';
2+
export const app = 'Web_Messaging_Deployment';
3+
export const deployment = 'ESWWebMessagingDeployme1716235390398';
4+
export const deploymentURL = `https://openstax.my.site.com/${deployment}`;
5+
export const scrt2URL = 'https://openstax.my.salesforce-scrt.com';
6+
export const scriptUrl = `${deploymentURL}/assets/js/bootstrap.min.js`;
7+
export const businessHoursURL =
8+
`${scrt2URL}/embeddedservice/v1/businesshours?orgId=${orgId}&esConfigName=${app}`;
9+
export const chatEmbedDefaults = {
10+
orgId,
11+
app,
12+
deploymentURL,
13+
scrt2URL,
14+
scriptUrl,
15+
businessHoursURL,
16+
} as const;
17+
18+
export const embeddedChatEvents = {
19+
READY: 'onEmbeddedMessagingReady',
20+
BUSINESS_HOURS_STARTED: 'onEmbeddedMessagingBusinessHoursStarted',
21+
BUSINESS_HOURS_ENDED: 'onEmbeddedMessagingBusinessHoursEnded',
22+
} as const;

src/components/HelpMenu/hooks.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import React from "react";
2+
import { embeddedChatEvents } from "./constants";
3+
4+
export interface WindowWithEmbed extends Window {
5+
embeddedservice_bootstrap?: any;
6+
}
7+
8+
export interface ChatEmbedServiceConfiguration {
9+
orgId: string,
10+
app: string,
11+
deploymentURL: string,
12+
scrt2URL: string,
13+
scriptUrl: string,
14+
businessHoursURL: string,
15+
}
16+
17+
export interface BusinessHours {
18+
startTime: number;
19+
endTime: number;
20+
}
21+
22+
export interface BusinessHoursResponse {
23+
businessHoursInfo: {
24+
isActive: boolean;
25+
businessHours: BusinessHours[];
26+
};
27+
}
28+
29+
export interface ChatEmbed extends BusinessHours {
30+
openChat: () => void;
31+
}
32+
33+
export const useScript = (src: string) => {
34+
const [ready, setReady] = React.useState(false);
35+
const [error, setError] = React.useState<Error | null>(null);
36+
37+
const scriptRef = React.useRef<HTMLScriptElement | null>(null);
38+
39+
React.useEffect(() => {
40+
// Already in the DOM? No need to add it again.
41+
if (document.querySelector(`script[src="${src}"]`)) {
42+
setReady(true);
43+
return;
44+
}
45+
46+
const script = document.createElement('script');
47+
script.src = src;
48+
script.async = true;
49+
script.onload = () => setReady(true);
50+
script.onerror = () => setError(new Error(`Failed to load ${src}`));
51+
document.head.appendChild(script);
52+
scriptRef.current = script;
53+
}, [src]);
54+
55+
return { ready, error };
56+
};
57+
58+
export const getBusinessHours = async (
59+
businessHoursURL: string,
60+
signal?: AbortSignal
61+
) => {
62+
const res = await fetch(businessHoursURL, { signal });
63+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
64+
return (await res.json()) as BusinessHoursResponse;
65+
};
66+
67+
export const getChatEmbed = async (businessHoursURL: string, signal?: AbortSignal) => {
68+
const { businessHoursInfo } = await getBusinessHours(businessHoursURL, signal);
69+
70+
const today = new Date().toISOString().slice(0, 10);
71+
const todaysHours = businessHoursInfo.businessHours.find(
72+
(h: BusinessHours) => today === new Date(h.startTime).toISOString().slice(0, 10)
73+
);
74+
75+
const openChat = async () => {
76+
const svc = (window as WindowWithEmbed).embeddedservice_bootstrap;
77+
if (!svc) return undefined;
78+
return await svc.utilAPI.launchChat();
79+
}
80+
81+
return !businessHoursInfo.isActive || todaysHours === undefined
82+
? null
83+
: { ...todaysHours, openChat };
84+
};
85+
86+
export const useEmbeddedChatService = ({
87+
orgId,
88+
app,
89+
deploymentURL,
90+
scrt2URL,
91+
scriptUrl,
92+
businessHoursURL,
93+
}: ChatEmbedServiceConfiguration) => {
94+
const [chatEmbed, setChatEmbed] = React.useState<ChatEmbed | null>(null);
95+
const [loading, setLoading] = React.useState(false);
96+
const [fetchError, setFetchError] = React.useState<Error | null>(null);
97+
98+
const { ready: scriptLoaded, error: scriptError } = useScript(scriptUrl);
99+
const controllerRef = React.useRef<AbortController | null>(null);
100+
101+
const updateChatEmbed = React.useCallback(({ signal }: AbortController) => {
102+
return () => {
103+
setLoading(true);
104+
setFetchError(null);
105+
106+
return getChatEmbed(businessHoursURL, signal)
107+
.then(setChatEmbed)
108+
.catch((err) => {
109+
if ((err as any).name !== "AbortError") {
110+
setFetchError(err);
111+
setChatEmbed(null);
112+
}
113+
})
114+
.finally(() => setLoading(false));
115+
}
116+
}, [businessHoursURL]);
117+
118+
React.useEffect(() => {
119+
if (!scriptLoaded || typeof window === 'undefined') return;
120+
121+
controllerRef.current = new AbortController();
122+
const update = updateChatEmbed(controllerRef.current);
123+
124+
// Don't try to set business hours until we know the chat is ready
125+
window.addEventListener(embeddedChatEvents.READY, update);
126+
window.addEventListener(embeddedChatEvents.BUSINESS_HOURS_STARTED, update);
127+
window.addEventListener(embeddedChatEvents.BUSINESS_HOURS_ENDED, update);
128+
129+
try {
130+
// `embeddedservice_bootstrap` is injected by the script we just added
131+
const svc = (window as WindowWithEmbed).embeddedservice_bootstrap;
132+
svc.settings.language = 'en_US';
133+
svc.settings.hideChatButtonOnLoad = true;
134+
svc.init(orgId, app, deploymentURL, { scrt2URL });
135+
} catch (e) {
136+
console.error('Error initializing Embedded Messaging', e);
137+
}
138+
139+
return () => {
140+
controllerRef.current?.abort();
141+
controllerRef.current = null;
142+
window.removeEventListener(embeddedChatEvents.READY, update);
143+
window.removeEventListener(embeddedChatEvents.BUSINESS_HOURS_STARTED, update);
144+
window.removeEventListener(embeddedChatEvents.BUSINESS_HOURS_ENDED, update);
145+
};
146+
}, [scriptLoaded, updateChatEmbed, orgId, app, deploymentURL, scrt2URL]);
147+
148+
return { chatEmbed, loading, error: scriptError ?? fetchError };
149+
}

0 commit comments

Comments
 (0)