---
name: gro-portal-widget
description: Build custom widgets for the Gro subscription customer portal (window.gro SDK). Use when someone asks for a Gro portal widget, a customer portal block or tab, or anything that runs inside the Gro portal sandbox, such as a check in form, a "send it now" prompt, a photo upload or a review link. Produces one self contained JavaScript file that matches the store's portal theme.
---

# Building Gro customer portal widgets

A Gro widget is **one JavaScript file** that runs inside the customer portal of
a Shopify store using Gro subscriptions. It renders in a sandboxed iframe and
talks to the portal only through `window.gro`. The merchant uploads the file in
the Gro dashboard (Storefront > Customer portal > Widgets) and places it on the
portal layout like any other block.

Full docs: https://usegro.co/docs/portal/widgets/ and
https://usegro.co/docs/portal/widget-sdk/

## What to deliver

- A single `.js` file. No build step required, no imports, no external scripts.
- CSS goes in a `<style>` element your script creates.
- Plain DOM APIs. A framework is fine only if it's bundled into the one file.
- Tell the merchant which **scopes** to grant and suggest a **visibility
  condition** (see below).

## The sandbox (hard limits)

- No network: `fetch`, `XMLHttpRequest`, WebSockets and beacons are blocked
  (`connect-src 'none'`). All data comes from `gro.context`; all changes go
  through `gro.*` methods.
- No cookies, storage, portal DOM or session token.
- No external scripts or stylesheets. Fonts may load from Google Fonts only.
- Images load from any `https:` or `data:` URL.
- Render into `#gro-widget-root`.

## Skeleton

```js
window.gro.onReady(function () {
  const root = document.getElementById("gro-widget-root");
  const { subscription, entities, lines } = window.gro.context;

  injectStyles();
  render();

  window.gro.onContextChange(render); // e.g. after a skip moved the date

  function render() {
    const pet = window.gro.context.entities[0];
    root.innerHTML = `
      <div class="card">
        <h3>Hi ${pet ? escapeHtml(pet.name) : "there"}!</h3>
        <p>Your plan is ${subscription.status.toLowerCase()}.</p>
      </div>`;
    window.gro.resize(); // always after rendering
  }

  function escapeHtml(text) {
    const div = document.createElement("div");
    div.textContent = text;
    return div.innerHTML;
  }
});
```

Always: do everything inside `onReady`, call `gro.resize()` after every render,
escape any text you put into HTML, and handle missing data (no entities, null
dates).

## `gro.context` (read only)

| Field                             | Type           | Notes                                      |
| --------------------------------- | -------------- | ------------------------------------------ |
| `subscription.id`                 | string         |                                            |
| `subscription.status`             | string         | `ACTIVE`, `PAUSED`, `CANCELLED`            |
| `subscription.order_count`        | number \| null | Orders actually received                   |
| `subscription.next_billing_date`  | string \| null | ISO date                                   |
| `subscription.next_delivery_date` | string \| null | ISO date, null when delivery isn't tracked |
| `triggers`                        | string[]       | Flows this widget can open right now       |
| `entities`                        | object[]       | `{ id, name, entity_type, attributes }`    |
| `lines`                           | object[]       | `{ id, product_title, variant_title }`     |

The customer's own name and contact details are never included. Greet using an
entity name (e.g. the pet) and fall back gracefully.

Other properties: `gro.scopes` (granted scopes), `gro.isPreview` (true in
previews, where every action rejects with `USER_CANCELLED`), `gro.branding`
(see Styling).

## Actions (each needs a scope)

