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.