// qr-bridge.js — QR code generation for match assignment (TM → Chalker) // Builds the assignment payload, signs it with CRC-32, and displays the QR code. // --------------------------------------------------------------------------- // QR Detection Helper — BarcodeDetector with jsQR fallback // --------------------------------------------------------------------------- /** @type {HTMLCanvasElement|null} reusable canvas for jsQR fallback */ let _qrFallbackCanvas = null; /** * Scan a video element for QR codes. * Uses native BarcodeDetector when available, falls back to jsQR. * * NOTE: The Chalker (chalker/js/chalker.js) has its own detectQRCode/isQRScanAvailable. * They INTENTIONALLY DIFFER and are not kept in sync: this TM copy has a jsQR fallback, * while the Chalker uses an iOS image-capture path (isIOS) instead. Don't "unify" them. * * @param {HTMLVideoElement} videoEl * @returns {Promise} decoded QR string, or null if nothing found */ async function detectQRCode(videoEl) { if ('BarcodeDetector' in window) { const detector = detectQRCode._detector || (detectQRCode._detector = new BarcodeDetector({ formats: ['qr_code'] })); const codes = await detector.detect(videoEl); return codes.length > 0 ? codes[0].rawValue : null; } // jsQR fallback — render video frame to canvas, decode from pixel data if (typeof jsQR === 'function') { if (!_qrFallbackCanvas) _qrFallbackCanvas = document.createElement('canvas'); const w = videoEl.videoWidth; const h = videoEl.videoHeight; if (!w || !h) return null; _qrFallbackCanvas.width = w; _qrFallbackCanvas.height = h; const ctx = _qrFallbackCanvas.getContext('2d'); ctx.drawImage(videoEl, 0, 0, w, h); const imageData = ctx.getImageData(0, 0, w, h); const code = jsQR(imageData.data, w, h); return code ? code.data : null; } return null; } /** * Check if QR scanning is available (native or jsQR fallback). * @returns {boolean} */ function isQRScanAvailable() { return ('BarcodeDetector' in window) || (typeof jsQR === 'function'); } /** * Build the signed match-assignment payload — transport-agnostic. * * Split out of openMatchQR() so the payload can be produced without rendering a * QR code. QR is one way to deliver it; the network layer (see * Docs/NETWORK-LAYER.md) is intended to be another, carrying this exact object * so there is only ever one definition of an assignment. * * The Chalker's result side already works this way — `buildResultPayload()` * builds, `showResultQRModal()` renders. * * Lane and referee are optional and are omitted when not assigned. * * @param {string} matchId - e.g. 'FS-1-3' * @returns {object|null} signed payload (CRC appended), or null if no such match */ function buildAssignmentPayload(matchId) { const match = matches.find(m => m.id === matchId); if (!match) return null; // Resolve referee name (if assigned) let refName = null; if (match.referee) { const refereePlayer = players.find(p => String(p.id) === String(match.referee)); refName = refereePlayer ? refereePlayer.name : String(match.referee); } // Build assignment payload — omit lane/referee if not assigned const payload = { v: 1, t: 'a', mid: match.id, tid: String(tournament.id), sid: config.server.serverId, p1: match.player1.name, p2: match.player2.name, sc: config.legs.x01Format || 501, bo: match.legs || 3, mr: config.legs.maxRounds || 13, slt: config.legs.shortLegThreshold || 21, ts: Math.floor(Date.now() / 1000) }; if (match.lane) payload.ln = parseInt(match.lane); if (refName) payload.ref = refName; return NewtonIntegrity.sign(payload); } /** * Open the QR modal for a live match. * Lane and referee are optional — included in the payload when assigned. * @param {string} matchId */ function openMatchQR(matchId) { const match = matches.find(m => m.id === matchId); if (!match) return; const warningEl = document.getElementById('matchQRWarning'); const codeEl = document.getElementById('matchQRCode'); const titleEl = document.getElementById('matchQRTitle'); const subtitleEl = document.getElementById('matchQRSubtitle'); // Reset state codeEl.innerHTML = ''; warningEl.style.display = 'none'; warningEl.textContent = ''; const signed = buildAssignmentPayload(matchId); if (!signed) return; const json = JSON.stringify(signed); // Generate QR code (qrcode-generator: typeNumber 0 = auto, EC level M) const qr = qrcode(0, 'M'); qr.addData(json); qr.make(); // Referee name is carried in the payload when one is assigned const refName = signed.ref || null; // Build subtitle with available info const parts = [`${match.player1.name} vs ${match.player2.name}`]; if (match.lane) parts.push(`Lane ${match.lane}`); parts.push(`${config.legs.x01Format || 501} Bo${match.legs || 3}`); if (refName) parts.push(`Ref: ${refName}`); titleEl.textContent = `${match.id} — Chalker QR`; subtitleEl.textContent = parts.join(' · '); codeEl.innerHTML = qr.createSvgTag(8, 2); pushDialog('matchQRModal', null, true); } window.openMatchQR = openMatchQR; window.buildAssignmentPayload = buildAssignmentPayload; // --------------------------------------------------------------------------- // Result QR Scanner (Chalker → TM) // --------------------------------------------------------------------------- /** @type {number|null} setInterval handle for the BarcodeDetector scan loop */ let _resultScanInterval = null; /** @type {MediaStream|null} active camera stream */ let _resultScanStream = null; /** * Open the result QR scanner camera modal. * matchId is known when called from winnerConfirmModal (validated against payload). * matchId is null when called from Match Controls (resolved from payload). * @param {string|null} matchId */ async function openResultQRScanner(matchId) { const videoEl = document.getElementById('qrResultScanVideo'); const errorEl = document.getElementById('qrResultScanError'); errorEl.style.display = 'none'; errorEl.textContent = ''; if (!isQRScanAvailable()) { errorEl.textContent = 'QR scanning is not available. Camera requires Chrome, Edge, or a secure context (HTTPS).'; errorEl.style.display = 'block'; pushDialog('qrResultScanModal', null, true); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } }); videoEl.srcObject = stream; _resultScanStream = stream; pushDialog('qrResultScanModal', null, true); _resultScanInterval = setInterval(async () => { try { const result = await detectQRCode(videoEl); if (result) handleResultQRPayload(result, matchId); } catch (_) { /* ignore per-frame errors */ } }, 300); } catch (err) { errorEl.textContent = `Camera error: ${err.name} — ${err.message}`; errorEl.style.display = 'block'; pushDialog('qrResultScanModal', null, true); } } /** * Stop the camera stream and scan interval. */ function stopResultQRScanner() { clearInterval(_resultScanInterval); _resultScanInterval = null; if (_resultScanStream) { _resultScanStream.getTracks().forEach(t => t.stop()); _resultScanStream = null; } const videoEl = document.getElementById('qrResultScanVideo'); if (videoEl) videoEl.srcObject = null; } /** * Handle a raw QR scan value. * Validates CRC, type, tournament, server, and optionally match ID. * On success, stops camera and shows the result preview modal. * On failure, shows an inline error and keeps the camera open. * @param {string} rawValue * @param {string|null} expectedMatchId */ function handleResultQRPayload(rawValue, expectedMatchId) { let payload; try { payload = JSON.parse(rawValue); } catch (_) { showResultScanError('Invalid QR code. Please scan a NewTon result QR from the Chalker.'); return; } if (payload.t !== 'r') { showResultScanError('Wrong QR type. Please scan a result QR from the Chalker.'); return; } if (!NewtonIntegrity.verify(payload)) { showResultScanError('Integrity check failed. The QR code may be corrupted or tampered.'); return; } if (payload.tid !== String(tournament.id)) { showResultScanError('Wrong tournament. This result belongs to a different tournament.'); return; } if (payload.sid !== config.server.serverId) { showResultScanError('Wrong server. This result was not generated by this Tournament Manager.'); return; } if (expectedMatchId && payload.mid !== expectedMatchId) { showResultScanError(`Match mismatch. Expected ${expectedMatchId}, got ${payload.mid}.`); return; } const match = matches.find(m => m.id === payload.mid); if (!match) { showResultScanError(`Match ${payload.mid} not found in this tournament.`); return; } // Validate leg data BEFORE tearing down the scanner — a malformed payload // throwing inside showResultQRPreview would be swallowed by the scan loop, // leaving the user with no modal and no error if (!Array.isArray(payload.legs) || payload.legs.length === 0) { showResultScanError('Result payload has no leg data.'); return; } // Valid — stop camera, close scan modal, show preview stopResultQRScanner(); popDialog(); showResultQRPreview(payload); } /** * Show an error message in the scan modal without closing it. * @param {string} message */ function showResultScanError(message) { const errorEl = document.getElementById('qrResultScanError'); if (errorEl) { errorEl.textContent = message; errorEl.style.display = 'block'; } } /** * Decode a base64-encoded visit score string into an array of numbers. * @param {string} encoded * @returns {number[]} */ function decodeVisitScores(encoded) { try { const bytes = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)); return Array.from(bytes); } catch (_) { return []; } } /** * Populate and open the result preview modal with decoded payload data. * If the referenced match is live, also shows extracted achievements, * lollipop counters, and completion buttons. * @param {object} payload */ function showResultQRPreview(payload) { const contentEl = document.getElementById('qrResultPreviewContent'); const actionsEl = document.getElementById('qrResultPreviewActions'); // Determine if this match can be completed from here const match = matches.find(m => m.id === payload.mid); const isLive = match && getMatchState(match) === 'live'; // Store for use by completion buttons _qrCompletionPayload = isLive ? payload : null; _qrLollipops = [0, 0]; const p1 = escapeHtml(payload.p1); const p2 = escapeHtml(payload.p2); const winnerName = payload.w === 1 ? p1 : p2; let p1Legs = 0, p2Legs = 0; payload.legs.forEach(l => { if (l.w === 1) p1Legs++; else p2Legs++; }); let html = `
${escapeHtml(payload.mid)} — ${escapeHtml(payload.sc)} Bo${escapeHtml(String(payload.bo))}
${winnerName} wins ${p1Legs}–${p2Legs}
`; payload.legs.forEach((leg, i) => { const [enc1, enc2] = leg.s.split('|'); const v1 = decodeVisitScores(enc1); const v2 = decodeVisitScores(enc2); const legWinnerName = leg.w === 1 ? p1 : p2; const cd = leg.cd === 0 ? 'TB' : String(leg.cd); html += ``; }); html += '
# Winner ${p1} ${p2} CD
${i + 1} ${legWinnerName} ${v1.join(', ')} ${v2.join(', ')} ${cd}
'; // --- Achievements section (only for live matches) --- if (isLive) { const threshold = (config.legs && config.legs.shortLegThreshold) || 21; const s1 = NewtonStats.extractAchievements(payload.legs, 0, threshold); const s2 = NewtonStats.extractAchievements(payload.legs, 1, threshold); const hasAny = NewtonStats.hasAny(s1) || NewtonStats.hasAny(s2); html += `
Achievements from this match
`; html += ``; const rows = [ ['180s', s1.oneEighties, s2.oneEighties], ['Tons', s1.tons, s2.tons], ['High outs', s1.highOuts.join(', ') || '—', s2.highOuts.join(', ') || '—'], ['Short legs', s1.shortLegs.join(', ') || '—', s2.shortLegs.join(', ') || '—'], ]; rows.forEach(([label, v1, v2]) => { html += ``; }); // Lollipop row — only shown if at least one player had a visit of 3 const p1Has3 = payload.legs.some(leg => { const [e1] = leg.s.split('|'); return decodeVisitScores(e1).includes(3); }); const p2Has3 = payload.legs.some(leg => { const parts = leg.s.split('|'); return decodeVisitScores(parts[1] || '').includes(3); }); if (p1Has3 || p2Has3) { const p1Cell = p1Has3 ? `0` : '—'; const p2Cell = p2Has3 ? `0` : '—'; html += ``; } html += '
Achievement ${p1} ${p2}
${label} ${v1} ${v2}
Lollipops ${p1Cell} ${p2Cell}
'; if (!hasAny && !(p1Has3 || p2Has3)) { html += `

