Cart Page Rebuilt Around Loyalty Points, Protected Gifts and a Dark Order Summary
Dawn's single-table cart section rewritten as a two-column page: per-line loyalty points computed in Liquid, gift lines a shopper can't edit away, bundle lines that link back to the builder that made them, and a summary panel that survives every re-render.
A Shopify cart line knows its price, its quantity and its properties. It has no idea that one line is a free gift, that another is half of a bundle, or that the shopper is in a loyalty tier. We rewrote Dawn's cart section into a two-column page that reads all three out of tags, metafields and Liquid, and recomputes them server-side after every cart mutation.
Fact Strip
- Client: Nudestix
- Surface: Cart
- Templates served: 1
- Complexity: High
- Attribution: Built on top of Dawn 6.0.2; this cart section is our modification. The section-rendering refresh loop and the line-item form are Dawn's. The two-column layout, the points column, the gift protection, the bundle links and the extensions to
CartItemsare ours. - Status: Live, verified 2026-09-06
- Evidence: Two code artifacts, documented from the code
- Platform primitives: 6
The Problem
Shopify's cart is deliberately thin. A line item carries a variant, a quantity, a price and whatever properties were attached when it was added — and nothing else. It has no concept of a customer tier, no concept of a gift, and no concept of "this line and that line were configured together upstream."
This storefront needs all three. There's a points-based loyalty program with named tiers, and a gift-with-purchase calendar that runs more or less continuously. Dawn's stock cart answers with a flat line-item table: no points anywhere, nothing stopping a shopper from deleting or re-quantifying a free gift they were given, and no summary panel that looks like the rest of the brand. Bundle lines were the quiet one — they linked back to a raw product page rather than to the builder a shopper had actually used to make them.
The Constraint
Dawn 6.0.2's main-cart-items renders one <table>, and its cart.js re-renders whole sections over the Cart AJAX API. Anything added to that section has to survive a complete server re-render on every quantity change, which rules out decorating the DOM and expecting the decoration to still be there a tap later.
Points can't be computed server-side, because the loyalty program lives in an app rather than in Shopify. What the theme can see is a tag on the customer record, so tier detection and the multiplier both had to be expressed in Liquid.
Gifts are the same shape of problem. A free gift is an ordinary line item with a zero price, so the only handles the theme has on one are the product's tags and final_line_price == 0. And the cart page isn't the only thing that can change a cart: the drawer and a back-button restore both reach the same state from outside this page.
What We Built
The section is rewritten inside a <cart-items> custom element and split in two: a col-lg-8 line-item table and a col-lg-4 black .order-summary panel carrying the subtotal, the shipping-and-taxes note, points earned, the estimated total, a mount point for the shipping-protection widget and the checkout button.
Points, per line. Each row renders a points-earned cell, with a mobile twin, computed in Liquid from the tier: tag the loyalty app writes onto customer.tags. The summary panel does not use that computation: its figure comes from the loyalty app's own data-lion-points-for attribute, which the app fills in after the section renders.
Protected gift lines. A per-line isProtectedCartGift flag is set from product tags (free-gwp, free-deluxe-gwp, cart-sample) or from a zero final_line_price. Where it's set, the <quantity-input> is swapped for a hidden updates[] input that submits the quantity without offering it, the remove control is suppressed, and the product link is neutered so the gift doesn't behave like a product a shopper is buying. Lines tagged compostable or bundle get the same link treatment, and compostable additionally disables the plus and minus buttons.
Bundle lines that remember where they came from. A bundle line reads item.product.metafields.bundle.collection and its own _bundleId line-item property, and renders an Edit link back into the builder at <collection.url>?edit=<bundleId>. A shopper who wants to change one shade in a set goes back to the tool that made it rather than to a product page that can't express it.
The refresh loop. cart.js extends Dawn's CartItems. updateQuantity() posts to routes.cart_change_url with a sections list, pushes add_to_cart_v2 and remove_from_cart events into the Google Analytics 4 dataLayer (with the brand taken from product_type), then re-renders main-cart-items, cart-icon-bubble and cart-live-region-text from the response. After each render it re-runs loyaltylion.ui.refresh(), re-instantiates the carousels, re-binds the incentive handlers, and calls enforceProtectedGiftQuantities(), which pushes any protected line back to a quantity of one through /cart/update.json.
Why This Way
Everything sits inside Dawn's section-rendering refresh rather than beside it. The points column, the incentive banners and the protected-gift state come back from the server on each mutation, as Liquid, rather than being patched in place by a script that has to be told what changed. That is the reason the section is worth rewriting instead of decorating: Dawn already re-renders this markup, and anything that isn't in the markup has to be re-applied by hand afterwards.
Points had to be visible on the line, not only in the summary, and the loyalty app's own widgets expose a cart-level figure. So the theme carries its own copy of the tier logic, read off the tag the app writes. That is the bill for this design, and it isn't small: a rule the program owns now exists in two places, and the Liquid copy stays true only for as long as someone updates it when the program changes.
Gift protection is enforced twice on purpose — declaratively in Liquid at render time, then defensively in JavaScript after every render — because a gift line can also be changed from the drawer or restored by a back-button navigation, and neither of those goes through this page's markup.
Why Not an App
Free gifts and cart rules are a mature app category, and that's exactly the argument. This cart already has vendors mounted in it: a loyalty program, a shipping-protection widget, a subscription mechanism. Each one is there because it does something the theme genuinely can't — issue points, underwrite a shipment, bill on a schedule. A gift-with-purchase app would have been the one addition that overlaps rather than adds: it would install a second layer writing to the same /cart/change and /cart/update routes this section already drives, and gift eligibility would then be evaluated by two systems that don't know about each other.
The rules themselves also aren't complicated enough to buy. They're product tags, and the merchandising team already maintains them to run the gift calendar. What a gift app adds here is a second cart mutation layer; what it takes away is the ability to read the rule in the theme, where the line renders. On a cart with this many tenants, the honest tie-breaker isn't features — it's how many things are allowed to write to the cart.
Implementation Notes
- The loyalty tier is parsed out of
customer.tagsby splitting ontier:and stripping a trailing"], which tells you the app writes the tag as a JSON-ish string rather than a plain value. - The multiplier
caseblock is written out twice in the same file, once for the desktop cell and once for the mobile one. getSectionsToRender()targetsmain-cart-itemsthrough the.cart-parentselector, so the whole two-column layout — summary panel included — is what gets replaced on each mutation.refreshCartSections()fetches/cart?sections=<ids>rather than posting, so it can be called after any out-of-band change: a free sample added elsewhere, a gift swapped, a cross-sell taken.- Line-item properties whose names begin with
_are hidden from the rendered option list, and a property whose value contains/uploads/renders as a download link instead of as text. - The section's own schema exposes padding and nothing else. Every commercial behavior is driven from theme settings in
settings_schema.json, so a merchandiser changes the cart's rules in one place rather than per section instance.
Edge Cases
- An empty cart renders the
.cart__warningsblock with a continue-shopping link, and adds a sign-in prompt when customer accounts are on and nobody is logged in. - Discounted lines render the original price struck through against the final price; undiscounted lines fall back to
compare_at_pricewith a computed percentage-off figure. - Unit-price measurement renders only when the variant is available and actually carries a measurement, rather than printing an empty unit.
- Line-item discount allocations render as their own
discountslist with an icon, so a shopper can see which promotion touched which line. - An empty cart disables the checkout button, and the
is-emptyclass handling is passed to JavaScript for the drawer variant of the same markup. - A
<noscript>update button is kept, so the cart form still submits for a shopper whose JavaScript never arrives.
Platform Primitives Used
- Cart AJAX API — every mutation on this page goes through it, including the corrective
/cart/update.jsoncall that restores a protected gift's quantity. - Section Rendering API — returns the re-rendered cart, icon bubble and live region in the same response as the mutation, so the points column is recomputed by Liquid rather than patched by script.
- Metafields —
bundle.collectionon the product is what lets a bundle line find its way back to the builder that produced it. - Line-item properties —
_bundleIdidentifies which configuration a line belongs to, and the underscore convention is what keeps it out of the shopper-facing option list. - Customer accounts —
customer.tagsis the only place the theme can see a loyalty tier, and an empty cart offers a sign-in prompt when nobody is signed in. - Liquid section schema and settings — deliberately near-empty here; the cart's behavior lives in global theme settings instead, so it can't be configured differently on two instances of the same section.
Integrations in Play
- LoyaltyLion — owns the program and writes the tier tag the theme reads; its UI is refreshed after each section re-render, and its
data-lion-points-forattribute supplies the summary panel's figure. - Route — sits alongside the summary panel, which renders the mount point its widget attaches to.
- Swiper — powers the carousels inside the cart, which are re-instantiated after each render because the markup they were bound to is replaced.
Where It Runs
One template, one surface: the cart page. The drawer variant reuses the same section markup, so the empty-state class handling is deferred to JavaScript rather than baked into Liquid. Two views share a definition of "empty" and arrive at it by different routes.
What This Demonstrates
Primary: AJAX cart drawer and line-item rules without an app — line-item rules expressed in the theme and enforced through the platform's own refresh cycle. It is also a loyalty-program storefront build: the tier the app owns becomes something the cart can render per line.
How We Know
Two code artifacts — the cart section and the cart module — running to roughly 900 lines between them, plus their metafield and tag dependencies traced through the theme. The client working records that survive on this thread describe the adjacent cart drawer rather than this page, so what's above is documented from the code itself, not from a record of the conversation that produced it. Where the code makes a choice we can't source to a decision, we've described what it does rather than why someone wanted it.
Related Builds
- Mobile sticky add-to-cart bar mirrored from the real product form — a different storefront applying the same rule one surface earlier: one form, one cart path, no second implementation.
- A collection template turned into a palette builder that carts one priced SKU — the tool a bundle line's
Editlink goes back to, and the reason bundle lines need an identity in the cart at all. - Tabbed collection product slider and the shared merchandising card — a third storefront adding to the cart from a rail, on a theme that isn't Dawn.
The Buy-vs-Build Question
Cart software is one of the most crowded shelves on the app store, and most of it is worth buying. The part that isn't is the part that has to know your merchandising: which line is a gift, which line came from a builder, what a tier is worth. That knowledge lives in your tags and your theme settings, and it renders where the line renders. See the slide cart and sticky add-to-cart decision.
Is Your Cart Doing Everything Your Merchandising Asks of It?
Gifts, tiers, bundles and subscriptions all end up in the same table, and most carts show them as if they were the same thing. Contact us today and we'll walk your cart with you.