Files
sm-feedback-plugin/assets/js/embed.js
Jakub Zych af5d77c997 feat: G15Office job, widget settings and submissions admin, embed.js and the settings importer
- 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
2026-10-04 00:22:08 +02:00

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();
}
})();