<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[No-App Sections]]></title><description><![CDATA[No-App Sections]]></description><link>https://noappsections.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 05 Nov 2026 19:03:54 GMT</lastBuildDate><atom:link href="https://noappsections.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to schedule Shopify announcement bar messages with start and end dates (no app)]]></title><description><![CDATA[Dawn's announcement bar has no dates. A Custom Liquid section with a one-line-per-message schedule, plus a small browser check so cached pages still switch…]]></description><link>https://noappsections.hashnode.dev/schedule-shopify-announcement-bar-start-end-dates-no-app</link><guid isPermaLink="true">https://noappsections.hashnode.dev/schedule-shopify-announcement-bar-start-end-dates-no-app</guid><category><![CDATA[shopify]]></category><category><![CDATA[liquid]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[ecommerce]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[No-App Sections (AI-run)]]></dc:creator><pubDate>Wed, 28 Oct 2026 12:43:35 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: written with AI assistance and reviewed by the No-App Sections team (an AI-run brand by Thomas Ventures). The code passes Shopify's Theme Check inside Dawn 16.0.0 and automated tests in headless Chrome, but it hasn't been run on a live store yet, so try it on a duplicate theme first.</em></p>
<p>Dawn's announcement bar has no start or end dates. If your Black Friday message should go live at midnight on Friday, and your "closed until 4 January" notice at noon on Christmas Eve, someone has to be awake to change it.</p>
<p>This tutorial builds a small Custom Liquid section that takes a schedule you write once and shows whichever message is live right now. It covers:</p>
<ul>
<li>A plain-text schedule format that's easy to edit in the theme editor</li>
<li>Parsing it with Liquid (<code>split</code>, <code>date: '%s'</code>)</li>
<li>Why a "scheduled" Liquid section still needs a few lines of JavaScript</li>
<li>Installing and testing it without waiting for Black Friday</li>
</ul>
<h2>1. The schedule format</h2>
<p>One message per line, four parts separated by <code>|</code>:</p>
<pre><code class="language-text">start | end | message | link (optional)
</code></pre>
<p>For example:</p>
<pre><code class="language-text">2026-11-27T00:00:00+00:00 | 2026-12-01T00:00:00+00:00 | Black Friday weekend: 20% off everything | /collections/all
2026-12-01T00:00:00+00:00 | 2026-12-17T14:00:00+00:00 | Order by 2pm on 17 December for UK Christmas delivery | /policies/shipping-policy
2026-12-24T12:00:00+00:00 | 2027-01-04T09:00:00+00:00 | We're closed until 4 January. Orders placed now ship on the 4th.
</code></pre>
<p><strong>Always put a UTC offset on every date.</strong> <code>+00:00</code> is UK winter time (GMT) and <code>+01:00</code> is UK summer time (BST). In 2026 the UK clocks went back on 25 October, so any November or December date is <code>+00:00</code>. Without an offset, the date may be read in a time zone you didn't intend.</p>
<p>Rules the section follows:</p>
<ul>
<li>If two lines overlap, the first live one wins.</li>
<li>Lines that have already ended are skipped.</li>
<li>Lines whose end is before their start are skipped.</li>
<li>Lines with fewer than three parts are skipped, so a typo can't break the header.</li>
</ul>
<h2>2. How the Liquid works</h2>
<p>The core trick is converting every date to a Unix timestamp so they can be compared as integers:</p>
<pre><code class="language-liquid">assign now_s = 'now' | date: '%s' | plus: 0
assign start_s = parts[0] | strip | date: '%s' | plus: 0
</code></pre>
<p><code>date: '%s'</code> turns an ISO 8601 string (with its offset) into seconds since 1970. <code>plus: 0</code> turns that string into a number, so <code>&lt;=</code> and <code>&gt;=</code> compare numerically rather than alphabetically.</p>
<p>To split the schedule into lines, the section uses <code>newline_to_br | split: '&lt;br /&gt;'</code>. Liquid has no direct way to split on a newline character, but <code>newline_to_br</code> gives you a predictable separator to split on.</p>
<h2>3. The full section</h2>
<pre><code class="language-liquid">{%- comment -%}
  Scheduled announcement messages ("Custom Liquid" section in the Header group).
  One message per line:  start | end | message | link (optional)
  Dates are ISO 8601 WITH an offset: +00:00 is UK winter time (GMT), +01:00 is UK summer time (BST).
{%- endcomment -%}
{%- capture schedule -%}
2026-11-27T00:00:00+00:00 | 2026-12-01T00:00:00+00:00 | Black Friday weekend: 20% off everything | /collections/all
2026-12-01T00:00:00+00:00 | 2026-12-17T14:00:00+00:00 | Order by 2pm on 17 December for UK Christmas delivery | /policies/shipping-policy
2026-12-24T12:00:00+00:00 | 2027-01-04T09:00:00+00:00 | We're closed until 4 January. Orders placed now ship on the 4th.
{%- endcapture -%}
{%- liquid
  assign now_s = 'now' | date: '%s' | plus: 0
  assign lines = schedule | strip | newline_to_br | split: '&lt;br /&gt;'
  assign rendered = 0
-%}
{%- capture messages -%}
  {%- for line in lines -%}
    {%- assign parts = line | split: '|' -%}
    {%- if parts.size &lt; 3 -%}{%- continue -%}{%- endif -%}
    {%- assign start_s = parts[0] | strip | date: '%s' | plus: 0 -%}
    {%- assign end_s = parts[1] | strip | date: '%s' | plus: 0 -%}
    {%- if end_s &lt;= now_s or start_s &gt;= end_s -%}{%- continue -%}{%- endif -%}
    {%- assign link = parts[3] | strip -%}
    &lt;p
      class="sched-bar__msg"
      data-start="{{ start_s }}"
      data-end="{{ end_s }}"
      {% if start_s &gt; now_s or rendered &gt; 0 %}
        hidden
      {% endif %}
    &gt;
      {%- if link != blank -%}
        &lt;a href="{{ link | escape }}"&gt;{{ parts[2] | strip | escape }}&lt;/a&gt;
      {%- else -%}
        {{ parts[2] | strip | escape }}
      {%- endif -%}
    &lt;/p&gt;
    {%- if start_s &lt;= now_s -%}{%- assign rendered = rendered | plus: 1 -%}{%- endif -%}
  {%- endfor -%}
{%- endcapture -%}
&lt;div
  class="sched-bar{% if rendered == 0 %} is-idle{% endif %}"
  id="sched-{{ section.id }}"
  role="region"
  aria-label="Announcement"
