Skip to main content

Create a custom Liquid block for nested cart lines

This tutorial covers how to create nested cart lines for extended warranties using the Cart AJAX API and metafields with a custom Liquid block.


Anchor to 1. Create variant metafield definition for extended warranties1. Create variant metafield definition for extended warranties

First, you'll need to create a metafield definition that maps a product to their available extended warranty options.

Create a metafield definition named extended_warranty on the product object and set the metafield type to a list of product variants.

Metafield definition for extended warranty

This metafield will store references to warranty product variants that can be offered for each product variant. This can be configured on the Shopify admin product details page.

Metafield definition for extended warranty

Anchor to 2. Custom Liquid block for creating nested cart line2. Custom Liquid block for creating nested cart line

Next, you'll create custom Liquid block that reads the extended warranty metafield and displays the available warranty options to customers.

On your theme editor, add a custom Liquid block on the product details page.

How to create a custom Liquid block

Paste the following snippet:

Custom Liquid block

{% assign current_variant = product.selected_or_first_available_variant %}
{% assign warranties = product.metafields.custom.extended_warranty.value %}

<div className="warranty-widget-{{ block.id }}">
<h3>Protect Your Purchase</h3>

<div className="warranty-options">
{% for warranty in warranties %}
<label>
<input type="checkbox" value="{{ warranty.id }}" />
<span>{{ warranty.title }}</span>
<span className="price">{{ warranty.price | money }}</span>
</label>
{% else %}
<p>No warranty options available</p>
{% endfor %}
</div>

<button className="add-btn" data-variant="{{ current_variant.id }}" {% unless warranties %}disabled{% endunless %}>
Add to Cart
</button>
</div>

<script>
{
const widget = document.querySelector('.warranty-widget-{{ block.id }}');
const options = widget.querySelector('.warranty-options');
const btn = widget.querySelector('.add-btn');

btn.onclick = async () => {
const selected = [...options.querySelectorAll(':checked')];
if (!selected.length) return alert('Please select a warranty');

const variantId = btn.dataset.variant;
const items = [
{ id: variantId, quantity: 1 },
...selected.map(input => ({ id: input.value, quantity: 1, parent_id: variantId }))
];

btn.disabled = true;
btn.textContent = 'Adding...';

try {
await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items })
});
location.href = '/cart';
} catch {
btn.textContent = 'Error';
setTimeout(() => (btn.textContent = 'Add to Cart', btn.disabled = false), 2000);
}
};
}
</script>

<style>
.warranty-widget-{{ block.id }} { margin: 20px 0; padding: 20px; border: 1px solid #ddd; }
.warranty-widget-{{ block.id }} label { display: block; margin: 10px 0; cursor: pointer; }
.warranty-widget-{{ block.id }} input { margin-right: 10px; }
.warranty-widget-{{ block.id }} .price { float: right; color: #666; }
.warranty-widget-{{ block.id }} .add-btn {
width: 100%; padding: 12px; background: #000; color: #fff;
border: none; cursor: pointer; margin-top: 15px;
}
.warranty-widget-{{ block.id }} .add-btn:disabled { opacity: 0.5; cursor: not-allowed; }
</style>

Once saved, the block will be rendered on the product details page and the "Add to Cart" button will enable multiple product variants to be added to the cart, with selected variants containing a reference to the parent cart line.

Snippet rendering product variant options for warranties.

When added to the cart, nested cart lines will have distinct representation on Checkout.

Checkout with a nested cart line. The leash has a warranty as its nested item
Note

If you want to build validation to ensure that only eligible products were nested, the Cart and Checkout Validation Function can be leveraged.


Anchor to 3. Defining nesting rules3. Defining nesting rules

You can use custom data like custom data like product reference lists to define nesting rules. Depending on the use case, you can either:

  • List all the products (or variants) a specific product (or variant) can be nested under.
  • List all the products (or variants) that can be nested under a specific product (or variant).

This data can be read by:

  • Themes in order to display the right products.
  • Cart validation functions in order to validate the data before nesting the product under another one in the cart.
Note

It is important to validate the cart using functions in order to ensure someone trying to manipulate the storefront using the cart API directly can't bypass the rules defined by the merchant through the app. Same goes for quantity and line splitting rules.

In this example, checkout and cart validation function has been deployed with an app for checking allowed nested cart lines. A metafield, nestable_products contains a list of allowed products that can be nested.

Example Cart Validation function

run.graphql

query RunInput {
cart {
lines {
id
quantity
lineRelationship {
parent {
id
quantity
merchandise {
...MerchandiseFields
}
}
}
merchandise {
...MerchandiseFields
}
}
}
}

fragment MerchandiseFields on Merchandise {
... on ProductVariant {
id
metafield(key: "nestable_variants") {
value
type
jsonValue
}
title
product {
id
title
metafield(key: "nestable_products", namespace: "custom") {
value
type
jsonValue
}
}
}
}

run.ts

import type {
RunInput,
CartValidationsGenerateRunResult,
ValidationError,
MerchandiseFields,
} from "../generated/api";

type ExtractMerchandise<T> = T extends { id: string }
? T
: never;
type Merchandise = ExtractMerchandise<MerchandiseFields>;

function performValidationChecks(lineMerch: Merchandise, parentMerch: Merchandise): boolean {
const childProductId = lineMerch.product?.id;
// This is a metafield that contains an array of product ids that are compatible with the parent product
const compatibleChildProducts = parentMerch.product?.metafield?.jsonValue;

// Check if the child product is in the list of compatible child products
return compatibleChildProducts?.includes(childProductId) ?? false;
}

export function cartValidationsGenerateRun(input: RunInput): CartValidationsGenerateRunResult {
const errors: ValidationError[] = [];

for (const line of input.cart.lines) {
const parent = line.lineRelationship?.parent;

if (!parent) continue;

const lineMerch = line.merchandise;
const parentMerch = parent.merchandise;

const isValid = performValidationChecks(lineMerch, parentMerch);
if (!isValid) {
errors.push({
message: `Parent ${parentMerch.title} is not compatible with child ${lineMerch.title}`,
target: "$.cart",
});
}
}

const operations = [
{
validationAdd: {
errors,
},
},
];

return { operations };
};

Was this page helpful?