HystoreWiki

6 min readView as Markdown

Isolated apps

An app is a small page with real JavaScript (a prize wheel, a price calculator, a quiz, a mini game, an animation) that the theme places anywhere in the store. It runs isolated, in a frame on another domain, and talks to the store only through a bridge with rules: it reads the public catalog, adds products to the cart and changes pages. It never reaches prices, payment, accounts or other visitors' data.

How it works

  • The app lives in apps/<name>/ in the theme, with index.html (required), scripts, styles and an optional app.json.
  • The theme places it on a page with <hystore-app data-app="<name>"></hystore-app>.
  • The store opens the app in a frame on hystoreapps.cloud, with a policy that blocks the network (fetch, XHR, WebSocket), cookies, storage, forms, new windows and navigating the store page.
  • The app calls window.hystore, the bridge, loaded by <script src="/sdk/v1.js">.

Step by step: a product wheel

Let's build a wheel that draws a product from the kits category and lets the visitor add it to the cart. Four files and one line in the theme.

1. Create the app folder

In the dashboard editor, click the + of the apps folder and name it roleta: it creates index.html, app.js and app.json at once. With the hystore tool or the AI, create the files below in apps/roleta/.

apps/roleta/index.html   the page (required)
apps/roleta/app.js       the script
apps/roleta/app.css      the style
apps/roleta/app.json     name, height and permissions

2. Declare the app

apps/roleta/app.jsonJSON
{
  "name": "Kit wheel",
  "description": "Draws a kit from the store",
  "height": 460,
  "permissions": ["customer"]
}
  • height: the frame's height, 40 to 2000 pixels (320 when missing).
  • name: read by screen readers.
  • permissions: only customer exists, which lets the app know the nick and skin of whoever is signed in. Without it, hystore.customer() is refused.

3. Write the page

apps/roleta/index.htmlHTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="app.css">
</head>
<body>
  <p id="hello" class="hello">Spin and find your kit</p>
  <div class="stage">
    <div class="pointer"></div>
    <div id="wheel" class="wheel"></div>
  </div>
  <button id="spin" class="spin" type="button" disabled>Spin</button>
  <div id="result" class="result" aria-live="polite"></div>

  <script src="/sdk/v1.js"></script>
  <script src="app.js"></script>
</body>
</html>

/sdk/v1.js is the bridge. The other files belong to the app, with relative addresses (app.css, app.js).

4. Style it

