PageSourceSearch

https://www.99dollarsocial.com/compare-mobile.js

js 99dollarsocial.com collected 2026-10-02 14:36:36 UTC 14,753 bytes, 307 lines download raw bytes

1/* ─────────────────────────────────────────────────────────────────
2   compare-mobile.js — mobile card view for the comparison tables
3   ─────────────────────────────────────────────────────────────────
4   On phones (≤720px) the comparison tables (.cmp-tbl / .cmp-tbl--alts
5   on the SPA, .compare on the niche landing pages) stop scrolling
6   sideways and become a stack of clean cards, each showing:
7
8       FEATURE LABEL
9       [ $99 Social value ]   [ selected competitor value ]
10
11   The header row is rebuilt in CSS into a sticky, horizontally
12   scrolling competitor selector ("$99 Social vs  Big agency / …").
13   See the "Mobile comparison" block at the end of styles.css.
14
15   This script is the small JS half of that progressive enhancement:
16     · marks <html class="cmv-ready"> so the CSS knows JS is live
17     · tap / keyboard-select a competitor pill → set
18       data-cmv="<nth-child index>" on the table so CSS reveals that
19       column, and scroll it into view in the selector
20     · stamps a11y attrs (tabindex, aria-pressed) on the pills
21     · clones the legend to the TOP of the section on mobile (the
22       desktop legend lives at the bottom)
23
24   No framework hooks — works with the prebuilt Preact bundle and the
25   Babel-compiled niche pages alike. A MutationObserver re-applies
26   everything after SPA route changes. If JS never runs, CSS falls
27   back to the first competitor (":not([data-cmv])") and keeps the
28   bottom legend. Loaded with `defer`; a copy lives in each niche's
29   js/ folder — keep them in sync.
30   ───────────────────────────────────────────────────────────── */
31(function () {
32  var MQ = window.matchMedia('(max-width: 720px)');
33  var CHIP_SEL = '.cmp-tbl-row--head .cmp-tbl-cell, .compare-row--head .compare-cell';
34  var HEAD_SEL = '.cmp-tbl-row--head, .compare-row--head';
35  var TABLE_SEL = '.cmp-tbl, .compare';
36
37  document.documentElement.classList.add('cmv-ready');
38
39  // One framing sentence per competitor, keyed by the pill label. Shown
40  // under the selector on mobile and swapped when the selection changes.
41  // (Editable marketing copy — mirrors the claims in the table rows.)
42  var SUMMARIES = {
43    'big agency': "Big agencies can cost thousands per month. $99 Social gives small businesses consistent, human-created content starting at $99/mo — with no long-term contract.",
44    'a hired agency': "A hired agency can cost thousands per month. $99 Social gives small businesses consistent, human-created content starting at $99/mo — with no long-term contract.",
45    'freelancer': "A good freelancer can vanish the week you need them most. $99 Social is a full writer-and-designer team for less than a freelancer's retainer — every month, from $99/mo.",
46    'diy in-house': "Doing it yourself is only \u201Cfree\u201D until you count the hours. $99 Social hands the whole thing to a real team from $99/mo — about ten minutes of your week.",
47    'diy': "Doing it yourself is only \u201Cfree\u201D until you count the hours. $99 Social hands the whole thing to a real team from $99/mo — about ten minutes of your week.",
48    'ai tools': "AI tools churn out generic posts you still have to fix. $99 Social uses AI for ideas only — real people write and design every post, from $99/mo.",
49    'other affordable services': "Other budget services lean on templates, thin support and hidden fees. $99 Social pairs you with a dedicated Account Manager, Content Specialist and QA Specialist from $99/mo.",
50    'other cheap service': "Other budget services lean on templates, thin support and hidden fees. $99 Social pairs you with a dedicated Account Manager, Content Specialist and QA Specialist from $99/mo.",
51    '_default': "Most ways to get your social done cost far more — or far more of your time. $99 Social delivers consistent, human-created content from $99/mo, with no long-term contract."
52  };
53  function summaryKey(name) { return name.toLowerCase().replace(/\s+/g, ' ').trim(); }
54
55  function cellIndex(cell) {
56    return Array.prototype.indexOf.call(cell.parentElement.children, cell) + 1;
57  }
58  // Column 1 = feature names, column 2 = $99 Social; 3+ are competitors.
59  function isChip(cell) { return cellIndex(cell) >= 3; }
60
61  function select(cell) {
62    var headRow = cell.parentElement;
63    var table = headRow.parentElement;
64    var idx = cellIndex(cell);
65    table.setAttribute('data-cmv', String(idx));
66    syncPressed(headRow, idx);
67    bringIntoView(headRow, cell);
68    updateMeta(table);
69  }
70
71  // ── Dynamic column labels, summary + "see the full breakdown" link ─
72  function activeIdx(table) { return parseInt(table.getAttribute('data-cmv') || '3', 10); }
73  function activeCell(table) {
74    var head = table.querySelector(HEAD_SEL);
75    return head ? head.children[activeIdx(table) - 1] : null;
76  }
77  function hasSections(table) {
78    var head = table.querySelector(HEAD_SEL);
79    return !!(head && head.querySelector('[data-cmp-section]'));
80  }
81  function moreBase() {
82    return (typeof window.__nav === 'function') ? '/compare' : 'https://www.99dollarsocial.com/compare';
83  }
84  function ensureMeta(table) {
85    var head = table.querySelector(HEAD_SEL);
86    if (!head || head.parentNode !== table || !MQ.matches) return;
87    var sum = table.querySelector(':scope > .cmv-summary');
88    if (!sum) {
89      sum = document.createElement('p');
90      sum.className = 'cmv-summary';
91      table.insertBefore(sum, head.nextSibling);
92    }
93    var labels = table.querySelector(':scope > .cmv-collabels');
94    if (!labels) {
95      labels = document.createElement('div');
96      labels.className = 'cmv-collabels';
97      labels.setAttribute('aria-hidden', 'true');
98      labels.innerHTML =
99        '<span class="cmv-collabels-us">$99 Social</span>' +
100        '<span class="cmv-collabels-them"></span>';
101      table.insertBefore(labels, sum.nextSibling);
102    }
103    var more = table.querySelector(':scope > .cmv-more');
104    if (!more && hasSections(table)) {
105      more = document.createElement('a');
106      more.className = 'cmv-more';
107      more.appendChild(document.createTextNode('See the full breakdown '));
108      var arr = document.createElement('span');
109      arr.className = 'cmv-more-arr';
110      arr.setAttribute('aria-hidden', 'true');
111      arr.textContent = '\u2192';
112      more.appendChild(arr);
113      table.appendChild(more); // bottom of the chart
114    }
115    updateMeta(table);
116  }
117  function updateMeta(table) {
118    if (!MQ.matches) return;
119    var cell = activeCell(table);
120    var nameEl = cell && cell.querySelector ? cell.querySelector('.cmp-tbl-head-name') : null;
121    var name = nameEl ? nameEl.textContent.trim() : (cell ? cell.textContent.trim() : '');
122    var sum = table.querySelector(':scope > .cmv-summary');
123    if (sum) {
124      sum.textContent = (cell && cell.getAttribute('data-cmp-summary')) ||
125        SUMMARIES[summaryKey(name)] || SUMMARIES._default;
126    }
127    var them = table.querySelector(':scope > .cmv-collabels .cmv-collabels-them');
128    if (them) them.textContent = name;
129    var more = table.querySelector(':scope > .cmv-more');
130    if (more) {
131      var section = cell ? cell.getAttribute('data-cmp-section') : '';
132      if (section) {
133        more.setAttribute('href', moreBase() + '#' + section);
134        more.setAttribute('data-section', section);
135        more.setAttribute('title', '$99 Social vs ' + name + ' \u2014 full comparison');
136      }
137    }
138  }
139  function refreshMeta() {
140    var tables = document.querySelectorAll(TABLE_SEL);
141    for (var i = 0; i < tables.length; i++) ensureMeta(tables[i]);
142  }
143
144  function syncPressed(headRow, activeIdx) {
145    for (var i = 2; i < headRow.children.length; i++) {
146      headRow.children[i].setAttribute('aria-pressed', i + 1 === activeIdx ? 'true' : 'false');
147    }
148  }
149
150  // Keep the tapped pill fully visible inside the horizontal scroller,
151  // accounting for the sticky "$99 Social vs" label pinned on the left.
152  function bringIntoView(headRow, cell) {
153    if (!MQ.matches) return;
154    var label = headRow.querySelector('.cmp-tbl-cell--us, .compare-cell--us');
155    var pad = (label ? label.offsetWidth : 0) + 12;
156    var left = cell.offsetLeft - pad;
157    var right = cell.offsetLeft + cell.offsetWidth - headRow.clientWidth + 12;
158    if (left < headRow.scrollLeft) headRow.scrollLeft = Math.max(0, left);
159    else if (right > headRow.scrollLeft) headRow.scrollLeft = right;
160  }
161
162  // ── A11y: on mobile the table is restyled into cards + a horizontal chip
163  //    selector, so the ARIA grid semantics (table/row/columnheader/cell) no
164  //    longer describe what's on screen — and the chip cells become
165  //    role="button", which would leave the enclosing row (and table) with
166  //    invalid / missing required children (the axe "aria-required-children"
167  //    failure). Strip the structural roles on mobile — the header row
168  //    becomes a labelled group of toggle buttons, everything else becomes
169  //    plain content — and restore the exact originals on desktop. Original
170  //    roles are stashed per element the first time, so restore is lossless
171  //    across React re-renders.
172  function stashRole(el) {
173    if (!el.hasAttribute('data-role0')) el.setAttribute('data-role0', el.getAttribute('role') || '');
174  }
175  function restoreRole(el) {
176    if (!el.hasAttribute('data-role0')) return;
177    var r = el.getAttribute('data-role0');
178    if (r) el.setAttribute('role', r); else el.removeAttribute('role');
179    el.removeAttribute('data-role0');
180  }
181  function isHeadRow(row) {
182    return row.classList.contains('cmp-tbl-row--head') || row.classList.contains('compare-row--head');
183  }
184  function syncTableRoles() {
185    var tables = document.querySelectorAll(TABLE_SEL);
186    for (var i = 0; i < tables.length; i++) {
187      var table = tables[i];
188      var rows = table.querySelectorAll('.cmp-tbl-row, .compare-row');
189      if (MQ.matches) {
190        // Drop the grid container role — the data rows below lose their
191        // row/cell roles too, so nothing is left dangling.
192        stashRole(table); table.removeAttribute('role');
193        for (var r = 0; r < rows.length; r++) {
194          var row = rows[r], head = isHeadRow(row);
195          stashRole(row);
196          if (head) {
197            row.setAttribute('role', 'group'); // a group can legitimately own buttons
198            if (!row.hasAttribute('aria-label')) {
199              row.setAttribute('aria-label', 'Choose a competitor to compare against $99 Social');
200              row.setAttribute('data-cmv-label', '1');
201            }
202          } else {
203            row.removeAttribute('role');
204          }
205          for (var c = 0; c < row.children.length; c++) {
206            var cell = row.children[c];
207            if (head && isChip(cell)) continue; // chip button roles handled below
208            stashRole(cell); cell.removeAttribute('role');
209          }
210        }
211      } else {
212        restoreRole(table);
213        for (var r2 = 0; r2 < rows.length; r2++) {
214          var row2 = rows[r2];
215          restoreRole(row2);
216          if (row2.getAttribute('data-cmv-label')) {
217            row2.removeAttribute('aria-label');
218            row2.removeAttribute('data-cmv-label');
219          }
220          for (var c2 = 0; c2 < row2.children.length; c2++) restoreRole(row2.children[c2]);
221        }
222      }
223    }
224  }
225
226  function stampAll() {
227    syncTableRoles();
228    var cells = document.querySelectorAll(CHIP_SEL);
229    for (var i = 0; i < cells.length; i++) {
230      var cell = cells[i];
231      if (!isChip(cell)) continue;
232      if (MQ.matches) {
233        cell.setAttribute('tabindex', '0');
234        cell.setAttribute('role', 'button');
235        if (!cell.hasAttribute('aria-pressed')) {
236          var table = cell.parentElement.parentElement;
237          var active = parseInt(table.getAttribute('data-cmv') || '3', 10);
238          cell.setAttribute('aria-pressed', cellIndex(cell) === active ? 'true' : 'false');
239        }
240      } else {
241        cell.removeAttribute('tabindex');
242        cell.removeAttribute('aria-pressed');
243        cell.setAttribute('role', 'columnheader'); // restore the grid header role
244      }
245    }
246    placeLegends();
247    refreshMeta();
248  }
249
250  // Clone the (desktop-bottom) legend to just above each table on mobile.
251  // Cloning — not moving — keeps React's original node untouched.
252  function placeLegends() {
253    var legends = document.querySelectorAll('.cmp-tbl-legend');
254    for (var i = 0; i < legends.length; i++) {
255      var orig = legends[i];
256      if (orig.classList.contains('cmv-legend-top')) continue; // it's a clone
257      var container = orig.parentElement;
258      if (!container) continue;
259      var block = container.querySelector('.cmpx-scroller, .cmp-tbl-wrap');
260      if (!block || block.parentElement !== container) continue;
261      var existing = container.querySelector('.cmv-legend-top');
262      if (MQ.matches) {
263        if (!existing) {
264          var clone = orig.cloneNode(true);
265          clone.classList.add('cmv-legend-top');
266          container.insertBefore(clone, block);
267        }
268      } else if (existing) {
269        existing.remove();
270      }
271    }
272  }
273
274  function chipFromEvent(e) {
275    var cell = e.target && e.target.closest ? e.target.closest(CHIP_SEL) : null;
276    return cell && isChip(cell) ? cell : null;
277  }
278
279  document.addEventListener('click', function (e) {
280    var more = e.target.closest ? e.target.closest('.cmv-more') : null;
281    if (more && typeof window.__nav === 'function') {
282      e.preventDefault();
283      window.__cmpSection = more.getAttribute('data-section') || '';
284      window.__nav('compare');
285      return;
286    }
287    if (!MQ.matches) return;
288    var cell = chipFromEvent(e);
289    if (cell) select(cell);
290  });
291
292  document.addEventListener('keydown', function (e) {
293    if (!MQ.matches || (e.key !== 'Enter' && e.key !== ' ')) return;
294    var cell = chipFromEvent(e);
295    if (cell) { e.preventDefault(); select(cell); }
296  });
297
298  // Re-apply after SPA route changes / re-renders (childList only — our own
299  // attribute writes don't retrigger it). Debounced; the query is cheap.
300  var t = null;
301  function queueStamp() { clearTimeout(t); t = setTimeout(stampAll, 150); }
302  new MutationObserver(queueStamp).observe(document.documentElement, { childList: true, subtree: true });
303  if (MQ.addEventListener) { MQ.addEventListener('change', stampAll); }
304  else if (MQ.addListener) { MQ.addListener(stampAll); }
305
306  stampAll();
307})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.