Build the kiosk shell

Build the kiosk application around an OmniLab experience: how it loads, its address, sign-in and the idle reset.

8 min read

For the developer building the kiosk application. The experience is OmniLab's; the screen, the scanner and everything around the experience are yours.

Choose how the kiosk loads the experience

SetupHow it worksUse it when
A WebViewThe kiosk app's WebView opens the experience address as its pageYour kiosk runs an app that controls the screen. It's the simpler setup, and the one to prefer.
A frame in a kiosk web pageYour page holds an iframe, written by hand or by the JavaScript tagYour kiosk runs a browser on a page you host

A frame must be on the kiosk page's own site

In a frame, sign-in only works when the experience is served from a subdomain of the kiosk page's domain. For a page at kiosk.lindenhall.example, serve the experience from play.lindenhall.example: see Custom domain. From any other domain, the browser treats the frame as third-party and blocks its cookies. A scanned loyalty card then stops at the identity check, every time, so the card values look wrong rather than the setup. The JavaScript tag writes the same kind of frame, so the rule applies to it too.

In a frame, avoid touchpoints that ask the visitor to sign in on screen. Through the JavaScript tag, that sign-in replaces your whole kiosk page. In a frame you write yourself, it runs inside the frame, and some identity providers refuse to load there: check on a device.

Kiosk URLs and parameters

Start from the touchpoint link in Studio. Add l with the kiosk's default language, then one parameter for each session:

SessionAddExample
A member scans a loyalty cardl, then fci= and the scanned valuehttps://play.lindenhall.example/lindenhall-autumn-kiosk?c=a315&l=en&fci=6345789012
A visitor starts without a cardl, then login=0https://play.lindenhall.example/lindenhall-autumn-kiosk?c=a315&l=en&login=0

l matters on a shared screen. The experience remembers a visitor's language choice on the device for 30 days, so without l the next visitor inherits it.

login=0 signs out whoever used the kiosk before. Without it, the next visitor can carry on as the last one. A new fci scan always starts a fresh sign-in, so it needs no login=0.

Leave embedded=1 out, in a WebView and in a frame fixed to the screen. It's meant for a page that grows the frame with the experience and scrolls to each panel. The JavaScript tag does both, and adds embedded=1 itself. Add it anywhere else and panels open off screen once the visitor scrolls a long page.

To fix the variant, add v. The full list is in Embed URLs and parameters.

Load different campaigns from one kiosk page

The JavaScript tag lets one kiosk page load whichever campaign its own address names. It adds embedded=1, and login=0 by default, so leave both out of id. It also sizes the frame to the experience and scrolls your page to each panel. It gives its frame no camera or location access, so use it only for experiences that need neither.

Kiosk page with the JavaScript tag
<div id="omnilab-container"></div>

<script>
  (function (b, o, n, u, s) {
    var a, t;
    a = b.createElement(u);
    a.async = 1;
    a.src = s;
    t = b.getElementsByTagName(u)[0];
    t.parentNode.insertBefore(a, t);
    o[n] = o[n] || [];
  })(document, window, "_om_async", "script", "https://cdn.21-digital.com/omplayer.js");

  _om_async.push([
    "init",
    {
      domain: "play.lindenhall.example",
      targetId: "omnilab-container",
      queryParam: "id",
    },
  ]);
</script>

The kiosk page's own address then picks the campaign:

  • https://kiosk.lindenhall.example/?id=lindenhall-autumn-kiosk opens the landing page, if the campaign has one.
  • https://kiosk.lindenhall.example/?id=lindenhall-autumn-kiosk%3Fc%3Da315%26l%3Den opens one touchpoint, in English.

URL-encode the whole value of id when it holds more than one parameter. The Staging script address and every option are in the JavaScript tag reference.

Start and end a session through the tag

The tag reads id once, when the kiosk page loads. So start each session by loading the kiosk page with that session's id, and end it by loading the page without one:

Start and end sessions on the kiosk page
const KIOSK_PAGE = "https://kiosk.lindenhall.example/";
const TOUCHPOINT = "lindenhall-autumn-kiosk?c=a315&l=en";

function startSession(scannedValue) {
  const experience = scannedValue ? TOUCHPOINT + "&fci=" + scannedValue : TOUCHPOINT;
  window.location.assign(KIOSK_PAGE + "?id=" + encodeURIComponent(experience));
}

function endSession() {
  window.location.assign(KIOSK_PAGE);
}

Without id, the tag loads nothing, so the page shows your welcome screen. A session without a card gets the tag's login=0, so it starts signed out.

Scanned values made of letters, digits, hyphens and dots pass through id intact. A value holding &, #, % or + arrives cut short or changed: for those, write the frame yourself, as in the kiosk page below.

The idle modal and message listener of the kiosk page below work here unchanged, with endSession() as written above. Set inSession to true when the page's address has an id.

Start a session as a known member with fci

Add the scanned value as fci, percent-encoded. What the visitor sees, and how to reword the dialog for an unknown card, is in Pass a known customer into an experience.

