← back to Shopify Sample Shipping

theme/sample-shipping-cart-engine.liquid

200 lines

{%- comment -%}
  ============================================================================
  TK-11333 — SAMPLE-SHIPPING CART POLICY ENGINE  (Option A — DTD-committed)
  ============================================================================
  STATUS: install on a DUPLICATE/DEV theme only. Steve publishes. Do NOT paste
  into the LIVE theme. Go-live gated on proto-autoapply proving the /discount
  permalink makes free shipping persist to checkout for an eligible customer.

  ARCHITECTURE (verified live 2026-09-09 — see verification/band-truth-2026-09-09.json):
    • The FREE-SAMPLE floor is a delivery-profile RATE, not this engine: a $0
      "Free Shipping (No Tracking)" method on the dedicated profile
      "Samples — Free Shipping (No Tracking)" (gid 96764067891), gated by
      cart TOTAL_PRICE <= $45. Samples are $4.25, so ~10 samples ship free with
      NO code and NO login. VERIFIED the band evaluates WHOLE-CART subtotal:
      any roll (General/carrier profile) pushes the total over $45 and the band
      switches off for the whole cart.
    • Store is Advanced Shopify — NO Shopify Functions — so a shipping RATE
      CANNOT be gated by customer tag, sample count, or product type.
    • PROVEN LIMIT: a TOTAL_PRICE band cannot tell 6 wallcovering samples
      ($25.50) from 3 WC + 3 fabric ($25.50) — identical price. So the retail
      per-type "5 and 5" rule CANNOT be a shipping charge below the cap. Below
      $45 everyone is free regardless of type. Per-type "5 and 5" is therefore
      GUIDANCE ONLY here (a true per-type charge needs Plus + Functions).

  WHAT THIS ENGINE DOES (and only this):
    1. DESIGNER EXTENSION: a logged-in designer (tag match) whose sample cart is
       OVER the band cap (11–12 samples, subtotal > $45) gets TRADESHIP
       auto-applied so their free shipping extends to 12. This is the ONLY case
       a code is auto-applied — it never fires when the band already gives free
       (no needless redirect) and SAMPLESHIP is NOT auto-applied at all (the band
       already covers the retail free range; SAMPLESHIP stays a manual code).
    2. HONEST MESSAGING: it shows a "why you're charged" message ONLY when a
       charge will ACTUALLY be incurred (cart subtotal is over the band cap AND
       no covering code applies). It NEVER claims a charge the live band won't
       impose. When free, it says so; it never nags "charged" on a free cart.
    3. SOFT CAP 20: at 21+ samples it disables the standard checkout button AND
       hides accelerated wallet buttons + shows a reduce-to-20 message. Honestly
       SOFT — the direct /checkout URL and some wallet flows can still slip past;
       a hard cap needs Plus/Functions.

  INSTALL:
    1. Save as snippets/sample-shipping-cart-engine.liquid on a DEV theme.
    2. Render near the cart checkout button:  {%- render 'sample-shipping-cart-engine' -%}
    3. Confirm TRADESHIP exists (create-freeship-codes.mjs --apply) before relying on the extension.
    4. Tune CONFIG (bandCap, codes, caps, checkout selectors) to the theme.
  ============================================================================
{%- endcomment -%}

<div id="dw-ship-policy" data-dw-ship-policy hidden></div>
<script type="application/json" id="dw-ship-cart-data">
{
  "isLoggedIn": {{ customer | default: false | json }},
  "tags": {{ customer.tags | default: '' | json }},
  "cartSubtotalCents": {{ cart.total_price | default: 0 }},
  "lines": [
    {%- for item in cart.items -%}
    {
      "product_type": {{ item.product.type | default: '' | json }},
      "variant_title": {{ item.variant.title | default: '' | json }},
      "sku": {{ item.sku | default: '' | json }},
      "quantity": {{ item.quantity }},
      "line_price_cents": {{ item.final_line_price | default: 0 }}
    }{%- unless forloop.last -%},{%- endunless -%}
    {%- endfor -%}
  ]
}
</script>