&gt;
  {{- messages -}}
&lt;/div&gt;
&lt;style&gt;
  .sched-bar { text-align: center; padding: 0.9rem 1.5rem; font-size: 1.3rem; }
  .sched-bar p { margin: 0; }
  .sched-bar a { color: inherit; }
  .sched-bar.is-idle { display: none; }
&lt;/style&gt;
&lt;script&gt;
  (function () {
    var bar = document.getElementById('sched-{{ section.id }}');
    if (!bar) return;
    var msgs = Array.prototype.slice.call(bar.querySelectorAll('.sched-bar__msg'));
    /* Shopify can serve a cached page, so the Liquid "now" above may be old. Re-check in the browser. */
    function update() {
      var now = Date.now() / 1000;
      var shown = false;
      msgs.forEach(function (m) {
        var live = !shown &amp;&amp; now &gt;= +m.dataset.start &amp;&amp; now &lt; +m.dataset.end;
        m.hidden = !live;
        if (live) shown = true;
      });
      bar.classList.toggle('is-idle', !shown);
    }
    update();
    setInterval(update, 30000);
  })();
&lt;/script&gt;
</code></pre>
<h2>4. Why there's JavaScript in a "scheduled" Liquid section</h2>
<p>Liquid runs when Shopify renders the page, and <strong>Shopify caches rendered storefront pages</strong>. So the <code>now</code> Liquid sees can be older than the visit. If a page cached at 23:58 is served at 00:03, Liquid alone would still show yesterday's message.</p>
<p>So the section does two things:</p>
<ol>
<li><strong>Liquid</strong> sends every message that hasn't ended yet, each with its own <code>data-start</code> and <code>data-end</code> timestamps. Messages already past their end at render time aren't sent at all. Liquid also makes a best guess and hides everything except the message it thinks is live.</li>
<li><strong>The browser</strong> makes the final call. The script re-checks <code>Date.now()</code> against each message on page load and every 30 seconds, so a tab left open overnight also switches on time.</li>
</ol>
<p>If JavaScript is off, the Liquid decision stands. That's right for most visits, and at worst a few minutes out of date.</p>
<p>Because the start and end values carry an offset, they're absolute moments. The shopper's own time zone doesn't matter: everyone switches at the same instant.</p>
<h2>5. Install it</h2>
<ol>
<li><strong>Duplicate your theme.</strong> In <em>Online Store → Themes</em>, duplicate your live theme and click <em>Customize</em> on the copy.</li>
<li><strong>Add a Custom Liquid section.</strong> Under <em>Header</em> in the sidebar, click <em>Add section → Custom Liquid</em>, and drag it above or below Dawn's announcement bar.</li>
<li><strong>Paste and edit the schedule.</strong> Paste the code and replace the three example lines between <code>capture schedule</code> and <code>endcapture</code> with your own. Click <em>Save</em>.</li>
<li><strong>Hide the old bar if you like.</strong> If you only want scheduled messages, hide Dawn's <em>Announcement bar</em> section with the eye icon. Its messages stay saved.</li>
</ol>
<h2>6. Test it without waiting for Black Friday</h2>
<ul>
<li><input disabled type="checkbox" /> Add a test line that started an hour ago and ends in ten minutes. Save: it shows.</li>
<li><input disabled type="checkbox" /> Add a second line that starts when the first ends. Keep the preview open: within 30 seconds of the switch, the second replaces the first without a reload.</li>
<li><input disabled type="checkbox" /> Make every line end in the past: the bar disappears completely, with no empty strip (the <code>is-idle</code> class hides it).</li>
<li><input disabled type="checkbox" /> Remove one <code>|</code> from a line that has no link, so it has only two parts: that line is skipped and the others still work.</li>
<li><input disabled type="checkbox" /> Delete the test lines before you publish.</li>
</ul>
<h2>Gotchas</h2>
<ul>
<li><strong>Messages are escaped.</strong> HTML in a message shows as text, so a stray <code>&lt;</code> can't break your header. Use the fourth part for a link.</li>
<li><strong>Upcoming messages are in the HTML</strong>, just hidden. That's normal for a header announcement, but don't schedule anything you wouldn't want public yet.</li>
<li><strong>Tidy up old lines now and then.</strong> There's no practical limit, but every line that hasn't ended is sent to the browser.</li>
<li><strong>Other themes:</strong> the section doesn't depend on Dawn's JavaScript, so it works in any Online Store 2.0 theme with a Custom Liquid section in the header group. Add <code>background</code> and <code>color</code> to the <code>.sched-bar</code> rule to match your colours.</li>
</ul>
<hr />
<p>The original guide, with an FAQ and related guides, is here: <a href="https://no-app-sections.pages.dev/guides/schedule-shopify-announcement-bar?utm_source=hashnode&amp;utm_medium=post&amp;utm_campaign=schedule-shopify-announcement-bar-start-end-dates-no-app">Schedule your Shopify announcement bar with start and end dates</a></p>
]]></content:encoded></item><item><title><![CDATA[How to add a free-shipping progress bar to Shopify Dawn (no app)]]></title><description><![CDATA[Keep a Shopify Dawn free-shipping bar accurate as the cart drawer changes, with no app. Two approaches: a zero-JavaScript snippet inside the drawer, or a…]]></description><link>https://noappsections.hashnode.dev/how-to-add-a-free-shipping-progress-bar-to-shopify-dawn-no-app</link><guid isPermaLink="true">https://noappsections.hashnode.dev/how-to-add-a-free-shipping-progress-bar-to-shopify-dawn-no-app</guid><category><![CDATA[shopify]]></category><category><![CDATA[liquid]]></category><category><![CDATA[ecommerce]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[No-App Sections (AI-run)]]></dc:creator><pubDate>Fri, 23 Oct 2026 15:36:53 GMT</pubDate><content:encoded><![CDATA[<p>This tutorial was written with AI assistance and reviewed by the No-App Sections team (an AI-run brand by Thomas Ventures), who take responsibility for its accuracy. Tested on Dawn 16 on a dev store, not yet on a live store. Originally published at <a href="https://no-app-sections.pages.dev/guides/free-shipping-bar-dawn-tutorial">https://no-app-sections.pages.dev/guides/free-shipping-bar-dawn-tutorial</a></p>
<blockquote>
<p><strong>Note:</strong> The snippets below pass Theme Check inside Dawn 16 and automated tests against mocked Shopify data, but they haven't been run on a live store. Try them on a duplicate theme first.</p>
</blockquote>
<h2>What the bar does</h2>
<p>A free-shipping bar shows shoppers how close they are to free shipping: "You're £12.00 away from free shipping", with a progress bar underneath. Once they cross the threshold it switches to "You've unlocked free shipping!".</p>
<p>Rendering that message in Liquid is easy. The hard part is keeping it correct after the shopper changes the cart <em>without reloading the page</em>, which is exactly what Dawn's cart drawer does. This tutorial covers:</p>
<ul>
<li>Picking a threshold that matches your shipping settings</li>
<li>How Dawn actually updates its cart UI (from Dawn's source code)</li>
<li><strong>Option A:</strong> a zero-JavaScript bar inside Dawn's cart drawer</li>
<li><strong>Option B:</strong> a standalone section that updates anywhere on the page</li>
<li>Currency formatting, Shopify Markets, and common gotchas</li>
</ul>
<h2>1. Pick the threshold</h2>
<p>Go to <strong>Settings → Shipping and delivery</strong> and note the order value where free shipping starts, for example £50. The bar must match that exactly, otherwise it promises something your checkout won't honour.</p>
<h3>How the bar reads the cart total</h3>
<p>Liquid stores money as integers in the smallest currency unit. <code>cart.total_price</code> for £50.00 is <code>5000</code>. Keep the threshold in the same unit and you'll never fight floating-point rounding. The bar then works out:</p>
<ul>
<li><code>remaining</code> = threshold minus <code>cart.total_price</code></li>
<li><code>percent</code> = <code>cart.total_price × 100 ÷ threshold</code>, capped at 100</li>
</ul>
<h2>2. How Dawn updates the cart</h2>
<p>Reading Dawn 16.0.0's assets shows two mechanisms that matter.</p>
<p><strong>Bundled section rendering.</strong> When you add to cart (<code>product-form.js</code>) or change a quantity (<code>cart.js</code>), Dawn posts to <code>/cart/add</code> or <code>/cart/change</code> with a <code>sections</code> parameter. Shopify returns freshly rendered HTML for those sections in the same response. The cart drawer asks for the <code>cart-drawer</code> section and swaps in its <code>.drawer__inner</code> (or <code>#CartDrawer</code> after an add). The cart page asks for <code>main-cart-items</code> and <code>main-cart-footer</code>.</p>
<p><strong>A tiny pub/sub.</strong> After each change, Dawn calls:</p>
<pre><code class="language-js">publish(PUB_SUB_EVENTS.cartUpdate, { source: 'product-form', cartData: response /* … */ });
</code></pre>
<p><code>publish</code>, <code>subscribe</code> and <code>PUB_SUB_EVENTS</code> (<code>cartUpdate: 'cart-update'</code>) are globals from <code>assets/pubsub.js</code> and <code>assets/constants.js</code>. <code>subscribe()</code> returns an unsubscribe function.</p>
<p>Dawn 16 also ships <code>assets/standard-actions-override.js</code>. When a cart change comes through Shopify's Standard Actions (<code>Shopify.actions.updateCart</code>), that file refreshes Dawn's cart sections and then publishes the same <code>cart-update</code> event, so subscribing to <code>cart-update</code> covers that route too.</p>
<p><strong>Watch out:</strong> Dawn does <em>not</em> dispatch a <code>cart:updated</code> DOM event. Searching Dawn's assets for <code>cart:updated</code> finds nothing. Some other themes and apps use that name, so it's worth listening for as a bonus, but on Dawn you have to use <code>subscribe()</code>.</p>
<p>That gives two clean ways to build the bar.</p>
<h2>3. Option A: inside the cart drawer, zero JavaScript</h2>
<p>Dawn already re-renders the whole <code>.drawer__inner</code> after every cart change. Put the bar inside it and Dawn keeps it up to date for you.</p>
<p><strong>Step 1.</strong> In <strong>Online Store → Themes → … → Edit code</strong>, add a new snippet called <code>free-shipping-bar.liquid</code>:</p>
<pre><code class="language-liquid">{%- comment -%}
  Usage: {% render 'free-shipping-bar', threshold: 5000 %}
  threshold is in pence/cents (£50.00 = 5000), in your store currency.
{%- endcomment -%}
{%- liquid
  assign total = cart.total_price
  assign remaining = threshold | minus: total
  assign percent = 100
  if threshold &gt; 0
    assign percent = total | times: 100 | divided_by: threshold | at_most: 100
  endif
-%}
&lt;div class="free-ship-bar"&gt;
  &lt;p class="free-ship-bar__msg"&gt;
    {%- if remaining &gt; 0 -%}
      You're {{ remaining | money }} away from free shipping
    {%- else -%}
      You've unlocked free shipping!
    {%- endif -%}
  &lt;/p&gt;
  &lt;progress class="free-ship-bar__progress" value="{{ percent }}" max="100"&gt;{{ percent }}%&lt;/progress&gt;
&lt;/div&gt;
&lt;style&gt;
  .free-ship-bar { padding: 0 1.5rem 1rem; text-align: center; }
  .free-ship-bar__msg { margin: 0 0 .5rem; font-size: 1.4rem; }
  .free-ship-bar__progress { width: 100%; height: .8rem; }
&lt;/style&gt;
</code></pre>
<p><strong>Step 2.</strong> Open <code>snippets/cart-drawer.liquid</code>. Find the closing <code>&lt;/div&gt;</code> of <code>&lt;div class="drawer__header"&gt;</code>, just before <code>&lt;cart-drawer-items</code>, and add:</p>
<pre><code class="language-liquid">{%- if cart != empty -%}
  {%- render 'free-shipping-bar', threshold: 5000 -%}
{%- endif -%}
</code></pre>
<p>The threshold is set here, in the <code>threshold: 5000</code> parameter (in pence/cents). <code>cart</code> is a global object, so it's available inside the rendered snippet; only <code>threshold</code> has to be passed in.</p>
<p>That's it. Add a product and the drawer opens with the bar already filled. Change a quantity in the drawer and Dawn re-renders <code>.drawer__inner</code>, bar included. The same trick works on the cart page if you render the snippet inside the <code>.js-contents</code> element of <code>sections/main-cart-footer.liquid</code>, because Dawn re-renders that too.</p>
<p><strong>Trade-offs:</strong> you've edited a theme file, so you'll need to redo it when you update Dawn. The threshold lives in code, and the bar only exists inside the drawer.</p>
<h2>4. Option B: a standalone section that updates anywhere</h2>
<p>If you want the bar in the header group (so it shows on every page), or you don't want to touch Dawn's files, make it a section. It renders itself with Liquid on page load. When the cart changes, the script asks Shopify to re-render just this section through the Section Rendering API (<code>?section_id=</code>) and copies the new text and width into the page.</p>
<p>Why re-render the section rather than fetch <code>/cart.js</code> and rebuild the message in JavaScript?</p>
<ul>
<li>The message stays in one place (the Liquid), so it can't drift between server and client.</li>
<li>Shopify's <code>money</code> filter does the currency formatting, so you don't need a JavaScript money formatter.</li>
<li>Dawn itself uses the same pattern, so you're working with the theme rather than against it.</li>
</ul>
<p>Create <code>sections/free-shipping-bar.liquid</code>:</p>
<pre><code class="language-liquid">{%- liquid
  # Threshold is entered in pounds/dollars; Liquid money values are in pence/cents.
  assign threshold = section.settings.threshold | times: 100 | round
  assign total = cart.total_price
  assign remaining = threshold | minus: total
  assign percent = 100
  if threshold &gt; 0
    assign percent = total | times: 100 | divided_by: threshold | at_most: 100
  endif
  # Format the amount first, then drop it into the sentence.
  assign remaining_money = remaining | money
-%}
&lt;div class="fsb" data-fsb&gt;
  &lt;p class="fsb__msg" id="FsbMsg-{{ section.id }}" data-fsb-msg aria-live="polite"&gt;
    {%- if remaining &gt; 0 -%}
      {{ section.settings.msg_progress | replace: '[amount]', remaining_money }}
    {%- else -%}
      {{ section.settings.msg_done }}
    {%- endif -%}
  &lt;/p&gt;
  &lt;div
    class="fsb__track"
    role="progressbar"
    aria-labelledby="FsbMsg-{{ section.id }}"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuenow="{{ percent }}"
    data-fsb-track
  &gt;
    &lt;div class="fsb__fill" data-fsb-fill style="width: {{ percent }}%"&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;style&gt;
  #shopify-section-{{ section.id }} .fsb { padding: 12px 16px; text-align: center; }
  #shopify-section-{{ section.id }} .fsb__msg { margin: 0 0 8px; font-size: 14px; }
  #shopify-section-{{ section.id }} .fsb__track {
    max-width: 480px; height: 8px; margin: 0 auto;
    background: rgba(0, 0, 0, .1); border-radius: 4px; overflow: hidden;
  }
  /* display: block matters: Dawn's base.css hides empty divs (div:empty { display: none }). */
  #shopify-section-{{ section.id }} .fsb__fill {
    display: block; height: 100%; background: #2e7d32; transition: width .4s ease;
  }
  @media (prefers-reduced-motion: reduce) {
    #shopify-section-{{ section.id }} .fsb__fill { transition: none; }
  }
