> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Admin Block Extensions

> Always-visible iframe panels embedded into admin resource pages

# Admin Block Extensions

An admin block is a **sandboxed iframe** the host renders inline on a
merchant admin page (product, order, customer, etc.). Use blocks for UI that
is always present on the page — review summaries, fulfilment status,
warehouse KPIs, or anything you want the merchant to see without clicking
a button.

If you want a button that opens a modal on demand, use an
[Admin Action](/extensions/admin-actions) instead.

## How the Host Renders It

`<AdminExtensionSlot target="..." resourceId resourceType domainSlug />`
mounts a sandboxed iframe per registered extension:

```html theme={null}
<iframe
  src="<appUrl-with-query-params>"
  sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
  style="width: 100%; height: 200px; border: none;"
  loading="lazy"
/>
```

Default height is **200px**; resize requests are clamped to a maximum of
**2000px** (see [Resize](#resizing-the-iframe) below).

The host calls
`GET /api/apps/admin-extensions?target=<target>&domainSlug=<slug>` to
discover registered extensions, then renders one iframe per result.

## Available Targets

Targets are filtered by exact string match against the wired slots in the
LaunchMyStore admin. Every slot uses the `admin.<resource>.render` naming
convention — your manifest's `target` must match exactly. **26 admin-block
slots** are currently wired (plus separate slots for [admin actions](/extensions/admin-actions)
and [print actions](/extensions/admin-print-actions)):

<Tip>
  **Building a backend entry for a product page** — like an "Edit SEO",
  "Reviews summary", or "Inventory note" panel that staff see while
  viewing a product? Use `target: "admin.product-details.block.render"`.
  The host appends `resourceId` (the product UUID) and `resourceType=product`
  to your iframe URL, so you can fetch the right product immediately
  without a separate routing step.
</Tip>

### Resource Detail Pages

| Target                                  | Where it renders          |
| --------------------------------------- | ------------------------- |
| `admin.product-details.block.render`    | Product detail page       |
| `admin.order-details.block.render`      | Order detail page         |
| `admin.customer-details.block.render`   | Customer detail page      |
| `admin.collection-details.block.render` | Collection detail page    |
| `admin.discount-details.block.render`   | Discount detail page      |
| `admin.abandoned-order-details.render`  | Abandoned checkout detail |

### List Pages

| Target                              | Where it renders         |
| ----------------------------------- | ------------------------ |
| `admin.product-list.render`         | Products list            |
| `admin.order-list.render`           | Orders list              |
| `admin.customer-list.render`        | Customers list           |
| `admin.collection-list.render`      | Collections list         |
| `admin.abandoned-order-list.render` | Abandoned checkouts list |
| `admin.gift-card-list.render`       | Gift cards list          |
| `admin.blog-list.render`            | Blogs list               |
| `admin.contact-list.render`         | Contact submissions      |
| `admin.newsletter-list.render`      | Newsletter subscribers   |

### Settings Pages

| Target                           | Where it renders   |
| -------------------------------- | ------------------ |
| `admin.settings.render`          | Main settings page |
| `admin.shipping-settings.render` | Shipping settings  |
| `admin.payment-settings.render`  | Payment settings   |
| `admin.pos-settings.render`      | POS settings       |
| `admin.account-settings.render`  | Account settings   |

### Analytics

| Target                           | Where it renders         |
| -------------------------------- | ------------------------ |
| `admin.analytics.render`         | Main analytics dashboard |
| `admin.sales-analytics.render`   | Sales analytics page     |
| `admin.product-analytics.render` | Product analytics page   |
| `admin.inventory.render`         | Inventory page           |

### Other

| Target                      | Where it renders                               |
| --------------------------- | ---------------------------------------------- |
| `admin.order-create.render` | Order creation page                            |
| `admin.app.configuration`   | App configuration screen (no `.render` suffix) |

<Note>
  The slot string is matched **exactly** — `product.details.block` won't
  render at `admin.product-details.block.render`. Always copy the target
  value verbatim from the table above.
</Note>

<Note>
  Targets not in the tables above won't render even if the API accepts the
  upload — only the wired slots resolve. If you need a new target,
  open a support ticket with the page and resource type you want.
</Note>

## Manifest

Declare admin blocks under `extensions.adminExtensions[]` in your app's
`app.json`:

```json theme={null}
{
  "handle": "my-reviews-app",
  "name": "Product Reviews",
  "version": "1.0.0",
  "extensions": {
    "adminExtensions": [
      {
        "handle": "reviews-panel",
        "title": "Reviews",
        "target": "admin.product-details.block.render",
        "url": "https://my-app.example.com/admin/product-reviews"
      },
      {
        "handle": "reviews-summary",
        "title": "Reviews Summary",
        "target": "admin.analytics.render",
        "url": "https://my-app.example.com/admin/reviews-analytics"
      }
    ]
  }
}
```

| Field         | Required | Description                                                      |
| ------------- | -------- | ---------------------------------------------------------------- |
| `handle`      | yes      | URL-safe identifier unique within the app.                       |
| `target`      | yes      | One of the wired slots above.                                    |
| `url`         | yes      | HTTPS URL loaded into the iframe. `appUrl` is an accepted alias. |
| `title`       | no       | Display label (defaults to `handle`).                            |
| `iconUrl`     | no       | URL of an SVG/PNG icon.                                          |
| `permissions` | no       | Array of permission strings — purely informational at this time. |

<Note>
  Relative URLs (e.g. `/extensions/.../iframe.html`) are absolutised by the
  host before being returned to the admin — you always get back an absolute
  URL, so there are no domain-resolution surprises inside the iframe. Use
  HTTPS for production apps; the absolutizer passes absolute URLs through
  untouched and does not enforce the scheme.
</Note>

## Iframe URL Parameters

The host appends the following query params when building the iframe `src`:

| Parameter                              | Always set | Value                                                                                                                      |
| -------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- |
| `target`                               | yes        | The extension's manifest `target`.                                                                                         |
| `domainSlug`                           | yes        | The merchant's domain slug.                                                                                                |
| `extensionId`                          | yes        | The host-assigned extension id. **You must echo this on `APP_BRIDGE_RESIZE` messages** so the host filters them correctly. |
| `host`                                 | yes        | `btoa(window.location.origin)` — base64-encoded admin origin, used to initialise the App Bridge SDK.                       |
| `resourceId`                           | optional   | Present when the slot was rendered with a `resourceId` prop (e.g. on `admin.product-details.block.render`).                |
| `resourceType`                         | optional   | Present together with `resourceId` — e.g. `"product"`, `"order"`.                                                          |
| `productId` / `orderId` / `customerId` | optional   | Typed alias of `resourceId`, set when `resourceType` is `product` / `order` / `customer`.                                  |

```javascript theme={null}
const params = new URLSearchParams(location.search);
const target       = params.get('target');         // "admin.product-details.block.render"
const domainSlug   = params.get('domainSlug');     // "acme-store"
const extensionId  = params.get('extensionId');    // host-assigned uuid
const host         = params.get('host');           // base64 origin
const resourceId   = params.get('resourceId');     // product id (UUID)
const resourceType = params.get('resourceType');   // "product"
```

<Note>
  For convenience, when `resourceType` is `product`, `order`, or `customer`
  the host **also** sets a typed alias param — `productId`, `orderId`, or
  `customerId` respectively (mirroring the SDK `useApi().data` shape). These
  carry the same value as `resourceId`. `locale` is **not** passed. Whichever
  param you read, always re-resolve the resource server-side using your
  session token before trusting it.
</Note>

## Bootstrap

Initialise the App Bridge SDK with the `apiKey` you registered the app
under, and the base64 `host` from the URL:

```javascript theme={null}
import { createApp } from '@launchmystore/app-bridge';

const params = new URLSearchParams(location.search);
const app = createApp({
  apiKey: process.env.NEXT_PUBLIC_APP_CLIENT_ID,
  host: params.get('host'),
});
```

`app.dispatch(action, payload)` and `app.dispatchAndWait(action, payload)`
take an **action string** and a payload object — not a `{type, payload}`
envelope.

## Resizing the Iframe

The iframe starts at **200px**. To grow it, **post the resize message
directly** — this one is not routed through `app.dispatch`:

```javascript theme={null}
const params = new URLSearchParams(location.search);
const extensionId = params.get('extensionId');

window.parent.postMessage({
  type: 'APP_BRIDGE_RESIZE',
  extensionId,
  height: document.body.scrollHeight,
}, '*');
```

The host clamps the height to **2000px** and applies it only if
`extensionId` matches. Omit `extensionId` and the resize is ignored —
your iframe stays empty-looking at 200px.

A typical auto-resize hook using `ResizeObserver`:

```javascript theme={null}
const extensionId = new URLSearchParams(location.search).get('extensionId');

const observer = new ResizeObserver(() => {
  window.parent.postMessage({
    type: 'APP_BRIDGE_RESIZE',
    extensionId,
    height: document.body.scrollHeight,
  }, '*');
});
observer.observe(document.body);
```

## App Bridge Actions (Admin host)

Admin blocks can use the full admin App Bridge action set. See
[App Bridge Overview](/app-bridge/overview) for the complete reference.
Common calls:

### Show a toast

```javascript theme={null}
app.dispatch('TOAST_SHOW', {
  message: 'Review approved!',
  duration: 3000,
  type: 'success',     // 'success' | 'error' | 'warning' | 'info'
});
```

### Open a modal

```javascript theme={null}
import { Modal } from '@launchmystore/app-bridge';

const modal = Modal.create(app, {
  title: 'Delete review?',
  message: 'This action cannot be undone.',
  primaryAction:   { label: 'Delete', onAction: () => doDelete() },
  secondaryActions: [{ label: 'Cancel', onAction: () => modal.close() }],
});
modal.dispatch();
```

### Navigate

```javascript theme={null}
app.dispatch('REDIRECT', {
  url: '/admin/products',
  newContext: false,
});

// or the action-builder alias
app.redirect.dispatch({ url: '/admin/products', newContext: false });
```

### Resource picker

```javascript theme={null}
import { ResourcePicker } from '@launchmystore/app-bridge';

ResourcePicker
  .create(app, { resourceType: 'product', multiple: true })
  .subscribe('select', (payload) => {
    console.log(payload.selection);
  })
  .dispatch();
```

### Session token (for backend calls)

```javascript theme={null}
const token = await app.getSessionToken();
const response = await fetch('/api/reviews', {
  headers: { Authorization: `Bearer ${token}` },
});
```

<Note>
  The SDK does expose `LOADING_START` / `LOADING_STOP` actions (dispatched
  by the React `useLoading` hook) that draw a thin progress bar on the
  admin host's title bar. They're for **host-level** affordance — inside
  your block iframe, render loading skeletons in your own UI rather than
  relying on the global bar.
</Note>

## Example: Reviews Panel

```jsx theme={null}
import { createApp } from '@launchmystore/app-bridge';
import { useEffect, useState } from 'react';

export default function ProductReviewsPanel() {
  const [product, setProduct] = useState(null);
  const [reviews, setReviews] = useState([]);
  const [loading, setLoading] = useState(true);

  const params = new URLSearchParams(location.search);
  const app = createApp({
    apiKey: process.env.NEXT_PUBLIC_APP_CLIENT_ID,
    host: params.get('host'),
  });

  useEffect(() => {
    const productId = params.get('resourceId');
    if (productId) loadReviews(productId);
  }, []);

  async function loadReviews(productId) {
    const token = await app.getSessionToken();
    const res = await fetch(`/api/reviews?productId=${productId}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const data = await res.json();
    setProduct(data.product);
    setReviews(data.reviews);
    setLoading(false);

    // Auto-resize once data is rendered
    requestAnimationFrame(() => {
      window.parent.postMessage({
        type: 'APP_BRIDGE_RESIZE',
        extensionId: params.get('extensionId'),
        height: document.body.scrollHeight,
      }, '*');
    });
  }

  function handleViewAll() {
    app.dispatch('REDIRECT', {
      url: `/admin/apps/my-reviews-app/products/${product.id}`,
    });
  }

  if (loading) return <div className="loading">Loading reviews...</div>;

  return (
    <div className="reviews-panel">
      <div className="panel-header">
        <h3>Customer Reviews</h3>
        <button onClick={handleViewAll}>View All</button>
      </div>

      <div className="reviews-summary">
        <div className="stat">
          <span className="stat-value">{reviews.length}</span>
          <span className="stat-label">Total Reviews</span>
        </div>
      </div>

      {reviews.slice(0, 3).map((review) => (
        <div key={review.id} className="review-item">
          <span>{review.author}</span>
          <span>{'★'.repeat(review.rating)}</span>
          <p>{review.body}</p>
        </div>
      ))}
    </div>
  );
}
```

## Discovery API

`GET /api/apps/admin-extensions?target=<target>&domainSlug=<slug>` returns
all installed extensions for that target. Pass `&type=admin_block` to
exclude action and print-action entries that also live in
`/api/apps/admin-extensions`.

```json theme={null}
{
  "extensions": [
    {
      "id": "reviews-panel",
      "appId": "my-reviews-app",
      "name": "Product Reviews",
      "handle": "reviews-panel",
      "url": "https://your-app.example.com/extensions/.../iframe.html",
      "appUrl": "https://your-app.example.com/extensions/.../iframe.html",
      "title": "Reviews",
      "iconUrl": null,
      "target": "admin.product-details.block.render",
      "type": "admin_block",
      "permissions": []
    }
  ]
}
```

The `url` and `appUrl` fields are server-absolutised so the admin always
receives an iframe-able HTTPS URL.

## Security

<AccordionGroup>
  <Accordion title="Iframe sandbox">
    Admin blocks render with `allow-scripts allow-same-origin allow-forms
            allow-popups`. The bridge postMessage protocol is the only cross-frame
    channel.
  </Accordion>

  <Accordion title="Verify session tokens server-side">
    Anything the iframe sends to your backend should be authorised with a
    fresh session token (`app.getSessionToken()`) and verified against your
    app's `clientSecret` server-side.
  </Accordion>

  <Accordion title="Use HTTPS in production">
    Always serve `url` / `appUrl` over HTTPS in production so the iframe
    loads on an HTTPS admin. Note the install pipeline does **not** reject
    HTTP schemes — absolute URLs are passed through as-is (and local dev
    URLs may resolve to `http`), so treat HTTPS as your responsibility.
  </Accordion>

  <Accordion title="Validate URL parameters">
    `resourceId` and `domainSlug` come from the merchant's browser. Always
    re-fetch the resource server-side with the session token before
    trusting it.
  </Accordion>
</AccordionGroup>

## Installing

Admin blocks are declared in `app.json`; the install pipeline doesn't write
per-handle schema files for them (unlike admin actions and print actions).
Instead, each `adminExtensions[]` entry is fanned out into a registry row
scoped to `(appId, storeId, handle, target)` so the discovery API can
return it.

Registry fan-out runs automatically on **both** install paths:

* **OAuth install** — when the merchant completes `POST /apps/oauth/token`
  with `grant_type=authorization_code`, the install pipeline creates the
  installation, then synchronously registers every
  `adminExtensions[]` entry from your app's published manifest.
* **Direct/developer install** — `POST /apps/store/install/:appId` runs
  the same fan-out.

Re-running install (reinstall, or the developer pushing a new app version)
is idempotent: rows keyed by `(appId, storeId, handle, target)` are
upserted, not duplicated. Uninstall destroys all registry rows for that
`(appId, storeId)` pair.

The `target` column accepts any string verbatim — there is no enum cap on
what you can register — but as noted above the host only renders entries
whose target matches one of the wired slot tables.

## See Also

* [Admin Actions](/extensions/admin-actions) — modal-iframe action buttons.
* [Admin Print Actions](/extensions/admin-print-actions) — printable
  templates triggered from admin resource pages.
* [App Bridge Overview](/app-bridge/overview) — full action reference.
* [Sessions & Authentication](/app-bridge/session-tokens) — verifying iframe
  calls server-side.
