# Apps isolados

Um app é uma pequena página com JavaScript de verdade (roleta, calculadora de
preço, quiz, mini jogo, animação) que o tema coloca em qualquer lugar da loja.
Ele roda isolado, num quadro em outro domínio, e conversa com a loja só por uma
ponte com regras: lê o catálogo público, põe produto no carrinho e troca de
página. Nunca chega em preço, pagamento, conta ou dados de outros visitantes.

## Como funciona

- O app mora em `apps/<nome>/` no tema, com `index.html` (obrigatório), scripts,
  estilos e um `app.json` opcional.
- O tema o coloca numa página com `<hystore-app data-app="<nome>"></hystore-app>`.
- A loja abre o app num quadro em `hystoreapps.cloud`, com uma política que
  bloqueia rede (`fetch`, XHR, WebSocket), cookies, armazenamento, formulários,
  janelas novas e navegação da página da loja.
- O app chama `window.hystore`, a ponte, carregada por `<script src="/sdk/v1.js">`.

> [!NOTE]
> Páginas de dinheiro e de conta (carrinho, pagamento, perfil, entrar) nunca
> mostram apps, nem quando o tema coloca um.

## Passo a passo: uma roleta de produtos

Vamos fazer uma roleta que sorteia um produto da categoria `kits` e deixa pôr
no carrinho. São quatro arquivos e uma linha no tema.

### 1. Crie a pasta do app

No editor do painel, clique no **+** da pasta `apps` e dê o nome `roleta`: ele
cria `index.html`, `app.js` e `app.json` de uma vez. Com a ferramenta hystore
ou a IA, crie os arquivos abaixo em `apps/roleta/`.

```text
apps/roleta/index.html   a página (obrigatória)
apps/roleta/app.js       o script
apps/roleta/app.css      o estilo
apps/roleta/app.json     nome, altura e permissões
```

### 2. Declare o app

```json apps/roleta/app.json
{
  "name": "Roleta de kits",
  "description": "Sorteia um kit da loja",
  "height": 460,
  "permissions": ["customer"]
}
```

- `height`: a altura do quadro, de 40 a 2000 pixels (320 se faltar).
- `name`: lido por leitores de tela.
- `permissions`: só existe `customer`, que deixa o app saber o nick e a skin
  de quem está logado. Sem ela, `hystore.customer()` é recusado.

### 3. Escreva a página

```html apps/roleta/index.html
<!doctype html>
<html lang="pt-BR">
<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">Gire e descubra o seu kit</p>
  <div class="stage">
    <div class="pointer"></div>
    <div id="wheel" class="wheel"></div>
  </div>
  <button id="spin" class="spin" type="button" disabled>Girar</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` é a ponte. Os outros arquivos são do próprio app, com endereço
relativo (`app.css`, `app.js`).

### 4. Dê o estilo

```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. Escreva o 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;

  // Cor e língua da loja.
  hystore.context().then(function (ctx) {
    document.documentElement.classList.toggle('light', ctx.colorMode === 'light');
  });

  // O nick de quem está logado (precisa de "customer" no app.json).
  hystore.customer().then(function (me) {
    if (me.loggedIn) document.getElementById('hello').textContent = 'Boa sorte, ' + me.nick + '!';
  }).catch(function () { /* sem a permissão: fica o texto padrão */ });

  // Os produtos públicos da categoria "kits".
  hystore.products({ category: 'kits', limit: 8 }).then(function (list) {
    products = list.filter(function (p) { return !p.soldOut; });
    if (!products.length) {
      result.textContent = 'Nenhum kit disponível agora.';
      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 = 'Saiu: ' + product.name + ' por ' + product.priceText;
    var buy = document.createElement('button');
    buy.type = 'button';
    buy.textContent = 'Pôr no carrinho';
    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 = 'Esgotado';
        // 'login': a loja já pediu para a pessoa entrar.
      });
    });
    text.appendChild(buy);
    result.appendChild(text);
  }
})();
```

> [!TIP]
> Monte textos com `textContent` e `createElement`, como acima. É o hábito
> certo para qualquer dado que vem de fora, mesmo dentro do quadro isolado.

### 6. Coloque o app numa página

Numa seção ou página do tema, onde o app deve aparecer:

```liquid sections/roleta.liquid
<section class="roleta">
  <h2>{{ section.settings.title }}</h2>
  <hystore-app data-app="roleta"></hystore-app>
</section>
{% schema %}
{
  "name": "Roleta de kits",
  "settings": [
    { "id": "title", "type": "text", "label": "Título", "default": "Tente a sorte" }
  ]
}
{% endschema %}
```

Depois adicione a seção em **Personalizar tema > Seções da página**, ou no
`templates/home.json`.

### 7. Veja na prévia e publique

1. Salve. A prévia do editor (e o link de prévia) mostram o app com os arquivos
   do **rascunho**.
2. Teste girar e pôr no carrinho.
3. Publique o tema. A loja passa a usar os arquivos da versão publicada.

## A ponte: window.hystore

