| 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 |
|