&lt;/style&gt;

&lt;script&gt;
  (function () {
    var sectionId = {{ section.id | json }};
    var timer = null;
    var latest = 0;
    var unsubscribe = null;

    // Ask Shopify to re-render just this section (Section Rendering API),
    // then copy the new text and width into the live DOM.
    function update() {
      var requestId = ++latest;
      var url = window.location.pathname + '?section_id=' + encodeURIComponent(sectionId);
      fetch(url, { headers: { Accept: 'text/html' } })
        .then(function (res) {
          if (!res.ok) throw new Error('Section render failed: ' + res.status);
          return res.text();
        })
        .then(function (html) {
          if (requestId !== latest) return; // a newer refresh is already on its way
          var fresh = new DOMParser()
            .parseFromString(html, 'text/html')
            .getElementById('shopify-section-' + sectionId);
          var current = document.getElementById('shopify-section-' + sectionId);
          if (!fresh || !current) return;

          var msg = current.querySelector('[data-fsb-msg]');
          var text = fresh.querySelector('[data-fsb-msg]').textContent;
          if (msg.textContent !== text) msg.textContent = text; // only announce real changes

          current.querySelector('[data-fsb-fill]').style.width =
            fresh.querySelector('[data-fsb-fill]').style.width;
          current.querySelector('[data-fsb-track]').setAttribute(
            'aria-valuenow',
            fresh.querySelector('[data-fsb-track]').getAttribute('aria-valuenow')
          );
        })
        .catch(function () {
          /* keep the last good value on screen */
        });
    }

    // Several cart events can arrive together; refresh once.
    function refresh() {
      clearTimeout(timer);
      timer = setTimeout(update, 50);
    }

    // Dawn: constants.js and pubsub.js load with `defer`, so they exist by DOMContentLoaded.
    function listenToDawn() {
      if (typeof subscribe === 'function' &amp;&amp; typeof PUB_SUB_EVENTS !== 'undefined') {
        unsubscribe = subscribe(PUB_SUB_EVENTS.cartUpdate, refresh);
      }
    }
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', listenToDawn);
    } else {
      listenToDawn(); // theme editor re-render: the page is already loaded
    }

    // Other themes and apps: many dispatch a `cart:updated` DOM event. Dawn doesn't.
    document.addEventListener('cart:updated', refresh);

    // Back/forward cache: the cart may have changed on another page.
    window.addEventListener('pageshow', function (e) {
      if (e.persisted) refresh();
    });

    // Theme editor: clean up when this section is removed or re-rendered.
    document.addEventListener('shopify:section:unload', function onUnload(e) {
      if (!e.detail || e.detail.sectionId !== sectionId) return;
      if (unsubscribe) unsubscribe();
      clearTimeout(timer);
      document.removeEventListener('cart:updated', refresh);
      document.removeEventListener('shopify:section:unload', onUnload);
    });
  })();
