Skip to content

Tutorial: How to embed external content in the app

External content from a third-party provider, such as a live chat, a loyalty page, a review widget, or a guided-selling wizard, is usually delivered as a snippet written for a website. This tutorial explains how to integrate such content into a Shopgate app.

It covers the four available approaches and how to choose between them, followed by the topics that apply to all of them: cookie consent, safe areas, and navigation from the external content back into the app.

Before you begin this tutorial, complete the following tasks:

The approach depends on where the content appears in the app.

The content is Approach Component type
A widget that floats above the app on every page, for example a chat button Load the provider SDK once and let it render itself subscribers
A provider page the user navigates to, for example a rewards page Register a route and load the provider script on it portals on app.routes
A provider web application, for example a configurator Embed it in an iframe on a route of its own portals on app.routes
A page with its own session or login Open it in the In-App Browser subscribers or a button

The first three approaches keep the content inside the app. Use the In-App Browser when the content requires its own session, its own login, or cannot be embedded.

Loading a Provider Script on Its Own Route

Section titled “Loading a Provider Script on Its Own Route”

Use this approach when the provider supplies a loader script that renders into a container element. The extension registers a route, the route loads the script, and the provider renders the content.

Register the route as a portal on the app.routes target.

{
"id": "@myAwesomeOrganization/rewards",
"components": [
{
"id": "RewardsPage",
"target": "app.routes",
"type": "portals",
"path": "frontend/routes/Rewards/index.jsx"
}
]
}

The portal component declares the route pattern.

frontend/routes/Rewards/index.jsx
import React from 'react';
import { Route } from '@shopgate/engage/components';
import Content from './Content';
export const REWARDS_ROUTE_PATTERN = '/rewards';
export default () => (
<Route pattern={REWARDS_ROUTE_PATTERN} component={Content} />
);

The content component injects the provider script into the document head. The load event of the script element is not sufficient to determine that the provider is ready, because most loader scripts request further resources and define their global object afterwards. Poll for the global object that your code requires, and render a loading indicator until it is available.

frontend/routes/Rewards/Content.jsx
import React, { useState, useCallback, useRef } from 'react';
import { Helmet } from 'react-helmet';
import { View, LoadingIndicator } from '@shopgate/engage/components';
import { useThemeComponents } from '@shopgate/engage/core/hooks';
// eslint-disable-next-line import/extensions
import { providerId, instanceId, pageTitle } from '../../config.json';
const SCRIPT_ID = 'rewards-provider-script';
const RewardsContent = () => {
const { AppBar } = useThemeComponents();
const [ready, setReady] = useState(false);
const pollingRef = useRef(null);
const onScriptLoad = useCallback(() => {
const check = () => {
if (window?.providerWidgets?.init) {
clearInterval(pollingRef.current);
setReady(true);
}
};
pollingRef.current = setInterval(check, 200);
check();
}, []);
const onChangeClientState = useCallback(() => {
const script = document.getElementById(SCRIPT_ID);
if (script && !ready) {
script.addEventListener('load', onScriptLoad);
}
}, [onScriptLoad, ready]);
return (
<View>
<AppBar title={pageTitle} />
<Helmet onChangeClientState={onChangeClientState}>
<script async src={`https://cdn.provider.example/loader/${providerId}`} id={SCRIPT_ID} />
</Helmet>
{ready
? <div className="provider-widget-instance" data-provider-instance-id={instanceId} />
: <LoadingIndicator />}
</View>
);
};
export default RewardsContent;

Providers that show user-specific content usually expect a token derived from a secret key. The secret key must not be available in the frontend. Create the token in a backend step that is hooked into the user pipeline, and add it to the pipeline response.

{
"trusted": true,
"steps": [
{
"path": "extension/hooks/addProviderToken.js",
"description": "Injects the provider token into the getUser response",
"hooks": ["shopgate.user.getUser.v1:after"],
"input": [{ "key": "mail" }],
"output": [{ "key": "providerToken", "addPipelineOutput": true }]
}
]
}
extension/hooks/addProviderToken.js
const { createHash } = require('crypto')
module.exports = async (context, input) => {
const { apiKey } = context.config
if (!input || !input.mail) {
return { providerToken: null }
}
return {
providerToken: createHash('sha256').update(input.mail + apiKey).digest('hex')
}
}

