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
3 changes: 3 additions & 0 deletions src/app/components/chat/chat.scss
Original file line number Diff line number Diff line change
@@ -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
174 changes: 174 additions & 0 deletions src/app/components/chat/chat.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>) => 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);

Comment thread
RoyEJohnson marked this conversation as resolved.
Comment thread
RoyEJohnson marked this conversation as resolved.
// 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;

Comment thread
RoyEJohnson marked this conversation as resolved.
// 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);
}
Comment thread
RoyEJohnson marked this conversation as resolved.
// 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';
}
Comment thread
RoyEJohnson marked this conversation as resolved.
// Note: Don't delete window.embeddedservice_bootstrap or __salesforceChatInitialized
// to maintain conversation state across component remounts
};
Comment thread
RoyEJohnson marked this conversation as resolved.
}, []);

// 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;
}
}
Comment thread
RoyEJohnson marked this conversation as resolved.
}, [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;
}
Comment thread
RoyEJohnson marked this conversation as resolved.

// Set sProduct for all users (logged in or not)
const hiddenFields: Record<string, string> = {
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;
}
3 changes: 1 addition & 2 deletions src/app/components/form-header/form-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ function FormHeader({
data: Record<string, string>;
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]);
Expand Down
38 changes: 38 additions & 0 deletions src/app/components/shell/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<Layout>
Expand All @@ -91,6 +128,7 @@ function MainRoutes() {
<Route path="/details/*" element={<NonPortalRouteWrapper><DetailsRoutes /></NonPortalRouteWrapper>} />
<Route path="/:dir/*" element={<OtherPageRoutes />} />
</Routes>
{showChat && <Chat />}
</Layout>
);
}
9 changes: 8 additions & 1 deletion src/app/contexts/shared-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/app/contexts/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(() => {
Expand Down
6 changes: 2 additions & 4 deletions src/app/pages/adoption/adoption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -297,8 +296,7 @@ export default function AdoptionForm() {
const [selectedRole, setSelectedRole] = useState('');
const [hideRoleSelector, setHideRoleSelector] = useState(false);
const ref = useRef<HTMLDivElement>(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);
Expand Down
Loading
Loading