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.