The addPipelineOutput property adds the new field to the pipeline response instead of the step output only. The frontend then reads it from the user state with getUserData. Configure the secret apiKey with "destination": "backend", and all values that the frontend requires with "destination": "frontend".

If the page requires a logged-in user, protect the route instead of handling the logged-out state in the component.

frontend/subscriptions/index.js
import { appWillInit$ } from '@shopgate/engage/core/streams';
import { authRoutes } from '@shopgate/engage/core/collections';
import { LOGIN_PATH } from '@shopgate/engage/core/constants';
import { REWARDS_ROUTE_PATTERN } from '../routes/Rewards';
export default (subscribe) => {
subscribe(appWillInit$, () => {
authRoutes.set(REWARDS_ROUTE_PATTERN, LOGIN_PATH);
});
};

To make the route reachable, add an entry to the tab bar with a portal.

{
"id": "TabBarItem",
"target": "tab-bar.cart.after",
"type": "portals",
"path": "frontend/portals/TabBarItem/index.jsx"
}

If the feature belongs in the navigation menu instead, use one of the NavMenu portals.

Embedding a Provider Web Application in an iframe

Section titled “Embedding a Provider Web Application in an iframe”

Use this approach when the content is a web application that your page cannot render itself. Embed it in an iframe on a dedicated route and communicate with it through postMessage.

Point the iframe at an HTML document that you ship as an extension asset, not at the provider directly. The provider snippet is placed in that document, which keeps it out of the app and provides a single place to translate provider events into messages that the app handles.

frontend/components/Wizard/index.jsx
import React, { useRef, useEffect, useCallback } from 'react';
import { View } from '@shopgate/engage/components';
import { useThemeComponents } from '@shopgate/engage/core/hooks';
import { useRoute, logger } from '@shopgate/engage/core';
// eslint-disable-next-line import/extensions
import { iFrameURL, customerId } from '../../config.json';
const Wizard = () => {
const { AppBar } = useThemeComponents();
const { params: { wizardId } } = useRoute();
const iframeRef = useRef();
const handleMessage = useCallback((event) => {
// Only react to messages that came from this iframe.
if (iframeRef.current?.contentWindow !== event.source) {
return;
}
try {
const message = JSON.parse(event.data);
switch (message?.type) {
case 'window-open':
window.open(message.url);
break;
default:
logger.warn('Unhandled message from iframe', message);
}
} catch (e) {
logger.error('Failed to process iframe message', e);
}
}, []);
useEffect(() => {
window.addEventListener('message', handleMessage, false);
return () => window.removeEventListener('message', handleMessage);
}, [handleMessage]);
return (
<View noKeyboardListener>
<AppBar title="Wizard" />
<iframe
ref={iframeRef}
src={`${iFrameURL}?customerId=${customerId}&wizardId=${wizardId}`}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
title="wizard"
/>
</View>
);
};
export default Wizard;

Note the following requirements:

  • Compare event.source with the contentWindow of the iframe. Every window on the page can send messages, and without this comparison any script can invoke the handler.

  • Set the sandbox attribute and grant only the permissions that the embedded document requires. The list above covers a typical interactive provider application. Remove allow-popups if the document never opens a popup.

  • Add the cache prop to the route so that the route remains in the route stack. Without it, navigating from the embedded application to a product page and back restarts the session inside the iframe.

    <Route pattern={WIZARD_ROUTE_PATTERN} component={Wizard} cache />

The message channel is also used for app behavior that the embedded document cannot perform itself. To hide the tab bar while an input field inside the iframe has focus, for example, dispatch HIDE_TAB_BAR and SHOW_TAB_BAR from the message handler.

A chat widget is not bound to a page. It renders itself into the document, is displayed above the app, and has to be present on every route. There is no component to place, so the extension consists of a subscriber that initializes the provider SDK once.

