Live SMS Credit Calculator for the Communication Page

Drop this into your Communication page and you'll get a draggable little widget that tells you how many credits your SMS will cost before you send it. Paste in your message, see the segment count, the encoding, and the total credits across every recipient on the communication. Saves you from finding out after the fact that one fancy quote turned a one-credit blast into a three-credit one across 2,000 recipients. EEk.

What Does This Thing Do?

It adds a floating SMS Calculator button to the Communication page that only shows up when the SMS editor is actually on screen. Click it and a draggable widget pops out where you can paste or type your message. It tells you the encoding (GSM-7 or UCS-2), the character count, how many credits a single message will take, and how many characters you have left in the current segment. If you have multiple recipients on the communication, it also calculates the total credits across all of them.

If your message tips into UCS-2 because of a sneaky character (π, emoji, em dash, anything outside the GSM character set), the widget calls out exactly which character did it. That's the part that will save you from sending a much more expensive message than you realized.

How It Works

  • A button appears on the page only when the SMS editor panel is present, so it stays out of the way for email and push communications.
  • Encoding detection checks every character against the GSM-7 basic and extension character sets. One non-GSM character flips the whole message to UCS-2.
  • A MutationObserver keeps the button visibility and recipient count in sync as you navigate around or adjust filters.

What Makes It Nice

  • One Lava assign at the top lets you swap "Credit" for "Segment" (or whatever your team calls it if that is helpful to distinguis, I know different providers may call them different things.).
  • The widget is draggable, so you can move it out of the way of the SMS preview and keep typing.
  • It auto-hides on pages that don't have the SMS editor, so you can drop it on a shared layout without worrying.

The Setup in 2 Steps

This one is short. Add a block, paste the code, optionally change one word.

Step 1 - Add an HTML Content Block to the Communication Page

You want the block to live on the same page as the SMS editor, anywhere below it or aboce it is fine. The script looks for the SMS editor in the DOM, so as long as the block renders on the same page, it'll find it.

  1. Navigate to your New Communication page
  2. Add an HTML Content block to the page, somewhere in the existing communication blocks
  3. Block settings:
    • Block Title: SMS Calculator (or leave default)

The button only shows up when the SMS editor is on the page, so it's safe to leave this block on the Communication page even when staff are sending emails instead of texts. It just stays hidden.

Step 2 - Paste the Code into the HTML Block

Edit your new HTML Content block and drop this into the HTML field. The only thing you might want to change is the very first line, {% assign creditTerm = 'Credit' %}. Swap 'Credit' for 'Segment' (or whatever terminology your team prefers).

{% assign creditTerm = 'Credit' %}

{% raw %}
<style>
  #segcalc-popover { border: none; padding: 0; margin: 0; background: transparent; overflow: visible; inset: auto; }
  #segcalc-popover::backdrop { background: transparent; }
</style>

<button type="button" class="btn btn-primary pull-right my-3 segcalc-trigger" popovertarget="segcalc-popover" style="display:none;">
  SMS Calculator
</button>
<div class="clearfix"></div>

<div id="segcalc-popover" popover>
  <div class="panel panel-default" style="width: 360px; margin: 0; box-shadow: 0 12px 40px rgba(0,0,0,0.15);">
    <div class="panel-heading segcalc-handle" style="cursor: move; user-select: none;">
      <button type="button" class="close" popovertarget="segcalc-popover" popovertargetaction="hide">&times;</button>
      <h3 class="panel-title">SMS <span data-term-plural></span> Calculator</h3>
    </div>
    <div class="panel-body">
      <textarea id="segcalc-input" class="form-control" rows="4" placeholder="Type or paste your message..."></textarea>
      <div class="row text-center" style="margin-top: 12px;">
        <div class="col-xs-3">
          <small class="text-muted">ENCODING</small>
          <div><strong id="segcalc-encoding" class="text-success">GSM-7</strong></div>
        </div>
        <div class="col-xs-3">
          <small class="text-muted">CHARS</small>
          <div><strong id="segcalc-chars">0</strong></div>
        </div>
        <div class="col-xs-3">
          <small class="text-muted" data-term-plural-upper></small>
          <div><strong id="segcalc-credits">0</strong></div>
        </div>
        <div class="col-xs-3">
          <small class="text-muted">LEFT</small>
          <div><strong id="segcalc-remaining">160</strong></div>
        </div>
      </div>
      <div id="segcalc-warn" class="alert alert-warning" style="display:none; margin-top: 12px; margin-bottom: 0; padding: 6px 10px; font-size: 12px;"></div>
      <div id="segcalc-total" style="display:none; margin-top: 12px; padding-top: 10px; border-top: 1px solid #eee; text-align: center;">
        <small class="text-muted">TOTAL FOR <span id="segcalc-recipient-count"></span> RECIPIENTS</small>
        <div style="font-size: 18px;"><strong id="segcalc-total-value">0</strong> <span data-term-plural-lower></span></div>
      </div>
    </div>
  </div>
