FoxrFoxr

Packing slips

Group a bundle's components under one header with its SKU and item count on your Shopify packing slip template.

Shopify renders a bundle parent row on its own in order emails and the admin, but not on packing slips — there it only stamps Part of: … on each component. This code rebuilds the grouping the packing slip renderer doesn't do.

Where to edit: Shopify admin → SettingsShipping and deliveryPacking slipsEdit packing slip template.

Back it up first

Select everything in the editor and paste it into a text file before you change anything. There's no version history for this template.

The result

Instead of six loose rows, the slip prints one bordered block per box: the bundle's name, its SKU and item count in the header, then the components indented beneath it. Non-bundle products in the same order print exactly as they did before.

Step 1 — Collect the bundles in this shipment

Put this above the line item loop. It walks the lines once and records each bundle's identity.

{% assign foxr_keys = "" %}
{% for line_item in line_items_in_shipment %}
  {% assign foxr_key = "" %}
  {% for foxr_group in line_item.groups %}
    {% unless foxr_group.deliverable? %}
      {% assign foxr_key = foxr_group.title %}
    {% endunless %}
  {% endfor %}
  {% if foxr_key == blank %}
    {% assign foxr_key = line_item.properties["_bundle_id"] %}
  {% endif %}
  {% if foxr_key != blank %}
    {% assign foxr_keys = foxr_keys | append: foxr_key | append: "~|~" %}
  {% endif %}
{% endfor %}
{% assign foxr_keys = foxr_keys | split: "~|~" | uniq %}

Liquid has no arrays you can push to, so the ids are glued into one string with a ~|~ separator and split back apart. uniq collapses the duplicates — every component of the same bundle contributes the same key.

The key is the bundle's group title when Shopify made a real bundle (merged cart lines), otherwise the app's _bundle_id property (individual cart lines). Handling both is what makes this work regardless of which cart line mode your builder uses.

Step 2 — Print the non-bundle lines

Your existing single loop stays exactly as it is, with one condition added inside it: skip anything belonging to a bundle.

{% if foxr_key == blank %}
  …your original line item markup…
{% endif %}

You can also drop the Part of: … line from this block — a line that says "part of" is by definition inside a bundle, so it's never reached here.

Step 3 — Print one block per bundle

For each key collected in step 1, scan the lines twice: once to work out the header (name, SKU, item count), once to render the components.

{% for foxr_bundle_key in foxr_keys %}
  {% assign foxr_title = "" %}
  {% assign foxr_sku = "" %}
  {% assign foxr_item_count = 0 %}
 
  {% for line_item in line_items_in_shipment %}
    {% assign foxr_key = "" %}
    {% for foxr_group in line_item.groups %}
      {% unless foxr_group.deliverable? %}
        {% assign foxr_key = foxr_group.title %}
      {% endunless %}
    {% endfor %}
    {% if foxr_key == blank %}
      {% assign foxr_key = line_item.properties["_bundle_id"] %}
    {% endif %}
 
    {% if foxr_key == foxr_bundle_key %}
      {% assign foxr_item_count = foxr_item_count | plus: line_item.shipping_quantity %}
      {% if foxr_sku == blank %}
        {% assign foxr_sku = line_item.properties["Bundle SKU"] %}
      {% endif %}
      {% if foxr_title == blank %}
        {% for foxr_group in line_item.groups %}
          {% unless foxr_group.deliverable? %}
            {% assign foxr_title = foxr_group.title %}
          {% endunless %}
        {% endfor %}
      {% endif %}
      {% if foxr_title == blank and line_item.properties["_foxr_builder"] != blank %}
        {% assign foxr_parts = line_item.properties["_foxr_builder"] | split: '"n":"' %}
        {% if foxr_parts.size > 1 %}
          {% assign foxr_title = foxr_parts[1] | split: '"' | first %}
        {% endif %}
      {% endif %}
      {% if foxr_title == blank %}
        {% assign foxr_title = line_item.properties["_bundle_name"] %}
      {% endif %}
    {% endif %}
  {% endfor %}
 
  {% if foxr_title == blank %}{% assign foxr_title = "Bundle" %}{% endif %}
 
  <div class="ia-bundle-group">
    <div class="ia-bundle-group-header">
      <p class="ia-bundle-group-title">{{ foxr_title }}</p>
      <p class="ia-bundle-group-meta">
        {% if foxr_sku != blank %}SKU {{ foxr_sku }} &middot; {% endif %}
        {{ foxr_item_count }} item{% unless foxr_item_count == 1 %}s{% endunless %}
      </p>
    </div>
 
    {% for line_item in line_items_in_shipment %}
      {% assign foxr_key = "" %}
      {% for foxr_group in line_item.groups %}
        {% unless foxr_group.deliverable? %}
          {% assign foxr_key = foxr_group.title %}
        {% endunless %}
      {% endfor %}
      {% if foxr_key == blank %}
        {% assign foxr_key = line_item.properties["_bundle_id"] %}
      {% endif %}
      {% if foxr_key == foxr_bundle_key %}
        <div class="ia-bundle-component">
          …your original line item markup…
        </div>
      {% endif %}
    {% endfor %}
  </div>
{% endfor %}