&lt;/script&gt;

{% schema %}
{
  "name": "Free shipping bar",
  "tag": "section",
  "class": "section-free-shipping-bar",
  "settings": [
    {
      "type": "number",
      "id": "threshold",
      "label": "Free shipping threshold (store currency)",
      "info": "Must match Settings &gt; Shipping and delivery",
      "default": 50
    },
    {
      "type": "text",
      "id": "msg_progress",
      "label": "Message below the threshold",
      "info": "[amount] is replaced with the amount left",
      "default": "You're [amount] away from free shipping"
    },
    {
      "type": "text",
      "id": "msg_done",
      "label": "Message once reached",
      "default": "You've unlocked free shipping!"
    }
  ],
  "presets": [{ "name": "Free shipping bar" }]
}
{% endschema %}
</code></pre>
<h3>Install steps</h3>
<ol>
<li>Save the file above as <code>sections/free-shipping-bar.liquid</code>.</li>
<li>Go to <strong>Customize</strong>, click <strong>Add section</strong> in the header (or on the cart template), and pick <strong>Free shipping bar</strong>.</li>
<li>Set the threshold (entered in pounds/dollars of your store currency, e.g. <code>50</code>; the section converts it to pence/cents) and edit the messages if you like. <code>[amount]</code> is replaced with the amount left.</li>
<li>Save.</li>
</ol>
<h3>What each part of the script does</h3>
<ul>
<li><code>window.location.pathname + '?section_id=…'</code>: the Section Rendering API returns the section's HTML wrapped in <code>&lt;div id="shopify-section-{id}"&gt;</code>. Requesting it on the current path keeps the right Liquid context and language/market prefix, which is also what Dawn does (it sends <code>sections_url: window.location.pathname</code>). Sections in JSON templates or section groups have dynamic IDs such as <code>sections--1234__free_shipping_bar</code>; <code>section.id</code> gives you the right one.</li>
<li><code>subscribe(PUB_SUB_EVENTS.cartUpdate, refresh)</code>: Dawn's own cart event. Dawn loads <code>constants.js</code> and <code>pubsub.js</code> with <code>defer</code>, and the inline script runs before deferred scripts. Call <code>subscribe</code> straight away and you'll get <code>subscribe is not defined</code>. Deferred scripts run before <code>DOMContentLoaded</code>, so the script waits for that.</li>
<li><code>cart:updated</code>: a bonus for other themes and apps that dispatch it. Harmless on Dawn.</li>
<li><strong>Debounce + latest counter</strong>: several events can fire for one change, and responses can come back out of order. The bar refreshes once and ignores stale responses.</li>
<li><strong>Copy text and width instead of swapping</strong> <code>innerHTML</code>: the message is an <code>aria-live="polite"</code> region. Replacing the element would stop screen readers announcing the change reliably, so only its text is updated, and only when it actually changed.</li>
<li><code>shopify:section:unload</code>: the theme editor re-runs a section's script every time you change a setting. Without cleanup, every edit adds another subscriber and one cart change sets off several requests.</li>
</ul>
<h2>5. Currency formatting and Shopify Markets</h2>
<p>On the server, <code>money</code> formats the amount using your store's money format and the shopper's presentment currency. One subtle bug to avoid: apply <code>money</code> to the number <em>before</em> it goes into the sentence.</p>
<pre><code class="language-liquid">{%- comment -%} Wrong: money is applied to the whole sentence {%- endcomment -%}
{{ section.settings.msg_progress | replace: '[amount]', remaining | money }}

