@blockrun/franklin 3.17.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -296,6 +296,22 @@ On-chain affiliate (20 bps in sell-token, force-set server-side) flows to BlockR
296
296
  - \`/v1/modal/{...path}\` — Modal GPU sandbox passthrough (create/exec/etc.).
297
297
  - \`/v1/pm/{...path}\` — prediction-market data passthrough.
298
298
 
299
+ **Surf — crypto data + chat (x402-paid)** via the generic \`BlockRun\` capability. ~55 curated endpoints. Tier-1 $0.001, Tier-2 $0.005, Tier-3 / chat $0.02.
300
+ - \`/v1/surf/exchange/*\` — CEX trading pairs, prices, perps, depth, klines, funding history, long/short ratio.
301
+ - \`/v1/surf/market/*\` — token rankings, fear/greed, futures, ETF flows, options skew, liquidations, on-chain indicators (NUPL/SOPR/MVRV), price indicators (RSI/MACD/BBANDS).
302
+ - \`/v1/surf/news/{feed,detail}\` — AI-curated crypto news.
303
+ - \`/v1/surf/onchain/{bridge,yield,gas-price,tx,schema,query,sql}\` — bridge/yield rankings, gas, tx detail, **raw SQL against 80+ indexed chain tables (Tier-3, $0.02)**, structured chain query, schema introspection.
304
+ - \`/v1/surf/token/{tokenomics,dex-trades,holders,transfers}\` — token analytics.
305
+ - \`/v1/surf/wallet/{detail,history,net-worth,transfers,protocols,labels/batch}\` — wallet intelligence + batch labels (CEX/Whale/Bridge/MEV).
306
+ - \`/v1/surf/social/*\` — KOL/CT mindshare, smart-follower history, tweets, user profiles. The canonical source for crypto-Twitter signal.
307
+ - \`/v1/surf/fund/{detail,portfolio,ranking}\` — VC fund profiles, portfolios, ranking.
308
+ - \`/v1/surf/project/{detail,defi/metrics,defi/ranking}\` — project profiles + DeFi protocol metrics.
309
+ - \`/v1/surf/chat/completions\` — surf-1.5 chat model with first-class citations (\`citation: ["source","chart"]\`). \$0.02/call flat.
310
+
311
+ For Surf workflows, prefer the bundled skills (\`/surf-market\`, \`/surf-chain\`, \`/surf-social\`, \`/surf-chat\`) — they document which endpoint to pick for which question and the cost trade-off. Skipped (use the dedicated tools instead): \`/v1/surf/prediction-market/*\` (use \`PredictionMarket\`), \`/v1/surf/search/*\` (use \`ExaSearch\`), \`/v1/surf/web/*\` (use \`BrowserX\`).
312
+
313
+ **Generic gateway primitive**: \`BlockRun({ path, method, params, body })\` is a single capability that signs x402 and forwards to ANY path under \`/api\`. Use it for Surf endpoints (above) and any future BlockRun partner that doesn't have a dedicated capability yet. Always specify the exact path; the primitive will not guess.
314
+
299
315
  **Endpoints that DO NOT exist** (common hallucinations — do NOT call):
300
316
  - \`/v1/image/generate\` (singular — use \`/v1/images/generations\`)
301
317
  - \`/v1/spending\` (no such route — derive from on-chain history if needed)
@@ -410,6 +410,63 @@ a:hover { text-decoration:underline; }
410
410
  .btn-danger { background:oklch(0.65 0.20 25 / 18%); color:var(--danger); border-color:oklch(0.65 0.20 25 / 35%); }
411
411
  .btn-danger:hover { background:oklch(0.65 0.20 25 / 30%); }
412
412
 
413
+ .nav-badge {
414
+ margin-left:auto; font-size:10px; font-weight:700; letter-spacing:0.3px;
415
+ padding:2px 7px; border-radius:8px;
416
+ background:oklch(0.65 0.20 25 / 22%); color:var(--danger);
417
+ border:1px solid oklch(0.65 0.20 25 / 35%);
418
+ }
419
+ .nav-badge.warn { background:oklch(0.78 0.14 85 / 22%); color:var(--gold); border-color:oklch(0.78 0.14 85 / 35%); }
420
+
421
+ .phone-list { display:flex; flex-direction:column; gap:10px; }
422
+ .phone-row {
423
+ display:grid; grid-template-columns:auto 1fr auto; gap:14px; align-items:center;
424
+ padding:14px 16px; background:var(--bg-card); border:1px solid var(--border);
425
+ border-radius:var(--radius); transition:border-color 0.15s, background 0.15s;
426
+ }
427
+ .phone-row:hover { background:var(--bg-card-hover); }
428
+ .phone-row.warn { border-color:oklch(0.78 0.14 85 / 50%); }
429
+ .phone-row.crit { border-color:oklch(0.65 0.20 25 / 55%); }
430
+ .phone-row.expired { opacity:0.65; border-color:oklch(0.65 0.20 25 / 45%); }
431
+ .phone-icon-bubble {
432
+ width:36px; height:36px; border-radius:10px; display:grid; place-items:center;
433
+ background:oklch(0.68 0.16 260 / 18%); color:var(--brand);
434
+ }
435
+ .phone-main { display:flex; flex-direction:column; gap:3px; min-width:0; }
436
+ .phone-num {
437
+ font-family:var(--mono); font-size:15px; font-weight:600; color:var(--text);
438
+ letter-spacing:0.02em;
439
+ }
440
+ .phone-meta { font-size:12px; color:var(--text-muted); display:flex; gap:10px; flex-wrap:wrap; }
441
+ .phone-meta .chip {
442
+ display:inline-flex; align-items:center; gap:4px;
443
+ padding:2px 7px; border-radius:6px; background:oklch(0 0 0 / 25%);
444
+ font-size:10.5px; letter-spacing:0.5px; text-transform:uppercase; font-weight:700;
445
+ }
446
+ .phone-meta .chip.green { color:var(--success); background:oklch(0.65 0.18 145 / 18%); }
447
+ .phone-meta .chip.amber { color:var(--gold); background:oklch(0.78 0.14 85 / 20%); }
448
+ .phone-meta .chip.red { color:var(--danger); background:oklch(0.65 0.20 25 / 20%); }
449
+ .phone-row .phone-actions { display:flex; align-items:center; gap:6px; }
450
+ .phone-row.expired .phone-num { text-decoration:line-through; }
451
+
452
+ .phone-empty {
453
+ padding:24px; text-align:center; border:1px dashed var(--border);
454
+ border-radius:var(--radius); color:var(--text-muted); font-size:13px;
455
+ line-height:1.6;
456
+ }
457
+ .phone-empty strong { color:var(--text); font-weight:600; display:block; margin-bottom:6px; font-size:14px; }
458
+
459
+ .phone-buy-form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:10px; }
460
+ .phone-buy-form select, .phone-buy-form input {
461
+ padding:7px 10px; background:oklch(0 0 0 / 35%); color:var(--text);
462
+ border:1px solid var(--border); border-radius:7px; font-size:13px;
463
+ font-family:var(--mono);
464
+ }
465
+ .phone-buy-form input { width:120px; }
466
+ .phone-status { font-size:12px; color:var(--text-muted); }
467
+ .phone-status.ok { color:var(--success); }
468
+ .phone-status.err { color:var(--danger); }
469
+
413
470
  @media (max-width:768px) {
414
471
  body { flex-direction:column; }
415
472
  .sidebar { width:100%; min-width:100%; flex-direction:row; padding:8px; overflow-x:auto; border-right:none; border-bottom:1px solid var(--border); }
@@ -455,6 +512,11 @@ a:hover { text-decoration:underline; }
455
512
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M7 14l4-4 4 4 5-5"/></svg>
456
513
  Markets
457
514
  </button>
515
+ <button class="nav-item" data-tab="phone">
516
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
517
+ Phone
518
+ <span class="nav-badge" id="phone-nav-badge" style="display:none"></span>
519
+ </button>
458
520
  <button class="nav-item" data-tab="sessions">
459
521
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
460
522
  Sessions
@@ -677,6 +739,46 @@ a:hover { text-decoration:underline; }
677
739
  </div>
678
740
  </div>
679
741
 
742
+ <!-- Phone & Voice -->
743
+ <div class="tab" id="tab-phone">
744
+ <div class="content-header">
745
+ <h2>Phone &amp; Voice</h2>
746
+ <p>Numbers your wallet owns. Leases run 30 days &mdash; renew before they expire or set auto-renew.</p>
747
+ </div>
748
+
749
+ <div class="card" style="margin-bottom:16px">
750
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;">
751
+ <h3 style="margin:0">Your numbers</h3>
752
+ <div style="display:flex;gap:8px;align-items:center">
753
+ <span class="phone-status" id="phone-list-status"></span>
754
+ <button class="btn btn-ghost" id="phone-refresh-btn" title="Refetch from BlockRun ($0.001)">Refresh</button>
755
+ </div>
756
+ </div>
757
+ <div id="phone-list" style="margin-top:12px">
758
+ <div class="phone-empty">Loading&hellip;</div>
759
+ </div>
760
+ </div>
761
+
762
+ <div class="card">
763
+ <h3 style="margin:0 0 6px">Add another number</h3>
764
+ <p class="wallet-hint">
765
+ $5 USDC for a fresh number, bound to your wallet for 30 days. <strong>This adds
766
+ a new number alongside any you already own &mdash; nothing is replaced.</strong>
767
+ Use it as caller ID for outbound AI voice calls, or (soon) to receive inbound calls.
768
+ Multiple numbers are fine; release any you no longer need to stop paying renewals on them.
769
+ </p>
770
+ <div class="phone-buy-form">
771
+ <select id="phone-buy-country">
772
+ <option value="US">United States (+1)</option>
773
+ <option value="CA">Canada (+1)</option>
774
+ </select>
775
+ <input id="phone-buy-areacode" placeholder="Area code (opt)" maxlength="6" />
776
+ <button class="btn btn-warn" id="phone-buy-btn">Buy for $5</button>
777
+ <span class="phone-status" id="phone-buy-status"></span>
778
+ </div>
779
+ </div>
780
+ </div>
781
+
680
782
  <!-- Learnings -->
681
783
  <div class="tab" id="tab-learnings">
682
784
  <div class="content-header">
@@ -1544,6 +1646,307 @@ document.addEventListener('visibilitychange', () => {
1544
1646
 
1545
1647
  document.getElementById('tasks-refresh-btn')?.addEventListener('click', fetchTasks);
1546
1648
 
1649
+ // ─── Phone & Voice ──────────────────────────────────────────────────────
1650
+ // Renders the user's wallet-owned numbers, days-remaining countdown,
1651
+ // renew / release / auto-renew controls, and the buy form. Drives the
1652
+ // sidebar nav badge so users with an expiring number see it even from
1653
+ // the Overview tab. Notification ladder uses the Notifications API,
1654
+ // dedupe-keyed in sessionStorage so we don't spam the user every open.
1655
+
1656
+ const phoneState = { data: null, countdownTimer: null };
1657
+
1658
+ function formatPhoneNumber(e164) {
1659
+ // E.164 → human, for display only. Keep raw value for actions.
1660
+ if (!e164) return '—';
1661
+ const m = String(e164).match(/^\\+1(\\d{3})(\\d{3})(\\d{4})$/);
1662
+ if (m) return '+1 (' + m[1] + ') ' + m[2] + '-' + m[3];
1663
+ return e164;
1664
+ }
1665
+
1666
+ function daysLeft(expiresAt) {
1667
+ const expiry = new Date(expiresAt).getTime();
1668
+ if (isNaN(expiry)) return 0;
1669
+ return Math.floor((expiry - Date.now()) / 86400000);
1670
+ }
1671
+
1672
+ function phoneTier(days) {
1673
+ if (days < 0) return 'expired';
1674
+ if (days <= 2) return 'crit';
1675
+ if (days <= 7) return 'warn';
1676
+ return 'ok';
1677
+ }
1678
+
1679
+ function phoneChipClass(tier) {
1680
+ if (tier === 'expired' || tier === 'crit') return 'red';
1681
+ if (tier === 'warn') return 'amber';
1682
+ return 'green';
1683
+ }
1684
+
1685
+ function phoneCountdownLabel(days) {
1686
+ if (days < 0) return 'expired ' + Math.abs(days) + 'd ago';
1687
+ if (days === 0) return 'expires today';
1688
+ if (days === 1) return '1 day left';
1689
+ return days + ' days left';
1690
+ }
1691
+
1692
+ function updatePhoneNavBadge(numbers) {
1693
+ const badge = document.getElementById('phone-nav-badge');
1694
+ if (!badge) return;
1695
+ let worst = 999;
1696
+ let anyExpired = false;
1697
+ numbers.forEach(n => {
1698
+ const d = daysLeft(n.expires_at);
1699
+ if (d < 0) anyExpired = true;
1700
+ if (d < worst) worst = d;
1701
+ });
1702
+ if (anyExpired) {
1703
+ badge.textContent = '!'; badge.className = 'nav-badge'; badge.style.display = '';
1704
+ } else if (worst <= 2) {
1705
+ badge.textContent = worst + 'd'; badge.className = 'nav-badge'; badge.style.display = '';
1706
+ } else if (worst <= 7) {
1707
+ badge.textContent = worst + 'd'; badge.className = 'nav-badge warn'; badge.style.display = '';
1708
+ } else {
1709
+ badge.style.display = 'none';
1710
+ }
1711
+ }
1712
+
1713
+ function maybeNotifyExpiry(numbers) {
1714
+ if (typeof Notification === 'undefined') return;
1715
+ if (Notification.permission !== 'granted') return;
1716
+ numbers.forEach(n => {
1717
+ const d = daysLeft(n.expires_at);
1718
+ let mark = null;
1719
+ if (d < 0) mark = 'expired';
1720
+ else if (d <= 1) mark = 't1';
1721
+ else if (d <= 3) mark = 't3';
1722
+ else if (d <= 7) mark = 't7';
1723
+ if (!mark) return;
1724
+ const key = 'phone:notify:' + n.phone_number + ':' + mark;
1725
+ if (sessionStorage.getItem(key)) return;
1726
+ sessionStorage.setItem(key, '1');
1727
+ const human = formatPhoneNumber(n.phone_number);
1728
+ const title = 'Franklin: ' + human;
1729
+ const body = mark === 'expired'
1730
+ ? 'This number has expired. Provision a new one in the Phone tab.'
1731
+ : (mark === 't1'
1732
+ ? 'Expires in 1 day. Click to renew for $5.'
1733
+ : (mark === 't3'
1734
+ ? 'Expires in 3 days. Click to renew for $5.'
1735
+ : 'Expires in a week. Renew when convenient.'));
1736
+ try {
1737
+ const notif = new Notification(title, { body, tag: key });
1738
+ notif.onclick = () => {
1739
+ try { window.focus(); } catch (e) {}
1740
+ location.hash = 'phone';
1741
+ activateTab('phone');
1742
+ notif.close();
1743
+ };
1744
+ } catch (e) { /* ignore */ }
1745
+ });
1746
+ }
1747
+
1748
+ function renderPhoneNumbers(data) {
1749
+ const list = document.getElementById('phone-list');
1750
+ if (!list) return;
1751
+ const numbers = (data && data.numbers) || [];
1752
+ updatePhoneNavBadge(numbers);
1753
+
1754
+ if (!numbers.length) {
1755
+ list.innerHTML = '<div class="phone-empty">' +
1756
+ '<strong>No numbers yet</strong>' +
1757
+ 'Provision a number below to give Franklin a phone identity. ' +
1758
+ 'Numbers cost $5 for a 30-day lease and are bound to your wallet.' +
1759
+ '</div>';
1760
+ return;
1761
+ }
1762
+
1763
+ const html = numbers.map(n => {
1764
+ const d = daysLeft(n.expires_at);
1765
+ const tier = phoneTier(d);
1766
+ const chipCls = phoneChipClass(tier);
1767
+ const rowCls = tier === 'ok' ? '' : (' ' + tier);
1768
+ const human = formatPhoneNumber(n.phone_number);
1769
+ const renewBtn = tier === 'expired'
1770
+ ? ''
1771
+ : '<button class="btn btn-warn" data-phone-renew="' + n.phone_number + '">Renew $5</button>';
1772
+ const releaseLabel = tier === 'expired' ? 'Remove' : 'Release';
1773
+ return ''
1774
+ + '<div class="phone-row' + rowCls + '">'
1775
+ + ' <div class="phone-icon-bubble">'
1776
+ + ' <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
1777
+ + ' <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>'
1778
+ + ' </svg>'
1779
+ + ' </div>'
1780
+ + ' <div class="phone-main">'
1781
+ + ' <div class="phone-num">' + human + '</div>'
1782
+ + ' <div class="phone-meta">'
1783
+ + ' <span class="chip">' + (n.chain || '—') + '</span>'
1784
+ + ' <span class="chip ' + chipCls + '">' + phoneCountdownLabel(d) + '</span>'
1785
+ + ' </div>'
1786
+ + ' </div>'
1787
+ + ' <div class="phone-actions">'
1788
+ + renewBtn
1789
+ + ' <button class="btn btn-ghost" data-phone-release="' + n.phone_number + '" title="' + releaseLabel + ' this number">' + releaseLabel + '</button>'
1790
+ + ' </div>'
1791
+ + '</div>';
1792
+ }).join('');
1793
+
1794
+ list.innerHTML = html;
1795
+
1796
+ list.querySelectorAll('[data-phone-renew]').forEach(btn => {
1797
+ btn.addEventListener('click', () => renewPhoneNumber(btn.getAttribute('data-phone-renew')));
1798
+ });
1799
+ list.querySelectorAll('[data-phone-release]').forEach(btn => {
1800
+ btn.addEventListener('click', () => releasePhoneNumber(btn.getAttribute('data-phone-release')));
1801
+ });
1802
+
1803
+ maybeNotifyExpiry(numbers);
1804
+ }
1805
+
1806
+ async function loadPhone(opts) {
1807
+ const force = !!(opts && opts.force);
1808
+ const statusEl = document.getElementById('phone-list-status');
1809
+ if (statusEl) statusEl.textContent = force ? 'Refreshing…' : 'Loading…';
1810
+ try {
1811
+ const url = '/api/phone/numbers';
1812
+ const r = force
1813
+ ? await fetch('/api/phone/numbers/refresh', { method: 'POST' })
1814
+ : await fetch(url);
1815
+ const data = await r.json();
1816
+ if (!r.ok) {
1817
+ if (statusEl) { statusEl.textContent = data.error || 'Failed to load'; statusEl.className = 'phone-status err'; }
1818
+ const list = document.getElementById('phone-list');
1819
+ if (list) list.innerHTML = '<div class="phone-empty"><strong>Could not load numbers</strong>' + (data.error || 'Unknown error') + '</div>';
1820
+ return;
1821
+ }
1822
+ phoneState.data = data;
1823
+ renderPhoneNumbers(data);
1824
+ if (statusEl) {
1825
+ statusEl.className = 'phone-status';
1826
+ statusEl.textContent = data.fromCache
1827
+ ? 'Cached ' + new Date(data.fetchedAt).toLocaleTimeString()
1828
+ : 'Synced ' + new Date(data.fetchedAt || Date.now()).toLocaleTimeString();
1829
+ }
1830
+ } catch (err) {
1831
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
1832
+ }
1833
+ }
1834
+
1835
+ async function renewPhoneNumber(num) {
1836
+ const statusEl = document.getElementById('phone-list-status');
1837
+ if (statusEl) { statusEl.textContent = 'Renewing ' + formatPhoneNumber(num) + '…'; statusEl.className = 'phone-status'; }
1838
+ try {
1839
+ const r = await fetch('/api/phone/numbers/renew', {
1840
+ method: 'POST',
1841
+ headers: { 'Content-Type': 'application/json' },
1842
+ body: JSON.stringify({ phoneNumber: num }),
1843
+ });
1844
+ const data = await r.json();
1845
+ if (!r.ok) {
1846
+ if (statusEl) { statusEl.textContent = data.error || 'Renew failed'; statusEl.className = 'phone-status err'; }
1847
+ return;
1848
+ }
1849
+ // Clear dedupe keys so a renewed number can re-notify if it expires again later
1850
+ Object.keys(sessionStorage).filter(k => k.startsWith('phone:notify:' + num + ':')).forEach(k => sessionStorage.removeItem(k));
1851
+ if (statusEl) { statusEl.textContent = 'Renewed — new expiry ' + new Date(data.expires_at).toLocaleDateString(); statusEl.className = 'phone-status ok'; }
1852
+ await loadPhone({});
1853
+ } catch (err) {
1854
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
1855
+ }
1856
+ }
1857
+
1858
+ async function releasePhoneNumber(num) {
1859
+ if (!confirm('Release ' + formatPhoneNumber(num) + '? This permanently gives up the number and cannot be undone.')) return;
1860
+ const statusEl = document.getElementById('phone-list-status');
1861
+ if (statusEl) { statusEl.textContent = 'Releasing…'; statusEl.className = 'phone-status'; }
1862
+ try {
1863
+ const r = await fetch('/api/phone/numbers/release', {
1864
+ method: 'POST',
1865
+ headers: { 'Content-Type': 'application/json' },
1866
+ body: JSON.stringify({ phoneNumber: num }),
1867
+ });
1868
+ const data = await r.json();
1869
+ if (!r.ok) {
1870
+ if (statusEl) { statusEl.textContent = data.error || 'Release failed'; statusEl.className = 'phone-status err'; }
1871
+ return;
1872
+ }
1873
+ if (statusEl) { statusEl.textContent = 'Released ' + formatPhoneNumber(num); statusEl.className = 'phone-status ok'; }
1874
+ await loadPhone({});
1875
+ } catch (err) {
1876
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
1877
+ }
1878
+ }
1879
+
1880
+ async function buyPhoneNumber() {
1881
+ const country = (document.getElementById('phone-buy-country') || {}).value || 'US';
1882
+ const areaCode = ((document.getElementById('phone-buy-areacode') || {}).value || '').trim();
1883
+ const statusEl = document.getElementById('phone-buy-status');
1884
+ const btn = document.getElementById('phone-buy-btn');
1885
+ const existingCount = ((phoneState.data && phoneState.data.numbers) || []).filter(n => daysLeft(n.expires_at) >= 0).length;
1886
+ const intro = existingCount > 0
1887
+ ? 'You already own ' + existingCount + ' active number' + (existingCount === 1 ? '' : 's') + '. This will ADD a new number — nothing is replaced.\\n\\n'
1888
+ : '';
1889
+ if (!confirm(intro + 'Buy a new phone number for $5? It will be charged from your wallet immediately and last 30 days.')) return;
1890
+ if (statusEl) { statusEl.textContent = 'Provisioning…'; statusEl.className = 'phone-status'; }
1891
+ if (btn) btn.disabled = true;
1892
+ try {
1893
+ const r = await fetch('/api/phone/numbers/buy', {
1894
+ method: 'POST',
1895
+ headers: { 'Content-Type': 'application/json' },
1896
+ body: JSON.stringify({ country, areaCode: areaCode || undefined }),
1897
+ });
1898
+ const data = await r.json();
1899
+ if (!r.ok) {
1900
+ if (statusEl) { statusEl.textContent = data.error || 'Purchase failed'; statusEl.className = 'phone-status err'; }
1901
+ return;
1902
+ }
1903
+ if (statusEl) { statusEl.textContent = 'Got ' + formatPhoneNumber(data.phone_number); statusEl.className = 'phone-status ok'; }
1904
+ await loadPhone({});
1905
+ } catch (err) {
1906
+ if (statusEl) { statusEl.textContent = 'Network error'; statusEl.className = 'phone-status err'; }
1907
+ } finally {
1908
+ if (btn) btn.disabled = false;
1909
+ }
1910
+ }
1911
+
1912
+ function startPhoneCountdown() {
1913
+ if (phoneState.countdownTimer) return;
1914
+ // Re-render every minute so countdown chips age in place. Cheap — no
1915
+ // network, just DOM. Pauses when tab not visible (see visibilitychange).
1916
+ phoneState.countdownTimer = setInterval(() => {
1917
+ if (document.visibilityState !== 'visible') return;
1918
+ if (phoneState.data) renderPhoneNumbers(phoneState.data);
1919
+ }, 60000);
1920
+ }
1921
+
1922
+ function stopPhoneCountdown() {
1923
+ if (phoneState.countdownTimer) {
1924
+ clearInterval(phoneState.countdownTimer);
1925
+ phoneState.countdownTimer = null;
1926
+ }
1927
+ }
1928
+
1929
+ document.querySelector('[data-tab="phone"]')?.addEventListener('click', () => {
1930
+ // Ask once for notification permission when the user first opens the tab.
1931
+ // We never auto-prompt on page load — that would be annoying.
1932
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
1933
+ Notification.requestPermission().catch(() => {});
1934
+ }
1935
+ loadPhone({});
1936
+ startPhoneCountdown();
1937
+ });
1938
+
1939
+ document.addEventListener('tab:deactivated', (e) => {
1940
+ if (e.detail && e.detail.name === 'phone') stopPhoneCountdown();
1941
+ });
1942
+
1943
+ document.getElementById('phone-refresh-btn')?.addEventListener('click', () => loadPhone({ force: true }));
1944
+ document.getElementById('phone-buy-btn')?.addEventListener('click', buyPhoneNumber);
1945
+
1946
+ // Prime the nav badge so an expiring number is visible even before the user
1947
+ // clicks into the Phone tab. Cached read — no network cost.
1948
+ loadPhone({});
1949
+
1547
1950
  loadOverview();
1548
1951
  loadSessions();
1549
1952
  loadMarkets();