# Apps aisladas

Una app es una pequeña página con JavaScript de verdad (ruleta, calculadora de
precio, quiz, mini juego, animación) que el tema coloca en cualquier lugar de
la tienda. Corre aislada, en un marco en otro dominio, y habla con la tienda
solo por un puente con reglas: lee el catálogo público, pone productos en el
carrito y cambia de página. Nunca llega a precios, pagos, cuentas ni datos de
otros visitantes.

## Cómo funciona

- La app vive en `apps/<nombre>/` en el tema, con `index.html` (obligatorio),
  scripts, estilos y un `app.json` opcional.
- El tema la coloca en una página con `<hystore-app data-app="<nombre>"></hystore-app>`.
- La tienda abre la app en un marco en `hystoreapps.cloud`, con una política
  que bloquea la red (`fetch`, XHR, WebSocket), cookies, almacenamiento,
  formularios, ventanas nuevas y la navegación de la página de la tienda.
- La app llama a `window.hystore`, el puente, cargado por `<script src="/sdk/v1.js">`.

> [!NOTE]
> Las páginas de dinero y de cuenta (carrito, pago, perfil, inicio de sesión)
> nunca muestran apps, aunque el tema coloque una.

## Paso a paso: una ruleta de productos

Vamos a hacer una ruleta que sortea un producto de la categoría `kits` y deja
ponerlo en el carrito. Son cuatro archivos y una línea en el tema.

### 1. Crea la carpeta de la app

En el editor del panel, haz clic en el **+** de la carpeta `apps` y ponle el
nombre `roleta`: crea `index.html`, `app.js` y `app.json` de una vez. Con la
herramienta hystore o la IA, crea los archivos de abajo en `apps/roleta/`.

```text
apps/roleta/index.html   la página (obligatoria)
apps/roleta/app.js       el script
apps/roleta/app.css      el estilo
apps/roleta/app.json     nombre, altura y permisos
```

### 2. Declara la app

```json apps/roleta/app.json
{
  "name": "Ruleta de kits",
  "description": "Sortea un kit de la tienda",
  "height": 460,
  "permissions": ["customer"]
}
```

- `height`: la altura del marco, de 40 a 2000 píxeles (320 si falta).
- `name`: lo leen los lectores de pantalla.
- `permissions`: solo existe `customer`, que deja a la app saber el nick y la
  skin de quien inició sesión. Sin él, `hystore.customer()` se rechaza.

### 3. Escribe la página

```html apps/roleta/index.html
<!doctype html>
<html lang="es">
<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">Gira y descubre tu 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` es el puente. Los otros archivos son de la propia app, con
dirección relativa (`app.css`, `app.js`).

