PageSourceSearch

https://spectrue.net/js/handlers.js?v=M42h_fix17

js spectrue.net collected 2026-10-02 01:05:41 UTC 29,986 bytes, 933 lines download raw bytes

1import {
2  $,
3  setStatus,
4  log,
5  disable,
6  renderCheckResult,
7  showModal,
8} from './ui.js?v=M42h_fix17';
9import { watchAuth, logout, currentUser, waitForAuthInit } from './auth.js?v=M42h_fix17';
10import {
11  meCredits,
12  checkCreate,
13  checkGet,
14  estimateRunCost, // Added
15} from './api.js?v=M42h_fix17';
16import { T, getCurrentLocale, isExtensionContext } from './i18n.js?v=M42h_fix17';
17import { logEventSafe, bucketizeLen, bucketizeDurationMs, bucketizeCount, bucketizeScore } from './analytics.js?v=M42h_fix17';
18import { prepareCheckPayload } from './html_extractor.js?v=M42h_fix17'; // M8.2
19
20// --- Состояние приложения ---
21export let lastCheckResult = null;
22export function setLastCheckResult(data) {
23  lastCheckResult = data;
24}
25let lastUserInput = '';
26let currentUserCredits = 0;
27
28// COSTS constant removed - logic moved to backend
29
30const CREDIT_COST_USD = 0.01; // 1 SC = $0.01
31const IDS_TO_DISABLE = ['start-check', 'text-input'];
32
33// --- Animation State ---
34let lastStatusChangeTime = 0;
35let lastStatusText = '';
36
37// --- Analytics trackers (privacy-minimized) ---
38let __analysisMode = 'standard';
39let __analysisStartTs = null;
40let __lastStage = null;
41const __stageStart = new Map();
42function __bucketizeStepDuration(ms) {
43  return bucketizeDurationMs(ms);
44}
45
46function requestHistoryRefresh() {
47  window.dispatchEvent(new CustomEvent('spectrue:load-history'));
48}
49
50// --- Функции обновления UI ---
51export function updateCreditsDisplay(data) {
52  if (data && data.credits !== undefined) {
53    currentUserCredits = data.credits;
54    const display = $('credits-display');
55    if (display) display.textContent = currentUserCredits;
56
57    const headerCredits = document.getElementById('header-credits-badge');
58    if (headerCredits) headerCredits.textContent = `${currentUserCredits} cr`;
59
60    const sidebarBalance = document.getElementById('sidebar-balance-display');
61    if (sidebarBalance) {
62      const label = T('credits.balance') || 'Balance';
63      sidebarBalance.textContent = `${label}: ${currentUserCredits}`;
64    }
65  }
66}
67
68let _costEstimationDebounce = null;
69
70export async function updateCostDisplay() {
71  const textInput = $('text-input');
72  // const deepToggle = $('deep-analysis-toggle'); // Model mode logic handled by backend heuristics if needed, or we pass params
73
74  const searchCostEl = $('cost-search');
75  const modelCostEl = $('cost-model');
76  const totalCostEl = $('cost-total');
77  const costContainer = document.querySelector('.cost-estimate-container');
78
79  // M105: Hide estimate for URLs since we can't predict article complexity
80  const inputValue = textInput?.value?.trim() || '';
81  const isUrlInput = /^https?:\/\//i.test(inputValue);
82
83  if (isUrlInput || !inputValue) {
84    // URL or empty - can't estimate accurately
85    if (searchCostEl) searchCostEl.textContent = '—';
86    if (modelCostEl) modelCostEl.textContent = '—';
87    if (totalCostEl) totalCostEl.textContent = '—';
88    if (costContainer) costContainer.classList.add('estimate-unavailable');
89    return;
90  }
91
92  if (costContainer) costContainer.classList.remove('estimate-unavailable');
93
94  // Show loading state
95  if (totalCostEl) totalCostEl.textContent = '...';
96
97  // Debounce API calls
98  if (_costEstimationDebounce) clearTimeout(_costEstimationDebounce);
99
100  _costEstimationDebounce = setTimeout(async () => {
101    try {
102      // Heuristics for pre-check estimation:
103      // Assume average complexity: 2 claims, 2 searches
104      // We pass input_length so backend can dynamically scale token costs
105      const payload = {
106        claim_count: 2,
107        search_count: 2,
108        input_type: 'text',
109        input_length: inputValue.length,
110      };
111
112      const user = currentUser();
113      if (!user) {
114        if (totalCostEl) totalCostEl.textContent = '—';
115        return;
116      }
117
118      const res = await estimateRunCost(payload);
119
120      if (res.ok && res.data) {
121        const { min, max, by_stage } = res.data;
122
123        // Format: "min–max" or "min"
124        const fmt = (a, b) => {
125          const valA = Math.round(parseFloat(a));
126          const valB = Math.round(parseFloat(b));
127          return valA === valB ? String(valA) : `${valA}–${valB}`;
128        };
129
130        if (totalCostEl) totalCostEl.textContent = fmt(min, max);
131
132        // Optional: Breakdown display if elements exist
133        if (searchCostEl && by_stage && by_stage.search) {
134          searchCostEl.textContent = fmt(
135            by_stage.search.min,
136            by_stage.search.max
137          );
138        }
139        if (modelCostEl && by_stage) {
140          // Sum up non-search stages for "Model" cost
141          let modelMin = 0;
142          let modelMax = 0;
143          for (const [stage, costs] of Object.entries(by_stage)) {
144            if (stage !== 'search') {
145              modelMin += parseFloat(costs.min || 0);
146              modelMax += parseFloat(costs.max || 0);
147            }
148          }
149          modelCostEl.textContent = fmt(modelMin, modelMax);
150        }
151      } else {
152
153        if (totalCostEl) totalCostEl.textContent = '—';
154      }
155    } catch (e) {
156
157      if (totalCostEl) totalCostEl.textContent = '—';
158    }
159  }, 500); // 500ms debounce
160}
161
162export function updateDynamicTranslatedUI() {
163  updateCostDisplay();
164  const startBtn = $('start-check');
165  if (startBtn && !startBtn.disabled) {
166    setStatus(T('status.ready'));
167  }
168  if (lastCheckResult) {
169    document.querySelectorAll('.popup-container').forEach((p) => p.remove());
170    renderCheckResult(lastCheckResult);
171  }
172}
173
174// --- Обработчики событий ---
175export async function handleLogout() {
176  try {
177    await logout();
178    sessionStorage.removeItem('spectrue_auth_reloading');
179    window.location.reload();
180  } catch (e) {
181
182    setStatus(T('status.network_error'));
183  }
184}
185
186async function getCheckPayload(overrides = {}) {
187  const textOrUrl = ($('text-input')?.value || '').trim();
188  if (!textOrUrl) {
189    setStatus(T('status.input_required'));
190    return null;
191  }
192  lastUserInput = textOrUrl;
193  setLastCheckResult(null);
194  const lang = getCurrentLocale();
195  const searchType = 'smart'; // T9: Always smart
196
197  const isDeep =
198    $('deep-analysis-toggle')?.checked ||
199    $('header-deep-toggle')?.checked ||
200    false;
201
202  // M8.2: Check if we have HTML content from extension page analysis
203  if (window.spectrueHtmlContent) {
204    const payload = {
205      html_content: window.spectrueHtmlContent,
206      lang,
207      analysis_mode: isDeep ? 'deep' : 'general',
208      source_info: window.spectrueSourceInfo || null, // M8.2
209      social_media_meta: window.spectrueSocialMeta || null, // M8.2
210      consent_store_text: window.spectrueConsentStoreText || false,
211    };
212
213    // Clear HTML content after use
214    delete window.spectrueHtmlContent;
215
216    return { ...payload, ...overrides };
217  }
218
219  // M8.2: Use HTML extraction if input is a URL
220  try {
221    const payload = await prepareCheckPayload(textOrUrl, {
222      lang,
223      searchType,
224      analysisMode: isDeep ? 'deep' : 'general',
225      sourceInfo: window.spectrueSourceInfo || null,
226      socialMediaMeta: window.spectrueSocialMeta || null,
227    });
228
229    return { ...payload, search_provider: 'auto', ...overrides };
230  } catch (error) {
231
232    // Fallback to text-only payload
233    return {
234      text: textOrUrl,
235      lang,
236      analysis_mode: isDeep ? 'deep' : 'general',
237      source_info: window.spectrueSourceInfo || null,
238      social_media_meta: window.spectrueSocialMeta || null,
239      ...overrides,
240    };
241  }
242}
243
244async function fallbackPoll(checkId) {
245  const progressContainer = $('progress-container');
246  try {
247    progressContainer.style.display = 'block';
248    $('progress-bar-inner').style.width = '10%';
249    $('progress-label').textContent = T('loader.analyzing');
250    setStatus(T('loader.analyzing'));
251
252    let tries = 0,
253      maxTries = 40,
254      delay = 2000;
255    while (tries < maxTries) {
256      await new Promise((r) => setTimeout(r, delay));
257      const status = await checkGet(checkId);
258      if (
259        status.ok &&
260        (status.data.status === 'done' || status.data.status === 'error')
261      ) {
262        if (status.data.status === 'done') {
263          const payload = { result: status.data.result, status: 'done' };
264          setLastCheckResult({ ...payload, id: checkId });
265          renderCheckResult(payload);
266          setStatus(T('status.done'));
267          const credits = await meCredits();
268          if (credits.ok) updateCreditsDisplay(credits.data);
269          setTimeout(() => requestHistoryRefresh(), 1000);
270        } else {
271          setStatus(T('status.api_error'));
272        }
273        return;
274      }
275      if (status.ok) {
276        $('progress-bar-inner').style.width = '60%';
277        setStatus(T('loader.analyzing'));
278      } else {
279        setStatus(T('status.get_status_error'));
280        return;
281      }
282      tries++;
283    }
284    setStatus(T('status.timeout'));
285  } finally {
286    progressContainer.style.display = 'none';
287    disable(IDS_TO_DISABLE, false);
288  }
289}
290
291function manageWebSocketConnection(checkId) {
292  const apiBase = window.SPECTRUE?.cfg?.apiBase || window.location.origin;
293  let wsUrl;
294  try {
295    const u = new URL(apiBase);
296    const proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
297    wsUrl = `${proto}//${u.host}/check/${checkId}/ws`;
298  } catch (_) {
299    const host = (
300      apiBase.includes('//') ? apiBase.split('//')[1] : apiBase
301    ).replace(/\/+$/, '');
302    const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
303    wsUrl = `${proto}//${host}/check/${checkId}/ws`;
304  }
305
306  // Add auth token to WS URL
307  import('./auth.js?v=M42h_fix17').then(async ({ idToken }) => {
308    let token = null;
309    try {
310      token = await idToken();
311    } catch (e) {
312
313    }
314
315    if (token) {
316      const separator = wsUrl.includes('?') ? '&' : '?';
317      wsUrl += `${separator}token=${encodeURIComponent(token)}`;
318    }
319
320    let ws;
321    try {
322      // Pass token via query param
323      ws = new WebSocket(wsUrl);
324    } catch (e) {
325      fallbackPoll(checkId);
326      return;
327    }
328
329    let gotFinal = false;
330    ws.onopen = () => {
331      // setStatus(T('loader.analyzing')); // Removed to prevent race condition with first message
332    };
333
334    ws.onmessage = async (event) => {
335      try {
336        const msg = JSON.parse(event.data);
337        switch (msg.type) {
338          case 'progress':
339            updateProgressBar(msg);
340            break;
341          case 'final_result': {
342            gotFinal = true;
343            try {
344              const isError = msg.data && msg.data.error_key;
345              const data = {
346                result: msg.data,
347                status: isError ? 'error' : 'done',
348              };
349              setLastCheckResult({ ...data, id: checkId });
350              renderCheckResult({ ...data, id: checkId });
351              setStatus(isError ? T('status.api_error') : T('status.done'));
352              // Analytics: final result shown (bucketized, no content)
353              try {
354                const now = Date.now();
355                const dur = __analysisStartTs ? now - __analysisStartTs : null;
356                const cv = Array.isArray(msg.data?.claim_verdicts)
357                  ? msg.data.claim_verdicts
358                  : Array.isArray(msg.data?.claims)
359                    ? msg.data.claims
360                    : null;
361                const claimsCount = Array.isArray(cv) ? cv.length : null;
362                const explain =
363                  msg.data?.explainability_score ??
364                  msg.data?.explainability ??
365                  msg.data?.rgba?.[3];
366                const verified =
367                  msg.data?.verified_score ??
368                  msg.data?.verified ??
369                  msg.data?.rgba?.[1];
370                logEventSafe(
371                  isError ? 'analysis_error' : 'analysis_result_shown',
372                  isError
373                    ? {
374                      mode: __analysisMode,
375                      error_type: msg.data?.error_key
376                        ? 'server_5xx'
377                        : 'unknown',
378                    }
379                    : {
380                      mode: __analysisMode,
381                      status: msg.data?.status || 'ok',
382                      duration_bucket:
383                        dur != null ? bucketizeDurationMs(dur) : null,
384                      claims_count_bucket:
385                        claimsCount != null
386                          ? bucketizeCount(claimsCount)
387                          : null,
388                      explainability_bucket: bucketizeScore(explain),
389                      verified_bucket: bucketizeScore(verified),
390                    }
391                );
392              } catch (e) {
393                /* no-op */
394              }
395
396              if (isError) {
397                if (msg.data.error_key === 'app.error.subsidy_exhausted') {
398                  showSubsidyExhaustedModal();
399                } else if (
400                  msg.data.error_key === 'app.error_insufficient_credits'
401                ) {
402                  const req = msg.data.required ?? '?';
403                  const rem = msg.data.remaining ?? '?';
404                  showInsufficientCreditsModal(req, rem);
405                }
406              }
407
408              import('./api.js?v=M42h_fix17').then(async ({ meCredits }) => {
409                const credits = await meCredits();
410                if (credits.ok) updateCreditsDisplay(credits.data);
411              });
412            } catch (renderError) {
413
414              setStatus(T('status.api_error'));
415            } finally {
416              // Delay history reload slightly to ensure Firestore consistency
417              setTimeout(() => requestHistoryRefresh(), 1000);
418
419              ws.close();
420              $('progress-container').style.display = 'none';
421              disable(IDS_TO_DISABLE, false);
422            }
423            break;
424          }
425          case 'error':
426            gotFinal = true;
427            ws.close();
428            await fallbackPoll(checkId);
429            break;
430        }
431      } catch (e) {
432
433        // Ensure UI is unblocked if catastrophic error
434        if (gotFinal) {
435          $('progress-container').style.display = 'none';
436          disable(IDS_TO_DISABLE, false);
437        }
438      }
439    };
440
441    ws.onerror = () => {
442      if (!gotFinal) fallbackPoll(checkId);
443    };
444    ws.onclose = () => {
445      if (!gotFinal) fallbackPoll(checkId);
446    };
447  });
448}
449
450// Queue for progress updates to prevent "blurring" of rapid statuses
451let statusUpdateQueue = [];
452let isProcessingQueue = false;
453const MIN_STATUS_DELAY = 333; // 1/3 second
454
455function updateProgressBar(msg) {
456  statusUpdateQueue.push(msg);
457  if (!isProcessingQueue) {
458    processStatusQueue();
459  }
460}
461
462async function processStatusQueue() {
463  if (statusUpdateQueue.length === 0) {
464    isProcessingQueue = false;
465    return;
466  }
467
468  isProcessingQueue = true;
469  const msg = statusUpdateQueue.shift();
470
471  // Execute the actual update
472  renderProgressUpdate(msg);
473
474  // Wait before next update
475  await new Promise((resolve) => setTimeout(resolve, MIN_STATUS_DELAY));
476  processStatusQueue();
477}
478
479function renderProgressUpdate(msg) {
480  const progressBarInner = $('progress-bar-inner');
481  const progressLabel = $('progress-label');
482
483  // Derive canonical stage key expected by stageMap.
484  // Only accept explicit short stage ids coming in msg.stage. Ignore msg.status_key entirely.
485  let stage = null;
486  const normalizeStatusKey = (key) => {
487    if (typeof key !== 'string') return key;
488    const k = key.trim();
489    // Accept loader.<name> (strip prefix) or plain short ids
490    if (k.startsWith('loader.')) return k.replace(/^loader\./, '');
491    // Otherwise just return lowercase token (short stage id)
492    return k.toLowerCase();
493  };
494
495  // Prefer explicit stage (msg.stage). Do NOT look at msg.status_key anymore.
496  const rawStage = msg.stage ?? null;
497  if (rawStage) {
498    try {
499      stage = normalizeStatusKey(rawStage);
500    } catch (e) {
501      stage = rawStage;
502    }
503  }
504
505  if (!stage) stage = 'analyzing_sentences';
506
507  // Analytics: pipeline_step (start/done) based on stage transitions.
508  try {
509    if (stage && stage !== __lastStage) {
510      const now = Date.now();
511      if (__lastStage) {
512        const started = __stageStart.get(__lastStage);
513        if (started) {
514          const d = now - started;
515          logEventSafe('pipeline_step', {
516            step: __lastStage,
517            phase: 'done',
518            mode: __analysisMode,
519            step_duration_bucket: __bucketizeStepDuration(d),
520          });
521        }
522      }
523      __stageStart.set(stage, now);
524      logEventSafe('pipeline_step', {
525        step: stage,
526        phase: 'start',
527        mode: __analysisMode,
528      });
529      __lastStage = stage;
530    }
531  } catch (e) {
532    /* no-op */
533  }
534
535  // Prefer explicit percent from backend when present; otherwise fall back to processed/total
536  let progressPercent = null;
537  if (typeof msg.percent === 'number' && !isNaN(msg.percent)) {
538    progressPercent = msg.percent;
539  } else if (msg.total > 0 && msg.processed != null) {
540    try {
541      progressPercent = (Number(msg.processed) / Number(msg.total)) * 100;
542      // Legacy compatibility: analyzing_sentences mapped 0-100 to 95-98 range
543      if (stage === 'analyzing_sentences' || stage === 'analyzing') {
544        progressPercent = 95 + progressPercent * 0.03;
545      }
546    } catch (e) {
547      progressPercent = null;
548    }
549  }
550
551  // Derive dynamic localization key
552  let currentKey = `loader.${stage}`;
553  if (stage === 'processing') currentKey = 'loader.analyzing';
554  if (stage === 'initializing') currentKey = 'loader.connecting';
555  if (stage === 'analyzing_sentences' || stage === 'analyzing') currentKey = 'loader.analyzing';
556  if (stage === 'finalizing') {
557    currentKey = 'loader.finalizing';
558    progressPercent = 98; // Pin visual progress near end
559  }
560
561  // If backend provided neither percent nor processed/total, maintain current bar width
562  if (progressPercent == null) {
563    progressPercent = parseFloat(progressBarInner.style.width) || 5;
564  }
565
566  // Normalize percent: backend may send a fraction 0..1 or a percentage 0..100
567  if (progressPercent != null) {
568    try {
569      let p = Number(progressPercent);
570      if (!isNaN(p)) {
571        if (p <= 1) p = p * 100; // treat 0..1 as fraction
572        // Clamp to 0..100
573        p = Math.max(0, Math.min(100, p));
574        progressPercent = p;
575      }
576    } catch (e) {
577      // Ignore and keep existing value
578    }
579  }
580
581  let progressText = T(currentKey) || currentKey;
582  if (
583    stage === 'analyzing_sentences' &&
584    msg.processed != null &&
585    msg.total != null
586  ) {
587    progressText += ` (${msg.processed}/${msg.total})`;
588  }
589
590  // Monotonic check: prevent backward jumps (except for restart/low values)
591  const currentWidth = parseFloat(progressBarInner.style.width) || 0;
592  if (
593    progressPercent < currentWidth &&
594    stage !== 'loading_article' &&
595    stage !== 'analyzing_input'
596  ) {
597
598    progressPercent = currentWidth;
599  }
600
601  progressBarInner.style.width = `${Math.min(98, Math.max(5, progressPercent))}%`;
602
603  // Animation logic: if status changed
604  const now = Date.now();
605  if (progressText !== lastStatusText) {
606    // 0. Push down existing overlapping statuses to prevent accumulation
607    // This triggers the .overlap-push-down animation (fast exit) defined in CSS
608    const existingClones = progressLabel.querySelectorAll('.status-text-clone');
609    existingClones.forEach((el) => el.classList.add('overlap-push-down'));
610
611    // 1. Create a falling clone of the old text for transition
612    if (lastStatusText) {
613      const clone = document.createElement('div');
614      clone.className = 'status-text-clone status-fall-away';
615      clone.textContent = lastStatusText;
616      progressLabel.appendChild(clone);
617      setTimeout(() => clone.remove(), 4000); // Sync with CSS 4s
618    }
619
620    // 2. Prepare/Get the container for the "live" text
621    let liveTextCont = progressLabel.querySelector('.status-content');
622    if (!liveTextCont) {
623      // Clear initial static text if it's there
624      if (
625        progressLabel.childNodes.length > 0 &&
626        !progressLabel.querySelector('.status-content')
627      ) {
628        progressLabel.innerHTML = '';
629      }
630      liveTextCont = document.createElement('span');
631      liveTextCont.className = 'status-content';
632      progressLabel.appendChild(liveTextCont);
633    }
634
635    // 3. Update active text with telescopic effect
636    liveTextCont.innerHTML = `<span class="status-new-text">${progressText}</span>`;
637
638    // 4. Trigger container flash (star)
639    progressLabel.classList.remove('trigger-flash');
640    void progressLabel.offsetWidth; // Force reflow
641    progressLabel.classList.add('trigger-flash');
642
643    lastStatusText = progressText;
644    lastStatusChangeTime = now;
645    setStatus(progressText);
646  }
647}
648
649function showSubsidyExhaustedModal() {
650  const modalHTML = `
651    <div class="modal-overlay" id="subsidy-exhausted-modal">
652      <div class="modal-content credits-modal">
653        <h2>${T('error.insufficient_credits_title')}</h2>
654        <p class="modal-message">
655          ${T('error.subsidy_exhausted')}
656        </p>
657        <div class="modal-actions">
658           <button class="btn-secondary" id="buy-credits-btn-subsidy">
659            ${T('error.insufficient_credits_buy_btn')}
660          </button>
661          <button class="btn-ghost" id="close-subsidy-modal-btn">
662            ${T('error.insufficient_credits_close_btn')}
663          </button>
664        </div>
665      </div>
666    </div>
667  `;
668
669  const existing = document.getElementById('subsidy-exhausted-modal');
670  if (existing) existing.remove();
671  document.body.insertAdjacentHTML('beforeend', modalHTML);
672
673  const modal = document.getElementById('subsidy-exhausted-modal');
674  const buyBtn = document.getElementById('buy-credits-btn-subsidy');
675  const closeBtn = document.getElementById('close-subsidy-modal-btn');
676
677  if (buyBtn) {
678    buyBtn.onclick = () => {
679      window.location.href = 'pricing.html?plan=pack';
680    };
681  }
682  if (closeBtn) closeBtn.onclick = () => modal.remove();
683  modal.onclick = (e) => {
684    if (e.target === modal) modal.remove();
685  };
686}
687
688function showInsufficientCreditsModal(required, remaining) {
689  // Check if user is on free or pro plan
690  const userPlan = window.SPECTRUE?.user?.plan || 'free';
691  const isFree = userPlan === 'free';
692
693  // Create modal
694  const modalHTML = `
695    <div class="modal-overlay" id="insufficient-credits-modal">
696      <div class="modal-content credits-modal">
697        <h2>${T('error.insufficient_credits_title')}</h2>
698        <p class="modal-message">
699          ${T('error.insufficient_credits_message', { required, remaining })}
700        </p>
701        <p class="modal-plan-info">
702          ${isFree ? T('error.insufficient_credits_free_plan') : T('error.insufficient_credits_pro_plan')}
703        </p>
704        <div class="modal-actions">
705          ${isFree
706      ? `
707            <button class="btn-primary" id="upgrade-to-pro-btn">
708              ${T('error.insufficient_credits_upgrade_btn')}
709            </button>
710          `
711      : ''
712    }
713          <button class="btn-secondary" id="buy-credits-btn">
714            ${T('error.insufficient_credits_buy_btn')}
715          </button>
716          <button class="btn-ghost" id="close-credits-modal-btn">
717            ${T('error.insufficient_credits_close_btn')}
718          </button>
719        </div>
720      </div>
721    </div>
722  `;
723
724  // Remove existing modal if any
725  const existing = document.getElementById('insufficient-credits-modal');
726  if (existing) existing.remove();
727
728  // Add to DOM
729  document.body.insertAdjacentHTML('beforeend', modalHTML);
730
731  // Add event listeners
732  const modal = document.getElementById('insufficient-credits-modal');
733  const upgradeBtn = document.getElementById('upgrade-to-pro-btn');
734  const buyBtn = document.getElementById('buy-credits-btn');
735  const closeBtn = document.getElementById('close-credits-modal-btn');
736
737  const getPricingUrl = (extraParams = '') => {
738    const params = new URLSearchParams(window.location.search);
739    const platform = params.get('platform');
740    let url = 'pricing.html';
741    const queryParts = [];
742    if (extraParams) queryParts.push(extraParams);
743    if (platform) queryParts.push(`platform=${platform}`);
744
745    if (queryParts.length > 0) {
746      url += '?' + queryParts.join('&');
747    }
748    return url;
749  };
750
751  if (upgradeBtn) {
752    upgradeBtn.onclick = () => {
753      window.location.href = getPricingUrl('plan=pro');
754    };
755  }
756
757  if (buyBtn) {
758    buyBtn.onclick = () => {
759      window.location.href = getPricingUrl('plan=pack');
760    };
761  }
762
763  if (closeBtn) {
764    closeBtn.onclick = () => {
765      modal.remove();
766    };
767  }
768
769  // Close on overlay click
770  modal.onclick = (e) => {
771    if (e.target === modal) {
772      modal.remove();
773    }
774  };
775}
776
777export async function handleStartCheck(overrides = {}) {
778  // M110: Wait for auth session restoration to avoid race conditions (e.g. guest mode showing momentarily)
779  await waitForAuthInit();
780
781  // Check if user is authenticated before making API call
782  const user = currentUser();
783  if (!user) {
784    // Show login required modal for guests
785    const title = T('auth.login_required_title') || 'Login Required';
786    const content = `
787      <p style="margin-bottom: 16px;">${T('auth.login_required_message') || 'Please log in to verify content.'}</p>
788      <button id="modal-login-btn" class="button primary" style="width: 100%;">${T('auth.login_btn') || 'Log In'}</button>
789    `;
790    showModal(title, content);
791
792    // Attach login handler
793    setTimeout(() => {
794      const loginBtn = document.getElementById('modal-login-btn');
795      if (loginBtn) {
796        loginBtn.onclick = () => {
797          const next = encodeURIComponent(window.location.href);
798          const authParams = new URLSearchParams();
799          authParams.set('next', next);
800          if (isExtensionContext()) {
801            authParams.set('platform', 'extension');
802            authParams.set('lang', getCurrentLocale());
803          }
804          window.location.href = `auth.html?${authParams.toString()}`;
805        };
806      }
807    }, 0);
808    return;
809  }
810
811  const payload = await getCheckPayload(overrides); // M8.2: Now async
812  if (!payload) return;
813
814  const checkOutBox = $('check-out');
815  if (checkOutBox) {
816    checkOutBox.innerHTML = '';
817    checkOutBox.style.display = 'none';
818  }
819
820  const progressContainer = $('progress-container');
821  try {
822    disable(IDS_TO_DISABLE, true);
823    setStatus(T('status.submitting'));
824    progressContainer.style.display = 'block';
825    $('progress-bar-inner').style.width = '2%';
826    $('progress-label').textContent = T('status.submitting');
827
828    // Analytics: analysis_start
829    try {
830      __analysisMode = payload.deep ? 'deep' : 'standard';
831      __analysisStartTs = Date.now();
832      const textLen = (payload.fact || '').length;
833      logEventSafe('analysis_start', {
834        mode: __analysisMode,
835        input_len_bucket: bucketizeLen(textLen),
836        has_user_sources:
837          Array.isArray(payload.sources) && payload.sources.length > 0 ? 1 : 0,
838      });
839    } catch (e) {
840      /* no-op */
841    }
842
843    logEventSafe('analysis_request_sent', { mode: __analysisMode });
844    const created = await checkCreate(payload);
845
846    if (!created.ok) {
847      // Special handling for 402 Payment Required
848      if (created.status === 402) {
849        progressContainer.style.display = 'none';
850        disable(IDS_TO_DISABLE, false);
851
852        let errorData = created.data || {};
853        // Try to parse nested details
854        const detailString =
855          errorData.message || errorData.detail || created.error;
856        if (detailString && typeof detailString === 'string') {
857          try {
858            if (detailString.trim().startsWith('{')) {
859              errorData = { ...errorData, ...JSON.parse(detailString) };
860            }
861          } catch (e) { }
862        }
863
864        if (errorData.error_key === 'app.error.subsidy_exhausted') {
865          showSubsidyExhaustedModal();
866          return;
867        }
868
869        const required =
870          errorData.required ?? errorData.required_credits ?? '?';
871        const remaining =
872          errorData.remaining ?? errorData.current_credits ?? '?';
873
874        // Show beautiful modal with upgrade options
875        showInsufficientCreditsModal(required, remaining);
876        return;
877      }
878
879      let errorData = null;
880      try {
881        if (created.error) errorData = JSON.parse(created.error);
882      } catch (e) {
883        /* ignore */
884      }
885      if (errorData && errorData.error_key) {
886        renderCheckResult({ result: errorData, status: 'error' });
887        setStatus(T('status.api_error'));
888      } else if (created.status === 0) {
889        window.dispatchEvent(
890          new CustomEvent('spectrue:offline', {
891            detail: { url: '/check', error: created.error || 'Network error' },
892          })
893        );
894      } else {
895        setStatus(
896          T('status.api_error_detail', { status: created.status, detail: '' })
897        );
898      }
899      progressContainer.style.display = 'none';
900      disable(IDS_TO_DISABLE, false);
901      return;
902    }
903
904    $('progress-bar-inner').style.width = '5%';
905    $('progress-label').textContent = T('loader.connecting');
906    setStatus(T('loader.connecting'));
907    setLastCheckResult({ id: created.data.id });
908
909    // Note: We no longer replace the input with parsed_text.text
910    // User prefers to keep their original URL visible in the input field
911
912    manageWebSocketConnection(created.data.id);
913  } catch (e) {
914    console.error(e);
915    setStatus('Error: ' + String(e)); // Temporary debug
916    disable(IDS_TO_DISABLE, false);
917    if (progressContainer) progressContainer.style.display = 'none';
918  }
919}
920
921export function syncDeepToggles() {
922  const sidebarToggle = $('deep-analysis-toggle');
923  const headerToggle = $('header-deep-toggle');
924
925  if (sidebarToggle && headerToggle) {
926    sidebarToggle.addEventListener('change', () => {
927      headerToggle.checked = sidebarToggle.checked;
928    });
929    headerToggle.addEventListener('change', () => {
930      sidebarToggle.checked = headerToggle.checked;
931    });
932  }
933}

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.