PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / js / support-request-button.js

support-request-button.js in 404 Solution 4.1.19, at includes/js/support-request-button.js

852 lines 34.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Reusable "Send debug log to developer" button + confirmation modal.
3 *
4 * Public API:
5 * ABJ404.SupportRequestButton.mount(rootEl, opts)
6 * - rootEl: HTMLElement to render the button into.
7 * - opts.triggered_from: required allowlisted slug
8 * (redirects_page, captured_404s_page, plugins_row_action,
9 * settings_debug, system_corrupt_install).
10 * - opts.context_summary: optional one-line description shown in
11 * the modal so the admin remembers which screen the report
12 * anchors to.
13 *
14 * mountAll() auto-bootstraps every .abj404-support-request-mount on the
15 * page using their data-* attributes. Callers that need finer control
16 * (e.g. lazy-mounting after AJAX content loads) call mount(rootEl,
17 * {...}) directly.
18 *
19 * State machine for the modal:
20 * idle -> button visible, modal closed.
21 * confirming-> modal open, primary button enabled.
22 * sending -> modal open, primary button disabled + spinner.
23 * success -> modal open, success message + close button.
24 * failure -> modal open, error message + retry button.
25 * cooldown -> modal open, cooldown message, no retry until elapsed.
26 *
27 * Accessibility:
28 * - Modal has role="dialog" + aria-modal="true" + aria-labelledby.
29 * - Focus is trapped in the modal while open and restored to the
30 * button on close.
31 * - ESC closes the modal (cancel semantics, no AJAX).
32 *
33 * Browser support: matches .browserslistrc (last 2 versions of each
34 * major browser). Uses fetch (via abj404SupportRequest.send), Promise,
35 * and standard DOM APIs. No jQuery dependency for the component itself
36 * so it can mount on a fatal-error fallback page where jQuery may not
37 * be loaded.
38 */
39
40 (function (window, document) {
41 'use strict';
42
43 var SELECTOR = '.abj404-support-request-mount';
44 var LINK_SELECTOR = '.abj404-support-request-link';
45 var STYLE_TAG_ID = 'abj404-srb-styles';
46
47 /**
48 * Polished modal styles. Injected once per page on first build so
49 * the modal works on a fatal-fallback page where the plugin's
50 * stylesheet may not be loaded. Kept self-contained (no external
51 * font, no external image).
52 */
53 var STYLE_BLOCK = [
54 '.abj404-srb-overlay {',
55 ' box-sizing: border-box;',
56 ' font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;',
57 ' padding: 24px;',
58 '}',
59 '.abj404-srb-overlay * { box-sizing: border-box; }',
60 '.abj404-srb-dialog {',
61 ' width: 100%;',
62 ' max-width: 560px;',
63 ' max-height: calc(100vh - 48px);',
64 ' overflow-y: auto;',
65 ' border-radius: 8px;',
66 ' box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(0, 0, 0, 0.04);',
67 ' padding: 26px 30px 22px;',
68 ' color: #1f2937;',
69 '}',
70 '.abj404-srb-title {',
71 ' margin: 0 0 8px;',
72 ' font-size: 18px;',
73 ' font-weight: 600;',
74 ' line-height: 1.3;',
75 ' color: #111827;',
76 '}',
77 '.abj404-srb-explainer {',
78 ' margin: 0 0 16px;',
79 ' color: #4b5563;',
80 ' line-height: 1.55;',
81 ' font-size: 14px;',
82 '}',
83 '.abj404-srb-context-summary {',
84 ' margin: 0 0 16px;',
85 ' padding: 10px 12px;',
86 ' background: #f3f4f6;',
87 ' border-left: 3px solid #2271b1;',
88 ' border-radius: 3px;',
89 ' font-size: 13px;',
90 ' color: #374151;',
91 '}',
92 '.abj404-srb-categories-heading {',
93 ' margin: 0 0 6px;',
94 ' font-size: 13px;',
95 ' font-weight: 600;',
96 ' color: #1f2937;',
97 '}',
98 '.abj404-srb-categories {',
99 ' margin: 0 0 18px;',
100 ' padding-left: 22px;',
101 ' font-size: 13px;',
102 ' color: #4b5563;',
103 ' line-height: 1.55;',
104 '}',
105 '.abj404-srb-categories li {',
106 ' margin: 0 0 2px;',
107 ' list-style: disc;',
108 '}',
109 '.abj404-srb-payload-details {',
110 ' margin: 0 0 18px;',
111 ' border: 1px solid #e5e7eb;',
112 ' border-radius: 6px;',
113 ' overflow: hidden;',
114 ' background: #fff;',
115 '}',
116 '.abj404-srb-payload-details > summary {',
117 ' cursor: pointer;',
118 ' padding: 10px 14px;',
119 ' background: #f9fafb;',
120 ' font-weight: 500;',
121 ' color: #2271b1;',
122 ' user-select: none;',
123 ' outline-offset: -2px;',
124 '}',
125 '.abj404-srb-payload-details[open] > summary {',
126 ' border-bottom: 1px solid #e5e7eb;',
127 '}',
128 '.abj404-srb-payload-preview {',
129 ' margin: 0;',
130 ' padding: 12px 14px;',
131 ' background: #fff;',
132 ' color: #111827;',
133 ' font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;',
134 ' font-size: 12px;',
135 ' line-height: 1.5;',
136 ' max-height: 220px;',
137 ' overflow: auto;',
138 ' white-space: pre-wrap;',
139 ' word-break: break-word;',
140 '}',
141 '.abj404-srb-form { display: block; }',
142 '.abj404-srb-field-label {',
143 ' display: block;',
144 ' margin: 0 0 14px;',
145 ' font-weight: 500;',
146 ' color: #1f2937;',
147 ' font-size: 13px;',
148 '}',
149 '.abj404-srb-field-label-text {',
150 ' display: block;',
151 ' margin-bottom: 5px;',
152 '}',
153 '.abj404-srb-user-message,',
154 '.abj404-srb-reply-email {',
155 ' display: block;',
156 ' width: 100%;',
157 ' border: 1px solid #d1d5db;',
158 ' border-radius: 4px;',
159 ' padding: 8px 10px;',
160 ' font-size: 14px;',
161 ' line-height: 1.45;',
162 ' font-family: inherit;',
163 ' color: #111827;',
164 ' background: #fff;',
165 ' transition: border-color 0.15s ease, box-shadow 0.15s ease;',
166 '}',
167 '.abj404-srb-user-message:focus,',
168 '.abj404-srb-reply-email:focus {',
169 ' border-color: #2271b1;',
170 ' outline: none;',
171 ' box-shadow: 0 0 0 2px rgba(34, 113, 177, 0.2);',
172 '}',
173 '.abj404-srb-user-message {',
174 ' resize: vertical;',
175 ' min-height: 96px;',
176 ' font-family: inherit;',
177 '}',
178 '.abj404-srb-consent {',
179 ' margin: 4px 0 16px;',
180 ' font-size: 12px;',
181 ' color: #4b5563;',
182 ' line-height: 1.5;',
183 '}',
184 '.abj404-srb-success,',
185 '.abj404-srb-error {',
186 ' margin: 12px 0 4px;',
187 ' padding: 10px 14px;',
188 ' border-radius: 4px;',
189 ' font-size: 13px;',
190 ' line-height: 1.45;',
191 '}',
192 '.abj404-srb-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }',
193 '.abj404-srb-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }',
194 '.abj404-srb-buttons {',
195 ' display: flex;',
196 ' gap: 10px;',
197 ' justify-content: flex-end;',
198 ' align-items: center;',
199 ' margin-top: 22px;',
200 ' flex-wrap: wrap;',
201 '}',
202 '.abj404-srb-buttons .abj404-srb-send {',
203 ' min-width: 110px;',
204 '}',
205 '@media (max-width: 540px) {',
206 ' .abj404-srb-overlay { padding: 12px; }',
207 ' .abj404-srb-dialog { padding: 20px 18px 16px; max-height: calc(100vh - 24px); }',
208 ' .abj404-srb-buttons { flex-direction: column-reverse; align-items: stretch; }',
209 ' .abj404-srb-buttons .abj404-srb-send,',
210 ' .abj404-srb-buttons .abj404-srb-cancel { width: 100%; min-width: 0; }',
211 '}'
212 ].join('\n');
213
214 function ensureStyles() {
215 if (!document.head || document.getElementById(STYLE_TAG_ID)) {
216 return;
217 }
218 var style = document.createElement('style');
219 style.id = STYLE_TAG_ID;
220 style.appendChild(document.createTextNode(STYLE_BLOCK));
221 document.head.appendChild(style);
222 }
223 var I18N_FALLBACK = {
224 button: 'Send debug log to developer',
225 modalTitle: 'Send debug log to developer',
226 explainer: 'This sends a one-time diagnostic report to the plugin developer so they can investigate the issue you are seeing.',
227 categoriesHeading: 'This report includes:',
228 categorySiteUrl: 'Site URL',
229 categoryVersions: 'Plugin, PHP, WordPress, and database versions',
230 categoryActivePlugins: 'List of active plugins',
231 categoryDebugLog: 'Recent debug log excerpt',
232 categoryUserMessage: 'Optional message and reply email you provide below',
233 showPayloadOpen: "Show what's in this report",
234 showPayloadClose: 'Hide report contents',
235 loadingPreview: 'Loading report contents...',
236 previewError: 'Could not load preview. The full report is still safe to send.',
237 userMessageLabel: 'What went wrong? (optional, helps us diagnose)',
238 replyEmailLabel: 'Where should we reply? (optional)',
239 consent: 'By clicking Send report, you consent to transmitting the information above to the plugin developer for support purposes. The data is used only to diagnose your issue and is not shared with third parties.',
240 send: 'Send report',
241 cancel: 'Cancel',
242 sending: 'Sending...',
243 retry: 'Retry',
244 close: 'Close',
245 successPrefix: 'Sent. Reference: ',
246 successSuffix: '. Thank you.',
247 cooldownTemplate: 'You already sent a report recently. Try again in {minutes} minute(s).',
248 genericError: 'Could not send report. Please try again later.'
249 };
250
251 // window.wp.i18n is the canonical translation surface in WP admin
252 // contexts. When unavailable (test harness, fatal-fallback page),
253 // fall back to the English strings above.
254 function t(key) {
255 if (window.wp && window.wp.i18n && typeof window.wp.i18n.__ === 'function') {
256 return window.wp.i18n.__(I18N_FALLBACK[key], '404-solution');
257 }
258 return I18N_FALLBACK[key];
259 }
260
261 function el(tag, attrs, children) {
262 var node = document.createElement(tag);
263 if (attrs) {
264 Object.keys(attrs).forEach(function (k) {
265 if (k === 'className') {
266 node.className = attrs[k];
267 } else if (k === 'text') {
268 node.textContent = attrs[k];
269 } else {
270 node.setAttribute(k, attrs[k]);
271 }
272 });
273 }
274 if (children) {
275 children.forEach(function (c) {
276 if (c) {
277 node.appendChild(c);
278 }
279 });
280 }
281 return node;
282 }
283
284 /**
285 * Mount the support-request button + modal into `rootEl`.
286 *
287 * @param {HTMLElement} rootEl
288 * @param {Object} opts
289 * @param {string} opts.triggered_from
290 * @param {string} [opts.context_summary]
291 * @returns {Object} controller with .destroy(), .openModal(), .getState()
292 */
293 function mount(rootEl, opts) {
294 opts = opts || {};
295 var triggeredFrom = String(opts.triggered_from || '');
296 var contextSummary = opts.context_summary ? String(opts.context_summary) : '';
297
298 if (!rootEl || !triggeredFrom) {
299 return { destroy: function () {}, openModal: function () {}, getState: function () { return 'idle'; } };
300 }
301
302 var state = 'idle';
303 var lastFocus = null;
304
305 // -- Render the trigger button --------------------------------
306 var button = el('button', {
307 type: 'button',
308 className: 'button abj404-support-request-button',
309 'aria-label': t('button')
310 });
311 button.textContent = t('button');
312 rootEl.innerHTML = '';
313 rootEl.appendChild(button);
314
315 // Modal scaffolding (created on first open; reused thereafter
316 // so re-opening preserves a typed message until the user
317 // explicitly cancels). One modal per mount() call.
318 var modal = null;
319 var modalEls = null;
320
321 // The button replaces rootEl's contents and serves as the
322 // explicit click target. attachLink() (below) reuses this same
323 // controller but binds the click handler to a caller-provided
324 // anchor element instead, leaving its inline DOM intact.
325 button.addEventListener('click', function () {
326 openModal();
327 });
328
329 function openModal() {
330 lastFocus = document.activeElement;
331 if (!modal) {
332 modal = buildModalDOM();
333 document.body.appendChild(modal.overlay);
334 }
335 modal.overlay.style.display = 'flex';
336 setState('confirming');
337 // Defer focus so screen readers announce the dialog.
338 window.setTimeout(function () {
339 if (modal && modal.firstFocusable) {
340 modal.firstFocusable.focus();
341 }
342 }, 0);
343 }
344
345 function closeModal() {
346 if (modal) {
347 modal.overlay.style.display = 'none';
348 }
349 setState('idle');
350 if (lastFocus && typeof lastFocus.focus === 'function') {
351 lastFocus.focus();
352 }
353 }
354
355 function setState(next) {
356 state = next;
357 if (!modal) {
358 return;
359 }
360 // Toggle UI groups based on state. Hidden via display:none
361 // because aria-hidden alone leaves them in the focus order
362 // for non-AT users.
363 modal.successBlock.style.display = (state === 'success') ? 'block' : 'none';
364 modal.errorBlock.style.display = (state === 'failure' || state === 'cooldown') ? 'block' : 'none';
365 modal.formBlock.style.display = (state === 'confirming' || state === 'sending') ? 'block' : 'none';
366 modal.sendButton.disabled = (state === 'sending' || state === 'cooldown' || state === 'success');
367 if (state === 'sending') {
368 modal.sendButton.textContent = t('sending');
369 } else if (state === 'failure') {
370 modal.sendButton.textContent = t('retry');
371 } else {
372 modal.sendButton.textContent = t('send');
373 }
374 if (state === 'success' || state === 'cooldown') {
375 modal.cancelButton.textContent = t('close');
376 } else {
377 modal.cancelButton.textContent = t('cancel');
378 }
379 }
380
381 function buildModalDOM() {
382 ensureStyles();
383 var titleId = 'abj404-srb-title-' + Math.random().toString(36).slice(2, 8);
384 var title = el('h2', { id: titleId, className: 'abj404-srb-title', text: t('modalTitle') });
385 var explainer = el('p', { className: 'abj404-srb-explainer', text: t('explainer') });
386
387 // Data-category list (GDPR: admin sees every category of
388 // data that will be transmitted before the consent step).
389 var categoriesHeading = el('p', { className: 'abj404-srb-categories-heading', text: t('categoriesHeading') });
390 var categoriesList = el('ul', { className: 'abj404-srb-categories' });
391 ['categorySiteUrl', 'categoryVersions', 'categoryActivePlugins', 'categoryDebugLog', 'categoryUserMessage'].forEach(function (key) {
392 categoriesList.appendChild(el('li', { text: t(key) }));
393 });
394
395 var contextNode = null;
396 if (contextSummary) {
397 contextNode = el('p', { className: 'abj404-srb-context-summary' });
398 contextNode.textContent = contextSummary;
399 }
400
401 // Collapsible "Show what's in this report" expander. Uses
402 // <details>/<summary> for native a11y semantics; the
403 // payload preview is fetched lazily on first open so the
404 // modal is responsive even if the preview AJAX is slow.
405 var details = el('details', { className: 'abj404-srb-payload-details' });
406 var summary = el('summary');
407 summary.textContent = t('showPayloadOpen');
408 var payloadPre = el('pre', {
409 className: 'abj404-srb-payload-preview',
410 'aria-live': 'polite'
411 });
412 payloadPre.textContent = '';
413 details.appendChild(summary);
414 details.appendChild(payloadPre);
415 var previewLoaded = false;
416 details.addEventListener('toggle', function () {
417 if (details.open) {
418 summary.textContent = t('showPayloadClose');
419 if (!previewLoaded) {
420 payloadPre.textContent = t('loadingPreview');
421 previewLoaded = true;
422 loadPreview(triggeredFrom, userMessageInput.value).then(function (preview) {
423 payloadPre.textContent = JSON.stringify(preview.payload, null, 2);
424 }).catch(function () {
425 payloadPre.textContent = t('previewError');
426 });
427 }
428 } else {
429 summary.textContent = t('showPayloadOpen');
430 // Reset the cache flag on close so the user can
431 // retry a failed preview by closing + re-opening
432 // the expander. Resetting it inside the catch
433 // would race with any auto-toggle the host fires
434 // shortly after, re-triggering loadPreview and
435 // overwriting the error message with "Loading...".
436 previewLoaded = false;
437 }
438 });
439
440 var userMessageLabel = el('label', { className: 'abj404-srb-field-label' });
441 var userMessageLabelText = el('span', { className: 'abj404-srb-field-label-text', text: t('userMessageLabel') });
442 var userMessageInput = el('textarea', {
443 className: 'abj404-srb-user-message',
444 rows: '4',
445 maxlength: '2000'
446 });
447 userMessageLabel.appendChild(userMessageLabelText);
448 userMessageLabel.appendChild(userMessageInput);
449
450 var replyEmailLabel = el('label', { className: 'abj404-srb-field-label' });
451 var replyEmailLabelText = el('span', { className: 'abj404-srb-field-label-text', text: t('replyEmailLabel') });
452 var replyEmailInput = el('input', {
453 type: 'email',
454 className: 'abj404-srb-reply-email'
455 });
456 replyEmailLabel.appendChild(replyEmailLabelText);
457 replyEmailLabel.appendChild(replyEmailInput);
458
459 var consent = el('p', { className: 'abj404-srb-consent', text: t('consent') });
460
461 var formBlock = el('div', { className: 'abj404-srb-form' }, [
462 userMessageLabel,
463 replyEmailLabel,
464 consent
465 ]);
466
467 var successBlock = el('div', {
468 className: 'abj404-srb-success notice notice-success',
469 role: 'status'
470 });
471 var errorBlock = el('div', {
472 className: 'abj404-srb-error notice notice-error',
473 role: 'alert'
474 });
475
476 var sendButton = el('button', {
477 type: 'button',
478 className: 'button button-primary abj404-srb-send'
479 });
480 sendButton.textContent = t('send');
481 var cancelButton = el('button', {
482 type: 'button',
483 className: 'button abj404-srb-cancel'
484 });
485 cancelButton.textContent = t('cancel');
486
487 var buttons = el('div', { className: 'abj404-srb-buttons' }, [cancelButton, sendButton]);
488
489 var dialog = el('div', {
490 className: 'abj404-srb-dialog',
491 role: 'dialog',
492 'aria-modal': 'true',
493 'aria-labelledby': titleId
494 }, [
495 title,
496 explainer,
497 contextNode,
498 categoriesHeading,
499 categoriesList,
500 details,
501 formBlock,
502 errorBlock,
503 successBlock,
504 buttons
505 ]);
506
507 var overlay = el('div', {
508 className: 'abj404-srb-overlay'
509 }, [dialog]);
510 overlay.style.display = 'none';
511 // Inline only the layout-mode / positioning props so the
512 // overlay floats correctly even before our injected
513 // <style> tag has parsed. Visual polish (padding, radius,
514 // shadow, typography) lives in STYLE_BLOCK, injected
515 // once by ensureStyles() in buildModalDOM().
516 overlay.style.position = 'fixed';
517 overlay.style.top = '0';
518 overlay.style.left = '0';
519 overlay.style.right = '0';
520 overlay.style.bottom = '0';
521 overlay.style.background = 'rgba(15, 23, 42, 0.55)';
522 overlay.style.zIndex = '160000';
523 overlay.style.alignItems = 'center';
524 overlay.style.justifyContent = 'center';
525 dialog.style.background = '#fff';
526
527 // Wire button + ESC + overlay-click handlers.
528 sendButton.addEventListener('click', function () {
529 doSend(userMessageInput.value, replyEmailInput.value);
530 });
531 cancelButton.addEventListener('click', function () {
532 closeModal();
533 });
534 overlay.addEventListener('click', function (e) {
535 if (e.target === overlay) {
536 closeModal();
537 }
538 });
539 dialog.addEventListener('keydown', function (e) {
540 if (e.key === 'Escape') {
541 e.preventDefault();
542 closeModal();
543 return;
544 }
545 if (e.key === 'Tab') {
546 trapFocus(e, dialog);
547 }
548 });
549
550 return {
551 overlay: overlay,
552 dialog: dialog,
553 firstFocusable: userMessageInput,
554 userMessageInput: userMessageInput,
555 replyEmailInput: replyEmailInput,
556 sendButton: sendButton,
557 cancelButton: cancelButton,
558 successBlock: successBlock,
559 errorBlock: errorBlock,
560 formBlock: formBlock,
561 payloadPre: payloadPre,
562 detailsEl: details,
563 resetPreviewLoaded: function () { previewLoaded = false; }
564 };
565 }
566
567 function doSend(userMessage, replyEmail) {
568 if (!modal) { return; }
569 modal.errorBlock.textContent = '';
570 modal.successBlock.textContent = '';
571 setState('sending');
572
573 var sender = window.abj404SupportRequest;
574 if (!sender || typeof sender.send !== 'function') {
575 modal.errorBlock.textContent = t('genericError');
576 setState('failure');
577 return;
578 }
579
580 sender.send({
581 triggered_from: triggeredFrom,
582 user_message: userMessage,
583 reply_email: replyEmail
584 }).then(function (data) {
585 var ref = (data && data.reference_id) ? String(data.reference_id) : '';
586 modal.successBlock.textContent = t('successPrefix') + ref + t('successSuffix');
587 setState('success');
588 }).catch(function (err) {
589 err = err || {};
590 if (typeof err.retry_after_seconds === 'number') {
591 var minutes = Math.max(1, Math.ceil(err.retry_after_seconds / 60));
592 modal.errorBlock.textContent = t('cooldownTemplate').replace('{minutes}', String(minutes));
593 setState('cooldown');
594 return;
595 }
596 modal.errorBlock.textContent = (err.message ? String(err.message) : t('genericError'));
597 setState('failure');
598 });
599 }
600
601 modalEls = modal; // initialize tracker (filled on first openModal)
602
603 return {
604 openModal: openModal,
605 closeModal: closeModal,
606 getState: function () { return state; },
607 destroy: function () {
608 if (modal && modal.overlay && modal.overlay.parentNode) {
609 modal.overlay.parentNode.removeChild(modal.overlay);
610 }
611 rootEl.innerHTML = '';
612 },
613 // Test hook so the JS suite can assert internal state without
614 // having to scrape the DOM. Not part of the public API.
615 __internalForTests: function () {
616 return { modal: modal, lastFocus: lastFocus };
617 }
618 };
619 }
620
621 /**
622 * Bind a click handler on an existing anchor / clickable element
623 * so that activating it opens the support-request modal in-place
624 * (preventDefault) instead of navigating elsewhere. Used for the
625 * `plugin_row_meta` link on wp-admin/plugins.php so the admin can
626 * send a debug log without leaving the Plugins listing and without
627 * depending on the plugin's Settings page rendering correctly.
628 *
629 * The link's own `href` is left untouched so it still acts as a
630 * fallback when JavaScript fails to load on the host page.
631 *
632 * @param {HTMLElement} linkEl
633 * @param {Object} opts
634 * @param {string} opts.triggered_from
635 * @param {string} [opts.context_summary]
636 * @returns {Object} controller with .openModal(), .closeModal(), .destroy()
637 */
638 function attachLink(linkEl, opts) {
639 opts = opts || {};
640 if (!linkEl || !opts.triggered_from) {
641 return { destroy: function () {}, openModal: function () {}, getState: function () { return 'idle'; } };
642 }
643 // mount() owns the modal lifecycle. Give it a detached host so
644 // the button it renders is never visible. The visible trigger
645 // is the linkEl supplied by the caller.
646 var hiddenHost = document.createElement('span');
647 hiddenHost.style.display = 'none';
648 var controller = mount(hiddenHost, opts);
649 var onClick = function (e) {
650 e.preventDefault();
651 controller.openModal();
652 };
653 linkEl.addEventListener('click', onClick);
654 var baseDestroy = controller.destroy;
655 controller.destroy = function () {
656 linkEl.removeEventListener('click', onClick);
657 baseDestroy();
658 };
659 return controller;
660 }
661
662 /**
663 * Lazy-load preview from the abj404_support_request_preview AJAX
664 * endpoint. Returns a Promise that resolves to {payload, ...} or
665 * rejects on transport / nonce / 4xx errors.
666 *
667 * The nonce is read from window.ABJ404.nonces.support_request_preview
668 * (populated by WordPress_Connector). When missing, the preview
669 * call is skipped and the .catch path is used.
670 *
671 * @param {string} triggeredFrom
672 * @param {string} userMessage
673 * @returns {Promise<Object>}
674 */
675 function loadPreview(triggeredFrom, userMessage) {
676 var ajaxurl = (typeof window.ajaxurl === 'string' && window.ajaxurl)
677 ? window.ajaxurl
678 : (window.ABJ404 && window.ABJ404.ajaxurl) ? String(window.ABJ404.ajaxurl) : '/wp-admin/admin-ajax.php';
679 var nonce = (window.ABJ404 && window.ABJ404.nonces && window.ABJ404.nonces.support_request_preview)
680 ? String(window.ABJ404.nonces.support_request_preview) : '';
681 if (!nonce) {
682 return Promise.reject(new Error('missing nonce'));
683 }
684 var formData = new FormData();
685 formData.append('action', 'abj404_support_request_preview');
686 formData.append('nonce', nonce);
687 formData.append('triggered_from', triggeredFrom);
688 formData.append('user_message', userMessage || '');
689 return fetch(ajaxurl, {
690 method: 'POST',
691 credentials: 'same-origin',
692 body: formData
693 }).then(function (response) {
694 return response.json().then(function (json) {
695 if (json && json.success === true && json.data) {
696 return json.data;
697 }
698 throw new Error('preview failed');
699 });
700 });
701 }
702
703 /**
704 * Focus-trap helper. Keeps Tab / Shift-Tab inside the dialog.
705 * @param {KeyboardEvent} e
706 * @param {HTMLElement} dialog
707 */
708 function trapFocus(e, dialog) {
709 var focusables = dialog.querySelectorAll(
710 'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])'
711 );
712 if (!focusables.length) { return; }
713 var first = focusables[0];
714 var last = focusables[focusables.length - 1];
715 var active = document.activeElement;
716 if (e.shiftKey && active === first) {
717 e.preventDefault();
718 last.focus();
719 } else if (!e.shiftKey && active === last) {
720 e.preventDefault();
721 first.focus();
722 }
723 }
724
725 /**
726 * Auto-mount every .abj404-support-request-mount on the page using
727 * its data-* attributes. Idempotent: a div that already has a
728 * mounted button is skipped.
729 *
730 * After mounting, applies the URL-driven auto-open behavior: when
731 * the request arrives at the plugin's Settings or degraded-admin
732 * screen with `?abj404_support_open=1` (and optional
733 * `abj404_support_trigger=<slug>`), the matching mount's modal is
734 * opened immediately. This is how the Plugins-page row action and
735 * other deep links land the user directly on the support modal.
736 */
737 function mountAll() {
738 var mounts = document.querySelectorAll(SELECTOR);
739 var firstMountedController = null;
740 var triggerMatchController = null;
741 var requestedTrigger = readAutoOpenTrigger();
742 var shouldAutoOpen = autoOpenRequested();
743 for (var i = 0; i < mounts.length; i++) {
744 var node = mounts[i];
745 if (node.getAttribute('data-abj404-srb-mounted') === '1') {
746 continue;
747 }
748 var triggeredFrom = node.getAttribute('data-triggered-from') || '';
749 var contextSummary = node.getAttribute('data-context-summary') || '';
750 var controller = mount(node, { triggered_from: triggeredFrom, context_summary: contextSummary });
751 node.setAttribute('data-abj404-srb-mounted', '1');
752 if (!firstMountedController) {
753 firstMountedController = controller;
754 }
755 if (requestedTrigger && triggeredFrom === requestedTrigger && !triggerMatchController) {
756 triggerMatchController = controller;
757 }
758 }
759 // Link-style triggers (e.g. the wp-admin/plugins.php row-meta
760 // entry) open the modal in-place without leaving the host
761 // page. Same idempotency contract as the mount divs above.
762 var linkTriggers = document.querySelectorAll(LINK_SELECTOR);
763 for (var j = 0; j < linkTriggers.length; j++) {
764 var linkNode = linkTriggers[j];
765 if (linkNode.getAttribute('data-abj404-srb-mounted') === '1') {
766 continue;
767 }
768 var linkTriggeredFrom = linkNode.getAttribute('data-triggered-from') || '';
769 var linkContextSummary = linkNode.getAttribute('data-context-summary') || '';
770 var linkController = attachLink(linkNode, {
771 triggered_from: linkTriggeredFrom,
772 context_summary: linkContextSummary
773 });
774 linkNode.setAttribute('data-abj404-srb-mounted', '1');
775 if (!firstMountedController) {
776 firstMountedController = linkController;
777 }
778 if (requestedTrigger && linkTriggeredFrom === requestedTrigger && !triggerMatchController) {
779 triggerMatchController = linkController;
780 }
781 }
782 if (shouldAutoOpen) {
783 var target = triggerMatchController || firstMountedController;
784 if (target && typeof target.openModal === 'function') {
785 target.openModal();
786 }
787 }
788 }
789
790 /**
791 * Returns true when the current URL signals that a support modal
792 * should auto-open on page load. Two signals:
793 * - query arg `abj404_support_open=1` (durable across refresh)
794 * - fragment `#abj404-support-request` (anchor target on the
795 * Settings page, so the section is in view AND the modal opens)
796 */
797 function autoOpenRequested() {
798 try {
799 var loc = window.location || {};
800 var search = String(loc.search || '');
801 if (search.indexOf('abj404_support_open=1') !== -1) {
802 return true;
803 }
804 var hash = String(loc.hash || '');
805 if (hash === '#abj404-support-request') {
806 return true;
807 }
808 // allow-silent-catch: defensive guard for non-browser test harnesses where window.location is mocked or absent; auto-open is a UX nicety and must never throw on the boot path
809 } catch (e) {
810 return false;
811 }
812 return false;
813 }
814
815 /**
816 * Optional trigger slug hint from the deep link. When present we
817 * prefer the matching mount (`data-triggered-from`) over the first
818 * one on the page, so a row-action click that says "I came from the
819 * plugins page" opens the mount marked as plugins_row_action.
820 */
821 function readAutoOpenTrigger() {
822 try {
823 var loc = window.location || {};
824 var search = String(loc.search || '');
825 var match = search.match(/[?&]abj404_support_trigger=([^&#]+)/);
826 if (match) {
827 return decodeURIComponent(match[1]);
828 }
829 // allow-silent-catch: defensive guard for non-browser test harnesses where window.location is mocked or absent; trigger hint is optional and must never throw on the boot path
830 } catch (e) {
831 return '';
832 }
833 return '';
834 }
835
836 window.ABJ404 = window.ABJ404 || {};
837 window.ABJ404.SupportRequestButton = {
838 mount: mount,
839 attachLink: attachLink,
840 mountAll: mountAll,
841 // Exposed for the JS unit test; not part of the public API.
842 __loadPreview: loadPreview
843 };
844
845 if (document.readyState === 'loading') {
846 document.addEventListener('DOMContentLoaded', mountAll);
847 } else {
848 mountAll();
849 }
850
851 })(window, document);
852