### 4. Dale 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. Escribe el 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;

  // El modo de color y el idioma de la tienda.
  hystore.context().then(function (ctx) {
    document.documentElement.classList.toggle('light', ctx.colorMode === 'light');
  });

  // El nick de quien inició sesión (necesita "customer" en app.json).
  hystore.customer().then(function (me) {
    if (me.loggedIn) document.getElementById('hello').textContent = '¡Suerte, ' + me.nick + '!';
  }).catch(function () { /* sin el permiso: queda el texto estándar */ });

  // Los productos públicos de la categoría "kits".
  hystore.products({ category: 'kits', limit: 8 }).then(function (list) {
    products = list.filter(function (p) { return !p.soldOut; });
    if (!products.length) {
      result.textContent = 'Ningún kit disponible ahora.';
      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 = 'Salió: ' + product.name + ' por ' + product.priceText;
    var buy = document.createElement('button');
    buy.type = 'button';
    buy.textContent = 'Poner en el carrito';
    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 = 'Agotado';
        // 'login': la tienda ya le pidió a la persona que inicie sesión.
      });
    });
    text.appendChild(buy);
    result.appendChild(text);
  }
})();
```

> [!TIP]
> Arma los textos con `textContent` y `createElement`, como arriba. Es el hábito
> correcto para cualquier dato que viene de afuera, incluso dentro del marco
> aislado.

### 6. Coloca la app en una página

En una sección o página del tema, donde la app debe aparecer:

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

Después agrega la sección en **Personalizar tema > Secciones de la página**, o
en `templates/home.json`.

### 7. Mira la vista previa y publica

1. Guarda. La vista previa del editor (y el enlace de vista previa) muestran la
   app con los archivos del **borrador**.
2. Prueba girar y poner en el carrito.
3. Publica el tema. La tienda pasa a usar los archivos de la versión
   publicada.

## El puente: window.hystore

Todas las funciones devuelven una *Promise*. Si se rechaza, falla con un error
cuyo mensaje es el motivo (mira [Errores](#errores)).

| Función | Qué hace | Devuelve |
|---|---|---|
| `hystore.context()` | idioma, modo de color y tienda | `{ locale, colorMode, store: { name, currency } }` |
| `hystore.products(opciones)` | productos activos, solo lo que ve un visitante | lista de productos |
| `hystore.categories(opciones)` | categorías activas | lista de categorías |
| `hystore.addToCart(id, cantidad)` | pone el producto en el carrito (cantidad de 1 a 99) | `'added'`, `'login'` o `'sold_out'` |
| `hystore.navigate(ruta)` | abre una página de la tienda, como `/loja` o `/noticias` | `true` |
| `hystore.open(lugar)` | abre `'cart'`, `'login'`, `'account'` o `'search'` | `true` |
| `hystore.customer()` | quién está mirando (con `customer` en `app.json`) | `{ loggedIn, nick, skinUrl }` |
| `hystore.resize(altura)` | cambia la altura del marco (40 a 4000) | la altura aplicada |
| `hystore.autosize()` | sigue sola la altura de la página de la app | nada |

### Opciones de products y categories

| Opción | Tipo | Ejemplo |
|---|---|---|
| `category` | slug de la categoría | `'kits'` |
| `server` | id del servidor | `'survival'` |
| `query` | texto de búsqueda (hasta 80 caracteres) | `'espada'` |
| `limit` | 1 a 48 (12 si falta) | `6` |

`categories` solo acepta `server`.

### Un producto

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

El precio es solo para mostrar: el carrito usa el precio de la tienda, y la app
manda solo el `id` del producto. Una categoría trae `id`, `name`, `slug`, `url`,
`description`, `image` y `productCount`.

### Errores

| Mensaje | Cuándo |
|---|---|
| `bad_payload` | argumento equivocado (un id que no es de producto, cantidad fuera de 1 a 99, una ruta que no es de la tienda) |
| `permission` | `customer()` sin `"customer"` en `app.json` |
| `rate_limited` | demasiadas llamadas seguidas; espera un poco |
| `failed` | la tienda no pudo responder ahora |
| `hystore: open this app inside the store` | la app se abrió fuera de la tienda (directo en su dirección) |
| `hystore: no answer` | la tienda no respondió en 10 segundos |

## Reglas y límites

- Carga solo los archivos de la propia app y `/sdk/v1.js`. Scripts y estilos de
  otros sitios, `<iframe>`, `<object>`, `<embed>` y `<form>` se rechazan al
  guardar, con la línea.
- Imágenes: imágenes `data:` o las imágenes del tema por su dirección completa
  (`https://theme-assets.hystore.com.br/...`, como informan `list_assets` del
  MCP y `GET /v1/theme/assets`).
- Tamaño: 10 apps por tema, 20 archivos y 512 KB por app. Nombres en letras
  minúsculas, números, `-` y `_`; archivos `.html`, `.js`, `.css` o `.json`.
- Cada mensaje al puente tiene hasta 4096 caracteres. Poner en el carrito:
  hasta 5 seguidas, después una cada 2 segundos. Cambiar de página y abrir
  lugares: 2 seguidas, después una cada 2 segundos. Arriba de eso la respuesta
  es `rate_limited`, y una avalancha de mensajes se ignora.
- La app nunca sabe el correo, el saldo, los pedidos ni el carrito de nadie.

## Probar y corregir

- **La app quedó en blanco**: abre las herramientas del navegador en la vista
  previa, elige el marco `hystoreapps.cloud` en la consola y mira el error. Un
  archivo bloqueado por la política (un script de afuera, por ejemplo) aparece
  ahí.
- **"open this app inside the store"**: abriste la dirección de la app
  directo. Solo habla con la tienda dentro del marco.
- **La altura no alcanza**: ajusta `height` en `app.json` o llama a
  `hystore.autosize()` al inicio del script.
- **Lo cambié y no aparece**: la tienda en el aire usa la versión publicada.
  Mira la vista previa (que usa el borrador) o publica.

## Ideas

- Calculadora de VIP: el jugador elige los días y ve el kit correcto, con
  `products({ category: 'vips' })`.
- Un quiz "qué kit va contigo", que termina en `addToCart`.
- Un contador de meta animado, que lee `context()` para el modo de color.
- Un mini juego de clics con el nick del jugador (`customer()`).