Todas as funções devolvem uma *Promise*. Em caso de recusa, ela é rejeitada com
um erro cuja mensagem é o motivo (veja [Erros](#erros)).

| Função | O que faz | Devolve |
|---|---|---|
| `hystore.context()` | língua, modo de cor e loja | `{ locale, colorMode, store: { name, currency } }` |
| `hystore.products(opções)` | produtos ativos, só o que um visitante vê | lista de produtos |
| `hystore.categories(opções)` | categorias ativas | lista de categorias |
| `hystore.addToCart(id, quantidade)` | põe o produto no carrinho (quantidade de 1 a 99) | `'added'`, `'login'` ou `'sold_out'` |
| `hystore.navigate(caminho)` | abre uma página da loja, como `/loja` ou `/noticias` | `true` |
| `hystore.open(lugar)` | abre `'cart'`, `'login'`, `'account'` ou `'search'` | `true` |
| `hystore.customer()` | quem está olhando (com `customer` no `app.json`) | `{ loggedIn, nick, skinUrl }` |
| `hystore.resize(altura)` | muda a altura do quadro (40 a 4000) | a altura aplicada |
| `hystore.autosize()` | segue a altura da página do app sozinho | nada |

### Opções de products e categories

| Opção | Tipo | Exemplo |
|---|---|---|
| `category` | slug da categoria | `'kits'` |
| `server` | id do servidor | `'survival'` |
| `query` | texto da busca (até 80 letras) | `'espada'` |
| `limit` | 1 a 48 (12 se faltar) | `6` |

`categories` aceita só `server`.

### Um produto

```json
{
  "id": "0f558e5e-636a-4e5a-9739-24c711463ebf",
  "name": "Kit Guerreiro",
  "slug": "kit-guerreiro",
  "url": "/loja/survival/kits/produto/kit-guerreiro",
  "price": 19.9,
  "priceText": "R$ 19,90",
  "originalPrice": 29.9,
  "originalPriceText": "R$ 29,90",
  "onSale": true,
  "image": "https://...",
  "shortDescription": "Espada, armadura e 64 maçãs",
  "durationDays": null,
  "minQuantity": 1,
  "soldOut": false,
  "digital": false,
  "categorySlug": "kits",
  "serverId": "survival"
}
```

O preço é só para mostrar: o carrinho usa o preço da loja, e o app manda só o
`id` do produto. Uma categoria traz `id`, `name`, `slug`, `url`, `description`,
`image` e `productCount`.

### Erros

| Mensagem | Quando |
|---|---|
| `bad_payload` | argumento errado (id que não é de produto, quantidade fora de 1 a 99, caminho que não é da loja) |
| `permission` | `customer()` sem `"customer"` no `app.json` |
| `rate_limited` | muitas chamadas seguidas; espere um pouco |
| `failed` | a loja não conseguiu responder agora |
| `hystore: open this app inside the store` | o app foi aberto fora da loja (direto no endereço) |
| `hystore: no answer` | a loja não respondeu em 10 segundos |

## Regras e limites

- Carregue só os arquivos do próprio app e o `/sdk/v1.js`. Scripts e estilos de
  outros sites, `<iframe>`, `<object>`, `<embed>` e `<form>` são recusados ao
  salvar, com a linha.
- Imagens: imagens `data:` ou as imagens do tema pelo endereço completo
  (`https://theme-assets.hystore.com.br/...`, como `list_assets` do MCP e
  `GET /v1/theme/assets` informam).
- Tamanho: 10 apps por tema, 20 arquivos e 512 KB por app. Nomes em letras
  minúsculas, números, `-` e `_`; arquivos `.html`, `.js`, `.css` ou `.json`.
- Cada mensagem para a ponte tem até 4096 caracteres. Pôr no carrinho: até 5
  seguidas, depois uma a cada 2 segundos. Trocar de página e abrir lugares: 2
  seguidas, depois uma a cada 2 segundos. Acima disso a resposta é
  `rate_limited`, e uma enxurrada de mensagens é ignorada.
- O app não sabe e-mail, saldo, pedidos nem o carrinho de ninguém.

## Testar e corrigir

- **O app ficou em branco**: abra as ferramentas do navegador na prévia,
  escolha o quadro `hystoreapps.cloud` no console e veja o erro. Um arquivo
  recusado pela política (um script de fora, por exemplo) aparece ali.
- **"open this app inside the store"**: você abriu o endereço do app direto.
  Ele só fala com a loja dentro do quadro.
- **A altura não cabe**: ajuste `height` no `app.json` ou chame
  `hystore.autosize()` no começo do script.
- **Mudei e não aparece**: a loja no ar usa a versão publicada. Veja na prévia
  (que usa o rascunho) ou publique.

## Ideias

- Calculadora de VIP: o jogador escolhe dias e vê o kit certo, com
  `products({ category: 'vips' })`.
- Quiz "qual kit combina com você", terminando em `addToCart`.
- Contador de meta com animação, lendo `context()` para o modo de cor.
- Mini jogo de clique com o nick do jogador (`customer()`).
