- SyncG15Office files each submission as a G15Office task with its screenshot through a fetchguard client limited to the configured host; a River job with 3 attempts, PHP's messages on failure, nothing sent when unconfigured - the replay of PHP's recorded G15Office exchange (classes/testdata) asserts the task JSON, headers and the screenshot part - settings screen (cabana field types only), read-only submissions list, manage_settings permission and navigation - embed.js served byte for byte at /plugins/golem15/feedback/assets/js/embed.js - feedback:import-settings copies the WinterCMS settings row once
644 lines
29 KiB
JavaScript
644 lines
29 KiB
JavaScript
/*!
|
|
* Golem15.Feedback embed widget.
|
|
*
|
|
* One script tag, self-configuring from its own <script data-key="wk_..."> tag:
|
|
* <script src="https://<backend-domain>/plugins/golem15/feedback/assets/js/embed.js"
|
|
* data-key="wk_xxx" async></script>
|
|
*
|
|
* 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 <select> popup (our
|
|
// Type dropdown lives inside this panel) when any ancestor has a CSS
|
|
// transform, so centering is done in JS via plain `top` in pixels
|
|
// instead. See positionPanelVertically().
|
|
'middle-right': {
|
|
btn: 'top: 50%; right: 0; transform: translateY(-50%) rotate(180deg); writing-mode: vertical-rl;' +
|
|
' border-radius: 10px 0 0 10px; padding: 16px 10px;',
|
|
panel: 'right: 64px;',
|
|
panelMaxWidth: 'calc(100vw - 84px)',
|
|
centerPanelVertically: true,
|
|
},
|
|
'middle-left': {
|
|
btn: 'top: 50%; left: 0; transform: translateY(-50%); writing-mode: vertical-rl;' +
|
|
' border-radius: 0 10px 10px 0; padding: 16px 10px;',
|
|
panel: 'left: 64px;',
|
|
panelMaxWidth: 'calc(100vw - 84px)',
|
|
centerPanelVertically: true,
|
|
},
|
|
};
|
|
|
|
function buildWidget(config) {
|
|
var colors = config.colors || {};
|
|
var labels = config.labels || {};
|
|
var position = POSITION_CSS.hasOwnProperty(config.position) ? config.position : 'bottom-right';
|
|
var posCss = POSITION_CSS[position];
|
|
var primary = colors.primary || '#111827';
|
|
var accent = colors.accent || '#ffffff';
|
|
|
|
var host = document.createElement('div');
|
|
host.setAttribute('data-g15-feedback-widget', '');
|
|
var shadow = host.attachShadow({ mode: 'open' });
|
|
|
|
var style = document.createElement('style');
|
|
style.textContent = [
|
|
':host { all: initial; }',
|
|
// Every box below is sized/positioned by width+padding together
|
|
// (e.g. .g15fb-panel's width/max-width vs. its own 16px padding);
|
|
// without border-box, padding adds on top of those budgets and
|
|
// the panel can render wider than intended, pushing it off-screen.
|
|
'.g15fb-btn, .g15fb-panel, .g15fb-panel * { box-sizing: border-box; }',
|
|
'.g15fb-btn { position: fixed; ' + posCss.btn + ' z-index: 2147483000;' +
|
|
' background: ' + primary + '; color: ' + accent + '; border: none;' +
|
|
' font: 600 14px/1.2 system-ui, -apple-system, sans-serif; cursor: pointer; white-space: nowrap;' +
|
|
' box-shadow: 0 2px 10px rgba(0,0,0,.25); }',
|
|
'.g15fb-panel { position: fixed; ' + posCss.panel + ' z-index: 2147483000; width: 320px;' +
|
|
' max-width: ' + posCss.panelMaxWidth + '; background: #fff; color: #111827; border-radius: 12px;' +
|
|
' box-shadow: 0 10px 40px rgba(0,0,0,.3); padding: 16px; font: 14px/1.4 system-ui, -apple-system, sans-serif;' +
|
|
' display: none; }',
|
|
'.g15fb-panel.g15fb-open { display: block; }',
|
|
'.g15fb-panel h3 { margin: 0 0 8px; font-size: 15px; }',
|
|
'.g15fb-panel textarea, .g15fb-panel input[type=email] {' +
|
|
' width: 100%; box-sizing: border-box; border: 1px solid #d1d5db; border-radius: 8px;' +
|
|
' padding: 8px; font: inherit; margin-bottom: 8px; background: #fff; color: #111827; }',
|
|
'.g15fb-panel textarea { min-height: 80px; resize: vertical; }',
|
|
'.g15fb-panel label { display: block; font-size: 12px; color: #6b7280; margin-bottom: 4px; }',
|
|
// A plain-content dropdown, not a native <select> — 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 =
|
|
'<h3>' + escapeHtml(labels.title || t('title')) + '</h3>' +
|
|
'<img class="g15fb-preview" alt="Screenshot preview" />' +
|
|
'<label class="g15fb-type-label">' + escapeHtml(t('typeLabel')) + '</label>' +
|
|
'<div class="g15fb-type-wrap">' +
|
|
'<button type="button" class="g15fb-type-btn"></button>' +
|
|
'<div class="g15fb-type-list" role="listbox">' +
|
|
'<div class="g15fb-type-opt" data-value="bug" role="option">' + escapeHtml(t('typeBug')) + '</div>' +
|
|
'<div class="g15fb-type-opt" data-value="feature" role="option">' + escapeHtml(t('typeFeature')) + '</div>' +
|
|
'<div class="g15fb-type-opt" data-value="other" role="option">' + escapeHtml(t('typeOther')) + '</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<textarea placeholder="' + escapeHtml(labels.placeholder || t('placeholder')) + '"></textarea>' +
|
|
'<input type="email" placeholder="' + escapeHtml(t('emailPlaceholder')) + '" />' +
|
|
'<div class="g15fb-row">' +
|
|
'<button type="button" class="g15fb-submit">' + escapeHtml(t('send')) + '</button>' +
|
|
'<button type="button" class="g15fb-cancel">' + escapeHtml(t('cancel')) + '</button>' +
|
|
'</div>' +
|
|
'<div class="g15fb-status" role="status"></div>' +
|
|
(config.allowHide
|
|
? '<button type="button" class="g15fb-hide">' + escapeHtml(t('hideWidget')) + '</button>'
|
|
: '');
|
|
|
|
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 <select> popup
|
|
// (the Type dropdown below) when any ancestor has a CSS transform,
|
|
// so the panel can carry no transform of its own while open.
|
|
function positionPanelVertically() {
|
|
if (!posCss.centerPanelVertically) {
|
|
return;
|
|
}
|
|
var margin = 12;
|
|
var top = (window.innerHeight / 2) - (panel.offsetHeight / 2);
|
|
top = Math.max(margin, Math.min(top, window.innerHeight - panel.offsetHeight - margin));
|
|
panel.style.top = top + 'px';
|
|
}
|
|
|
|
window.addEventListener('resize', function () {
|
|
if (panel.classList.contains('g15fb-open')) {
|
|
positionPanelVertically();
|
|
}
|
|
});
|
|
|
|
function openPanel() {
|
|
panel.classList.add('g15fb-open');
|
|
positionPanelVertically();
|
|
statusEl.textContent = t('capturing');
|
|
screenshotBlob = null;
|
|
previewImg.style.display = 'none';
|
|
|
|
screenshotCapture = loadHtml2Canvas()
|
|
.then(function (html2canvas) {
|
|
// Our own widget host is a shadow-DOM element already sitting
|
|
// in document.body by the time this runs (it's how the button
|
|
// that just got clicked exists at all) — no reason to capture
|
|
// our own floating button/panel inside the visitor's report.
|
|
return html2canvas(document.body, {
|
|
logging: false,
|
|
useCORS: true,
|
|
ignoreElements: function (element) {
|
|
return element === host;
|
|
},
|
|
});
|
|
})
|
|
.then(function (canvas) {
|
|
return new Promise(function (resolve) {
|
|
canvas.toBlob(function (blob) {
|
|
resolve(blob);
|
|
}, 'image/png', 0.85);
|
|
});
|
|
})
|
|
.then(function (blob) {
|
|
screenshotBlob = blob;
|
|
if (blob) {
|
|
previewImg.src = URL.createObjectURL(blob);
|
|
previewImg.style.display = 'block';
|
|
// The preview adds height to the panel — recenter now
|
|
// that it actually changed, not just at open time.
|
|
positionPanelVertically();
|
|
}
|
|
statusEl.textContent = '';
|
|
})
|
|
.catch(function (err) {
|
|
// Screenshot capture is best-effort — a failure (e.g. a tainted
|
|
// canvas from cross-origin images) must never block sending
|
|
// the text feedback itself. Still surfaced to devtools so a
|
|
// silent failure mode isn't invisible to whoever's debugging.
|
|
console.warn('[feedback-widget] screenshot capture failed', err);
|
|
statusEl.textContent = '';
|
|
});
|
|
}
|
|
|
|
function closePanel() {
|
|
panel.classList.remove('g15fb-open');
|
|
}
|
|
|
|
button.addEventListener('click', function () {
|
|
if (panel.classList.contains('g15fb-open')) {
|
|
closePanel();
|
|
} else {
|
|
openPanel();
|
|
}
|
|
});
|
|
|
|
cancelBtn.addEventListener('click', closePanel);
|
|
|
|
submitBtn.addEventListener('click', function () {
|
|
var message = textarea.value.trim();
|
|
if (!message) {
|
|
statusEl.textContent = t('pleaseDescribe');
|
|
return;
|
|
}
|
|
|
|
submitBtn.disabled = true;
|
|
statusEl.textContent = t('sending');
|
|
|
|
// Wait for any in-flight screenshot capture to settle before
|
|
// building the request — see screenshotCapture's declaration.
|
|
screenshotCapture.then(function () {
|
|
var form = new FormData();
|
|
form.append('message', message);
|
|
form.append('type', typeValue);
|
|
if (emailInput.value.trim()) {
|
|
form.append('email', emailInput.value.trim());
|
|
}
|
|
form.append('page_url', location.href);
|
|
form.append('user_agent', navigator.userAgent);
|
|
form.append('console_log', consoleBuffer.join('\n'));
|
|
if (screenshotBlob) {
|
|
form.append('screenshot', screenshotBlob, 'screenshot.png');
|
|
}
|
|
|
|
return fetch(API_ROOT + '/submit', { method: 'POST', body: form });
|
|
})
|
|
.then(function (res) {
|
|
if (!res.ok) {
|
|
throw new Error('submit failed');
|
|
}
|
|
return res.json();
|
|
})
|
|
.then(function () {
|
|
statusEl.textContent = labels.success || t('success');
|
|
textarea.value = '';
|
|
emailInput.value = '';
|
|
setTimeout(closePanel, 1500);
|
|
})
|
|
.catch(function () {
|
|
statusEl.textContent = t('error');
|
|
})
|
|
.finally(function () {
|
|
submitBtn.disabled = false;
|
|
});
|
|
});
|
|
|
|
// Updates every static string in place after a locale switch —
|
|
// called by window.G15Feedback.setLocale(), never rebuilds the DOM.
|
|
// t() already reads the (by-then-updated) outer LANG variable, so
|
|
// only labels — the admin-configured, already-locale-resolved title/
|
|
// placeholder/success strings from /config — need to be passed in.
|
|
function refreshLocale(newLabels) {
|
|
labels = newLabels || {};
|
|
button.textContent = labels.title || t('title');
|
|
panel.querySelector('h3').textContent = labels.title || t('title');
|
|
panel.querySelector('.g15fb-type-label').textContent = t('typeLabel');
|
|
textarea.placeholder = labels.placeholder || t('placeholder');
|
|
emailInput.placeholder = t('emailPlaceholder');
|
|
submitBtn.textContent = t('send');
|
|
cancelBtn.textContent = t('cancel');
|
|
statusEl.textContent = '';
|
|
|
|
for (var i = 0; i < typeOpts.length; i++) {
|
|
var value = typeOpts[i].getAttribute('data-value');
|
|
typeOpts[i].textContent = t(TYPE_KEY_MAP[value]);
|
|
}
|
|
setType(typeValue);
|
|
|
|
if (hideBtn) {
|
|
hideBtn.textContent = t('hideWidget');
|
|
}
|
|
}
|
|
|
|
// hide()/show() toggle the ENTIRE widget (button + panel) — host is
|
|
// the single shadow-DOM root both live under. Exposed on the
|
|
// returned instance so window.G15Feedback.hide()/.show() (the host
|
|
// page's sync mechanism for the persisted preference) can drive it.
|
|
function hideHost() {
|
|
host.style.display = 'none';
|
|
}
|
|
|
|
function showHost() {
|
|
host.style.display = '';
|
|
}
|
|
|
|
return { refreshLocale: refreshLocale, hide: hideHost, show: showHost };
|
|
}
|
|
|
|
var activeWidget = null;
|
|
|
|
// Re-fetches /config for the current LANG and applies it to the already-
|
|
// built widget in place. A no-op until the widget exists — the initial
|
|
// build already renders in whatever LANG is current at that point.
|
|
function refreshFromNetwork() {
|
|
if (!activeWidget) {
|
|
return;
|
|
}
|
|
fetchConfig()
|
|
.then(function (config) {
|
|
if (config && activeWidget) {
|
|
activeWidget.refreshLocale(config.labels || {});
|
|
}
|
|
})
|
|
.catch(function () {
|
|
// Best-effort — the widget just keeps showing the previous locale.
|
|
});
|
|
}
|
|
|
|
// Public API for the host page to sync the widget to an in-app locale
|
|
// switch (e.g. Nuxt i18n) without a full reload. data-lang only covers
|
|
// the locale at page-load time.
|
|
window.G15Feedback = window.G15Feedback || {};
|
|
window.G15Feedback.setLocale = function (lang) {
|
|
var normalized = lang === 'en' ? 'en' : 'pl';
|
|
if (normalized === LANG) {
|
|
return;
|
|
}
|
|
LANG = normalized;
|
|
refreshFromNetwork();
|
|
};
|
|
|
|
// The host page is the ONLY caller of these — it owns the persisted
|
|
// preference (via its own authenticated request) and just tells the
|
|
// widget to reflect the result. This file never persists anything itself.
|
|
window.G15Feedback.hide = function () {
|
|
pendingHidden = true;
|
|
if (activeWidget) {
|
|
activeWidget.hide();
|
|
}
|
|
};
|
|
window.G15Feedback.show = function () {
|
|
pendingHidden = false;
|
|
if (activeWidget) {
|
|
activeWidget.show();
|
|
}
|
|
};
|
|
|
|
function init() {
|
|
var fetchedForLang = LANG;
|
|
fetchConfig()
|
|
.then(function (config) {
|
|
if (!config) {
|
|
return;
|
|
}
|
|
activeWidget = buildWidget(config);
|
|
if (pendingHidden) {
|
|
activeWidget.hide();
|
|
}
|
|
// setLocale() may have run while this request was already in
|
|
// flight — its labels reflect fetchedForLang, not the
|
|
// now-current LANG. One corrective refetch closes that race.
|
|
if (LANG !== fetchedForLang) {
|
|
refreshFromNetwork();
|
|
}
|
|
})
|
|
.catch(function () {
|
|
// Widget disabled, unknown key, or this origin isn't allow-listed —
|
|
// stay completely silent rather than surface an error on the host page.
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|