/*! * Golem15.Feedback embed widget. * * One script tag, self-configuring from its own * * html2canvas is lazy-loaded from cdnjs (pinned version + SRI) only when the * widget is actually opened — this file stays hand-written and small rather * than vendoring a ~500KB third-party library inline with no build step to * verify it against. */ (function () { 'use strict'; var CURRENT_SCRIPT = document.currentScript; var WIDGET_KEY = CURRENT_SCRIPT && CURRENT_SCRIPT.getAttribute('data-key'); var API_BASE = (function () { var src = CURRENT_SCRIPT ? CURRENT_SCRIPT.src : ''; try { return new URL(src).origin; } catch (e) { return ''; } })(); if (!WIDGET_KEY || !API_BASE) { return; // missing/misconfigured snippet — stay silent, never break the host page } // data-lang lets the host page sync the widget to its own current locale // (e.g. Nuxt's i18n locale at page-load time — not reactive to an // in-app language switch without a reload, since the script only reads // this once). Anything other than 'en' falls back to 'pl'. var LANG = (CURRENT_SCRIPT.getAttribute('data-lang') === 'en') ? 'en' : 'pl'; // Same idea as data-lang, for the per-user "hide this widget" preference: // covers the state at page-load time (the host page already knows it — // it rides along on the user's own /fetch response); window.G15Feedback // .hide()/.show() handle every later change without a reload. var pendingHidden = CURRENT_SCRIPT.getAttribute('data-hidden') === 'true'; var I18N = { pl: { title: 'Zgłoś opinię', placeholder: 'Co poszło nie tak?', emailPlaceholder: 'Email (opcjonalnie)', typeLabel: 'Rodzaj zgłoszenia', typeBug: 'Błąd', typeFeature: 'Propozycja funkcji', typeOther: 'Inne', send: 'Wyślij', cancel: 'Anuluj', capturing: 'Przechwytywanie zrzutu ekranu…', sending: 'Wysyłanie…', pleaseDescribe: 'Opisz, co poszło nie tak.', success: 'Dzięki za zgłoszenie!', error: 'Nie udało się wysłać — spróbuj ponownie.', hideWidget: 'Ukryj widget', }, en: { title: 'Send feedback', placeholder: 'What went wrong?', emailPlaceholder: 'Email (optional)', typeLabel: 'Type', typeBug: 'Bug', typeFeature: 'Feature request', typeOther: 'Other', send: 'Send', cancel: 'Cancel', capturing: 'Capturing screenshot…', sending: 'Sending…', pleaseDescribe: 'Please describe the issue.', success: 'Thanks for the feedback!', error: 'Could not send feedback — please try again.', hideWidget: 'Hide widget', }, }; function t(key) { return I18N[LANG][key]; } var TYPE_KEY_MAP = { bug: 'typeBug', feature: 'typeFeature', other: 'typeOther' }; var API_ROOT = API_BASE + '/_feedback/api/v1/' + encodeURIComponent(WIDGET_KEY); // html2canvas 1.4.1 (the upstream, unmaintained since 2022) throws on any // element whose computed style uses a modern CSS color function — // oklch(), oklab(), lab(), color-mix() — which is Tailwind v4's default // palette encoding. That made every single capture on this app fail, // regardless of page content: "Attempting to parse an unsupported color // function 'oklch'". html2canvas-pro is an actively maintained, // API-compatible fork that adds support for those functions; it still // exposes itself as window.html2canvas. var HTML2CANVAS_URL = 'https://cdn.jsdelivr.net/npm/html2canvas-pro@2.3.9/dist/html2canvas-pro.min.js'; var HTML2CANVAS_INTEGRITY = 'sha512-KGyDjROZQZxrvNCSjDB9/3H3nv6/Zl64VAmW4Dnb3oykEDApl27U+Md5h0p656d04uAnoGxB8KGUWsUI0u3sTA=='; // --- console log buffer ------------------------------------------------- // Wrapped as early as possible (top of this file, before any UI exists) so // errors logged before the widget is ever opened are still captured. var LOG_LIMIT = 50; var consoleBuffer = []; function pushLog(level, text) { consoleBuffer.push('[' + level + '] ' + text); if (consoleBuffer.length > LOG_LIMIT) { consoleBuffer.shift(); } } ['log', 'warn', 'error'].forEach(function (level) { var original = console[level]; console[level] = function () { try { var text = Array.prototype.slice.call(arguments).map(function (a) { if (typeof a === 'string') { return a; } try { return JSON.stringify(a); } catch (e) { return String(a); } }).join(' '); pushLog(level, text); } catch (e) { // never let logging itself throw } return original.apply(console, arguments); }; }); window.addEventListener('error', function (event) { pushLog('error', event.message + ' (' + event.filename + ':' + event.lineno + ')'); }); // --- html2canvas lazy loader -------------------------------------------- var html2canvasPromise = null; function loadHtml2Canvas() { if (window.html2canvas) { return Promise.resolve(window.html2canvas); } if (html2canvasPromise) { return html2canvasPromise; } html2canvasPromise = new Promise(function (resolve, reject) { var script = document.createElement('script'); script.src = HTML2CANVAS_URL; script.integrity = HTML2CANVAS_INTEGRITY; script.crossOrigin = 'anonymous'; script.onload = function () { resolve(window.html2canvas); }; script.onerror = function () { html2canvasPromise = null; reject(new Error('Failed to load html2canvas')); }; document.head.appendChild(script); }); return html2canvasPromise; } // --- API calls ----------------------------------------------------------- function fetchConfig() { return fetch(API_ROOT + '/config?lang=' + LANG, { headers: { Accept: 'application/json' } }) .then(function (res) { if (!res.ok) { throw new Error('config fetch failed'); } return res.json(); }) .then(function (body) { return body.data; }); } // --- widget UI ------------------------------------------------------------- function escapeHtml(str) { var div = document.createElement('div'); div.textContent = str == null ? '' : str; return div.innerHTML; } // Bottom corners cover an app's own bottom nav/action bar on mobile (the // reported problem), so a Marker.io-style vertical edge tab is offered // as an alternative — pinned to the middle of the left/right viewport // edge instead of a corner, out of the way of bottom chrome. var POSITION_CSS = { 'bottom-right': { btn: 'bottom: 20px; right: 20px; border-radius: 999px; padding: 12px 18px;', panel: 'bottom: 76px; right: 20px;', panelMaxWidth: 'calc(100vw - 40px)', }, 'bottom-left': { btn: 'bottom: 20px; left: 20px; border-radius: 999px; padding: 12px 18px;', panel: 'bottom: 76px; left: 20px;', panelMaxWidth: 'calc(100vw - 40px)', }, // The panel intentionally has NO `top`/`transform` here for vertical // centering — Chromium mispositions a native — a native // popup's on-screen position is computed by the browser itself // and is known to end up in the wrong place under perfectly // normal conditions (a transformed ancestor, devtools' device // emulation scaling, etc.), none of which this widget controls. // This dropdown is just ordinary DOM content positioned relative // to its own wrapper, so none of that applies. '.g15fb-type-wrap { position: relative; margin-bottom: 8px; }', '.g15fb-type-btn { width: 100%; text-align: left; display: flex; justify-content: space-between;' + ' align-items: center; border: 1px solid #d1d5db; border-radius: 8px; padding: 8px;' + ' font: inherit; background: #fff; color: #111827; cursor: pointer; }', '.g15fb-type-btn::after { content: "\\25BE"; opacity: .6; margin-left: 8px; }', '.g15fb-type-list { display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0;' + ' background: #fff; border: 1px solid #d1d5db; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.15);' + ' z-index: 1; overflow: hidden; }', '.g15fb-type-list.g15fb-open { display: block; }', '.g15fb-type-opt { padding: 8px 10px; cursor: pointer; }', '.g15fb-type-opt:hover, .g15fb-type-opt.g15fb-active { background: #f3f4f6; }', '.g15fb-preview { display: none; width: 100%; max-height: 120px; object-fit: cover; border-radius: 8px;' + ' margin-bottom: 8px; border: 1px solid #e5e7eb; }', '.g15fb-row { display: flex; gap: 8px; align-items: center; }', '.g15fb-submit { background: ' + primary + '; color: ' + accent + '; border: none; border-radius: 8px;' + ' padding: 8px 14px; font: inherit; cursor: pointer; }', '.g15fb-submit:disabled { opacity: .6; cursor: default; }', '.g15fb-cancel { background: transparent; border: none; color: #6b7280; cursor: pointer; font: inherit; }', '.g15fb-status { font-size: 12px; color: #6b7280; margin-top: 6px; min-height: 14px; }', '.g15fb-hide { display: block; margin-top: 10px; border: none; background: none; padding: 0;' + ' font: inherit; font-size: 12px; color: #9ca3af; cursor: pointer; text-decoration: underline; }', '.g15fb-hide:hover { color: #6b7280; }', ].join('\n'); var button = document.createElement('button'); button.type = 'button'; button.className = 'g15fb-btn'; button.textContent = labels.title || t('title'); var panel = document.createElement('div'); panel.className = 'g15fb-panel'; panel.innerHTML = '

' + escapeHtml(labels.title || t('title')) + '

' + 'Screenshot preview' + '' + '
' + '' + '
' + '
' + escapeHtml(t('typeBug')) + '
' + '
' + escapeHtml(t('typeFeature')) + '
' + '
' + escapeHtml(t('typeOther')) + '
' + '
' + '
' + '' + '' + '
' + '' + '' + '
' + '
' + (config.allowHide ? '' : ''); shadow.appendChild(style); shadow.appendChild(button); shadow.appendChild(panel); document.body.appendChild(host); var textarea = panel.querySelector('textarea'); var emailInput = panel.querySelector('input[type=email]'); var submitBtn = panel.querySelector('.g15fb-submit'); var cancelBtn = panel.querySelector('.g15fb-cancel'); var statusEl = panel.querySelector('.g15fb-status'); var previewImg = panel.querySelector('.g15fb-preview'); var hideBtn = panel.querySelector('.g15fb-hide'); if (hideBtn) { hideBtn.addEventListener('click', function () { // Hides immediately — this file has no token/network access // of its own, so persistence and the confirmation message // are entirely the host page's job (it already holds a // valid auth token and its own toast library). hideHost(); window.dispatchEvent(new CustomEvent('g15feedback:hide-requested')); }); } var typeValue = 'bug'; var typeBtn = panel.querySelector('.g15fb-type-btn'); var typeList = panel.querySelector('.g15fb-type-list'); var typeOpts = panel.querySelectorAll('.g15fb-type-opt'); function setType(value) { typeValue = value; for (var i = 0; i < typeOpts.length; i++) { var opt = typeOpts[i]; var match = opt.getAttribute('data-value') === value; opt.classList.toggle('g15fb-active', match); if (match) { typeBtn.textContent = opt.textContent; } } } setType(typeValue); typeBtn.addEventListener('click', function (e) { e.stopPropagation(); typeList.classList.toggle('g15fb-open'); }); for (var t15 = 0; t15 < typeOpts.length; t15++) { typeOpts[t15].addEventListener('click', function (e) { setType(e.currentTarget.getAttribute('data-value')); typeList.classList.remove('g15fb-open'); }); } // Closes the dropdown on any click elsewhere in the widget (typing // in the textarea, hitting Send, etc.) — clicks outside the widget // entirely don't reach this listener, which is fine: the button // itself never blocks interacting with the host page underneath it. shadow.addEventListener('click', function (e) { if (e.target !== typeBtn && !typeList.contains(e.target)) { typeList.classList.remove('g15fb-open'); } }); var screenshotBlob = null; // Tracks the in-flight capture so submit can wait for it to settle // instead of racing it — capture takes a beat (html2canvas load + // render), and a visitor who types fast and hits Send immediately // would otherwise submit before screenshotBlob is ever set, silently // sending no screenshot at all. Always resolves (never rejects) so // .then() alone is enough to wait on it. var screenshotCapture = Promise.resolve(); // Vertical centering for the middle-left/middle-right positions is // done here in plain pixels rather than CSS `transform: // translateY(-50%)` — Chromium mispositions a native