Two things to settle before kiosk testing:

  • The recognition integration. Members skip the sign-in screen only with a recognition integration, set up for each campaign. Ask your Customer Success Manager to confirm it works with your identity system, in the environment you test.
  • The addresses sign-in passes through. Sign-in briefly leaves the experience domain. A WebView that only opens approved addresses must allow those too: see Build the WebView.

For anonymous sessions, start with login=0 and put an acquisition form on the touchpoint. The visitor completes it on the kiosk keyboard before playing.

Layout messages

In a frame, the experience posts its height to your page. A frame fixed to the screen, like the sample below, can ignore it. Games fill the frame, a longer page scrolls inside it, and panels open on screen, as in a phone's browser.

A kiosk page that grows the frame with the experience, and scrolls, needs embedded=1 and every message in Messages between the page and the experience. The JavaScript tag handles them for you. In a WebView, there's no page around the experience, so there's nothing to handle.

Detect an abandoned session with omnilab:idle

A kiosk left mid-game blocks the next visitor. A small script inside the experience notices that nobody has touched the screen, and tells the kiosk. The kiosk then offers to reset.

SideWhat it doesWho builds it
Inside the experienceWatches for activity and sends omnilab:idleYou write the script. A Studio admin adds it once.
Around the experienceReceives the signal, shows the modal, ends the sessionYou, in the kiosk application

Neither half is built in. Both live in your codebase, and you build, test and maintain them.

Add the idle script

Idle detection, scoped to kiosk campaigns
<script>
(function () {
  // The path of each campaign that runs on a kiosk: its link after the domain.
  const KIOSK_CAMPAIGNS = ["/lindenhall-autumn-kiosk", "/lindenhall-christmas-kiosk"];
  const IDLE_TIMEOUT = 10000; // milliseconds without a touch

  const path = window.location.pathname;
  if (!KIOSK_CAMPAIGNS.some((c) => path === c || path.startsWith(c + "/"))) return;

  let timer;

  function sendIdle() {
    // In a frame, this reaches the kiosk page. In a WebView, it reaches this
    // page's own window, where the kiosk app's injected script picks it up.
    window.parent.postMessage({ type: "omnilab:idle", timestamp: Date.now() }, "*");
    reset();
  }

  function reset() {
    clearTimeout(timer);
    timer = setTimeout(sendIdle, IDLE_TIMEOUT);
  }

  ["click", "mousedown", "mousemove", "keydown", "scroll", "touchstart", "touchmove"].forEach(
    (type) => document.addEventListener(type, reset, { passive: true })
  );

  reset();
})();
</script>

Behaviour to know before you build the kiosk side:

  • The campaign list matches the path. Each entry is a campaign's link after the domain, such as /lindenhall-autumn-kiosk. On any other campaign, the script returns at once.
  • The signal repeats. An abandoned kiosk sends omnilab:idle every 10 seconds, not once.
  • Only interaction inside the experience counts. A tap on your own modal doesn't reset this timer.
  • It runs wherever a listed campaign opens. On a phone, nothing listens for the signal, so nothing happens.

Give the code to a Studio admin. They add it in the Global Organization's Scripts tab, with Script Context ALL and Trigger Event VISIT: see Add a script. ALL runs it in a WebView as well as in a frame; the campaign list keeps it off every other campaign. Every campaign it covers must then be published again.

Receive the signal in a kiosk web page

This page is a complete kiosk shell for the frame setup. It shows a welcome screen, starts a session on a scan or a tap, and runs the modal. Its Home button ends the session at any moment. Replace the address with your touchpoint link.

Kiosk page with the idle modal
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Lindenhall kiosk</title>
  <style>
    html, body { margin: 0; height: 100%; overflow: hidden; font-family: sans-serif; }
    #welcome, #omnilab-embed, #idle-modal { position: fixed; inset: 0; }
    #welcome { display: flex; align-items: center; justify-content: center; font-size: 5vh; }
    #omnilab-embed { width: 100%; height: 88vh; border: 0; }
    #home-button { position: fixed; left: 0; bottom: 0; width: 100%; height: 12vh; font-size: 4vh; }
    #idle-modal { display: flex; flex-direction: column; align-items: center; justify-content: center;
      gap: 3vh; background: rgba(0, 0, 0, 0.8); color: #fff; font-size: 4vh; }
    #idle-modal button { font-size: 3vh; padding: 2vh 6vh; }
    [hidden] { display: none !important; }
  </style>
