(() => { if (location.protocol !== 'https:') return; const core = globalThis.MerchantLensCore; const records = new Map(); let enabled = false; let settingsRevision = 0; const sendCaptureState = () => window.postMessage({channel:'merchant-lens:control:v1',enabled}, location.origin); function syncSetting() { const revision = settingsRevision; chrome.storage.local.get({enabled:true}).then(settings => { if (revision === settingsRevision) setEnabled(settings.enabled); }).catch(() => { enabled = false; hide(); }); } function setEnabled(value) { enabled = value !== false; if (!enabled) hide(); sendCaptureState(); } chrome.storage.onChanged.addListener((changes, area) => { if (area === 'local' && changes.enabled) { settingsRevision++; setEnabled(changes.enabled.newValue); } }); const initialRevision = settingsRevision; chrome.storage.local.get({enabled:true}).then(settings => { if (initialRevision === settingsRevision) setEnabled(settings.enabled); }).catch(() => setEnabled(false)); const lifetime = 30 * 60 * 1000; const hoverDelay = 300; let hoverTimer = null, hoverRow = null; let host, panel, current, lastPoint = {x: 0, y: 0}; const bank = location.hostname === 'selfserve.rogersbank.com' ? 'rogers' : 'capital-one'; const bankName = bank === 'rogers' ? 'ROGERS BANK' : 'CAPITAL ONE'; const onCard = () => bank === 'rogers' ? /^\/transactions(?:\/|$)/i.test(location.pathname) : /^\/Card(?:\/|$)/i.test(location.pathname); function prune() { const now = Date.now(); for (const [key, value] of records) if (now - value.seen > lifetime) records.delete(key); if (!onCard()) { records.clear(); hide(); } } window.addEventListener('message', event => { if (event.source === window && event.origin === location.origin && event.data?.channel === 'merchant-lens:ready:v1') { sendCaptureState(); return; } if (event.source !== window || event.origin !== location.origin || event.data?.channel !== 'merchant-lens:v1' || !Array.isArray(event.data.rows)) return; if (!enabled || !onCard()) return; prune(); for (const raw of event.data.rows.slice(0, 5000)) { if (!core.valid(raw) || (raw.bank || 'capital-one') !== bank) continue; const seen = typeof raw.capturedAt === 'number' && Number.isFinite(raw.capturedAt) ? Math.min(Date.now(),raw.capturedAt) : Date.now(); if (Date.now() - seen >= lifetime) continue; const r = Object.fromEntries(['bank','name','amount','last4','holder','date','postedDate','state','mcc','type','category'].map(k => [k, raw[k]])); const key = JSON.stringify(r); records.set(key, {record: r, seen}); if (records.size > 5000) records.delete(records.keys().next().value); } }); function ensurePanel() { if (host?.isConnected) return; host = document.createElement('div'); host.setAttribute('data-merchant-lens', ''); host.style.cssText = 'all:initial!important;position:fixed!important;z-index:2147483647!important;pointer-events:none!important;display:none!important'; // A closed shadow root prevents the bank's styles from altering the tooltip. const shadow = host.attachShadow({mode: 'closed'}); const style = document.createElement('style'); style.textContent = 'article{box-sizing:border-box;width:300px;max-width:calc(100vw - 24px);padding:16px 18px;background:#102b39;color:#f2f7fa;border:1px solid #386172;border-radius:12px;box-shadow:0 8px 30px #0004;font:14px/1.5 system-ui,sans-serif;overflow-wrap:anywhere}small{color:#acc4d0;font-size:11px;letter-spacing:.06em}h2{font-size:23px;margin:5px 0 9px;color:#83ead0}p{margin:4px 0}footer{border-top:1px solid #386172;margin-top:12px;padding-top:8px;color:#acc4d0;font-size:11px}'; panel = document.createElement('article'); panel.setAttribute('role','tooltip'); shadow.append(style, panel); document.documentElement.append(host); } function hide() { clearTimeout(hoverTimer); hoverTimer = null; hoverRow = null; if (host) host.style.setProperty('display', 'none', 'important'); current = null; } function position(x, y) { if (!host) return; const box = panel.getBoundingClientRect(); host.style.setProperty('left', Math.max(12, Math.min(x + 16, innerWidth - box.width - 12)) + 'px', 'important'); host.style.setProperty('top', Math.max(12, Math.min(y + 18, innerHeight - box.height - 12)) + 'px', 'important'); } function show(record, x, y) { if (!enabled) return; ensurePanel(); if (current !== record) { panel.replaceChildren(); for (const [tag, text] of [['small',`MERCHANT LENS · ${bankName}`],['h2',`MCC ${record.mcc}`],['p',record.name],['p',`Type: ${record.type || 'Not supplied'}`],['p',`Category: ${record.category || 'Not supplied'}`],['footer','Bank-reported classification · Reward eligibility depends on your card terms.']]) { const el = document.createElement(tag); el.textContent = text; panel.append(el); } current = record; } host.style.setProperty('display', 'block', 'important'); position(x, y); } function lookup(target) { const all = [...records.values()].map(v => v.record); let el = target instanceof Element ? target : target?.parentElement; if (bank === 'rogers') { const tr = el?.closest('tr'); if (!tr || tr.innerText.length > 1600) return null; const holder = tr.cells?.[2]?.innerText?.trim() || ''; const record = core.unique(core.candidates(tr.innerText, all, {holder})); return record ? {record, row:tr} : null; } // Choose the smallest enclosing row-like region with name, amount and card. for (let depth = 0; el && depth < 10; depth++, el = el.parentElement) { if (el === document.body || el === document.documentElement) break; const text = el.innerText || el.textContent || ''; if (text.length > 1600) break; const matches = core.candidates(text, all); if (!matches.length) continue; const row = core.unique(matches); // Do not accidentally match a whole list when hovering blank space. const names = new Set(matches.map(r => core.norm(r.name))); return names.size === 1 && row ? {record:row, row:el} : null; } return null; } document.addEventListener('pointerover', event => { prune(); if (!enabled || !onCard()) return; lastPoint = {x: event.clientX, y: event.clientY}; const match = lookup(event.target); if (!match) { hide(); return; } if (hoverRow === match.row) return; hide(); hoverRow = match.row; const row = match.row; hoverTimer = setTimeout(() => { hoverTimer = null; prune(); if (!enabled || !onCard() || hoverRow !== row || !row.isConnected) { hide(); return; } const fresh = lookup(document.elementFromPoint(lastPoint.x, lastPoint.y)); if (fresh?.row === row) show(fresh.record, lastPoint.x, lastPoint.y); else hide(); }, hoverDelay); }, {passive: true}); document.addEventListener('pointerout', event => { if (hoverRow && (!(event.relatedTarget instanceof Node) || !hoverRow.contains(event.relatedTarget))) hide(); }, {passive:true}); document.addEventListener('pointermove', event => { lastPoint = {x:event.clientX, y:event.clientY}; if (current) position(event.clientX, event.clientY); }, {passive: true}); document.addEventListener('focusin', event => { prune(); if (!enabled || !onCard()) return; const match = lookup(event.target); hide(); if (match) { const box = event.target.getBoundingClientRect(); show(match.record, box.left, box.bottom); } }); document.addEventListener('focusout', hide); document.addEventListener('keydown', event => { if (event.key === 'Escape') hide(); }); document.addEventListener('keydown', event => { if (!event.altKey || !event.shiftKey || event.ctrlKey || event.metaKey || event.repeat || (event.code !== 'KeyM' && event.key?.toLowerCase() !== 'm') || !onCard()) return; event.preventDefault(); const at = Date.now(); // Give the browser's own command first chance; the worker deduplicates it. setTimeout(() => { try { chrome.runtime.sendMessage({type:'merchant-lens:shortcut',at}).catch(() => {}); } catch { syncSetting(); } }, 250); }, true); document.documentElement?.addEventListener('pointerleave', hide); window.addEventListener('scroll', hide, true); window.addEventListener('blur', hide); window.addEventListener('focus', syncSetting); document.addEventListener('visibilitychange', () => { if (!document.hidden) syncSetting(); else hide(); }); window.addEventListener('pagehide', () => { records.clear(); hide(); }); setInterval(prune, 15000); chrome.runtime.onMessage.addListener((message, sender, respond) => { if (message?.type === 'merchant-lens:set-enabled' && typeof message.enabled === 'boolean') { settingsRevision++; setEnabled(message.enabled); respond({ok:true}); return; } if (message?.type === 'merchant-lens:status') { prune(); respond({count: records.size, onCard: onCard(), enabled, bank:bankName}); } }); })();