No achievements detected in this match.

`; } else if (!hasAny) { html += `

No automatic achievements detected. Add lollipops manually if needed.

`; } html += '
'; } // --- Status message when completion is not available --- if (!isLive) { let reason = ''; if (payload.tid !== String(tournament.id)) { reason = 'This result belongs to a different tournament.'; } else if (payload.sid !== config.server.serverId) { reason = 'This result was generated by a different Tournament Manager instance.'; } else if (!match) { reason = `Match ${payload.mid} was not found in this tournament.`; } else { const state = getMatchState(match); if (state === 'completed') { reason = `Match ${payload.mid} has already been completed.`; } else if (state === 'pending') { reason = `Match ${payload.mid} is not yet ready — waiting for players to advance.`; } else if (state === 'ready') { reason = `Match ${payload.mid} has not been started yet.`; } else { reason = `Match ${payload.mid} is not in a live state (current state: ${state}).`; } } html += `

${reason} This result cannot be applied from here.

`; } document.getElementById('qrResultPreviewTitle').textContent = isLive ? 'Complete Match via QR' : 'Result QR'; contentEl.innerHTML = html; // --- Action buttons --- const qualifier = typeof Qualifiers !== 'undefined' && Qualifiers.isQualifierId(payload.mid); if (isLive && qualifier) { // a qualifier: only the score, to decide who goes through (Docs/QUALIFIERS.md) actionsEl.innerHTML = ` A qualifier: only the score is kept. `; } else if (isLive) { actionsEl.innerHTML = ` `; } else { actionsEl.innerHTML = ``; } pushDialog('qrResultPreviewModal', null, true); } /** * Increment lollipop counter for one player. * @param {number} idx - 0 for p1, 1 for p2 */ function qrIncrementLollipop(idx) { _qrLollipops[idx]++; const el = document.getElementById(`qrLollipop${idx}`); if (el) el.textContent = _qrLollipops[idx]; } /** * Decrement lollipop counter for one player (minimum 0). * @param {number} idx - 0 for p1, 1 for p2 */ function qrDecrementLollipop(idx) { if (_qrLollipops[idx] > 0) _qrLollipops[idx]--; const el = document.getElementById(`qrLollipop${idx}`); if (el) el.textContent = _qrLollipops[idx]; } /** * Apply achievements from the QR payload to a player's stats object. * Adds to existing values. * @param {object} player * @param {{ oneEighties, tons, highOuts, shortLegs, lollipops }} stats */ function applyAchievementsToPlayer(player, stats) { if (!player || !player.stats || !stats) return; player.stats.oneEighties = (player.stats.oneEighties || 0) + (stats.oneEighties || 0); player.stats.tons = (player.stats.tons || 0) + (stats.tons || 0); player.stats.lollipops = (player.stats.lollipops || 0) + (stats.lollipops || 0); if (stats.highOuts && stats.highOuts.length) { player.stats.highOuts = (player.stats.highOuts || []).concat(stats.highOuts); } if (stats.shortLegs && stats.shortLegs.length) { player.stats.shortLegs = (player.stats.shortLegs || []).concat(stats.shortLegs); } } /** * Complete the match from the QR payload. * @param {boolean} includeAchievements - whether to extract and apply achievements */ function applyQRResult(includeAchievements) { const payload = _qrCompletionPayload; if (!payload) return; const match = matches.find(m => m.id === payload.mid); if (!match || getMatchState(match) !== 'live') return; let p1Legs = 0, p2Legs = 0; payload.legs.forEach(l => { if (l.w === 1) p1Legs++; else p2Legs++; }); const winnerPlayerNumber = payload.w; // 1 or 2 const winnerLegs = winnerPlayerNumber === 1 ? p1Legs : p2Legs; const loserLegs = winnerPlayerNumber === 1 ? p2Legs : p1Legs; let achievements = null; // nothing in a qualifier counts (Docs/QUALIFIERS.md) if (typeof Qualifiers !== 'undefined' && Qualifiers.isQualifierId(payload.mid)) includeAchievements = false; if (includeAchievements) { const threshold = (config.legs && config.legs.shortLegThreshold) || 21; const s1 = NewtonStats.extractAchievements(payload.legs, 0, threshold); const s2 = NewtonStats.extractAchievements(payload.legs, 1, threshold); s1.lollipops = _qrLollipops[0]; s2.lollipops = _qrLollipops[1]; // Resolve actual player objects from the match const p1 = players.find(p => String(p.id) === String(match.player1.id)); const p2 = players.find(p => String(p.id) === String(match.player2.id)); if (p1) applyAchievementsToPlayer(p1, s1); if (p2) applyAchievementsToPlayer(p2, s2); // Build achievements map keyed by player ID (for transaction record) achievements = {}; if (p1 && NewtonStats.hasAny(s1)) achievements[String(p1.id)] = s1; if (p2 && NewtonStats.hasAny(s2)) achievements[String(p2.id)] = s2; if (Object.keys(achievements).length === 0) achievements = null; } popDialog(); const keepLegs = !(typeof Qualifiers !== 'undefined' && Qualifiers.isQualifierId(payload.mid)); completeMatch(payload.mid, winnerPlayerNumber, winnerLegs, loserLegs, 'QR', achievements, keepLegs ? (payload.legs || null) : null, payload.fls || null); // If this result arrived over the network, it can be dropped now that it has been // applied — and only now. A result the operator has not accepted stays pending. if (typeof NetworkClient !== 'undefined' && typeof NetworkClient.clearResult === 'function') { NetworkClient.clearResult(payload.mid); } renderBracket(); refreshTournamentUI(); // Refresh Match Controls if it is showing const cc = document.getElementById('matchCommandCenterModal'); const ccIsOpen = cc && cc.style.display === 'block'; if (ccIsOpen && typeof showMatchCommandCenter === 'function') { showMatchCommandCenter(); } } window.openResultQRScanner = openResultQRScanner; window.stopResultQRScanner = stopResultQRScanner; /** @type {[number, number]} lollipop counters for [p1, p2] in the preview modal */ let _qrLollipops = [0, 0]; /** @type {object|null} the validated payload currently shown in the preview modal */ let _qrCompletionPayload = null; // --------------------------------------------------------------------------- // QR Inspector (Developer Console — Validate Stats QR) // --------------------------------------------------------------------------- /** * Open the result QR scanner in inspector mode. * CRC-only validation — no tid/sid/mid blocking. * Shows full raw payload with per-field validation status. */ async function openQRInspector() { const videoEl = document.getElementById('qrResultScanVideo'); const errorEl = document.getElementById('qrResultScanError'); errorEl.style.display = 'none'; errorEl.textContent = ''; if (!isQRScanAvailable()) { errorEl.textContent = 'QR scanning is not available. Camera requires Chrome, Edge, or a secure context (HTTPS).'; errorEl.style.display = 'block'; pushDialog('qrResultScanModal', null, true); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } }); videoEl.srcObject = stream; _resultScanStream = stream; pushDialog('qrResultScanModal', null, true); _resultScanInterval = setInterval(async () => { try { const result = await detectQRCode(videoEl); if (result) handleQRInspectorPayload(result); } catch (_) { /* ignore per-frame errors */ } }, 300); } catch (err) { errorEl.textContent = `Camera error: ${err.name} — ${err.message}`; errorEl.style.display = 'block'; pushDialog('qrResultScanModal', null, true); } } /** * Handle a scanned QR in inspector mode. * Shows full raw payload with validation status for each check. * Never blocks — always shows what arrived. * @param {string} rawValue */ function handleQRInspectorPayload(rawValue) { stopResultQRScanner(); popDialog(); const contentEl = document.getElementById('qrResultPreviewContent'); let payload; try { payload = JSON.parse(rawValue); } catch (_) { contentEl.innerHTML = '

Could not parse QR data as JSON.

' + escapeHtml(rawValue) + '
'; pushDialog('qrResultPreviewModal', null, true); return; } const ok = '✓'; const err = '✗'; const crcValid = NewtonIntegrity.verify(payload); const tidMatch = payload.tid === String(tournament.id); const sidMatch = payload.sid === config.server.serverId; const isResult = payload.t === 'r'; const matchFound = payload.mid ? !!matches.find(m => m.id === payload.mid) : false; const fmtTs = payload.ts ? new Date(payload.ts * 1000).toLocaleString() : '—'; const firstStarter = escapeHtml(payload.fls ? (payload.fls === 1 ? payload.p1 : payload.p2) : '—'); let html = `
QR Payload Inspector
This Tournament Manager
Tournament ID${tournament.id}
Server ID${config.server.serverId}
${payload.ln ? `` : ''} ${payload.ref ? `` : ''} ${payload.t1 ? `` : ''} ${payload.t2 ? `` : ''}
FieldValueCheck
Type t${escapeHtml(payload.t ?? '—')}${payload.t === 'r' ? ok + ' Result QR' : payload.t === 'a' ? ok + ' Assignment QR' : err + ' unknown type'}
CRC${escapeHtml(payload.crc ?? '—')}${crcValid ? ok + ' valid' : err + ' FAILED'}
Version v${escapeHtml(payload.v ?? '—')}
Match mid${escapeHtml(payload.mid ?? '—')}${matchFound ? ok + ' found' : err + ' not in tournament'}
Tournament tid${escapeHtml(payload.tid ?? '—')}${tidMatch ? ok + ' match' : err + ' mismatch'}
Server sid${escapeHtml(payload.sid ?? '—')}${sidMatch ? ok + ' match' : err + ' mismatch'}
Timestamp ts${fmtTs}
Player 1 p1${escapeHtml(payload.p1 ?? '—')}
Player 2 p2${escapeHtml(payload.p2 ?? '—')}
Winner w${escapeHtml(payload.w ?? '—')}
First starter fls${firstStarter}
Format sc${escapeHtml(payload.sc ?? '—')}
Best-of bo${escapeHtml(payload.bo ?? '—')}
Max rounds mr${escapeHtml(payload.mr ?? '—')}
Lane ln${escapeHtml(payload.ln)}
Referee ref${escapeHtml(payload.ref)}
Team 1 t1${escapeHtml(payload.t1)}
Team 2 t2${escapeHtml(payload.t2)}
`; if (Array.isArray(payload.legs)) { let p1Legs = 0, p2Legs = 0; payload.legs.forEach(l => { if (l.w === 1) p1Legs++; else p2Legs++; }); html += `
${escapeHtml(payload.w === 1 ? payload.p1 : payload.p2)} wins ${p1Legs}–${p2Legs}
`; html += ``; payload.legs.forEach((leg, i) => { const [enc1, enc2] = (leg.s || '|').split('|'); const v1 = decodeVisitScores(enc1 || ''); const v2 = decodeVisitScores(enc2 || ''); const legWinner = escapeHtml(leg.w === 1 ? payload.p1 : payload.p2); const throwsFirst = escapeHtml(((payload.fls - 1 + i) % 2 === 0) ? payload.p1 : payload.p2); const cd = leg.cd === 0 ? 'TB' : String(leg.cd); html += ``; }); html += '
#WinnerFirst ${escapeHtml(payload.p1 ?? 'P1')} ${escapeHtml(payload.p2 ?? 'P2')} CD
${i + 1} ${legWinner} ${throwsFirst} ${v1.join(', ')} ${v2.join(', ')} ${cd}
'; } // Completion support: offer buttons if this is a valid result for a live match in this TM const canComplete = crcValid && tidMatch && sidMatch && isResult && matchFound && Array.isArray(payload.legs) && getMatchState(matches.find(m => m.id === payload.mid)) === 'live'; if (canComplete) { const threshold = (config.legs && config.legs.shortLegThreshold) || 21; const s1 = NewtonStats.extractAchievements(payload.legs, 0, threshold); const s2 = NewtonStats.extractAchievements(payload.legs, 1, threshold); const hasAny = NewtonStats.hasAny(s1) || NewtonStats.hasAny(s2); html += `
Achievements from this match
${(() => { const ip1 = payload.legs.some(leg => { const [e1] = leg.s.split('|'); return decodeVisitScores(e1).includes(3); }); const ip2 = payload.legs.some(leg => { const parts = leg.s.split('|'); return decodeVisitScores(parts[1] || '').includes(3); }); if (!ip1 && !ip2) return ''; const c1 = ip1 ? '0' : '—'; const c2 = ip2 ? '0' : '—'; return ``; })()}
Achievement ${escapeHtml(payload.p1 ?? 'P1')} ${escapeHtml(payload.p2 ?? 'P2')}
180s${s1.oneEighties}${s2.oneEighties}
Tons${s1.tons}${s2.tons}
High outs${s1.highOuts.join(', ') || '—'}${s2.highOuts.join(', ') || '—'}
Short legs${s1.shortLegs.join(', ') || '—'}${s2.shortLegs.join(', ') || '—'}
Lollipops${c1}${c2}
${!hasAny ? '

No automatic achievements detected. Add lollipops manually if needed.

' : ''}
`; } _qrCompletionPayload = canComplete ? payload : null; _qrLollipops = [0, 0]; const actionsEl = document.getElementById('qrResultPreviewActions'); document.getElementById('qrResultPreviewTitle').textContent = canComplete ? 'Inspector — Complete Match' : 'QR Payload Inspector'; contentEl.innerHTML = html; if (canComplete) { actionsEl.innerHTML = ` `; } else { actionsEl.innerHTML = ``; } pushDialog('qrResultPreviewModal', null, true); } window.openQRInspector = openQRInspector; window.qrIncrementLollipop = qrIncrementLollipop; window.qrDecrementLollipop = qrDecrementLollipop; window.applyQRResult = applyQRResult;