</head>
<body>
  <div id="welcome">Scan your card, or touch the screen to start</div>
  <iframe id="omnilab-embed" allow="camera; geolocation" hidden></iframe>
  <button id="home-button" type="button" hidden>Home</button>
  <div id="idle-modal" role="alertdialog" aria-labelledby="idle-title" hidden>
    <p id="idle-title">Are you still there?</p>
    <p>Returning home in <span id="idle-countdown"></span> seconds</p>
    <button id="idle-stay" type="button">Stay</button>
    <button id="idle-home" type="button">Return home</button>
  </div>

  <script>
    const EXPERIENCE_ORIGIN = "https://play.lindenhall.example";
    const TOUCHPOINT_URL = EXPERIENCE_ORIGIN + "/lindenhall-autumn-kiosk?c=a315&l=en";
    const COUNTDOWN_SECONDS = 10;
    const GRACE_MS = 30000; // after Stay, ignore the signal for this long

    const welcome = document.getElementById("welcome");
    const frame = document.getElementById("omnilab-embed");
    const modal = document.getElementById("idle-modal");
    const countdown = document.getElementById("idle-countdown");
    const homeButton = document.getElementById("home-button");

    let inSession = false;
    let countdownTimer = null;
    let ignoreIdleUntil = 0;

    function startSession(scannedValue) {
      const who = scannedValue ? "&fci=" + encodeURIComponent(scannedValue) : "&login=0";
      frame.src = TOUCHPOINT_URL + who;
      frame.hidden = false;
      homeButton.hidden = false;
      welcome.hidden = true;
      inSession = true;
      ignoreIdleUntil = 0;
    }

    function endSession() {
      clearInterval(countdownTimer);
      modal.hidden = true;
      frame.src = "about:blank";
      frame.hidden = true;
      homeButton.hidden = true;
      welcome.hidden = false;
      inSession = false;
    }

    function showIdleModal() {
      let remaining = COUNTDOWN_SECONDS;
      countdown.textContent = remaining;
      modal.hidden = false;
      countdownTimer = setInterval(() => {
        remaining -= 1;
        countdown.textContent = remaining;
        if (remaining <= 0) endSession();
      }, 1000);
    }

    function stay() {
      clearInterval(countdownTimer);
      modal.hidden = true;
      // Taps on this modal never reach the experience, so its timer is still
      // running and signals again within seconds. Give the visitor time first.
      ignoreIdleUntil = Date.now() + GRACE_MS;
    }

    document.getElementById("idle-stay").addEventListener("click", stay);
    document.getElementById("idle-home").addEventListener("click", endSession);
    homeButton.addEventListener("click", endSession);

    window.addEventListener("message", (event) => {
      if (event.origin !== EXPERIENCE_ORIGIN) return;
      if (!event.data || event.data.type !== "omnilab:idle") return;
      if (!inSession || !modal.hidden || Date.now() < ignoreIdleUntil) return;
      showIdleModal();
    });

    // Most USB scanners type the barcode, then press Enter.
    let scanned = "";
    document.addEventListener("keydown", (event) => {
      if (inSession) return;
      if (event.key === "Enter") {
        startSession(scanned.trim());
        scanned = "";
      } else if (event.key.length === 1) {
        scanned += event.key;
      }
    });
    welcome.addEventListener("click", () => startSession(""));
  </script>
</body>
</html>

What each part guards against:

  • The origin check. A shell that acts on every message acts on messages from any page the frame loads.
  • The modal.hidden check. Without it, each repeated signal restarts the countdown, and the modal never times out.
  • The grace period. Without it, the modal comes back within 10 seconds of Stay.
  • about:blank on the way home. The experience can't unload itself. If the kiosk doesn't act on the signal, nothing happens.
  • The Home button. It has its own bar below the frame, so it never covers the experience. It ends the session the same way the countdown does.

Receive the signal in a WebView

In a WebView, the experience is the top-level page, so window.parent is the page itself. The signal lands on the experience's own window. Inject this script into every page the WebView loads, to pass the signal on to your app:

Injected into the experience page by the kiosk app
(function () {
  if (window.__omnilabIdleBridge) return;
  window.__omnilabIdleBridge = true;

  function forward(message) {
    const json = JSON.stringify(message);
    if (window.ReactNativeWebView) {
      window.ReactNativeWebView.postMessage(json); // React Native
    } else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.omnilab) {
      window.webkit.messageHandlers.omnilab.postMessage(json); // iOS WKWebView
    } else if (window.OmnilabBridge) {
      window.OmnilabBridge.postMessage(json); // Android WebView
    }
  }

  window.addEventListener("message", (event) => {
    if (event.source !== window || event.origin !== window.location.origin) return;
    if (event.data && event.data.type === "omnilab:idle") forward(event.data);
  });
})();
true;
PlatformInject the script withReceive the signal with
React NativeThe injectedJavaScript proponMessage, in event.nativeEvent.data
iOS WKWebViewA WKUserScript at document end, main frame onlyA WKScriptMessageHandler added under the name omnilab
Android WebViewevaluateJavascript, in onPageFinishedaddJavascriptInterface under the name OmnilabBridge, with a @JavascriptInterface method postMessage(String)

Build the modal and the Home button natively, with the same rules as the kiosk page above: one countdown at a time, a grace period after Stay, and Return home. To end the session, replace the experience with your welcome screen. Start the next one by loading the address again, with l, then fci or login=0.

What else the kiosk application handles

  • Device permissions for the camera and location, if the experience uses them.
  • Scanner hardware and other peripherals.
  • Logging and remote support diagnostics for the kiosk itself.
  • Any server-to-server API access, from your own backend. Never put OmniLab client credentials in the kiosk page or the WebView bundle.

Next steps

On this page