case study · liquid · theme · metafields
Tiered cart promotions, three render paths, one contract
Background. Originally built for a US home-goods DTC brand running frequent stacked promotions. Off-the-shelf promotion apps couldn’t match the design or the exact rules, and each added script weight to every page. What you can try on the demo store is a clean-room rebuild of that engine on Dawn, following the same information architecture our latest production module shipped with.
The display rule that matters most: a promotion’s surface must cover its math. A reward judged across the whole store can’t honestly be a card wrapping a handful of rows, so the layout splits by coverage:
- Storewide rewards ride the top of the cart. Rewards sharing a basis share one milestone bar — here a free gift at $60 and 10% off at $120 sit as two dots on a single track that fills toward the larger threshold, with a status row per reward: the gift’s actual product (thumbnail, link), a claim button the moment it’s earned, “Applied at checkout ✓” once the order tier lands. The free-shipping bar above it measures every line, gifts included — exactly what its Function measures — so the two bars can honestly show different numbers. Rewards on different bases never merge into one bar.
- Scoped promotions group the line items they cover. Rewards sharing a scope form one card with a tiered ladder header; items discounted by a flat promo group under that discount’s own name; leftovers trail in a plain card. Every row appears in exactly one group while every ladder computes over the full cart.
- Containment nests. When one promotion’s product set is a strict subset of another’s, its card renders inside the parent card — two levels, three, whatever the config implies. The backend materializes the containment forest when it resolves collections into product lists, the deepest scope claims shared items, and partially overlapping campaigns stay side by side. So a candles-wide campaign can wrap a narrower fragrance campaign without either one lying about its numbers:

Nothing is hardcoded. One versioned shop metafield holds the whole rewards config — thresholds, gift products, scopes, the order percent:
{
"v": 2,
"free_shipping": { "enabled": true, "threshold": 80 },
"rewards": [
{ "id": "gift-tealight", "kind": "gift", "gift_handle": "tealight-set-of-six",
"condition": { "basis": "subtotal", "threshold": 60 }, "scope": null },
{ "id": "gift-trimmer", "kind": "gift", "gift_handle": "brass-wick-trimmer",
"condition": { "basis": "item_count", "threshold": 3 },
"scope": { "collection_handle": "candles", "product_handles": ["…"], "parent": null } }
]
}
The theme renders it, a custom Discount Function enforces it at checkout (gifts zeroed, order percent applied — re-read on every evaluation, so a console edit reaches checkout in about a minute), and the ops console edits it. Scope collections are materialized into product-handle lists — and into the containment forest — by the backend, because Function input queries are static: one resolved list, two consumers, zero drift. Change a threshold in the console and the bar, the ladders, and the checkout total all move together.
Platform scars, earned honestly. The gift mechanics are a Function rather than native Buy-X-Get-Y because we measured, on a live store, that stacked gift BXGYs cannibalize each other’s qualifying items and an amount-based gift greedily consumes even other gifts’ lines. Shopify also never auto-adds a gift — a discount only zeroes it once it’s in the cart — so an earned gift offers an “Add your free gift →” button right in its status row: an anchor with a plain GET fallback, progressively enhanced into an AJAX add that re-renders through the cart’s own section contract, so claiming from the drawer never closes the drawer and the page never reloads. Gift lines are excluded from every reward’s basis — the platform never lets a gift unlock itself, and neither does the display. And the awkward edge is stated, not hidden: claim a gift, drop below its threshold, and it sits in its group at full price — milestone back to hollow, savings line gone — until you remove it or re-qualify. The checkout agrees, because the display never promises what the Function won’t pay.
No tables, no display JavaScript. The cart layout is plain divs on a CSS grid — nested grouping needs that freedom, and Dawn’s original table markup couldn’t legally nest the group structure anyway. Both surfaces re-render server-side through the Section Rendering API on every cart mutation; the one script in the module is the claim button’s fetch. Rows keep every id and name Dawn’s cart JS relies on, keyed to the item’s cart position rather than its display position — quantity steppers and remove buttons keep working no matter how the display regroups.
Try it. Add two candles and a bowl on the demo store: watch the milestone bar fill dot by dot, claim the gift without leaving the drawer, cross $120 and see “Applied at checkout ✓” — then open the ops console, move a threshold, and watch the bar and every ladder follow.
two-minute walkthrough