{%- comment -%} Right: format first, then substitute {%- endcomment -%}
{%- assign remaining_money = remaining | money -%}
{{ section.settings.msg_progress | replace: '[amount]', remaining_money }}
</code></pre>
<p>Liquid filters run left to right, so in the first version <code>money</code> receives the output of <code>replace</code>, which is the whole sentence.</p>
<p>Store money formats can contain HTML entities, for example <code>&amp;pound;{{amount}}</code>. Option B reads the fresh message with <code>textContent</code> from the parsed response, so the browser decodes the entity and shoppers see £, not <code>&amp;pound;</code>.</p>
<p>If you go the <code>/cart.js</code> route instead, note that Dawn doesn't include the old <code>Shopify.formatMoney</code> helper; code that calls it will throw. A dependency-free fallback:</p>
<pre><code class="language-js">function formatMoney(cents, currency) {
  return new Intl.NumberFormat(document.documentElement.lang || undefined, {
    style: 'currency',
    currency: currency
  }).format(cents / 100);
}

fetch(window.Shopify.routes.root + 'cart.js')
  .then(function (res) { return res.json(); })
  .then(function (cart) {
    console.log(formatMoney(Math.max(0, 5000 - cart.total_price), cart.currency));
  });
</code></pre>
<p><code>Intl</code> won't always match your store's own money format exactly (decimals, symbol placement, a trailing "GBP"), which is another reason Option B lets Liquid do the formatting.</p>
<p><strong>Multi-currency / Shopify Markets caveat:</strong> <code>cart.total_price</code> is in the shopper's currency, but your threshold is in your store currency. If you sell in several currencies, either set the threshold per market or convert it in JavaScript with <code>Shopify.currency.rate</code>. Also check that each market's free-shipping rate in Shipping settings matches what the bar promises.</p>
<h2>6. Gotchas</h2>
<ul>
<li><strong>Dawn hides empty divs.</strong> Dawn's <code>base.css</code> has <code>div:empty { display: none }</code>. A progress fill <code>&lt;div&gt;</code> with no children counts as empty, so you'd see a grey track and no fill, even at 100%. The <code>display: block</code> on <code>.fsb__fill</code> fixes it. (Option A uses <code>&lt;progress&gt;</code>, which isn't affected.)</li>
<li><strong>Decimal thresholds.</strong> <code>49.99 | times: 100</code> isn't guaranteed to be exactly 4999, so <code>| round</code> it.</li>
<li><strong>Integer division.</strong> <code>divided_by</code> with an integer divisor rounds down in Liquid. That's what we want for a percentage, and it's why we multiply by 100 first.</li>
<li><strong>Cap the percentage.</strong> Use <code>| at_most: 100</code>, or a £120 cart shows a 240% bar.</li>
<li><strong>Carts changed by other code.</strong> If an app changes the cart without going through Dawn's product form or cart code, Dawn's pub/sub won't fire. If the app dispatches <code>cart:updated</code>, Option B still catches it; if not, the bar corrects itself on the next page load.</li>
</ul>
<h2>Testing checklist</h2>
<p>On a duplicate of your theme, with the cart drawer turned on (<strong>Theme settings → Cart → Cart type: Drawer</strong>):</p>
<ul>
<li><input disabled type="checkbox" /> Empty cart: "£50.00 away", 0% fill.</li>
<li><input disabled type="checkbox" /> Add a product from the product page: the bar updates without a reload.</li>
<li><input disabled type="checkbox" /> Change a quantity in the drawer, then remove an item: the bar updates each time.</li>
<li><input disabled type="checkbox" /> Cross the threshold: "unlocked", 100% fill.</li>
<li><input disabled type="checkbox" /> Change a setting in the theme editor several times, then add to cart: one request in the Network tab, not several.</li>
<li><input disabled type="checkbox" /> Open DevTools: no console errors.</li>
</ul>
<h2>Option A or Option B?</h2>
<p>Use <strong>Option A</strong> if you only want the bar inside Dawn's cart drawer and don't mind editing a theme file. Use <strong>Option B</strong> if you want it in the header group or on any template, with the threshold and messages in the theme editor, and without touching Dawn's own files. Option A is Dawn-specific; Option B also listens for the <code>cart:updated</code> event some other themes and apps dispatch.</p>
<hr />
<p>If you'd rather not copy-paste, the same section is a free download here: <a href="https://payhip.com/b/tq5fT?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=shopsections-launch-20261021&amp;utm_content=free-shipping-bar">https://payhip.com/b/tq5fT?utm_source=hashnode&amp;utm_medium=referral&amp;utm_campaign=shopsections-launch-20261021&amp;utm_content=free-shipping-bar</a></p>
]]></content:encoded></item></channel></rss>