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

# Custom UI with Liquid

> Build your own bundle UI in Liquid using Panori Bundles' public metafields (Pro plan).

<Info>
  This feature is available on the **Pro plan** only. The public metafields are written and kept in sync automatically whenever you save a bundle in the app.
</Info>

## Overview

On the Pro plan, Panori Bundles writes the bundle configuration into a **public metafield namespace** (`panda_bundles`) on every mother variant. This lets you build a fully custom bundle picker directly in your theme's Liquid — no Theme App Extension block required.

***

## Available Metafields

Each mother variant gets a metafield with the full bundle configuration for that variant.

| Scope          | Namespace       | Key              | Type                     | Liquid accessor                                   |
| -------------- | --------------- | ---------------- | ------------------------ | ------------------------------------------------- |
| Product        | `panda_bundles` | `config`         | `json`                   | `product.metafields.panda_bundles.config`         |
| Mother Variant | `panda_bundles` | `bundle_config`  | `json`                   | `variant.metafields.panda_bundles.bundle_config`  |
| Mother Variant | `panda_bundles` | `child_variants` | `list.variant_reference` | `variant.metafields.panda_bundles.child_variants` |

`child_variants` is a `list.variant_reference` — Shopify resolves each entry to a full variant object in Liquid, giving you live access to `.price`, `.title`, `.image`, `.available`, and all other variant properties.

### Data structure

```json theme={null}
{
  "bundleId": "a1b2c3d4-...",
  "steps": [
    {
      "id": "step-uuid",
      "label": "Choose your base",
      "maxVariants": 1,
      "isGift": false,
      "childVariants": [
        {
          "variantId": "gid://shopify/ProductVariant/111",
          "productId": "gid://shopify/Product/222",
          "price": "29.99",
          "available": true,
          "imageUrl": "https://cdn.shopify.com/..."
        }
      ]
    }
  ],
  "discount": {
    "type": "percentage",
    "value": 10
  }
}
```

<Note>
  `discount.type` can be `"percentage"`, `"fixed"`, or `"custom"`. For `"custom"`, the price lives directly on the Shopify variant — no calculation needed.
</Note>

***

## Building the UI in Liquid

Use `bundle_config` for step structure and `child_variants` for live variant data. Combine them by matching `variantId` from the config against the resolved variant objects.

```liquid theme={null}
{% for variant in product.variants %}
  {% assign bundle_configs = variant.metafields.panda_bundles.bundle_config.value %}
  {% assign child_variants = variant.metafields.panda_bundles.child_variants.value %}

  {% if bundle_configs != blank %}
    {% assign bundle = bundle_configs[0] %}

    <div data-bundle-variant="{{ variant.id }}">

      {% for step in bundle.steps %}
        <div data-step-id="{{ step.id }}">
          <p><strong>{{ step.label }}</strong></p>

          {% for cv in step.childVariants %}
            {%- comment -%}
              Look up the live variant object from child_variants by matching the GID.
              This gives us live price, title, image and availableForSale.
            {%- endcomment -%}
            {% assign live_variant = nil %}
            {% for v in child_variants %}
              {% if v.id == cv.variantId %}
                {% assign live_variant = v %}
                {% break %}
              {% endif %}
            {% endfor %}

            {% if live_variant.available %}
              <label>
                <input
                  type="checkbox"
                  data-step-id="{{ step.id }}"
                  data-variant-id="{{ cv.variantId }}"
                  data-max="{{ step.maxVariants | default: 99 }}"
                />
                {% if live_variant.image %}
                  {{ live_variant.image | image_url: width: 96 | image_tag: loading: 'lazy', width: 48, height: 48 }}
                {% endif %}
                <span>{{ live_variant.title }}</span>
                <span>{{ live_variant.price | money }}</span>
              </label>
            {% endif %}

          {% endfor %}
        </div>
      {% endfor %}

    </div>
  {% endif %}
{% endfor %}
```

<Note>
  `child_variants` is a `list.variant_reference` — Shopify resolves it to actual variant objects at render time. Prices, availability, and images are always live. You do not need to manage staleness.
</Note>

***

## Submitting the Bundle to Cart

When the customer confirms their selection, you must POST to `/cart/add.js` with the selected variants **plus a `_bundle_config_json` property** on the first line item and a shared `_bundle_id` across all items.

<Steps>
  <Step title="Collect selections">
    Gather the selected `variantId` per step from your UI state.
  </Step>

  <Step title="Build the payload">
    Construct the `_bundle_config_json` from the bundle metafield and the customer's selections.

    ```js theme={null}
    const bundleInstanceId = crypto.randomUUID();

    // bundle = variant.metafields.panda_bundles.bundle_config (from a data attribute or API)
    // getSelections(stepId) returns [{ variantId: "gid://...", quantity: 1 }]
    const payload = JSON.stringify({
      bundleId: bundle.bundleId,
      motherVariantId: "gid://shopify/ProductVariant/MOTHER_ID",
      steps: bundle.steps.map((step) => ({
        stepId: step.id,
        selectedVariants: getSelections(step.id),
      })),
    });
    ```
  </Step>

  <Step title="POST to /cart/add.js">
    Add each selected child variant as a separate line item. Attach `_bundle_config_json` to the **first item only**, and `_bundle_id` to **all items**.

    ```js theme={null}
    // getSelections returns [{ variantId, quantity }] — quantity can be > 1 for the same variant
    const allSelections = bundle.steps.flatMap((step) => getSelections(step.id));

    const items = allSelections.map(({ variantId, quantity }, idx) => ({
      id: numericId(variantId), // strip the GID prefix
      quantity,
      properties: {
        _bundle_id: bundleInstanceId,
        ...(idx === 0 ? { _bundle_config_json: payload } : {}),
      },
    }));

    await fetch("/cart/add.js", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ items }),
    });

    function numericId(gid) {
      return gid.split("/").at(-1);
    }
    ```
  </Step>
</Steps>

***

## Server-side Validation

<Warning>
  Do **not** include `discount`, `isGift`, `maxVariants`, or `allowedVariantIds` in the `_bundle_config_json` payload. These fields are **ignored** — Panori Bundles reads them exclusively from the server-side metafield.
</Warning>

When the cart is processed, the Panori Bundles cart transform function validates every bundle instance against the saved configuration:

| Check                | What is validated                                                                            |
| -------------------- | -------------------------------------------------------------------------------------------- |
| **Bundle exists**    | `bundleId` must match a known bundle in the shop config                                      |
| **Mother variant**   | `motherVariantId` must belong to that bundle                                                 |
| **Step exists**      | Every `stepId` must exist on the server-side mother variant                                  |
| **Allowed variants** | Each `selectedVariantId` must be in that step's `childVariants` list                         |
| **Max selections**   | Sum of all `quantity` values in a step must not exceed `maxVariants`                         |
| **Quantity**         | Each cart line's `quantity` must exactly match the declared `quantity` in `selectedVariants` |

If any check fails, the bundle is not merged and the items remain as separate, full-price lines in the cart.