| Call                                                              | Scope                       | Notes                                                                                                                          |
| ----------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `gro.entity.update(id, { name?, attributes?, image_url? })`       | `entity.update`             | `attributes` replaces the whole set: copy existing ones first. Values must be string, number, boolean or string[] (no nesting) |
| `gro.activity.log({ action, description?, changes?, metadata? })` | `subscription.activity_log` | `action` is stored as `widget_<action>`. `changes` = `{ field: { from, to } }`. Image URLs in `metadata` show as thumbnails    |
| `gro.media.upload(file)`                                          | `media.upload`              | PNG, JPEG, WEBP or GIF up to 5 MB. Resolves `{ data: { url } }`                                                                |
| `<a href target="_blank" rel="noopener">`                         | `link.open`                 | Normal links can open a new tab after a click                                                                                  |
| `gro.trigger.skip()`                                              | `flow.skip`                 | Opens the portal's own skip flow                                                                                               |
| `gro.trigger.sendNow()`                                           | `flow.ship_now`             | Opens send now                                                                                                                 |
| `gro.trigger.reschedule()`                                        | `flow.reschedule`           | Opens the date picker                                                                                                          |
| `gro.trigger.pause()`                                             | `flow.pause`                | Opens the pause flow                                                                                                           |
| `gro.trigger.resume()`                                            | `flow.resume`               | Opens resume                                                                                                                   |

`gro.entity.get(id)` reads an entity from the context (no scope).

### Triggers

- They open the portal's native flow (with the store's reasons, retention
  playbooks and confirm step). They never change anything on their own.
- Call them **directly in a click or key press handler**, before any `await`,
  or they reject with `NEEDS_CLICK`.
- Check first: `gro.trigger.can("sendNow")` (method or flow name). Only show the
  button when it's true.
- They resolve `{ flow, outcome }` with outcome `completed`, `saved` (took a
  retention offer instead) or `dismissed`. Closing is not an error.
- Cancel is not available as a trigger.

### Errors

Rejected promises carry `err.code`: `SCOPE_DENIED`, `SETTING_DISABLED`,
`VALIDATION_FAILED`, `NOT_AVAILABLE`, `FLOW_BUSY`, `NEEDS_CLICK`,
`RATE_LIMITED` (burst of 5, then 1 per second), `USER_CANCELLED` (declined, or
preview), `BACKEND_ERROR`, `UNKNOWN_CAPABILITY`. Catch them and show a friendly
message.

## Styling (match the store's portal theme)

Never hardcode colours, radii or fonts. The frame already sets `body` to the
theme font, text size and body text colour, with a transparent background. Use
these CSS variables (always with a fallback):

| Variable                                                           | Use                                                | Default               |
| ------------------------------------------------------------------ | -------------------------------------------------- | --------------------- |
| `--gro-brand-color`                                                | Accent, highlights, selected states                | `#111827`             |
| `--gro-heading-text-color`                                         | Headings                                           | `#111827`             |
| `--gro-body-text-color`                                            | Body text                                          | `#374151`             |
| `--gro-muted-text-color`                                           | Secondary text, labels                             | `#6b7280`             |
| `--gro-link-color`                                                 | Links                                              | `#111827`             |
| `--gro-success-color`                                              | Success messages                                   | `#166534`             |
| `--gro-focus-color`                                                | Focus rings                                        | `#6b7280`             |
| `--gro-card-background-color`                                      | Cards                                              | `#ffffff`             |
| `--gro-subtle-background-color`                                    | Subtle fills, chips, table headers                 | `#f9fafb`             |
| `--gro-border-color`                                               | Borders, dividers                                  | `#e5e7eb`             |
| `--gro-border-width`                                               | Card and input borders                             | `1px`                 |
| `--gro-card-radius`                                                | Card corners                                       | `10px`                |
| `--gro-primary-button-background-color`                            | Main button                                        | `#111827`             |
| `--gro-primary-button-text-color`                                  | Main button text                                   | `#ffffff`             |
| `--gro-secondary-button-background-color`                          | Secondary button                                   | `#f3f4f6`             |
| `--gro-secondary-button-text-color`                                | Secondary button text                              | `#111827`             |
| `--gro-button-radius`                                              | Button and input corners                           | `8px`                 |
| `--gro-tab-background-color` / `--gro-tab-active-background-color` | Your own tabs: bar and selected tab                | `#f3f4f6` / `#ffffff` |
| `--gro-tab-text-color` / `--gro-tab-active-text-color`             | Tab text                                           | `#6b7280` / `#111827` |
| `--gro-tab-radius`, `--gro-tab-font-size`                          | Tab bar shape and size                             | `20px`, `14px`        |
| `--gro-winback-color`                                              | Offer accent for cancelled customers               | `#16a34a`             |
| `--gro-alert-color`, `--gro-paused-color`                          | Alert and paused borders, error text               | `#bb0a0a`, `#d97706`  |
| `--gro-reward-badge-color`, `--gro-reward-badge-icon-color`        | Earned loyalty badges and their tick, may be unset | unset                 |
| `--gro-heading-font-size`, `--gro-heading-font-weight`             | Card titles                                        | `20px`, `600`         |
| `--gro-base-font-size`                                             | Body text size                                     | `14px`                |
| `--gro-small-font-size`                                            | Labels, hints and badges                           | `12px`                |
| `--gro-button-font-size`                                           | Button text size                                   | `14px`                |
| `--gro-font-stack`                                                 | Theme font with fallbacks, for `font-family`       | font + fallbacks      |
| `--gro-background-color`, `--gro-upsells-background-color`         | Page and add extras backgrounds, may be unset      | unset                 |