</div>

<script>
(function () {
{% endraw %}
  var TERM_SINGULAR = {{ creditTerm | ToJSON }};
  var TERM_PLURAL = {{ creditTerm | Pluralize | ToJSON }};
{% raw %}

  document.querySelectorAll('[data-term-plural]').forEach(function (el) { el.textContent = TERM_PLURAL; });
  document.querySelectorAll('[data-term-plural-upper]').forEach(function (el) { el.textContent = TERM_PLURAL.toUpperCase(); });
  document.querySelectorAll('[data-term-plural-lower]').forEach(function (el) { el.textContent = TERM_PLURAL.toLowerCase(); });

  var GSM_BASIC = '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\u001bÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
  var GSM_EXT = '\f^{}\\[~]|€';

  function analyze(text) {
    var isUcs2 = false;
    for (var i = 0; i < text.length; i++) {
      var ch = text.charAt(i);
      if (GSM_BASIC.indexOf(ch) === -1 && GSM_EXT.indexOf(ch) === -1) {
        isUcs2 = true;
        break;
      }
    }

    var count, singleLimit, concatLimit;
    if (isUcs2) {
      count = text.length;
      singleLimit = 70; concatLimit = 67;
    } else {
      count = 0;
      for (var j = 0; j < text.length; j++) count += GSM_EXT.indexOf(text.charAt(j)) !== -1 ? 2 : 1;
      singleLimit = 160; concatLimit = 153;
    }

    var unicodeChars = [];
    if (isUcs2) {
      var seen = {};
      var graphemes = (typeof Intl !== 'undefined' && Intl.Segmenter)
        ? Array.from(new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(text), function (s) { return s.segment; })
        : Array.from(text);
      for (var k = 0; k < graphemes.length; k++) {
        var g = graphemes[k];
        if (g.length === 1 && (GSM_BASIC.indexOf(g) !== -1 || GSM_EXT.indexOf(g) !== -1)) continue;
        if (seen[g]) continue;
        seen[g] = true;
        unicodeChars.push(g);
      }
    }

    var credits, remaining;
    if (count === 0) { credits = 0; remaining = singleLimit; }
    else if (count <= singleLimit) { credits = 1; remaining = singleLimit - count; }
    else { credits = Math.ceil(count / concatLimit); remaining = (credits * concatLimit) - count; }

    return { encoding: isUcs2 ? 'UCS-2' : 'GSM-7', count: count, credits: credits, remaining: remaining, unicodeChars: unicodeChars };
  }

  var input = document.getElementById('segcalc-input');
  var trigger = document.querySelector('.segcalc-trigger');
  var popover = document.getElementById('segcalc-popover');
  var els = {
    enc: document.getElementById('segcalc-encoding'),
    chars: document.getElementById('segcalc-chars'),
    segs: document.getElementById('segcalc-credits'),
    rem: document.getElementById('segcalc-remaining'),
    warn: document.getElementById('segcalc-warn'),
    totalWrap: document.getElementById('segcalc-total'),
    totalValue: document.getElementById('segcalc-total-value'),
    recipientCount: document.getElementById('segcalc-recipient-count')
  };

  var recipientCount = 0;

  function readRecipientCount() {
    var label = document.querySelector('.sms-editor-panel-title-right .label-info');
    if (!label) return 0;
    var match = label.textContent.replace(/,/g, '').match(/\d+/);
    return match ? parseInt(match[0], 10) : 0;
  }

  function refreshTotal() {
    if (recipientCount > 0) {
      els.recipientCount.textContent = recipientCount.toLocaleString();
      els.totalWrap.style.display = 'block';
      var r = analyze(input.value);
      els.totalValue.textContent = (r.credits * recipientCount).toLocaleString();
    } else {
      els.totalWrap.style.display = 'none';
    }
  }

  function update() {
    var r = analyze(input.value);
    els.enc.textContent = r.encoding;
    els.enc.className = r.encoding === 'UCS-2' ? 'text-warning' : 'text-success';
    els.chars.textContent = r.count;
    els.segs.textContent = r.credits;
    els.rem.textContent = r.remaining;
    if (r.encoding === 'UCS-2') {
      var preview = r.unicodeChars.slice(0, 6).join(' ');
      els.warn.textContent = 'UCS-2 triggered by: ' + preview + (r.unicodeChars.length > 6 ? '…' : '');
      els.warn.style.display = 'block';
    } else {
      els.warn.style.display = 'none';
    }
    refreshTotal();
  }
  input.addEventListener('input', update);

  popover.addEventListener('toggle', function (e) {
    if (e.newState !== 'open') return;
    recipientCount = readRecipientCount();
    refreshTotal();
    var rect = trigger.getBoundingClientRect();
    var width = 360;
    var left = Math.min(rect.left, window.innerWidth - width - 8);
    popover.style.position = 'fixed';
    popover.style.top = (rect.bottom + 8) + 'px';
    popover.style.left = Math.max(8, left) + 'px';
    setTimeout(function () { input.focus(); }, 50);
  });

  var header = popover.querySelector('.segcalc-handle');
  var dragState = null;

  header.addEventListener('pointerdown', function (e) {
    if (e.target.closest('.close')) return;
    e.preventDefault();
    var rect = popover.getBoundingClientRect();
    dragState = { offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top };
    header.setPointerCapture(e.pointerId);
  });

  header.addEventListener('pointermove', function (e) {
    if (!dragState) return;
    var newLeft = e.clientX - dragState.offsetX;
    var newTop = e.clientY - dragState.offsetY;
    newLeft = Math.max(0, Math.min(window.innerWidth - popover.offsetWidth, newLeft));
    newTop = Math.max(0, Math.min(window.innerHeight - popover.offsetHeight, newTop));
    popover.style.left = newLeft + 'px';
    popover.style.top = newTop + 'px';
  });

  header.addEventListener('pointerup', function () { dragState = null; });
  header.addEventListener('pointercancel', function () { dragState = null; });

  var rafScheduled = false;
  function syncFromDom() {
    if (rafScheduled) return;
    rafScheduled = true;
    requestAnimationFrame(function () {
      rafScheduled = false;
      var present = document.querySelector('.sms-editor-panel-title') !== null;
      trigger.style.display = present ? '' : 'none';
      if (!present && popover.matches(':popover-open')) {
        popover.hidePopover();
      }
      var newCount = readRecipientCount();
      if (newCount !== recipientCount) {
        recipientCount = newCount;
        if (popover.matches(':popover-open')) refreshTotal();
      }
    });
  }
  syncFromDom();
  new MutationObserver(syncFromDom).observe(document.body, { childList: true, subtree: true });

  update();
})();
</script>
{% endraw %}