apps/roleta/app.cssCSS
* { box-sizing: border-box; }
body { margin: 0; padding: 16px; font: 15px/1.5 system-ui, sans-serif; color: #e8eef8; background: transparent; text-align: center; }
html.light body { color: #0b1526; }
.hello { margin: 0 0 12px; font-weight: 700; }
.stage { position: relative; width: 260px; height: 260px; margin: 0 auto 16px; }
.wheel { width: 100%; height: 100%; border-radius: 50%; border: 6px solid #f5b544; transition: transform 4s cubic-bezier(.17, .67, .12, .99); }
.pointer { position: absolute; top: -6px; left: 50%; z-index: 1; transform: translateX(-50%); border: 12px solid transparent; border-top: 22px solid #f5b544; }
.spin { padding: 10px 28px; border: 0; border-radius: 12px; background: #0ea5e9; color: #fff; font-weight: 700; cursor: pointer; }
.spin:disabled { opacity: .5; cursor: default; }
.result { min-height: 64px; margin-top: 14px; }
.result button { margin-left: 8px; padding: 6px 14px; border: 0; border-radius: 10px; background: #f5b544; color: #1c1305; font-weight: 700; cursor: pointer; }

5. Write the script

apps/roleta/app.jsJavaScript
(function () {
  var wheel = document.getElementById('wheel');
  var spin = document.getElementById('spin');
  var result = document.getElementById('result');
  var products = [];
  var turns = 0;

  // The store's colour mode and language.
  hystore.context().then(function (ctx) {
    document.documentElement.classList.toggle('light', ctx.colorMode === 'light');
  });

  // The nick of whoever is signed in (needs "customer" in app.json).
  hystore.customer().then(function (me) {
    if (me.loggedIn) document.getElementById('hello').textContent = 'Good luck, ' + me.nick + '!';
  }).catch(function () { /* without the permission: the default text stays */ });

  // The public products of the "kits" category.
  hystore.products({ category: 'kits', limit: 8 }).then(function (list) {
    products = list.filter(function (p) { return !p.soldOut; });
    if (!products.length) {
      result.textContent = 'No kit available right now.';
      return;
    }
    paint();
    spin.disabled = false;
  });

  function paint() {
    var slice = 360 / products.length;
    var colors = ['#0ea5e9', '#1d4ed8'];
    var stops = products.map(function (p, i) {
      return colors[i % 2] + ' ' + i * slice + 'deg ' + (i + 1) * slice + 'deg';
    });
    wheel.style.background = 'conic-gradient(' + stops.join(', ') + ')';
  }

  spin.addEventListener('click', function () {
    var i = Math.floor(Math.random() * products.length);
    var slice = 360 / products.length;
    turns += 5;
    wheel.style.transform = 'rotate(' + (turns * 360 - (i * slice + slice / 2)) + 'deg)';
    spin.disabled = true;
    setTimeout(function () {
      show(products[i]);
      spin.disabled = false;
    }, 4200);
  });

  function show(product) {
    result.textContent = '';
    var text = document.createElement('p');
    text.textContent = 'You got: ' + product.name + ' for ' + product.priceText;
    var buy = document.createElement('button');
    buy.type = 'button';
    buy.textContent = 'Add to cart';
    buy.addEventListener('click', function () {
      hystore.addToCart(product.id, 1).then(function (answer) {
        if (answer === 'added') hystore.open('cart');
        else if (answer === 'sold_out') buy.textContent = 'Sold out';
        // 'login': the store already asked the person to sign in.
      });
    });
    text.appendChild(buy);
    result.appendChild(text);
  }
})();

6. Place the app on a page

In a section or page of the theme, where the app should show:

sections/roleta.liquidLiquid
<section class="roleta">
  <h2>{{ section.settings.title }}</h2>
  <hystore-app data-app="roleta"></hystore-app>
</section>
{% schema %}
{
  "name": "Kit wheel",
  "settings": [
    { "id": "title", "type": "text", "label": "Title", "default": "Try your luck" }
  ]
}
{% endschema %}

Then add the section in Customize theme > Sections of the page, or in templates/home.json.

7. Look at the preview and publish

  1. Save. The editor's preview (and the preview link) show the app with the draft files.
  2. Try spinning and adding to the cart.
  3. Publish the theme. The store then uses the files of the published version.

The bridge: window.hystore

Every function returns a Promise. When refused, it rejects with an error whose message is the reason (see Errors).

Function What it does Returns
hystore.context() language, colour mode and store { locale, colorMode, store: { name, currency } }
hystore.products(options) active products, only what a visitor sees list of products
hystore.categories(options) active categories list of categories
hystore.addToCart(id, quantity) adds the product to the cart (quantity 1 to 99) 'added', 'login' or 'sold_out'
hystore.navigate(path) opens a store page, such as /loja or /noticias true
hystore.open(place) opens 'cart', 'login', 'account' or 'search' true
hystore.customer() who is looking (with customer in app.json) { loggedIn, nick, skinUrl }
hystore.resize(height) changes the frame's height (40 to 4000) the height applied
hystore.autosize() follows the height of the app's page by itself nothing

Options of products and categories

Option Type Example
category category slug 'kits'
server server id 'survival'
query search text (up to 80 characters) 'sword'
limit 1 to 48 (12 when missing) 6

categories only takes server.

A product

JSON
{
  "id": "0f558e5e-636a-4e5a-9739-24c711463ebf",
  "name": "Warrior kit",
  "slug": "warrior-kit",
  "url": "/loja/survival/kits/produto/warrior-kit",
  "price": 19.9,
  "priceText": "$19.90",
  "originalPrice": 29.9,
  "originalPriceText": "$29.90",
  "onSale": true,
  "image": "https://...",
  "shortDescription": "Sword, armour and 64 apples",
  "durationDays": null,
  "minQuantity": 1,
  "soldOut": false,
  "digital": false,
  "categorySlug": "kits",
  "serverId": "survival"
}

The price is only for showing: the cart uses the store's price, and the app only sends the product id. A category has id, name, slug, url, description, image and productCount.

Errors

Message When
bad_payload wrong argument (an id that is not a product, a quantity outside 1 to 99, a path outside the store)
permission customer() without "customer" in app.json
rate_limited too many calls in a row; wait a little
failed the store could not answer right now
hystore: open this app inside the store the app was opened outside the store (straight at its address)
hystore: no answer the store did not answer in 10 seconds

Rules and limits

  • Load only the app's own files and /sdk/v1.js. Scripts and styles from other sites, <iframe>, <object>, <embed> and <form> are refused when saved, with the line.
  • Images: data: images or the theme's images by their full address (https://theme-assets.hystore.com.br/..., as the MCP list_assets and GET /v1/theme/assets give).
  • Size: 10 apps per theme, 20 files and 512 KB per app. Names in lowercase letters, numbers, - and _; files .html, .js, .css or .json.
  • Each message to the bridge has up to 4096 characters. Adding to the cart: up to 5 in a row, then one every 2 seconds. Changing pages and opening places: 2 in a row, then one every 2 seconds. Above that the answer is rate_limited, and a flood of messages is ignored.
  • The app never knows anyone's e-mail, balance, orders or cart.

Test and fix

  • The app is blank: open the browser's developer tools on the preview, pick the hystoreapps.cloud frame in the console and read the error. A file blocked by the policy (an outside script, for example) shows there.
  • "open this app inside the store": you opened the app's address directly. It only talks to the store inside the frame.
  • The height does not fit: adjust height in app.json or call hystore.autosize() at the start of the script.
  • I changed it and nothing shows: the live store uses the published version. Look at the preview (which uses the draft) or publish.

Ideas

  • VIP calculator: the player picks days and sees the right kit, with products({ category: 'vips' }).
  • A "which kit fits you" quiz, ending in addToCart.
  • An animated goal counter, reading context() for the colour mode.
  • A click mini game with the player's nick (customer()).