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:

Nested promotion cards: a fragrance campaign rendered inside the candles campaign

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

← All case studies