In-video shopping

Viewers add products to a cart without leaving the video and check out; the order reaches your page, or a payment plugin, through one contract.

Overview

A shoppable MP4E video has two official plugins working together. Each Product card has an Add to cart button; the Cart (mp4e:cart) keeps what the viewer added, shows a badge with the count and a panel with the items and the total, and hands the order over when the viewer presses Checkout. The video keeps playing while the viewer shops; it pauses for the checkout.

Make interactive sets all of this up when a video's products have prices: the Shopping cart feature adds the Cart and turns Add to cart on in the product cards.

MP4E never takes a payment itself. Who does is the Cart's Checkout setting:

  • Your site (the default) — the cart tells your page, which runs its own checkout and answers the cart (below).
  • Payment plugin — the cart hands the order to a plugin that talks to its own payment service (below).
Charge from your own prices
The order carries the prices the video showed. Anyone can edit a video file, so treat the order as what the viewer asked for: look each product and its price up on your server before you charge. Never put a secret key in a video or in a plugin's settings.

The order

PropTypeDefaultDescription
orderIdstring-A new random id for every checkout ("order_" and 12 letters and digits). Send it back with checkoutResult.
itemsItem[]-The lines of the cart, in the order they were added (shape below).
totalnumber-The sum of price × quantity over the items.
currencystring-The cart's currency, a three-letter code such as "USD". Every item is in it: a cart holds one currency.

Each item:

PropTypeDefaultDescription
objectIdstring-The tracked product the item came from, when it came from one. Absent for a product added from a timed card.
namestring-The product name, as the card showed it.
pricenumber-The unit price, a number (49.99), never text.
currencystring-The item's currency — always the order's.
quantitynumber-A whole number from 1 to 99.
An order
{
  "orderId": "order_k3f9x0q2m7ab",
  "items": [
    { "objectId": "obj_12", "name": "Studio headphones", "price": 129, "currency": "USD", "quantity": 1 },
    { "name": "Travel case", "price": 24.5, "currency": "USD", "quantity": 2 }
  ],
  "total": 178,
  "currency": "USD"
}

Checkout on your site

When the viewer presses Checkout, the cart pauses the video, shows Ready to check out (the items, the total and "Checkout continues on this site.") and emits checkout with { order }. Your page hears it through onEvent (or engine.onEvent) as plugin:mp4e:cart:checkout, as in the examples above. Listen for that name. The same event also arrives inside the plugin:emit envelope that every plugin event uses (see Player Events), but other plugins emit an event called checkout too, and the envelope's pluginId names the plugin instance that emitted it, not the plugin, so the envelope cannot tell you the cart sent it.

Until your page answers, the viewer sees the Ready view; nothing else happens.

Answering the cart

Answer with the cart's checkoutResult action, { orderId, status, message? }, through callPluginAction('mp4e:cart', 'checkoutResult', …) (exported by @mp4e/react and @mp4e/player; mp4e:cart is the Cart's plugin key). Send one or more — for example handled when your checkout opens, then completed once it is paid:

PropTypeDefaultDescription
handledstatus-Your page has taken over (it opened its own checkout). The cart's panel closes and the video stays paused for your page to resume. The order stays open, so a completed or failed sent later still lands.
completedstatus-The order is paid. The cart empties, shows a thank-you with the order code and emits checkoutCompleted.
failedstatus-The payment did not go through. The cart shows message (or "The payment did not go through.") and keeps its items so the viewer can try again; it emits checkoutFailed.

The call resolves with the cart's answer:

PropTypeDefaultDescription
{ accepted: true }answer-The result was applied.
{ accepted: true, ignored: true }answer-A handled or failed for an order the viewer cancelled: nothing changes.
{ accepted: false, reason: "orderId" }answer-No such open order — none is waiting, or it is another one.
{ accepted: false, reason: "status" }answer-status is not one of handled, completed, failed.

React

Checkout on your site (React)
import { MP4EPlayer, callPluginAction } from '@mp4e/react';

const CART = 'mp4e:cart';

async function checkout(order) {
  // Your page takes over: the cart's panel closes, the video stays paused.
  await callPluginAction(CART, 'checkoutResult', { orderId: order.orderId, status: 'handled' });

  // Your own checkout: look the prices up on your server, take the payment.
  const result = await openMyCheckout(order);

  await callPluginAction(CART, 'checkoutResult', result.paid
    ? { orderId: order.orderId, status: 'completed' }
    : { orderId: order.orderId, status: 'failed', message: result.message });
}

export function ShoppableVideo() {
  return (
    <MP4EPlayer
      src="/video.mp4"
      onEvent={(event, data) => {
        if (event === 'plugin:mp4e:cart:checkout') checkout(data.order);
      }}
    />
  );
}

