# 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">`.

> [!NOTE]
> Money and account pages (cart, payment, profile, sign-in) never show apps,
> even when the theme places one.

## 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/`.

```text
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

```json apps/roleta/app.json
{
  "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

```html apps/roleta/index.html
<!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

```css apps/roleta/app.css
* { 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

```js apps/roleta/app.js
(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);
  }
})();
```

> [!TIP]
> Build texts with `textContent` and `createElement`, as above. It is the right
> habit for any data that comes from outside, even inside the isolated frame.

### 6. Place the app on a page

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

```liquid sections/roleta.liquid
<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](#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()`).
