From af5d77c997d538f479870592947460ec064adcc0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 4 Oct 2026 00:22:08 +0200 Subject: [PATCH] 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 --- README.md | 32 +- admin.go | 82 +++ assets.go | 35 + assets/js/embed.js | 643 ++++++++++++++++++ classes/classes_test.go | 295 ++++++++ classes/g15office_client.go | 247 +++++++ classes/str_limit.go | 177 +++++ classes/sync_g15office.go | 132 ++++ classes/testdata/g15office-task.upstream.yaml | 32 + console/import_settings.go | 180 +++++ controllers/admin_registry.go | 13 + controllers/submissions/config_list.yaml | 13 + controllers/submissions_admin_controller.go | 36 + feedback_test.go | 170 +++++ jobs.go | 54 ++ models/settings/fields.yaml | 108 +++ models/submission/columns.yaml | 32 + plugin.go | 8 + routes.go | 2 + 19 files changed, 2285 insertions(+), 6 deletions(-) create mode 100644 admin.go create mode 100644 assets.go create mode 100644 assets/js/embed.js create mode 100644 classes/g15office_client.go create mode 100644 classes/str_limit.go create mode 100644 classes/testdata/g15office-task.upstream.yaml create mode 100644 console/import_settings.go create mode 100644 controllers/admin_registry.go create mode 100644 controllers/submissions/config_list.yaml create mode 100644 controllers/submissions_admin_controller.go create mode 100644 jobs.go create mode 100644 models/settings/fields.yaml create mode 100644 models/submission/columns.yaml diff --git a/README.md b/README.md index f551341..55f5ae6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,11 @@ The plugin ID, the table names, the config keys and the settings code are a cont - **Hiding the widget.** `PUT /_feedback/api/v1/me/hidden` (behind golem15.user's `jwt.auth`) stores the signed-in user's preference: `hidden` must be a Laravel boolean (`true`, `false`, `1`, `0`, `"1"`, `"0"`), else 422 `{"error":"Validation failed","errors":{...}}`. The answer is `{"hidden":bool}`. Every golem15.user payload (fetch, login, update) carries `feedback_widget_hidden` through a `GetApiArrayEvent` listener: the stored value, false without a row. - **CORS.** The widget's preflights are answered by the framework's path-scoped CORS layer, so the plugin registers no `OPTIONS` route. - **Rate limits.** The named buckets `feedback-config` (60 a minute) and `feedback-submit` (10 a minute) count per client IP behind the configured trusted proxies. -- **Settings.** `models.Settings` is the singleton row of `golem15_feedback_settings`. `models.Instance` creates it on first access with the WinterCMS defaults and a new widget key (`wk_` plus 32 random letters and digits). +- **Settings.** `models.Settings` is the singleton row of `golem15_feedback_settings`. `models.Instance` creates it on first access with the WinterCMS defaults and a new widget key (`wk_` plus 32 random letters and digits). Operators edit it under Settings › Feedback Widget (code `feedback`, permission `golem15.feedback.manage_settings`): the WinterCMS form with cabana's field types, the colors as text fields and the widget key read-only. A row saved without a key gets a new one. +- **G15Office tasks.** Every submission queues a `classes.SyncG15OfficeArgs` job (kind `golem15.feedback.sync_g15office`, queue `feedback`, 3 attempts, a `summer_jobs` row labelled with the WinterCMS job class). `classes.SyncG15Office` files it with `classes.G15OfficeClient`: `POST /_support/api/v1/projects//tasks` with the title (`Feedback: ` and the message collapsed and cut to 80 columns), a Markdown description (the message, the page, the reporter email and user agent, the console log cut to 4000 columns), the task type (`bug` → Bug, `feature` → Feature, `other` → Task), the configured task status and priority; then `POST /_support/api/v1/tasks//attachments` with the screenshot as the multipart field `file`. Requests carry `Authorization: Bearer ` and `Accept: application/json` and go through a `fetchguard.Client` limited to the base URL's host (https only). Success marks the submission `sent` with the task id; a failure marks it `failed` with the error and the job is retried. Without a base URL, token and project nothing is sent: the job fails with the WinterCMS "not configured" message. +- **Submissions list.** Admin › Feedback › Submissions (controller `golem15.feedback.submissions`) is a read-only list: type, status, reporter email, page, message, task id and date, all as plain text. +- **Widget script.** `GET /plugins/golem15/feedback/assets/js/embed.js` serves the WinterCMS plugin's `embed.js` byte for byte (`application/javascript`, with an ETag), at the path embedding pages already use. +- **Settings import.** `feedback:import-settings` copies a WinterCMS installation's widget settings, the widget key included, into the singleton once. ## Usage @@ -56,15 +60,23 @@ plugins: module: git.golem15.com/golem15/sm-feedback-plugin ``` -The application's `http.cors.paths` must list `_feedback/api/*`: the widget calls the API from other origins. +The application's `http.cors.paths` must list `_feedback/api/*`: the widget calls the API from other origins. A page embeds the widget with its key: + +```html + +``` + +Set the G15Office connection with the three `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__*` variables (see Configuration) and run a queue worker: `serve` runs one in-process when `queue.work_in_serve` is on, `queue:work` in its own process. After moving an installation from WinterCMS, run `feedback:import-settings` once so embed snippets keep their widget key. The plugin's own `go.mod` replaces the framework with `../../../../summercms.go` and the user plugin with `../user`, so it expects the framework checkout next to the application checkout and the user plugin mounted beside it. ## API reference -- `feedback`: `Plugin`, the `golem15.feedback` plugin (config, lang, migrations, models, routes, buckets); `BucketConfig`, `BucketSubmit`. +- `feedback`: `Plugin`, the `golem15.feedback` plugin (config, lang, migrations, models, routes, buckets, jobs, commands, admin controllers, permissions, navigation, settings); `BucketConfig`, `BucketSubmit`, `EmbedJSPath`, `EmbedJS`, `SettingsCode`. - `controllers/api`: `Config`, `Submit`, `SubmitRules`, `MeHidden`, `MeHiddenRules`, `KeyMatches`, `OriginAllowed`. -- `classes`: `IsAllowedImage`, `SniffImageMIME`, `AllowedImageMIMEs`; `Screenshot`, `StoreSubmission`, `ScreenshotOf`; the job contract `SyncG15OfficeArgs`, `SyncG15OfficeKind`, `SyncG15OfficeQueue`, `SyncG15OfficeLabel`, `SyncG15OfficeAttempts`. +- `classes`: `IsAllowedImage`, `SniffImageMIME`, `AllowedImageMIMEs`; `Screenshot`, `StoreSubmission`, `ScreenshotOf`; the job contract `SyncG15OfficeArgs`, `SyncG15OfficeKind`, `SyncG15OfficeQueue`, `SyncG15OfficeLabel`, `SyncG15OfficeAttempts`; the job `SyncG15Office`, `SyncDeps`, `TaskTitle`, `TaskDescription`, `StrLimit`, `StrWidth`; the client `G15OfficeClient`, `NewG15OfficeClient`, `NewG15OfficeClientWith`, `TaskFields`, `ErrNotConfigured`, `G15OfficeTimeout`, `ConfigBaseURL`, `ConfigToken`, `ConfigProject`. +- `controllers`: `SubmissionsAdminController`, `AdminControllers`, `PermissionManageSettings`. +- `console`: `ImportSettings`, `Import`, `IsNothingToImport`, `ErrSettingsCustomised`. - `models`: `Settings` (table `golem15_feedback_settings`), `SettingsID`, `SettingsCode`, `DefaultSettings`, `Instance`, `NewWidgetKey`, `Labels`, `URLHost`, `ASCIILower`; `Submission` (table `golem15_feedback_submissions`), `StatusPending`, `StatusSent`, `StatusFailed`, `TypeBug`, `TypeFeature`, `TypeOther`, `ScreenshotField`, `G15OfficeTypeMap`; `UserPreference` (table `golem15_feedback_user_preferences`), `IsWidgetHidden`, `SetWidgetHidden`; `Register`, `All`. - `updates`: the migrations (`202610030201_create_feedback_submissions`, `202610030202_create_feedback_user_preferences`, `202610030203_create_feedback_settings`), `Register`, `All`. @@ -74,15 +86,21 @@ Defaults live in `config/config.yaml` and are merged under `golem15.feedback.*`. | Key | Default | Meaning | |-----|---------|---------| -| `golem15.feedback.g15_office.base_url` | empty | The G15Office base URL submissions are filed under. | +| `golem15.feedback.g15_office.base_url` | empty | The G15Office base URL submissions are filed under (https; requests may reach only its host). | | `golem15.feedback.g15_office.token` | empty | The G15Office API token. Never commit it. | | `golem15.feedback.g15_office.project` | empty | The G15Office project the tasks are created in. | Override them with `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL`, `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__TOKEN` and `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__PROJECT`. +## CLI commands + +| Command | Purpose | +|---------|---------| +| `feedback:import-settings [--force]` | Copy the WinterCMS `system_settings` row `golem15_feedback_settings` (widget key, origins, colors, labels, G15Office status and priority) into `golem15_feedback_settings`. It refuses settings that were already changed unless `--force` is given, and reports a missing `system_settings` table or row without failing. | + ## Dependencies -- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bouncer`, `compass`, `conga`, `lagoon`, `pact`, `party`, `phrasebook`, `surf`, `wire`). +- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `cabana`, `compass`, `conga`, `fetchguard`, `lagoon`, `pact`, `party`, `phrasebook`, `surf`, `wire`; `tide` in tests). - gocloud.dev blob storage for the screenshots, through the application's uploads bucket. - The user plugin, `git.golem15.com/golem15/sm-user-plugin`. - GORM and gormigrate, as pinned in `go.mod`. @@ -93,3 +111,5 @@ Override them with `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL`, `SUMMER_GOL ```sh go test ./... # starts Postgres with testcontainers (needs Docker) ``` + +`classes/testdata/g15office-task.upstream.yaml` is the G15Office exchange the WinterCMS job made, recorded through the framework's upstream recording proxy; `TestSyncG15Office` replays it offline and fails on any difference in the requests (URL, headers, the task JSON, the screenshot part). diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..85ffa34 --- /dev/null +++ b/admin.go @@ -0,0 +1,82 @@ +package feedback + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/sm-feedback-plugin/controllers" + "git.golem15.com/golem15/sm-feedback-plugin/models" + "git.golem15.com/golem15/summercms/modules/pact" +) + +//go:embed controllers/submissions/config_list.yaml models/submission/columns.yaml models/settings/fields.yaml +var adminFS embed.FS + +var ( + _ pact.AdminAssets = (*Plugin)(nil) + _ pact.HasAdminControllers = (*Plugin)(nil) + _ pact.HasPermissions = (*Plugin)(nil) + _ pact.HasNavigation = (*Plugin)(nil) + _ pact.HasSettings = (*Plugin)(nil) +) + +// AdminFS is the admin YAML: the settings form and the submissions list. +func (p *Plugin) AdminFS() fs.FS { return adminFS } + +// AdminControllers is the submissions list. +func (p *Plugin) AdminControllers() []pact.AdminController { + return controllers.AdminControllers() +} + +// Permissions is registerPermissions: golem15.feedback.manage_settings for +// the developer role. +func (*Plugin) Permissions() []pact.Permission { + return []pact.Permission{{ + Code: controllers.PermissionManageSettings, + Tab: "golem15.feedback::lang.plugin.tab", + Label: "golem15.feedback::lang.settings.manage_settings", + Roles: []string{"developer"}, + }} +} + +// Navigation is the Feedback menu with its submissions entry. +func (*Plugin) Navigation() []pact.NavigationItem { + return []pact.NavigationItem{{ + Code: "feedback", + Label: "golem15.feedback::lang.plugin.menu_label", + Icon: "message-square", + Permissions: []string{"golem15.feedback.*"}, + Order: 600, + Controller: "golem15.feedback.submissions", + SideMenu: []pact.NavigationItem{{ + Code: "submissions", + Label: "golem15.feedback::lang.submissions.menu_label", + Icon: "inbox", + Permissions: []string{controllers.PermissionManageSettings}, + Controller: "golem15.feedback.submissions", + }}, + }} +} + +// SettingsCode is the settings screen's code. WinterCMS keys the screen +// by plugin and code ("settings"); cabana keys it by code alone across all +// plugins, so the plugin's own name keeps it apart from other plugins'. +const SettingsCode = "feedback" + +// Settings is registerSettings: the widget settings singleton +// (golem15_feedback_settings) on cabana's settings screen. +func (*Plugin) Settings() []pact.SettingsItem { + return []pact.SettingsItem{{ + Code: SettingsCode, + Label: "golem15.feedback::lang.settings.menu_label", + Description: "golem15.feedback::lang.settings.settings_description", + Category: "golem15.feedback::lang.plugin.tab", + Icon: "message-square", + Model: `Golem15\Feedback\Models\Settings`, + Order: 500, + Keywords: []string{"feedback", "widget", "g15office"}, + Permissions: []string{controllers.PermissionManageSettings}, + Form: "models/settings/fields.yaml", + NewModel: func() any { return &models.Settings{} }, + }} +} diff --git a/assets.go b/assets.go new file mode 100644 index 0000000..5d29a79 --- /dev/null +++ b/assets.go @@ -0,0 +1,35 @@ +package feedback + +import ( + "bytes" + "crypto/sha256" + _ "embed" + "encoding/hex" + "net/http" + "time" +) + +// EmbedJSPath is where the widget script is served: the path the WinterCMS +// plugin's file had under the web root, which embedding pages and the +// application's frontend proxy request. +const EmbedJSPath = "/plugins/golem15/feedback/assets/js/embed.js" + +// embedJS is the WinterCMS plugin's assets/js/embed.js, byte for byte. +// +//go:embed assets/js/embed.js +var embedJS []byte + +var embedJSETag = func() string { + sum := sha256.Sum256(embedJS) + return `"` + hex.EncodeToString(sum[:16]) + `"` +}() + +// EmbedJS serves the widget script with a JavaScript content type and an +// ETag, answering conditional and range requests like a static file. +func EmbedJS() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("ETag", embedJSETag) + http.ServeContent(w, r, "embed.js", time.Time{}, bytes.NewReader(embedJS)) + } +} diff --git a/assets/js/embed.js b/assets/js/embed.js new file mode 100644 index 0000000..6c8c2bc --- /dev/null +++ b/assets/js/embed.js @@ -0,0 +1,643 @@ +/*! + * 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