Plain JavaScript

Checkout on your site (@mp4e/player)
import { callPluginAction } from '@mp4e/player';

// engine: the MP4Engine you created as in Getting Started

engine.onEvent('plugin:mp4e:cart:checkout', async ({ order }) => {
  const response = await fetch('/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(order),
  });
  const { paid, message } = await response.json();

  await callPluginAction('mp4e:cart', 'checkoutResult', paid
    ? { orderId: order.orderId, status: 'completed' }
    : { orderId: order.orderId, status: 'failed', message });
});

One order at a time

  • While an order is open the cart shows that order (its items and total) and offers no second Checkout, even after the panel is closed and opened again.
  • The cart holds still meanwhile: adding, removing and changing quantities are refused, and the viewer is told "Finish or cancel your checkout first".
  • The viewer can press Cancel checkout. The order is dropped in the video and nothing is sent to your page. If your page still answers completed for it, the cart honours it: it thanks the viewer and removes exactly that order's items. A late handled or failed for a cancelled order is ignored.
  • Closing the panel gives back the pause the checkout took (unless you answered handled, or the viewer already pressed play).

Following the cart

The cart also emits itemAdded { item }, cartChanged { items, count, total, currency }, checkoutCompleted { orderId } and checkoutFailed { orderId, message }, and keeps three variables current — mp4e:cart:items, mp4e:cart:count (the number of items) and mp4e:cart:total — which your page sees through variable:changed:

Mirror the cart on your page
<MP4EPlayer
  src="/video.mp4"
  onEvent={(event, data) => {
    if (event === 'plugin:mp4e:cart:cartChanged') {
      // { items, count, total, currency }
      setBasketCount(data.count);
    }
    if (event === 'plugin:mp4e:cart:checkoutCompleted') {
      analytics.track('video_order', { orderId: data.orderId });
    }
  }}
/>

The cart lasts for the viewing session; it is not kept when the page is reloaded.

Checkout through a payment plugin

With the Cart's checkout set to "plugin" and checkoutPlugin set to a payment plugin's key, the cart pauses the video, shows "Completing the payment…" and calls that plugin's pay action with the order and the key to answer it through. The cart does not emit checkout in this mode. A payment plugin — a background service or a visible one — implements:

PropTypeDefaultDescription
pay { order, replyTo }action-The cart calls it with the order and replyTo — the key to answer the cart through — when the viewer presses Checkout. Return { accepted: true } once the payment has started, or { accepted: false } when it cannot start; the cart then shows "The payment could not start. Please try again."
callAction(replyTo, 'checkoutResult', { orderId, status, message? })answer-Call it when the payment ends: status completed, or failed with a message for the viewer (handled works too). The same statuses as your page would send.
A payment plugin
// A payment plugin (key acme:pay)
mp4e.onAction('pay', ({ order, replyTo }) => {
  startPayment(order)                       // your payment service
    .then(() => mp4e.callAction(replyTo, 'checkoutResult',
      { orderId: order.orderId, status: 'completed' }))
    .catch((err) => mp4e.callAction(replyTo, 'checkoutResult',
      { orderId: order.orderId, status: 'failed', message: 'Your card was declined.' }));
  return { accepted: true };
});

No rule is needed: the plugin answers the cart directly. It can also emit events of its own (for example paymentCompleted) for your page and your analytics.

The payment plugin talks to its own payment service, and the server side (creating the payment, confirming it, fulfilling the order) stays yours — see Plugin-to-Plugin Communication for mp4e.onAction.

Choosing the checkout as the host

Your page decides over the creator: with overrides you can set the Cart's checkout and checkoutPlugin and inject your own payment plugin, without changing the file. Overrides match by id, so find the Cart overlay already in the video — the overlay whose type is mp4e:cart — and override it by its layer and overlay ids. Take them from the video, not from this page: Make interactive usually names them layer_mi_cart and overlay_mi_cart, but adds _2, _3… when the creator already used those ids, and a Cart added by hand has the creator's own ids (the Studio shows them on the overlay). An override whose ids match nothing adds a new overlay instead — a second Cart:

Use your payment plugin for every video
<MP4EPlayer
  src="/video.mp4"
  overrides={{
    // The Cart overlay already in the video (type 'mp4e:cart'), matched by its ids
    layers: [{
      id: 'layer_mi_cart',
      overlays: [{
        id: 'overlay_mi_cart',
        config: { checkout: 'plugin', checkoutPlugin: 'acme:pay' },
      }],
    }],
    // Your payment plugin, injected into the video
    plugins: { 'acme:pay': acmePayPlugin },
  }}
/>
One Cart per video
Change the Cart that is there; never add a second Cart overlay. Two carts would each take every Add to cart, and the viewer would see two badges.