The same values are in JavaScript on `gro.branding.theme` (snake_case keys,
numbers for sizes, e.g. `theme.card_radius === 10`; `heading_font_weight` is a plain number, not px). Variables update live when
the merchant edits the theme; use `gro.onBrandingChange(fn)` only if your script
reads theme values (canvas, SVG, computed colours).

House style for widgets:

```css
.card {
  background: var(--gro-card-background-color, #fff);
  border: var(--gro-border-width, 1px) solid var(--gro-border-color, #e5e7eb);
  border-radius: var(--gro-card-radius, 10px);
  padding: 16px;
}
.card h3 {
  margin: 0 0 4px;
  font-size: var(--gro-heading-font-size, 20px);
  font-weight: var(--gro-heading-font-weight, 600);
  color: var(--gro-heading-text-color, #111827);
}
.card p {
  margin: 0 0 12px;
  color: var(--gro-muted-text-color, #6b7280);
}
.button {
  font: inherit;
  font-size: var(--gro-button-font-size, 14px);
  font-weight: 600;
  border: none;
  cursor: pointer;
  padding: 10px 16px;
  border-radius: var(--gro-button-radius, 8px);
  background: var(--gro-primary-button-background-color, #111827);
  color: var(--gro-primary-button-text-color, #fff);
}
.button.secondary {
  background: var(--gro-secondary-button-background-color, #f3f4f6);
  color: var(--gro-secondary-button-text-color, #111827);
}
.button:focus-visible,
input:focus-visible {
  outline: 2px solid var(--gro-focus-color, #6b7280);
  outline-offset: 2px;
}
input,
select,
textarea {
  font: inherit;
  padding: 8px 10px;
  box-sizing: border-box;
  border: var(--gro-border-width, 1px) solid var(--gro-border-color, #e5e7eb);
  border-radius: var(--gro-button-radius, 8px);
  background: var(--gro-card-background-color, #fff);
  color: var(--gro-body-text-color, #374151);
}
```

Keep the page background transparent, put content in a card like the portal's
own blocks, and design for widths from about 300px (phone sidebar) to 800px.

## Visibility condition (set by the merchant when publishing)

One of: always show, product title contains `<text>`, entity type equals
`<type>`, order count at least `<n>`, order count at most `<n>`. Suggest the one
that fits the widget (e.g. entity type `dog` for a puppy check in).

## Before you hand it over

1. One file, starts with `window.gro.onReady(...)`, renders into
   `#gro-widget-root`, calls `gro.resize()` after every render.
2. No `fetch`, no external scripts, no hardcoded colours.
3. Handles missing entities, null dates and `gro.isPreview` (show the UI, but
   expect actions to reject with `USER_CANCELLED`).
4. Triggers are called straight from click handlers and guarded with
   `gro.trigger.can()`.
5. List the scopes to grant and the suggested visibility condition, plus where
   on the layout it would sit best (e.g. under the next delivery card).