frontend/subscriptions.js
import { createMessenger } from '@provider/messenger';
import { appDidStart$, routeDidEnter$, logger } from '@shopgate/engage/core';
// eslint-disable-next-line import/extensions
import { widgetKey, pagesWithoutWidget } from './config.json';
export default (subscribe) => {
let messenger = null;
subscribe(appDidStart$, async () => {
if (!widgetKey) {
logger.warn('No widget key configured');
return;
}
messenger = await createMessenger({ widgetKey });
});
subscribe(routeDidEnter$, ({ action }) => {
if (!messenger) {
return;
}
const hidden = pagesWithoutWidget.includes(action.route.pattern);
messenger.setVisibility({ main: true, button: !hidden });
});
};

Two adjustments are required in an app. The widget has to be hidden on routes where it would overlap important controls, such as the checkout button in the cart. The routeDidEnter$ subscription handles this, based on a list of route patterns from the extension configuration. In addition, the positioning of the provider has to be corrected, because it is written for a browser window. See Safe Areas and the Tab Bar.

External content usually sets third-party cookies. In most cases it must not be loaded before the user has accepted the corresponding cookie category. Subscribe to the consent stream instead of the app start stream.

import { comfortCookiesAccepted$ } from '@shopgate/engage/tracking/streams';
subscribe(comfortCookiesAccepted$, async () => {
messenger = await createMessenger({ widgetKey });
});

For content that occupies a whole route, render a fallback component instead of the iframe while the consent is missing, so that the page explains the state instead of showing an empty frame. The selector for this decision is getAreComfortCookiesAccepted from @shopgate/engage/tracking/selectors.

For more information about the cookie categories, see the Consent Manager guide.

Provider stylesheets position elements relative to the viewport. In the app, the viewport contains an app bar at the top and a tab bar at the bottom, and many devices add insets for the status bar, the notch, and the home indicator. Content that is anchored to bottom: 0 is therefore covered by the tab bar, and a full-screen overlay is displayed below the status bar.

The theme provides the required measurements as CSS variables.

Variable Description
--app-bar-height Height of the app bar at the top
--tabbar-height Height of the tab bar at the bottom
--footer-height Height of all elements below the content area
--safe-area-inset-top Inset for the status bar and the notch
--safe-area-inset-bottom Inset for the home indicator
import { injectGlobal } from '@shopgate/engage/styles';
injectGlobal({
'#provider-chat-button': {
bottom: 'max(calc(var(--footer-height) + 16px), max(var(--safe-area-inset-bottom), 16px))',
},
});

Verify the result on a device with a notch and on a tablet.

External content links to the website of the shop, because that is the only address it knows. Following such a link inside the app either leaves the app or opens a web page that duplicates an existing app screen.

Register a redirect for the URL pattern that the external content produces and resolve it to the matching app route.

frontend/subscriptions/index.js
import { appDidStart$, redirects } from '@shopgate/engage/core';
import { fetchSearchResults } from '@shopgate/engage/search';
import { getProductRoute } from '@shopgate/engage/product';
// eslint-disable-next-line import/extensions
import { productPagePattern } from '../config.json';
export default (subscribe) => {
subscribe(appDidStart$, ({ dispatch }) => {
redirects.set(productPagePattern, async ({ action }) => {
const { redirectMeta: { pathParams = {} } = {} } = action ?? {};
const searchPhrase = pathParams.productNumber;
if (!searchPhrase) {
return null;
}
const results = await dispatch(fetchSearchResults({
searchPhrase,
limit: 1,
resolveCachedProducts: true,
}));
const product = results?.products?.[0];
return product?.id ? getProductRoute(product.id) : null;
});
});
};

Add the URL pattern to the extension configuration, because every shop builds its product URLs differently. Returning null leaves the link unchanged, which is the expected result when the target cannot be resolved to an app route.

All values that differ per merchant belong in the configuration block of the extension-config.json. Use "destination": "frontend" for values that the user interface requires, and "destination": "backend" for secrets.

Typical entries are the account or widget ID of the provider, the URL of the embedded document, the route patterns on which a floating widget is hidden, the labels of the tab bar entry, and the URL pattern for the redirect.