Save the block, head to a communication that has SMS enabled, and you should see a blue SMS Calculator button float in on the right side of the page. Click it, drag it wherever you want, paste a message, and watch it count.


A Note on the Total Credits Math

The total at the bottom of the widget is your per-recipient credit count times the number of recipients on the communication. It's an estimate based on the raw SMS segment math, not a quote from your provider. Things that can shift the actual cost include:

  • Per-country pricing tiers (international numbers usually cost more)
  • Failed sends and retries (some providers charge for both)
  • Merge fields like {{ Person.NickName }} that resolve to different character counts for different people

For most domestic texting with no merge fields, the number you see in the widget is going to match your usage. For anything more involved, treat it as a ballpark.


Troubleshooting

  • The button never shows up. The script looks for the .sms-editor-panel-title class to know when the SMS editor is on the page. If Spark renames that class in a future updates, the button just stops appearing. Hopefully I'll spot this before you and upate the recipe and leave a comment, but if you spot it before me please let me know.
  • The total recipients section never appears. The recipient count is scraped from the label that says "X Recipients" inside the SMS editor header. If that label format changes (different class, different wording), the total stops showing. The selector is in readRecipientCount.

That's It!

Two steps and you've got a live SMS credit calculator sitting next to your editor. Paste in your message, see what it costs, and catch the sneaky character that would have tripled the bill. If you find issues or have ideas for improvements, let me know!