How each header value is resolved:

  • Name — the native group title; for individual mode it's pulled out of the _foxr_builder property by string-splitting on "n":" (Liquid can't parse JSON), then _bundle_name, then literally "Bundle".
  • SKU — the visible Bundle SKU property, which is your parent product's SKU. It's shown once in the header instead of on every row, so the component rows should suppress it (see below).
  • Count — the components' shipping_quantity, summed.

Inside the component markup, suppress the SKU so it isn't repeated on every row:

{% for property in line_item.properties %}
  {% unless property.last == blank or property.first == 'Bundle SKU' %}
    {{ property.first }}: {{ property.last }}
  {% endunless %}
{% endfor %}

Step 4 — The CSS

Append these to the end of the template's existing <style> block. page-break-inside: avoid keeps a bundle from splitting across two printed pages.

.ia-bundle-group {
  border: 1px solid #d9d9d9;
  border-radius: 6px;
  padding: 10px 12px;
  margin-bottom: 14px;
  page-break-inside: avoid;
}
.ia-bundle-group-header {
  border-bottom: 1px solid #e6e6e6;
  padding-bottom: 6px;
  margin-bottom: 8px;
}
.ia-bundle-group-title {
  font-weight: bold;
  margin: 0;
}
.ia-bundle-group-meta {
  margin: 2px 0 0;
  font-size: 12px;
  color: #6b7177;
}
.ia-bundle-component {
  padding-left: 12px;
  border-left: 2px solid #e3e3e3;
  margin-bottom: 6px;
}

Everything else in the template — header, addresses, notes, footer, the original CSS — stays untouched.

Showing the bundle's image

Packing slips don't print product images by default, but if yours does, know what's actually available: the bundle's own image is not reliably on the order lines, only the component images are.

  • line_item.image on each component is the component's photo, and it's always there.
  • _bundle_image_url — the image you set in the builder's General Settings — is written to the line only on older, legacy-format lines. On current merged-mode orders it isn't present, because that value now lives in the app's Cart Transform configuration rather than on the cart line.

So the safe recipe is: use the bundle image if the property happens to exist, and fall back to the first component's photo.

{% assign foxr_image_url = "" %}
{% for line_item in line_items_in_shipment %}
  {% comment %} …same key resolution as above… {% endcomment %}
  {% if foxr_key == foxr_bundle_key and foxr_image_url == blank %}
    {% if line_item.properties["_bundle_image_url"] != blank %}
      {% assign foxr_image_url = line_item.properties["_bundle_image_url"] %}
    {% elsif line_item.image != blank %}
      {% assign foxr_image_url = line_item.image | img_url: 'small' %}
    {% endif %}
  {% endif %}
{% endfor %}
 
{% if foxr_image_url != blank %}
  <img src="{{ foxr_image_url }}" width="60" alt="{{ foxr_title }}">
{% endif %}

Drop the <img> into .ia-bundle-group-header next to the title.

If the real bundle image matters to you

Showing the parent product's image on every slip needs a change in the app — stamping the image URL as a visible property on every line, the way Bundle SKU is — not more Liquid. It's on our list; contact support if it's blocking you. Like the SKU, it would apply to orders placed after it ships. Orders already in your admin would keep showing the component photo.

The preview lies

The preview in the packing slip editor doesn't include line item properties, so the SKU line looks missing there. Only a real print — Orders → More actions → Print packing slips — shows the true output. Always test with a real order that contains a bundle.

Next

Notification emails — the same idea for order confirmation and shipping emails.

On this page