> For the complete documentation index, see [llms.txt](https://eraya.gitbook.io/eraya-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://eraya.gitbook.io/eraya-docs/product-detail-test/product-detail-test-theme-integration.md).

# Product Detail Test — Theme Integration

A **Product Image & Title Test** compares different titles, descriptions and images for the *same* product. Unlike every other Eraya test type, this one is rendered by your **theme**, not by JavaScript — so it needs a one-time integration into your Liquid before your first test can run.

This page is for developers integrating it by hand. You only need to do this once: after the integration is in place, every future product test runs without touching the theme again.

{% hint style="info" %}
**Prefer not to do this yourself?** Eraya can patch a duplicate of your theme for you. This guide is for merchants and agencies who want to own the change — for example, to fold it into a theme that's under version control.
{% endhint %}

***

### Why the theme renders it (and not the SDK)

It's a fair question, because every other Eraya visual test patches the DOM from JavaScript. This one deliberately does not.

A theme renders `product.title` in far more places than it looks: the `<h1>`, the breadcrumb, the browser `<title>`, JSON-LD structured data, the sticky add-to-cart bar, cart line items, search results. If the SDK swapped three of those and missed two, **it would have no way to detect it** — what a page renders is a fact only Liquid has. The shopper would see a mix that exists in neither variation, and the experiment would measure something nobody designed.

Rendering in Liquid makes that failure impossible by construction. The trade-off is this integration, which is why it exists.

#### The flow

1. When a test goes live, Eraya writes a **metafield onto each product** in the test containing every variation's overrides.
2. On the visitor's first page, the Eraya SDK buckets them and writes a **cart attribute** naming their variation, then reloads once.
3. Every page from then on is server-rendered: your Liquid reads the metafield, reads the cart attribute, and renders that variation.

{% hint style="info" %}
**The control arm gets the cart attribute too.** It has to — without it, control orders carry no assignment and can't be attributed, which would silently delete one side of your experiment.
{% endhint %}

***

### Prerequisites

| Requirement                          | Why                                                                                                                |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| Eraya app embed enabled on the theme | The SDK does the bucketing and writes the cart attribute. No `theme.liquid` edit is needed — the embed handles it. |
| A backup of your theme               | Duplicate the theme before you start. These are edits to core product rendering.                                   |
| Shopify Liquid familiarity           | You'll be editing `main-product`, `card-product`, gallery and cart snippets.                                       |

#### Will this work on my theme?

The **contract** is theme-agnostic — a product metafield plus a cart attribute, both plain Liquid. Any theme on any Shopify plan can read them, Online Store 2.0 or vintage. Steps 1–3 (resolver, media pool, gallery) work anywhere.

Steps 4 and 5 are shaped by Dawn conventions and need adapting:

| Assumption                                              | If your theme differs                                                                                                                                                                                                                                                                 |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A `hide_variants` section setting strips variant images | Skip that snippet — it exists to stop Dawn's rule from removing images your variation added.                                                                                                                                                                                          |
| The variant picker reads `product.variants \| json`     | Find what your picker actually reads and apply the same rewrite to that. Some themes serialise the whole `product`, some use a `<script type="application/json">` with a custom shape. The rule is unchanged: never leave a media reference pointing at a slide the override removed. |

{% hint style="warning" %}
**The real limit: surfaces rendered from JSON, not Liquid.**

Anything your theme builds from Shopify's Ajax APIs — `/products/<handle>.js`, `/search/suggest.json`, `/cart.js` — receives the **real** product data. Those endpoints know nothing about the metafield, so a quick-view modal or a JSON-driven search dropdown will show the original title and image while the product page shows the variation.

Dawn avoids this by rendering those surfaces through the Section Rendering API, which runs your Liquid and therefore picks up the integration for free. Heavily JS-driven themes often don't.
{% endhint %}

Two of these matter differently:

* **Discovery surfaces — quick view, JSON search, JS-rendered collection cards.** These come *before* the shopper decides, so they're part of what you're testing. Patch them with the JavaScript API.
* **Cart surfaces built from `/cart.js`.** Leave them. The shopper has already seen the variation and acted on it, so the cart is downstream of the decision the experiment measures. More to the point, Shopify's **checkout and the order itself always show the real product title** — you cannot change that from a theme. Rewriting the cart line only moves the inconsistency one step later, and a cart that disagrees with the confirmation email is worse than one that doesn't.

A quick way to see what you're dealing with before you start: search your theme for `products/` + `.js`, `suggest.json` and `cart.js`. Hits outside cart code mean JS work ahead; few or none means the Liquid integration covers you.

***

### Let an AI coding agent do it

If you use Claude Code, Cursor, Copilot or similar, you can hand the whole integration over. Pull your theme locally first (`shopify theme pull`), open the theme folder in your agent, and paste the prompt below.

{% hint style="warning" %}
**Work on a duplicate theme, never the live one.** The agent edits core product rendering across a dozen files. Review its diff and preview the result before you publish.
{% endhint %}

The prompt is self-contained — it carries the full data contract, so the agent doesn't need to read this page.

```
You are integrating the Eraya "Product Image & Title Test" into this Shopify theme.
The theme is in the current directory. Work only inside it.

## What this does

Eraya A/B tests a product's title, description and images. Eraya writes a metafield
onto each tested product, and its JavaScript SDK writes a cart attribute naming the
visitor's variation. Your job is to make the Liquid read both and render the right
variation. The SDK renders nothing — Liquid is the only renderer, deliberately, so a
partially-applied variation is impossible.

## Data contract (stable — do not invent field names)

Product metafield: namespace `app--258333999105`, key `product_test`, type json.
Reached in Liquid as: product.metafields['app--258333999105']['product_test'].value

Shape:
{
  "v": 1,
  "test_id": "6857f2a19c4b2d0012ab34cd",
  "product_id": "7412345678901",
  "variations": {
    "<variationId>": {
      "title": "...",                                          // optional
      "description": "<p>html</p>",                            // optional
      "media": [ { "id": "31234567890", "media_type": "image", "url": "..." } ],  // optional
      "variant_media": { "44123456789": { "id": "312...", "media_type": "image", "url": "..." } }, // optional
      "default_variant_id": "44123456789"                      // optional
    }
  }
}

Rules:
- The control variation has NO entry in `variations`. "No entry" means "render the
  real product". Do not add a control branch.
- Optional keys are omitted entirely, never emitted as empty.
- Cart attribute: `_eraya_product_test_<test_id>` = the variation id.
  NEVER write anything else under the `_eraya_product_test_` prefix — Eraya
  attributes orders by matching that exact prefix, and a sibling key silently
  breaks attribution.
- Optional media pools: same namespace, keys `test_media_1` … `test_media_4`, type
  list.file_reference. They let Liquid resolve images that are not attached to the
  product. Include them.

## Step 1 — resolver block

Insert at the TOP of every file that renders a tested product surface, before any output.
Substitute the product variable if the file uses a different one (`card_product` in
card-product.liquid, `item.product` in cart files).

{%- liquid
  assign eraya_cfg = product.metafields['app--258333999105']['product_test'].value
  assign eraya_entry = nil
  if eraya_cfg and eraya_cfg.v == 1 and eraya_cfg.test_id
    assign eraya_key = '_eraya_product_test_' | append: eraya_cfg.test_id
    assign eraya_vid = cart.attributes[eraya_key]
    if eraya_vid
      assign eraya_entry = eraya_cfg.variations[eraya_vid]
    endif
  endif

  assign eraya_title = product.title
  assign eraya_description = product.description
  if eraya_entry
    if eraya_entry.title != blank
      assign eraya_title = eraya_entry.title
    endif
    if eraya_entry.description != blank
      assign eraya_description = eraya_entry.description
    endif
  endif
  assign eraya_pool = '' | split: ','
-%}

Then replace `product.title` -> `eraya_title` and `product.description` ->
`eraya_description` at every render site in that file, INCLUDING `| escape` uses in
alt / aria-label / title attributes, data- attributes, and JSON-LD structured data.

## Step 2 — media pool

`paginate` is a block tag and CANNOT go inside a {%- liquid -%} tag. Place this
between the resolver block and the gallery block, in files that render galleries:

{%- paginate product.metafields['app--258333999105']['test_media_1'].value by 128 -%}
  {%- assign eraya_pool_1 = product.metafields['app--258333999105']['test_media_1'].value | uniq -%}
  {%- assign eraya_pool = eraya_pool | concat: eraya_pool_1 -%}
{%- endpaginate -%}
(repeat for test_media_2, test_media_3, test_media_4)

## Step 3 — gallery override

{%- liquid
  assign eraya_media = product.media
  assign eraya_has_media_override = false
  if eraya_entry and eraya_entry.media
    assign eraya_media = '' | split: ','
    for eraya_ref in eraya_entry.media
      assign eraya_ref_id = eraya_ref.id | times: 1
      assign eraya_hit = eraya_pool | where: 'id', eraya_ref_id
      if eraya_hit.size == 0
        assign eraya_hit = product.media | where: 'id', eraya_ref_id
      endif
      assign eraya_media = eraya_media | concat: eraya_hit
    endfor
    if eraya_media.size == 0
      assign eraya_media = product.media
    else
      assign eraya_has_media_override = true
    endif
  endif
-%}

Replace `product.media` with `eraya_media` throughout that file's gallery logic:
the loop, media_count, `where: 'media_type'` filters and the thumbnail list.

## Step 4 — per-variant featured media (only if the theme has variant images)

{%- liquid
  assign eraya_vfm = product.selected_or_first_available_variant.featured_media
  if eraya_entry and eraya_entry.variant_media
    assign eraya_vkey = product.selected_or_first_available_variant.id | append: ''
    assign eraya_vm = eraya_entry.variant_media[eraya_vkey]
    if eraya_vm and eraya_vm.id
      assign eraya_vm_id = eraya_vm.id | times: 1
      assign eraya_vhit = eraya_pool | where: 'id', eraya_vm_id | first
      if eraya_vhit == nil
        assign eraya_vhit = product.media | where: 'id', eraya_vm_id | first
      endif
      if eraya_vhit
        assign eraya_vfm = eraya_vhit
      endif
    endif
  endif
  if eraya_has_media_override and eraya_vfm
    assign eraya_vfm_in = eraya_media | where: 'id', eraya_vfm.id
    if eraya_vfm_in.size == 0
      assign eraya_vfm = nil
    endif
  endif
-%}

If the theme has a `hide_variants` section setting, an explicit gallery override must
win over it, or it strips images the merchant chose to show:

{%- liquid
  if eraya_has_media_override
    assign variant_images = '' | split: ','
  endif
-%}

## Step 5 — the variant JSON blob

Themes serialise `product.variants | json` for the variant picker. If the gallery
changed but the blob still points at a removed slide, the picker's switchTo throws
and TAKES PRICE AND AVAILABILITY UPDATES DOWN WITH IT. Rebuild it per variant —
rename the original key (JSON is last-wins) and re-inject featured_media only when
the target survived:

{%- liquid
  assign eraya_rewrite = false
  if eraya_entry and product.variants.size <= 250
    if eraya_has_media_override or eraya_entry.variant_media
      assign eraya_rewrite = true
    endif
  endif

  capture eraya_variants_json
    if eraya_rewrite
      echo '['
      for eraya_v in product.variants limit: 250
        capture eraya_vj
          echo eraya_v | json
        endcapture
        assign eraya_vj = eraya_vj | replace: '"featured_media":', '"eraya_prev_media":'

        assign eraya_target = eraya_v.featured_media
        if eraya_entry.variant_media
          assign eraya_vkey = eraya_v.id | append: ''
          assign eraya_vm = eraya_entry.variant_media[eraya_vkey]
          if eraya_vm and eraya_vm.id
            assign eraya_vm_id = eraya_vm.id | times: 1
            assign eraya_vhit = eraya_pool | where: 'id', eraya_vm_id | first
            if eraya_vhit == nil
              assign eraya_vhit = product.media | where: 'id', eraya_vm_id | first
            endif
            if eraya_vhit
              assign eraya_target = eraya_vhit
            endif
          endif
        endif
        if eraya_target
          assign eraya_target_in = eraya_media | where: 'id', eraya_target.id
          if eraya_target_in.size > 0
            assign eraya_inject = '{"featured_media":{"id":' | append: eraya_target.id | append: '},'
            assign eraya_vj = eraya_vj | replace_first: '{', eraya_inject
          endif
        endif

        echo eraya_vj
        unless forloop.last
          echo ','
        endunless
      endfor
      echo ']'
    else
      echo product.variants | json
    endif
  endcapture

  if eraya_entry and eraya_entry.title != blank
    assign eraya_variants_json = eraya_variants_json | replace: product.title, eraya_entry.title
  endif
-%}

Output {{ eraya_variants_json }} where the theme had {{ product.variants | json }}.

## Which files to patch

Do NOT guess from a file list. Search the theme:

  grep -rln "product.title\|card_product.title\|item.product.title\|product.media" \
    sections snippets templates layout

Patch every result that renders a product to a shopper. In a Dawn-family theme that
is typically: main-product, featured-product, product-media-gallery,
product-media-modal, product-variant-picker, card-product, product-thumbnail,
main-cart-items, cart-drawer, cart-notification-product, predictive-search.

Skip files that only render admin/checkout-owned surfaces.

Steps 4 and 5 assume Dawn conventions. If this theme has no `hide_variants` setting,
skip that snippet. If the variant picker does not read `product.variants | json`, find
what it actually reads and apply the same rewrite there — the rule is unchanged: never
leave a media reference pointing at a slide the gallery override removed.

## Surfaces Liquid cannot reach — report, do not fake

Some themes build product surfaces from Shopify's Ajax APIs instead of Liquid:
`/products/<handle>.js`, `/search/suggest.json`, `/cart.js`. Those endpoints return the
REAL product data and know nothing about the metafield, so a quick-view modal or a JSON
search dropdown will keep showing the original title and image no matter what you do in
Liquid.

Search for them:

  grep -rn "products/.*\.js\|suggest\.json\|/cart\.js" assets sections snippets

Handle them as follows:

- CART surfaces built from /cart.js: LEAVE THEM ALONE. The shopper has already acted,
  and Shopify's checkout and order always show the real product title anyway — a
  rewritten cart line would just disagree with the confirmation email.
- DISCOVERY surfaces (quick view, JSON search, JS-rendered cards): do NOT rewrite the
  theme's own JavaScript. Instead list them in your final report, with the file and the
  function that renders them, so the merchant can attach Eraya's JS API
  (window.Eraya.sdk.getProductTestManager() -> getProductTest(productId)).

If you do patch a discovery surface, patch title AND gallery together or not at all. A
surface showing the variation's title with the original image is the mixed state this
whole design exists to prevent.

## Hard rules — these fail SILENTLY if you get them wrong

1. Payload ids are JSON strings; Liquid media.id is an integer, and `==` is
   type-sensitive. ALWAYS coerce: `assign x = ref.id | times: 1`. Without it every
   lookup misses and the gallery silently falls back to the control.
2. An empty array is truthy in Liquid. `if eraya_entry.media` is true for []. This is
   only safe because the writer omits the key. Never emit [] yourself.
3. `variant_media` keys are strings; variant.id is an integer. Index with
   `| append: ''`.
4. `paginate` cannot live inside a {% liquid %} tag.
5. Never change the `_eraya_product_test_` prefix.
6. Everything must fail OPEN: any missing/stale data renders the real product, never
   a blank title or empty gallery.

## Deliverable

- Edit the theme files in place. Do not create a parallel implementation.
- Keep the diff minimal: no reformatting, no renaming, no unrelated refactors.
- Add the comment marker `eraya-product-test v1` at the top of each block you insert
  so the integration can be found and removed later.
- When done, list every file you changed and what you applied to each (title,
  description, gallery, variant media, variant JSON).
- Then verify: with no test running and with no cart attribute set, every patched
  surface must render exactly what it rendered before your changes.
```

To check the agent's work, use Testing your integration below — force a variation with the cart snippet and confirm every surface changes together.

***

### The data contract

Everything below is stable API. Eraya changes it additively.

#### 1. The product metafield

|           |                                                  |
| --------- | ------------------------------------------------ |
| Namespace | `app--258333999105`                              |
| Key       | `product_test`                                   |
| Type      | `json`                                           |
| Written   | when a test starts, on every product in the test |
| Cleared   | when the test stops (written as `{}`)            |

```liquid
product.metafields['app--258333999105']['product_test'].value
```

Shape:

```json
{
  "v": 1,
  "test_id": "6857f2a19c4b2d0012ab34cd",
  "product_id": "7412345678901",
  "variations": {
    "var_2": {
      "title": "Merino Wool Crew — Ethically Made",
      "description": "<p>Rich text HTML…</p>",
      "media": [
        { "id": "31234567890", "media_type": "image", "url": "https://cdn.shopify.com/…" }
      ],
      "variant_media": {
        "44123456789": { "id": "31234567891", "media_type": "image", "url": "https://…" }
      },
      "default_variant_id": "44123456789"
    }
  }
}
```

Rules worth knowing before you write code against it:

* **The control never has an entry.** Falling through to the real product *is* the control, so omitting it is what lets Liquid treat "no entry" as "no override" without a separate flag.
* **Keys are omitted, never empty.** A variation that overrides only the title has no `media` key at all. This matters — see the empty-array gotcha.
* **`variations` is keyed by variation id**, matching the cart attribute's value.
* `v` is the payload version. Gate on it, so a future `v: 2` renders the real product on an un-updated theme instead of breaking.

#### 2. The cart attribute

```
_eraya_product_test_<test_id>   →   <variation_id>
```

The SDK writes it. You only read it.

{% hint style="danger" %}
**Never write anything else under the `_eraya_product_test_` prefix.** Eraya attributes orders by matching this exact prefix on line-item properties and recovering the test id with `name.replace(prefix, '')`. A sibling key like `_eraya_product_test_type_123` would match first and produce a variation of `"product"` against a test id of `"type_123"` — which doesn't error, it just yields a test with traffic and zero orders on both arms. That reads as a real result.
{% endhint %}

#### 3. The media reference pools (optional)

| Namespace           | Keys                                                           | Type                  |
| ------------------- | -------------------------------------------------------------- | --------------------- |
| `app--258333999105` | `test_media_1`, `test_media_2`, `test_media_3`, `test_media_4` | `list.file_reference` |

Only needed if your tests use images **from the Shopify Files library** rather than images already attached to the product.

Liquid can turn a media id back into a renderable object only if that media belongs to the product being rendered. An image pulled from Files resolves to nothing, and the gallery silently falls back to the control. The pools fix that: a `list.file_reference` metafield resolves any file into a media object, attached to the product or not. They're sharded across four keys for capacity.

***

### Step 1 — The resolver block

This is the core. It goes at the **top of every file** that renders a tested product surface, before anything is output.

```liquid
{%- liquid
  # --- eraya-product-test v1 ---------------------------------------------------
  # Reads the app-reserved product metafield and the per-test cart attribute the
  # Eraya SDK writes. Unknown payload version, no attribute, or control bucket
  # all leave eraya_entry nil, which renders the real product.
  assign eraya_cfg = product.metafields['app--258333999105']['product_test'].value
  assign eraya_entry = nil
  if eraya_cfg and eraya_cfg.v == 1 and eraya_cfg.test_id
    assign eraya_key = '_eraya_product_test_' | append: eraya_cfg.test_id
    assign eraya_vid = cart.attributes[eraya_key]
    if eraya_vid
      assign eraya_entry = eraya_cfg.variations[eraya_vid]
    endif
  endif

  assign eraya_title = product.title
  assign eraya_description = product.description
  if eraya_entry
    if eraya_entry.title != blank
      assign eraya_title = eraya_entry.title
    endif
    if eraya_entry.description != blank
      assign eraya_description = eraya_entry.description
    endif
  endif
  assign eraya_pool = '' | split: ','
-%}
```

Every path that isn't "this visitor is bucketed into a variation that overrides this product" leaves `eraya_entry` nil and `eraya_title` / `eraya_description` holding the real values. **The block is safe to add before you've built anything else** — with no test running it's a no-op.

{% hint style="info" %}
In snippets the product variable may be named something else. In `card-product.liquid` it's `card_product`; in cart files it's `item.product`. Substitute accordingly — the rest of the block is identical.
{% endhint %}

Then replace the render sites in that file:

```liquid
{{ product.title }}        →  {{ eraya_title }}
{{ product.description }}  →  {{ eraya_description }}
```

Including the ones that are easy to miss: `| escape` in `alt` and `aria-label` attributes, `data-` attributes, and anything feeding structured data.

***

### Step 2 — The media pool

Skip this if your tests only ever use images already attached to the product.

Pool assembly uses `paginate`, which is a block tag — it **cannot** live inside a `{%- liquid -%}` tag, so it sits between the resolver block and the gallery block:

```liquid
{%- comment -%} eraya-product-test v1: media reference pool {%- endcomment -%}
{%- paginate product.metafields['app--258333999105']['test_media_1'].value by 128 -%}
  {%- assign eraya_pool_1 = product.metafields['app--258333999105']['test_media_1'].value | uniq -%}
  {%- assign eraya_pool = eraya_pool | concat: eraya_pool_1 -%}
{%- endpaginate -%}
{%- paginate product.metafields['app--258333999105']['test_media_2'].value by 128 -%}
  {%- assign eraya_pool_2 = product.metafields['app--258333999105']['test_media_2'].value | uniq -%}
  {%- assign eraya_pool = eraya_pool | concat: eraya_pool_2 -%}
{%- endpaginate -%}
{%- paginate product.metafields['app--258333999105']['test_media_3'].value by 128 -%}
  {%- assign eraya_pool_3 = product.metafields['app--258333999105']['test_media_3'].value | uniq -%}
  {%- assign eraya_pool = eraya_pool | concat: eraya_pool_3 -%}
{%- endpaginate -%}
{%- paginate product.metafields['app--258333999105']['test_media_4'].value by 128 -%}
  {%- assign eraya_pool_4 = product.metafields['app--258333999105']['test_media_4'].value | uniq -%}
  {%- assign eraya_pool = eraya_pool | concat: eraya_pool_4 -%}
{%- endpaginate -%}
```

***

### Step 3 — The gallery override

```liquid
{%- liquid
  # eraya-product-test v1: gallery override
  assign eraya_media = product.media
  assign eraya_has_media_override = false
  if eraya_entry and eraya_entry.media
    assign eraya_media = '' | split: ','
    for eraya_ref in eraya_entry.media
      # Payload ids are JSON strings; media.id is an integer and Liquid's == is
      # type-sensitive. Without | times: 1 every lookup silently misses.
      assign eraya_ref_id = eraya_ref.id | times: 1
      assign eraya_hit = eraya_pool | where: 'id', eraya_ref_id
      if eraya_hit.size == 0
        assign eraya_hit = product.media | where: 'id', eraya_ref_id
      endif
      assign eraya_media = eraya_media | concat: eraya_hit
    endfor
    if eraya_media.size == 0
      # Pool missing or stale: fail open to the real gallery rather than blank.
      assign eraya_media = product.media
    else
      assign eraya_has_media_override = true
    endif
  endif
-%}
```

Then replace `product.media` with `eraya_media` throughout the file's gallery logic — the loop, `media_count`, the `where: 'media_type'` filters and the thumbnail list.

Note the resolution order: **pool first, product second**. And note that the whole thing fails open — a stale or missing pool renders the real gallery, never a blank one.

***

### Step 4 — Per-variant featured media

Only needed if your tests bind images to specific variants.

```liquid
{%- liquid
  # eraya-product-test v1: variant featured media
  assign eraya_vfm = product.selected_or_first_available_variant.featured_media
  if eraya_entry and eraya_entry.variant_media
    # variant_media keys are strings; Liquid's variant.id is an integer.
    assign eraya_vkey = product.selected_or_first_available_variant.id | append: ''
    assign eraya_vm = eraya_entry.variant_media[eraya_vkey]
    if eraya_vm and eraya_vm.id
      assign eraya_vm_id = eraya_vm.id | times: 1
      assign eraya_vhit = eraya_pool | where: 'id', eraya_vm_id | first
      if eraya_vhit == nil
        assign eraya_vhit = product.media | where: 'id', eraya_vm_id | first
      endif
      if eraya_vhit
        assign eraya_vfm = eraya_vhit
      endif
    endif
  endif
  if eraya_has_media_override and eraya_vfm
    # The gallery hoists this image ahead of the list. If the override dropped
    # it, hoisting would show an image the merchant deliberately removed.
    assign eraya_vfm_in = eraya_media | where: 'id', eraya_vfm.id
    if eraya_vfm_in.size == 0
      assign eraya_vfm = nil
    endif
  endif
-%}
```

#### Themes that hide variant images

Dawn-family themes have a `hide_variants` setting that strips variant images from the gallery. An explicit gallery override has to win over that rule, or it will strip images the merchant deliberately chose to show:

```liquid
{%- liquid
  if eraya_has_media_override
    assign variant_images = '' | split: ','
  endif
-%}
```

***

### Step 5 — The variant JSON blob

Themes serialise `product.variants | json` into the page for the variant picker to read. If the gallery changed but that blob still points at a removed slide, the picker's `switchTo` throws — **taking price and availability updates down with it**.

The fix is to rebuild the blob per variant rather than string-matching ids:

```liquid
{%- liquid
  assign eraya_rewrite = false
  if eraya_entry and product.variants.size <= 250
    if eraya_has_media_override or eraya_entry.variant_media
      assign eraya_rewrite = true
    endif
  endif

  capture eraya_variants_json
    if eraya_rewrite
      echo '['
      for eraya_v in product.variants limit: 250
        capture eraya_vj
          echo eraya_v | json
        endcapture
        # JSON is last-wins, so rename the original key and re-inject only when
        # the target survived the gallery override. No id matching, no collisions.
        assign eraya_vj = eraya_vj | replace: '"featured_media":', '"eraya_prev_media":'

        assign eraya_target = eraya_v.featured_media
        if eraya_entry.variant_media
          assign eraya_vkey = eraya_v.id | append: ''
          assign eraya_vm = eraya_entry.variant_media[eraya_vkey]
          if eraya_vm and eraya_vm.id
            assign eraya_vm_id = eraya_vm.id | times: 1
            assign eraya_vhit = eraya_pool | where: 'id', eraya_vm_id | first
            if eraya_vhit == nil
              assign eraya_vhit = product.media | where: 'id', eraya_vm_id | first
            endif
            if eraya_vhit
              assign eraya_target = eraya_vhit
            endif
          endif
        endif
        if eraya_target
          assign eraya_target_in = eraya_media | where: 'id', eraya_target.id
          if eraya_target_in.size > 0
            assign eraya_inject = '{"featured_media":{"id":' | append: eraya_target.id | append: '},'
            assign eraya_vj = eraya_vj | replace_first: '{', eraya_inject
          endif
        endif

        echo eraya_vj
        unless forloop.last
          echo ','
        endunless
      endfor
      echo ']'
    else
      echo product.variants | json
    endif
  endcapture

  if eraya_entry and eraya_entry.title != blank
    assign eraya_variants_json = eraya_variants_json | replace: product.title, eraya_entry.title
  endif
-%}
```

Then output `{{ eraya_variants_json }}` where the theme had `{{ product.variants | json }}`.

The `limit: 250` is a deliberate ceiling — products above it fall back to the untouched blob rather than bloating the page.

***

### Which surfaces to patch

The title a shopper sees in the cart has to match the one they saw on the product page, or the experiment is measuring a confusing experience rather than a clean variation. Here's the full surface map, using Dawn file names as the reference:

| Surface                  | Dawn file                                   | What to apply                             |
| ------------------------ | ------------------------------------------- | ----------------------------------------- |
| Product page             | `sections/main-product.liquid`              | Title, description, gallery, variant JSON |
| Media gallery            | `snippets/product-media-gallery.liquid`     | Gallery, variant featured media           |
| Media lightbox           | `snippets/product-media-modal.liquid`       | Gallery, variant featured media           |
| Variant picker           | `snippets/product-variant-picker.liquid`    | Variant JSON blob                         |
| Collection / grid cards  | `snippets/card-product.liquid`              | Title, first image                        |
| Thumbnails               | `snippets/product-thumbnail.liquid`         | Title (alt text, `data-shopify-title`)    |
| Featured product section | `sections/featured-product.liquid`          | Same as product page                      |
| Cart page                | `sections/main-cart-items.liquid`           | Title, line image                         |
| Cart drawer              | `snippets/cart-drawer.liquid`               | Title, line image                         |
| Add-to-cart notification | `sections/cart-notification-product.liquid` | Title, image                              |
| Predictive search        | `sections/predictive-search.liquid`         | Title, image                              |

{% hint style="warning" %}
Your theme will differ. Search for `product.title`, `card_product.title`, `item.product.title` and `product.media` across the whole theme and work from the results — that's more reliable than working from this table.
{% endhint %}

***

### Gotchas

These four cost real debugging time on the reference integration. All of them fail *silently*.

#### Gotcha 1: id type coercion

Payload ids are JSON **strings**. `media.id` in Liquid is an **integer**. Liquid's `==` is type-sensitive, so `where: 'id', "31234567890"` matches nothing at all.

```liquid
assign eraya_ref_id = eraya_ref.id | times: 1   {%- comment -%} required {%- endcomment -%}
```

#### Gotcha 2: an empty array is truthy

`if eraya_entry.media` is true even for `[]`. The guard only works because the writer **omits the key entirely** when there's no gallery override. If you build your own payload writer, never emit `[]`.

#### Gotcha 3: `variant_media` keys are strings

`variant.id` is an integer, so index with an appended empty string:

```liquid
assign eraya_vkey = product.selected_or_first_available_variant.id | append: ''
```

#### Gotcha 4: `paginate` can't live inside `{% liquid %}`

It's a block tag. Pool assembly must sit between your `liquid` tags, not inside one.

***

### JavaScript API

If you have custom JS — a third-party review widget, a bundle builder, a custom gallery — that needs to know the visitor's assignment, the SDK exposes it:

```js
const pt = window.Eraya.sdk.getProductTestManager()
```

{% hint style="info" %}
These live on the product-test engine, not on `window.Eraya` directly. They're contract — shapes change additively.
{% endhint %}

#### `getProductTestAssignment(productId)` — synchronous

Which variation this visitor is in, or `null` if the product isn't under test or they aren't bucketed yet. Everything it reads is already in memory, so there's no network cost — use it for analytics tagging or to branch on the arm.

```js
const a = pt.getProductTestAssignment('7412345678901')
// { testId, testName, variationId, variationName, isControl }
```

#### `getProductTest(productId)` — async

The assignment plus whatever the variation overrides for that product.

```js
const data = await pt.getProductTest('7412345678901')
// { …assignment, productId, title?, description?, media?, variantMedia?, defaultVariantId? }
```

Async because the override content isn't in the page config — the shop-level metafield carries assignment data only, so this reads Eraya's app proxy at `/apps/eraya-proxy/config.json`. Results are memoised per product, and concurrent calls for the same product share one request.

Control returns a result with **no override fields set**. That's a meaningful answer ("you're in control, nothing is changed"), not an absence — and it costs no network call.

#### `getProductVariantMedia(productId, variantId)` — async

```js
const media = await pt.getProductVariantMedia('7412345678901', '44123456789')
// { id, mediaType, url }  |  null
```

{% hint style="warning" %}
`null` does **not** mean "no image". It means this variation didn't override *this specific variant*, so the theme falls through to the variant's real `featured_media` — which the SDK can't see. If you want "what is the gallery showing right now", read `media[0]` off `getProductTest()` instead.
{% endhint %}

#### Patching a JSON-rendered surface

This is what you use for a quick-view modal or a JSON search dropdown — anywhere the markup came from `/products/<handle>.js` or `/search/suggest.json` and therefore carries the real product data.

```js
async function applyErayaOverride(productId, root) {
  const pt = window.Eraya?.sdk?.getProductTestManager?.()
  if (!pt) return

  const data = await pt.getProductTest(productId)
  // null = not under test or not bucketed. Control returns an object with no
  // override fields, so both cases fall through and leave the real product.
  if (!data) return

  if (data.title) {
    root.querySelectorAll('[data-eraya-title]').forEach((el) => {
      el.textContent = data.title
    })
  }
  if (data.media?.length) {
    const img = root.querySelector('[data-eraya-image]')
    if (img) img.src = data.media[0].url
  }
}
```

Call it right after you inject the JSON-rendered markup, before it becomes visible — the same reason the Liquid path exists is the reason a visible swap is worse than none.

{% hint style="warning" %}
**Patch every field you patch anywhere.** If the modal shows the variation's title but its original image, the shopper sees a combination that exists in neither arm — the exact failure the Liquid-only design avoids. Either override title *and* gallery on that surface, or leave the surface alone entirely.
{% endhint %}

Mark the elements you intend to override (`data-eraya-title`, `data-eraya-image` above) rather than matching on class names — theme classes change with every update, and a selector that silently stops matching gives you a half-applied variation with no error.

***

### Testing your integration

#### Force a variation

Set the cart attribute directly and reload:

```js
await fetch('/cart/update.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    attributes: { '_eraya_product_test_<TEST_ID>': '<VARIATION_ID>' }
  })
})
location.reload()
```

Get both ids from the test's detail page in Eraya. Clear with `null` to return to whatever the SDK assigns you.

#### Check each one

| Check                    | Expected                                               |
| ------------------------ | ------------------------------------------------------ |
| No test running          | Every surface renders the real product                 |
| Control bucket           | Every surface renders the real product                 |
| Variant bucket           | Title, description and gallery all change **together** |
| Add to cart in a variant | Cart line shows the variation's title and image        |
| Switch variants          | Price and availability still update (Step 5 works)     |
| Test stopped             | Everything reverts with no theme change                |

That last one matters: stopping a test clears the metafield, so Liquid falls straight back to the real product. **Ending an experiment never requires a theme edit.**

#### Verify attribution

Place a test order in a variant bucket and confirm it appears on the test's results page. If traffic is climbing but both arms show zero orders, the cart attribute prefix is wrong — see the warning in the data contract.

***

### Ending an integration

The Liquid is inert when no test is running: no metafield means no `eraya_entry`, which means the real product renders. You can safely leave it in place between tests — that's the intended steady state.

To remove it entirely, delete the resolver, pool and gallery blocks and revert `eraya_title` / `eraya_description` / `eraya_media` / `eraya_variants_json` back to their `product.*` originals.