<script>
(function () {
  // ---------------- CONFIG (tune to the store) ----------------
  var CONFIG = {
    designerCode: 'TRADESHIP',       // segment-scoped free-ship code, maxShip $30
    bandCapCents: 4500,              // live band: free while whole-cart subtotal <= $45
    designerCap:  12,                // total samples free for designers (via code)
    retailWC:     5,                 // per-type GUIDANCE only (cannot be a charge below the band)
    retailFabric: 5,                 // per-type GUIDANCE only
    softCap:      20,                // block standard checkout above this many samples
    // customer tags that mean "gets designer free shipping" — mirror the DW Trade/Designers segment
    designerTags: ['trade','trade_approved','sample-freeship','interior designer',
      'interior design','interior','architect','contractor','commercial property owner',
      'wallcovering installer','interior designer - residential','interior designer - commercial'],
    // product.type values that count as Fabric (VERIFIED live: catalog uses "Fabric" and
    // "Wallcovering"; toLowerCase makes "Fabric" -> "fabric"). Extra synonyms are harmless.
    fabricTypes: ['fabric','fabrics','textile','textiles'],
    // VERIFIED against carnegie-color-swatch sections/cart.liquid: the checkout button is
    // <button class="cart-checkout button" name="checkout">. [name="checkout"] + .cart-checkout both match.
    checkoutBtnSelector: '[name="checkout"], button[name="checkout"], .cart-checkout, .cart__checkout, #checkout, #cart-checkout',
    // accelerated wallet / dynamic checkout buttons to hide when over the soft cap.
    // VERIFIED: carnegie renders <div class="additional-checkout-buttons"> for content_for_additional_checkout_buttons.
    walletSelector: '.additional-checkout-buttons, .shopify-payment-button, [data-shopify="payment-button"], .dynamic-checkout__content',
    messageMountSelector: '#dw-ship-policy'
  };
  var APPLIED_KEY = 'dw_ship_code_applied'; // sessionStorage guard against redirect loops

  function readData() {
    try { return JSON.parse(document.getElementById('dw-ship-cart-data').textContent); }
    catch (e) { return { isLoggedIn: false, tags: '', cartSubtotalCents: 0, lines: [] }; }
  }
  function isSample(l) {
    return (l.variant_title || '').toLowerCase() === 'sample' || /-sample$/i.test(l.sku || '');
  }
  function isFabric(l) {
    return CONFIG.fabricTypes.indexOf((l.product_type || '').toLowerCase()) !== -1;
  }
  function isDesigner(tags) {
    var set = String(tags || '').toLowerCase().split(',').map(function (s) { return s.trim(); });
    return CONFIG.designerTags.some(function (t) { return set.indexOf(t) !== -1; });
  }

  function evaluate(d) {
    var samples   = d.lines.filter(isSample);
    var total     = samples.reduce(function (n, l) { return n + l.quantity; }, 0);
    var wc        = samples.filter(function (l) { return !isFabric(l); }).reduce(function (n, l) { return n + l.quantity; }, 0);
    var fab       = samples.filter(isFabric).reduce(function (n, l) { return n + l.quantity; }, 0);
    var hasNonSample = d.lines.some(function (l) { return !isSample(l); }); // e.g. a roll
    var designer  = !!d.isLoggedIn && isDesigner(d.tags);
    var overSoftCap = total > CONFIG.softCap;

    // The LIVE band already gives free shipping while whole-cart subtotal <= $45.
    var bandCovers = d.cartSubtotalCents <= CONFIG.bandCapCents;

    // A covering code closes the gap ONLY for a logged-in designer within the 12-sample cap.
    var codeCovers = designer && total <= CONFIG.designerCap && total > 0;

    // Will the customer ACTUALLY be charged shipping? (this is what the message must reflect)
    var willBeFree   = bandCovers || codeCovers;
    var willBeCharged = total > 0 && !willBeFree;

    // Auto-apply the designer code ONLY when it changes the outcome:
    // designer, over the band cap, still within the 12 cap, not over the soft cap.
    var shouldApplyCode = designer && !bandCovers && total <= CONFIG.designerCap && !overSoftCap;

    // Honest reason (only populated when a charge is real, or as a soft note).
    var reason = '';
    if (overSoftCap) {
      reason = 'Sample orders are limited to ' + CONFIG.softCap + ' per order. Please reduce to ' +
        CONFIG.softCap + ' or fewer to check out.';
    } else if (willBeCharged) {
      if (designer && total > CONFIG.designerCap) {
        reason = 'Free sample shipping covers up to ' + CONFIG.designerCap + ' samples for trade accounts. ' +
          'You have ' + total + ' — shipping on the rest is at carrier cost.';
      } else if (hasNonSample) {
        reason = 'Free sample shipping covers sample-only orders up to $' + (CONFIG.bandCapCents/100).toFixed(0) +
          '. Rolls and larger orders ship at standard carrier rates.';
      } else {
        reason = 'Free sample shipping covers orders up to $' + (CONFIG.bandCapCents/100).toFixed(0) +
          ' (about 10 samples). Your order is over that — shipping is at carrier cost.';
      }
    }
    return { total: total, wc: wc, fab: fab, designer: designer, bandCovers: bandCovers,
      willBeFree: willBeFree, willBeCharged: willBeCharged, shouldApplyCode: shouldApplyCode,
      overSoftCap: overSoftCap, reason: reason };
  }

  function applyCode(code) {
    // /discount/CODE?redirect=/cart sets the session discount cookie (this is a full-page nav).
    // Guarded so we redirect at most once per cart signature (no loops).
    var sig = code + ':' + (document.getElementById('dw-ship-cart-data').textContent.length);
    if (sessionStorage.getItem(APPLIED_KEY) === sig) return;
    sessionStorage.setItem(APPLIED_KEY, sig);
    window.location.assign('/discount/' + encodeURIComponent(code) + '?redirect=/cart');
  }

  function render(state) {
    var mount = document.querySelector(CONFIG.messageMountSelector);
    if (mount) {
      mount.hidden = false;
      if (state.overSoftCap) {
        mount.innerHTML = '<div class="dw-ship-msg dw-ship-msg--block" role="alert" style="padding:.75rem 1rem;border:1px solid #b00;border-radius:8px;margin:.5rem 0;color:#b00;">' +
          state.reason + '</div>';
      } else if (state.reason) { // real charge -> honest "why charged"
        mount.innerHTML = '<div class="dw-ship-msg" role="status" style="padding:.75rem 1rem;border:1px solid #d8c9a8;border-radius:8px;margin:.5rem 0;background:#faf6ee;">' +
          state.reason + '</div>';
      } else if (state.total > 0 && state.willBeFree) {
        mount.innerHTML = '<div class="dw-ship-msg dw-ship-msg--ok" role="status" style="padding:.5rem 1rem;color:#2e6b2e;">Free sample shipping applied.</div>';
      } else { mount.innerHTML = ''; }
    }
    // soft cap: disable standard checkout + hide accelerated wallet buttons (still SOFT — /checkout URL can slip past)
    var over = state.overSoftCap;
    document.querySelectorAll(CONFIG.checkoutBtnSelector).forEach(function (b) {
      if (over) { b.setAttribute('disabled','disabled'); b.setAttribute('aria-disabled','true'); b.style.opacity='0.5'; b.style.pointerEvents='none'; }
      else { b.removeAttribute('disabled'); b.removeAttribute('aria-disabled'); b.style.opacity=''; b.style.pointerEvents=''; }
    });
    document.querySelectorAll(CONFIG.walletSelector).forEach(function (w) { w.style.display = over ? 'none' : ''; });
  }

  function run() {
    var d = readData();
    var state = evaluate(d);
    render(state);
    // auto-apply the designer code ONLY when it actually changes the outcome (11-12 designer samples)
    if (state.shouldApplyCode) applyCode(CONFIG.designerCode);
  }

  if (document.readyState !== 'loading') run();
  else document.addEventListener('DOMContentLoaded', run);
})();
</script>