| @@ -49,8 +49,44 @@ | ||
| 49 | 49 | return window.MetasyncConfig && window.MetasyncConfig.ottoName ? window.MetasyncConfig.ottoName : 'OTTO'; |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | /** |
| 53 | + * Whether a Search Atlas API key is currently configured. | |
| 54 | + * | |
| 55 | + * The key is no longer rendered into an editable input — it lives encrypted | |
| 56 | + * server-side and is shown only as a masked, read-only display. Presence is | |
| 57 | + * signalled by the data-has-key attribute on that display element, so the | |
| 58 | + * cleartext key never needs to exist in the DOM for the UI to know it is set. | |
| 59 | + * | |
| 60 | + * @returns {boolean} | |
| 61 | + */ | |
| 62 | + function searchAtlasKeyConfigured() { | |
| 63 | + var $display = $('#searchatlas-api-key-display'); | |
| 64 | + if ($display.length === 0) { | |
| 65 | + return false; | |
| 66 | + } | |
| 67 | + return $display.attr('data-has-key') === '1'; | |
| 68 | + } | |
| 69 | + | |
| 70 | + /** | |
| 71 | + * Update the read-only masked API key display. | |
| 72 | + * | |
| 73 | + * @param {string} maskedKey Server-provided masked value (e.g. "••••••••ab12"), | |
| 74 | + * or falsy to render the "Not configured" state. | |
| 75 | + */ | |
| 76 | + function setSearchAtlasMaskedDisplay(maskedKey) { | |
| 77 | + var $display = $('#searchatlas-api-key-display'); | |
| 78 | + if ($display.length === 0) { | |
| 79 | + return; | |
| 80 | + } | |
| 81 | + if (maskedKey) { | |
| 82 | + $display.text(maskedKey).attr('data-has-key', '1'); | |
| 83 | + } else { | |
| 84 | + $display.html('<em style="color:#646970;">Not configured</em>').attr('data-has-key', '0'); | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + /** | |
| 53 | 89 | * Update integration status indicator in header |
| 54 | 90 | * @param {boolean} isIntegrated - Whether the integration is active |
| 55 | 91 | * @param {string} statusText - Status text to display |
| 56 | 92 | * @param {string} titleText - Tooltip text |
| @@ -166,8 +202,134 @@ | ||
| 166 | 202 | return whitelabelFields.join('&'); |
| 167 | 203 | } |
| 168 | 204 | |
| 169 | 205 | /** |
| 206 | + * White-label branding fields that are mirrored into the metasync.php | |
| 207 | + * plugin header. Keyed by the form input name. | |
| 208 | + */ | |
| 209 | + var brandingFieldConfigs = { | |
| 210 | + 'metasync_options[general][white_label_plugin_name]': { label: 'Plugin Name', url: false }, | |
| 211 | + 'metasync_options[general][white_label_plugin_description]': { label: 'Plugin Description', url: false }, | |
| 212 | + 'metasync_options[general][white_label_plugin_author]': { label: 'Author', url: false }, | |
| 213 | + 'metasync_options[general][white_label_plugin_author_uri]': { label: 'Author URL', url: true }, | |
| 214 | + 'metasync_options[general][white_label_plugin_uri]': { label: 'Plugin URL', url: true } | |
| 215 | + }; | |
| 216 | + | |
| 217 | + /** | |
| 218 | + * Client-side mirror of the server-side plugin-header contract for the | |
| 219 | + * white-label branding fields, for immediate feedback while typing or | |
| 220 | + * submitting. The server-side validation stays authoritative. | |
| 221 | + * | |
| 222 | + * @param {string} value Trimmed field value. | |
| 223 | + * @param {boolean} isUrl Whether the value must be an http(s) URL. | |
| 224 | + * @returns {string} Empty when valid, otherwise the problem description. | |
| 225 | + */ | |
| 226 | + function validateBrandingValue(value, isUrl) { | |
| 227 | + if (value === '') { | |
| 228 | + return ''; | |
| 229 | + } | |
| 230 | + if (/[\r\n]/.test(value)) { | |
| 231 | + return 'must be a single line'; | |
| 232 | + } | |
| 233 | + if (value.indexOf('*/') !== -1) { | |
| 234 | + return 'must not contain "*/"'; | |
| 235 | + } | |
| 236 | + if (/<\?php|\?>/i.test(value)) { | |
| 237 | + return 'must not contain PHP tags'; | |
| 238 | + } | |
| 239 | + // Control characters, line/paragraph separators, and invisible format | |
| 240 | + // characters (including the bidirectional marks used for brand spoofing). | |
| 241 | + // Unicode property escapes cover exactly the ranges the server checks | |
| 242 | + // (Cc, Cf, Zl, Zp) without literal control-character sequences. | |
| 243 | + try { | |
| 244 | + if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) { | |
| 245 | + return 'must not contain control or invisible characters'; | |
| 246 | + } | |
| 247 | + } catch (e) { | |
| 248 | + // Very old engines without Unicode property escapes: fall through, | |
| 249 | + // the server-side check remains authoritative. | |
| 250 | + } | |
| 251 | + // Mirror of the server allow-list: letters (incl. Unicode), marks, | |
| 252 | + // numbers, punctuation, symbols and space separators (incl. NBSP and | |
| 253 | + // the ideographic space, which the server-side contract accepts). | |
| 254 | + try { | |
| 255 | + if (!/^[\p{L}\p{M}\p{N}\p{P}\p{S}\p{Zs}]+$/u.test(value)) { | |
| 256 | + return 'contains characters that are not allowed'; | |
| 257 | + } | |
| 258 | + } catch (e) { | |
| 259 | + // Very old engines without Unicode property escapes: fall through, | |
| 260 | + // the server-side check remains authoritative. | |
| 261 | + } | |
| 262 | + if (isUrl) { | |
| 263 | + var parsed; | |
| 264 | + try { | |
| 265 | + parsed = new URL(value); | |
| 266 | + } catch (e) { | |
| 267 | + return 'must be a valid http:// or https:// address'; | |
| 268 | + } | |
| 269 | + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { | |
| 270 | + return 'must be a valid http:// or https:// address'; | |
| 271 | + } | |
| 272 | + } | |
| 273 | + return ''; | |
| 274 | + } | |
| 275 | + | |
| 276 | + /** | |
| 277 | + * Remove all inline white-label branding field errors. | |
| 278 | + */ | |
| 279 | + function clearBrandingFieldErrors() { | |
| 280 | + $('.metasync-branding-field-error').remove(); | |
| 281 | + } | |
| 282 | + | |
| 283 | + /** | |
| 284 | + * Show an inline error beside a branding input. Accepts either the full | |
| 285 | + * input name or the bare setting key (as returned by the server). | |
| 286 | + * | |
| 287 | + * @param {string} fieldKey Input name or white-label setting key. | |
| 288 | + * @param {string} message Error message to display. | |
| 289 | + */ | |
| 290 | + function showBrandingFieldError(fieldKey, message) { | |
| 291 | + var inputName = brandingFieldConfigs[fieldKey] | |
| 292 | + ? fieldKey | |
| 293 | + : 'metasync_options[general][' + fieldKey + ']'; | |
| 294 | + var input = $('input[name="' + inputName + '"]'); | |
| 295 | + if (input.length === 0) { | |
| 296 | + return; | |
| 297 | + } | |
| 298 | + input.last().after( | |
| 299 | + $('<p>').addClass('description metasync-branding-field-error') | |
| 300 | + .css({ color: 'var(--dashboard-error, #b32d2e)', fontWeight: 600, marginTop: '4px' }) | |
| 301 | + .text(message) | |
| 302 | + ); | |
| 303 | + } | |
| 304 | + | |
| 305 | + /** | |
| 306 | + * Validate every branding input currently in the DOM (both save paths — | |
| 307 | + * the form submit handler and the floating "Save Now" button — call this). | |
| 308 | + * Renders the inline per-field errors and returns the message list. | |
| 309 | + * | |
| 310 | + * @returns {string[]} Error messages; empty when everything is valid. | |
| 311 | + */ | |
| 312 | + function validateBrandingFieldsInDom() { | |
| 313 | + clearBrandingFieldErrors(); | |
| 314 | + var problems = []; | |
| 315 | + Object.keys(brandingFieldConfigs).forEach(function (inputName) { | |
| 316 | + var config = brandingFieldConfigs[inputName]; | |
| 317 | + var input = $('input[name="' + inputName + '"]'); | |
| 318 | + if (input.length === 0) { | |
| 319 | + return; | |
| 320 | + } | |
| 321 | + var problem = validateBrandingValue(String(input.val() || '').trim(), config.url); | |
| 322 | + if (problem) { | |
| 323 | + var fullMessage = config.label + ' ' + problem + '.'; | |
| 324 | + showBrandingFieldError(inputName, fullMessage); | |
| 325 | + problems.push(fullMessage); | |
| 326 | + } | |
| 327 | + }); | |
| 328 | + return problems; | |
| 329 | + } | |
| 330 | + | |
| 331 | + /** | |
| 170 | 332 | * Display notice message in plugin area |
| 171 | 333 | * @param {string} type - Notice type: 'success' or 'error' |
| 172 | 334 | * @param {string} title - Notice title |
| 173 | 335 | * @param {string} message - Notice message |
| @@ -235,9 +397,10 @@ | ||
| 235 | 397 | |
| 236 | 398 | function metasyncLGLogin(user, pass) { |
| 237 | 399 | jQuery.post(ajaxurl, { |
| 238 | 400 | action: 'metasync_lglogin', |
| 239 | - username: user, password: pass | |
| 401 | + username: user, password: pass, | |
| 402 | + nonce: metaSync.lglogin_nonce || '' | |
| 240 | 403 | }, function (response) { |
| 241 | 404 | if (typeof response.token !== 'undefined') { |
| 242 | 405 | $('#linkgraph_token').val(response.token); |
| 243 | 406 | $('#linkgraph_customer_id').val(response.customer_id); |
| @@ -470,9 +633,9 @@ | ||
| 470 | 633 | |
| 471 | 634 | function resetConnectButton() { |
| 472 | 635 | var $button = $('#connect-searchatlas-btn'); |
| 473 | 636 | var $progressContainer = $('.metasync-sa-connect-progress'); |
| 474 | - var hasApiKey = $('#searchatlas-api-key').val().trim() !== ''; | |
| 637 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 475 | 638 | |
| 476 | 639 | $button.prop('disabled', false) |
| 477 | 640 | .removeClass('connecting authenticating success dashboard-loading') // Remove all loading classes |
| 478 | 641 | .html(hasApiKey ? '🔄 Re-authenticate with ' + getPluginName() : '🔗 Connect to ' + getPluginName()); |
| @@ -480,9 +643,14 @@ | ||
| 480 | 643 | } |
| 481 | 644 | |
| 482 | 645 | function startSearchAtlasPolling(nonceToken) { |
| 483 | 646 | var pollCount = 0; |
| 484 | - var maxPolls = 12; // Poll for 60 seconds (12 * 5 seconds) | |
| 647 | + // WP-558 (Option A): poll for 2 minutes (24 * 5s). Each poll also drives | |
| 648 | + // the outbound pull-based connect fallback server-side, which needs a | |
| 649 | + // slightly longer window than the legacy push-only 60s to ride out the | |
| 650 | + // human SSO-authorization delay. ~24 requests stays inside the CA | |
| 651 | + // endpoint's 120/hour per-domain budget. | |
| 652 | + var maxPolls = 24; // Poll for 120 seconds (24 * 5 seconds) | |
| 485 | 653 | var $progressContainer = $('.metasync-sa-connect-progress'); |
| 486 | 654 | var $progressFill = $('.metasync-sa-connect-progress-fill'); |
| 487 | 655 | var $progressText = $('.metasync-sa-connect-progress-text'); |
| 488 | 656 | var $button = $('#connect-searchatlas-btn'); |
| @@ -521,20 +689,20 @@ | ||
| 521 | 689 | |
| 522 | 690 | // ✅ Reset the authentication flow while keeping the timeout component |
| 523 | 691 | resetConnectButton(); |
| 524 | 692 | |
| 525 | - showConnectError('⏰ Authentication Timeout', | |
| 526 | - 'The authentication process timed out after 60 seconds. Please complete the authentication more quickly or check for network issues.', | |
| 693 | + showConnectError('⏰ Authentication Timeout', | |
| 694 | + 'The authentication process timed out after 2 minutes. Please complete the authentication more quickly or check for network issues.', | |
| 527 | 695 | [{ |
| 528 | 696 | text: '🔄 Try Again', |
| 529 | 697 | action: function () { |
| 530 | - handleSearchAtlasConnect(); | |
| 698 | + handleSearchAtlasConnect(); | |
| 531 | 699 | } |
| 532 | 700 | }, { |
| 533 | 701 | text: '💬 Contact Support', |
| 534 | - action: function () { | |
| 702 | + action: function () { | |
| 535 | 703 | var supportEmail = metaSync.support_email || '[email protected]'; |
| 536 | - window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (30s)', '_blank'); | |
| 704 | + window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (120s)', '_blank'); | |
| 537 | 705 | } |
| 538 | 706 | }] |
| 539 | 707 | ); |
| 540 | 708 | return; |
| @@ -569,9 +737,9 @@ | ||
| 569 | 737 | |
| 570 | 738 | // Handle different status codes with enhanced UX |
| 571 | 739 | if (statusCode === 200) { |
| 572 | 740 | // Success: Update all UI elements to reflect connected state |
| 573 | - updateUIForConnectedState(response.data.api_key, response.data.otto_pixel_uuid); | |
| 741 | + updateUIForConnectedState(response.data.masked_key, response.data.otto_pixel_uuid); | |
| 574 | 742 | |
| 575 | 743 | // Refresh Plugin Auth Token field to show the auto-generated token |
| 576 | 744 | // This will either show the existing token or the newly auto-generated one |
| 577 | 745 | $.ajax({ |
| @@ -602,9 +770,9 @@ | ||
| 602 | 770 | $button.closest('.metasync-sa-connect-container').removeClass('metasync-sa-connect-success-animation'); |
| 603 | 771 | }, 600); |
| 604 | 772 | |
| 605 | 773 | // Track 1-click activation in GA4 |
| 606 | - var hasExistingApiKey = $('#searchatlas-api-key').val() && $('#searchatlas-api-key').val().trim() !== ''; | |
| 774 | + var hasExistingApiKey = searchAtlasKeyConfigured(); | |
| 607 | 775 | $.ajax({ |
| 608 | 776 | url: metaSync.ajax_url, |
| 609 | 777 | type: 'POST', |
| 610 | 778 | data: { |
| @@ -737,9 +905,9 @@ | ||
| 737 | 905 | var timeElapsed = currentPoll * 5; |
| 738 | 906 | var timeRemaining = (maxPolls - currentPoll) * 5; |
| 739 | 907 | $progressTime.text(timeElapsed + 's elapsed, ' + timeRemaining + 's remaining'); |
| 740 | 908 | |
| 741 | - // Update progress text based on time elapsed (optimized for 60-second timeout) | |
| 909 | + // Update progress text based on time elapsed (optimized for 120-second timeout) | |
| 742 | 910 | var progressMessages = [ |
| 743 | 911 | 'Establishing connection and opening authentication window...', |
| 744 | 912 | 'Please complete authentication in the popup window...', |
| 745 | 913 | 'Almost done! Finalizing your authentication...' |
| @@ -879,9 +1047,18 @@ | ||
| 879 | 1047 | }, { |
| 880 | 1048 | text: '📚 Learn More About Registration', |
| 881 | 1049 | action: function () { |
| 882 | 1050 | var docDomain = metaSync.documentation_domain || 'https://searchatlas.com'; |
| 883 | - window.open(docDomain, '_blank'); | |
| 1051 | + try { | |
| 1052 | + var parsedDoc = new URL(docDomain); | |
| 1053 | + if (parsedDoc.protocol === 'https:' || parsedDoc.protocol === 'http:') { | |
| 1054 | + var a = document.createElement('a'); | |
| 1055 | + a.href = parsedDoc.href; | |
| 1056 | + a.target = '_blank'; | |
| 1057 | + a.rel = 'noopener'; | |
| 1058 | + a.click(); | |
| 1059 | + } | |
| 1060 | + } catch (e) { /* invalid URL */ } | |
| 884 | 1061 | } |
| 885 | 1062 | }, { |
| 886 | 1063 | text: '🔄 Try Authentication Again', |
| 887 | 1064 | action: function () { |
| @@ -963,9 +1140,9 @@ | ||
| 963 | 1140 | 'Check if firewall is blocking the request' |
| 964 | 1141 | ]; |
| 965 | 1142 | } else if (context === 'timeout') { |
| 966 | 1143 | recoverySuggestions = [ |
| 967 | - 'Complete authentication within 60 seconds', | |
| 1144 | + 'Complete authentication within 2 minutes', | |
| 968 | 1145 | 'Check if the popup window needs attention', |
| 969 | 1146 | 'Ensure you have your ' + getPluginName() + ' login ready', |
| 970 | 1147 | 'Try the authentication process again', |
| 971 | 1148 | 'Contact support if timeouts persist' |
| @@ -1095,17 +1272,17 @@ | ||
| 1095 | 1272 | |
| 1096 | 1273 | // Add connection status indicator |
| 1097 | 1274 | function updateConnectionStatus() { |
| 1098 | 1275 | var $button = $('#connect-searchatlas-btn'); |
| 1099 | - var $apiKeyField = $('#searchatlas-api-key'); | |
| 1100 | - | |
| 1101 | - // Only update status if we're on a page with the API key field (General Settings) | |
| 1276 | + var $apiKeyField = $('#searchatlas-api-key-display'); | |
| 1277 | + | |
| 1278 | + // Only update status if we're on a page with the API key display (General Settings) | |
| 1102 | 1279 | // On other pages, preserve the PHP-determined status in the header |
| 1103 | 1280 | if ($apiKeyField.length === 0) { |
| 1104 | - return; // Don't update status on pages without the API key field | |
| 1281 | + return; // Don't update status on pages without the API key display | |
| 1105 | 1282 | } |
| 1106 | - | |
| 1107 | - var hasApiKey = $apiKeyField.val() && $apiKeyField.val().trim() !== ''; | |
| 1283 | + | |
| 1284 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 1108 | 1285 | var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== ''; |
| 1109 | 1286 | var isFullyConnected = hasApiKey && hasOttoUuid; |
| 1110 | 1287 | |
| 1111 | 1288 | // Update button text based on connection state |
| @@ -1118,11 +1295,24 @@ | ||
| 1118 | 1295 | $button.html('🔗 Connect to ' + getPluginName()); |
| 1119 | 1296 | } |
| 1120 | 1297 | } |
| 1121 | 1298 | |
| 1122 | - // Update header status indicator (only on General Settings page) | |
| 1123 | - if (isFullyConnected) { | |
| 1299 | + // Update header status indicator (only on General Settings page). | |
| 1300 | + // the header badge must reflect the confirmed heartbeat | |
| 1301 | + // round-trip (metaSync.is_connected), NOT mere API-key/UUID presence — | |
| 1302 | + // otherwise a revoked/invalid key still shows "Connected" because the | |
| 1303 | + // key string and UUID remain stored locally. | |
| 1304 | + // NOTE: wp_localize_script stringifies PHP booleans (true -> "1", | |
| 1305 | + // false -> ""), so normalise to a real boolean rather than ===. | |
| 1306 | + var heartbeatConnected = metaSync.is_connected === true | |
| 1307 | + || metaSync.is_connected === '1' | |
| 1308 | + || metaSync.is_connected === 1; | |
| 1309 | + if (isFullyConnected && heartbeatConnected) { | |
| 1124 | 1310 | updateHeaderStatus(true, 'Synced', getPluginName() + ' API key and ' + getOttoName() + ' UUID are configured'); |
| 1311 | + } else if (isFullyConnected) { | |
| 1312 | + // Key + UUID are configured but the heartbeat is not succeeding | |
| 1313 | + // (e.g. the key was revoked on the Search Atlas side). | |
| 1314 | + updateHeaderStatus(false, 'Not Connected', getPluginName() + ' API key is set but the heartbeat is not responding — the connection may be revoked or unreachable.'); | |
| 1125 | 1315 | } else if (hasApiKey && !hasOttoUuid) { |
| 1126 | 1316 | updateHeaderStatus(false, 'Warning', 'Connected but ' + getOttoName() + ' UUID is missing — deploys will not work. Please reconnect.'); |
| 1127 | 1317 | } else { |
| 1128 | 1318 | updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID'); |
| @@ -1128,11 +1318,11 @@ | ||
| 1128 | 1318 | updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID'); |
| 1129 | 1319 | } |
| 1130 | 1320 | } |
| 1131 | 1321 | |
| 1132 | - // Monitor API key field changes | |
| 1133 | - $('#searchatlas-api-key').on('input', updateConnectionStatus); | |
| 1134 | - | |
| 1322 | + // The API key is no longer manually editable (one-click auth only), so there | |
| 1323 | + // is no input event to monitor; status changes are driven by connect/disconnect. | |
| 1324 | + | |
| 1135 | 1325 | // Initial status update |
| 1136 | 1326 | updateConnectionStatus(); |
| 1137 | 1327 | |
| 1138 | 1328 | // Initialize dashboard iframe functionality |
| @@ -1141,13 +1331,12 @@ | ||
| 1141 | 1331 | // Settings dropdown now handled by inline script in HTML |
| 1142 | 1332 | |
| 1143 | 1333 | // Add debug function for connection status (accessible in console) |
| 1144 | 1334 | window.debugConnectionStatus = function () { |
| 1145 | - var apiKey = $('#searchatlas-api-key').val(); | |
| 1146 | - var hasApiKey = apiKey.trim() !== ''; | |
| 1147 | - | |
| 1335 | + var hasApiKey = searchAtlasKeyConfigured(); | |
| 1336 | + | |
| 1148 | 1337 | console.log('🔍 Connection Status Debug:', { |
| 1149 | - searchatlas_api_key: hasApiKey ? (apiKey.substring(0, 8) + '...') : 'EMPTY', | |
| 1338 | + searchatlas_credential_state: hasApiKey ? 'CONFIGURED (masked)' : 'EMPTY', | |
| 1150 | 1339 | otto_pixel_uuid: metaSync.otto_pixel_uuid || 'NOT SET', |
| 1151 | 1340 | connection_state: hasApiKey && metaSync.otto_pixel_uuid ? 'CONNECTED' : |
| 1152 | 1341 | hasApiKey ? 'PARTIAL (Missing ' + getOttoName() + ' UUID)' : 'NOT CONNECTED', |
| 1153 | 1342 | dashboard_tab_visible: hasApiKey && metaSync.otto_pixel_uuid ? 'YES' : 'NO', |
| @@ -1269,9 +1458,8 @@ | ||
| 1269 | 1458 | } |
| 1270 | 1459 | |
| 1271 | 1460 | var $resetButton = $('#reset-searchatlas-auth'); |
| 1272 | 1461 | var $connectButton = $('#connect-searchatlas-btn'); |
| 1273 | - var $apiKeyField = $('#searchatlas-api-key'); | |
| 1274 | 1462 | |
| 1275 | 1463 | // Show loading state (prevent dashboard.js conflicts) |
| 1276 | 1464 | $resetButton.prop('disabled', true) |
| 1277 | 1465 | .addClass('no-loading') // Prevent dashboard.js interference |
| @@ -1290,11 +1478,11 @@ | ||
| 1290 | 1478 | nonce: metaSync.reset_auth_nonce |
| 1291 | 1479 | }, |
| 1292 | 1480 | success: function (response) { |
| 1293 | 1481 | if (response.success) { |
| 1294 | - // Clear the API key field | |
| 1295 | - $apiKeyField.val(''); | |
| 1296 | - | |
| 1482 | + // Reset the masked display to the "Not configured" state | |
| 1483 | + setSearchAtlasMaskedDisplay(''); | |
| 1484 | + | |
| 1297 | 1485 | // Update button states |
| 1298 | 1486 | $connectButton.html('🔗 Connect to ' + getPluginName()); |
| 1299 | 1487 | $resetButton.remove(); // Remove reset button since no longer connected |
| 1300 | 1488 | |
| @@ -1389,10 +1577,18 @@ | ||
| 1389 | 1577 | /** |
| 1390 | 1578 | * Update UI elements when account is disconnected |
| 1391 | 1579 | */ |
| 1392 | 1580 | function updateUIForDisconnectedState() { |
| 1581 | + // a cached manual-sync cooldown must not survive disconnect — | |
| 1582 | + // it keeps the Sync Now button locked after the user reconnects even | |
| 1583 | + // though the server-side cooldown stamp was cleared with the auth data. | |
| 1584 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 1585 | + refreshManualSyncCooldownUI(); | |
| 1586 | + $('#sendAuthToken').prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now'); | |
| 1587 | + | |
| 1393 | 1588 | // Update API key field placeholder |
| 1394 | - $('#searchatlas-api-key').attr('placeholder', 'Your API key will appear here after authentication'); | |
| 1589 | + // Reset the masked API key display to the "Not configured" state | |
| 1590 | + setSearchAtlasMaskedDisplay(''); | |
| 1395 | 1591 | |
| 1396 | 1592 | // ✅ Clear OTTO Pixel UUID field |
| 1397 | 1593 | $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(''); |
| 1398 | 1594 | |
| @@ -1442,14 +1638,13 @@ | ||
| 1442 | 1638 | /** |
| 1443 | 1639 | * Update UI elements when account is connected/authenticated |
| 1444 | 1640 | * Complementary function to updateUIForDisconnectedState() |
| 1445 | 1641 | */ |
| 1446 | - function updateUIForConnectedState(apiKey, ottoPixelUuid) { | |
| 1447 | - // Update API key field | |
| 1448 | - if (apiKey) { | |
| 1449 | - $('#searchatlas-api-key').val(apiKey); | |
| 1450 | - } | |
| 1451 | - | |
| 1642 | + function updateUIForConnectedState(maskedKey, ottoPixelUuid) { | |
| 1643 | + // Update the read-only masked API key display. Only the masked value is | |
| 1644 | + // ever shown — the cleartext key is never rendered into the DOM. | |
| 1645 | + setSearchAtlasMaskedDisplay(maskedKey || '••••••••'); | |
| 1646 | + | |
| 1452 | 1647 | // ✅ Update OTTO Pixel UUID field |
| 1453 | 1648 | if (ottoPixelUuid) { |
| 1454 | 1649 | $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(ottoPixelUuid); |
| 1455 | 1650 | } |
| @@ -1580,9 +1775,23 @@ | ||
| 1580 | 1775 | $('#' + id).val(''); |
| 1581 | 1776 | } |
| 1582 | 1777 | } |
| 1583 | 1778 | |
| 1779 | + function updateAnalyticsConsentStatus() { | |
| 1780 | + var $toggle = $('#metasync_analytics_opt_in'); | |
| 1781 | + var $status = $('.metasync-consent-status'); | |
| 1782 | + if (!$toggle.length || !$status.length) { | |
| 1783 | + return; | |
| 1784 | + } | |
| 1785 | + | |
| 1786 | + var label = $toggle.is(':checked') ? $status.data('enabled-label') : $status.data('disabled-label'); | |
| 1787 | + $status.text(label); | |
| 1788 | + } | |
| 1789 | + | |
| 1790 | + $(document).on('change', '#metasync_analytics_opt_in', updateAnalyticsConsentStatus); | |
| 1791 | + | |
| 1584 | 1792 | $(function () { |
| 1793 | + updateAnalyticsConsentStatus(); | |
| 1585 | 1794 | $('#addNewTime').on('click', function () { |
| 1586 | 1795 | $('#daysTime').append( |
| 1587 | 1796 | '<li>' + |
| 1588 | 1797 | '<select name="metasync_options[localseo][days][]">' + |
| @@ -1630,31 +1839,10 @@ | ||
| 1630 | 1839 | }); |
| 1631 | 1840 | $(document).on('click', '#number-delete', deleteNumber); |
| 1632 | 1841 | }); |
| 1633 | 1842 | |
| 1634 | - function deleteSourceUrl() { | |
| 1635 | - $(this).parent().remove(); | |
| 1636 | - } | |
| 1637 | 1843 | $(function () { |
| 1638 | - $('#addNewSourceUrl').on('click', function () { | |
| 1639 | - $('#source_urls').append( | |
| 1640 | - '<li>' + | |
| 1641 | - '<input type="text" class="regular-text" name="source_url[]">' + | |
| 1642 | - '<select name="search_type[]">' + | |
| 1643 | - '<option value="exact">Exact</option>' + | |
| 1644 | - '<option value="contain">Contain</option>' + | |
| 1645 | - '<option value="start">Start With</option>' + | |
| 1646 | - '<option value="end">End With</option>' + | |
| 1647 | - '</select>' + | |
| 1648 | - '<button id="source_url_delete">Remove</button>' + | |
| 1649 | - '</li>'); | |
| 1650 | - return; | |
| 1651 | - }); | |
| 1652 | - $(document).on('click', '#source_url_delete', deleteSourceUrl); | |
| 1653 | - }); | |
| 1654 | 1844 | |
| 1655 | - $(function () { | |
| 1656 | - | |
| 1657 | 1845 | setToken(); |
| 1658 | 1846 | |
| 1659 | 1847 | $('body').on('click', '#wp_metasync_sync', function (e) { |
| 1660 | 1848 | e.preventDefault(); |
| @@ -1853,8 +2041,9 @@ | ||
| 1853 | 2041 | method: 'POST', |
| 1854 | 2042 | url: 'admin-ajax.php', |
| 1855 | 2043 | data: { |
| 1856 | 2044 | action: 'metasync_send_giapi', |
| 2045 | + nonce: metaSync.nonce, | |
| 1857 | 2046 | metasync_giapi_url: url.val(), |
| 1858 | 2047 | metasync_giapi_action: action.val() |
| 1859 | 2048 | } |
| 1860 | 2049 | }) |
| @@ -1995,17 +2184,18 @@ | ||
| 1995 | 2184 | if (showAlerts) { |
| 1996 | 2185 | $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now'); |
| 1997 | 2186 | } |
| 1998 | 2187 | |
| 1999 | - if ($('#searchatlas-api-key') && $('#searchatlas-api-key').val() === '') { | |
| 2000 | - $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key'); | |
| 2001 | - $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2188 | + if ($('#searchatlas-api-key-display').length > 0 && !searchAtlasKeyConfigured()) { | |
| 2189 | + if (!is_hb) { | |
| 2190 | + $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key'); | |
| 2191 | + $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2002 | 2192 | |
| 2003 | - // Remove stale PHP-rendered "✓ Synced" label. | |
| 2004 | - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2193 | + // Remove stale PHP-rendered "✓ Synced" label. | |
| 2194 | + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2005 | 2195 | |
| 2006 | - // Update header status to "Not Synced" for missing API key | |
| 2007 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required'); | |
| 2196 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required'); | |
| 2197 | + } | |
| 2008 | 2198 | |
| 2009 | 2199 | if (showAlerts) { |
| 2010 | 2200 | showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.'); |
| 2011 | 2201 | } |
| @@ -2010,34 +2200,23 @@ | ||
| 2010 | 2200 | showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.'); |
| 2011 | 2201 | } |
| 2012 | 2202 | |
| 2013 | 2203 | } else if (response && response.throttled) { |
| 2014 | - // Handle throttling response | |
| 2015 | - var remainingMinutes = response.remaining_minutes || 5; | |
| 2016 | - //$('#sendAuthTokenTimestamp').html('Please wait ' + remainingMinutes + ' minutes before syncing again'); | |
| 2017 | - //$('#sendAuthTokenTimestamp').css({ color: 'orange' }); | |
| 2018 | - | |
| 2019 | - // Disable sync button and show countdown | |
| 2204 | + // Compute expiry locally from a duration (remaining_seconds) so server/client | |
| 2205 | + // clock drift cannot lock the user out or let them bypass the throttle. | |
| 2206 | + var remainingSeconds = (typeof response.remaining_seconds === 'number') | |
| 2207 | + ? response.remaining_seconds | |
| 2208 | + : ((response.remaining_minutes || 5) * 60); | |
| 2209 | + var expiryMs = Date.now() + (remainingSeconds * 1000); | |
| 2210 | + var remainingMinutes = Math.max(1, Math.ceil(remainingSeconds / 60)); | |
| 2211 | + | |
| 2020 | 2212 | if (showAlerts) { |
| 2021 | - $('#sendAuthToken').prop('disabled', true).text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2022 | - | |
| 2023 | - // Start countdown timer | |
| 2024 | - var countdownInterval = setInterval(function () { | |
| 2025 | - remainingMinutes--; | |
| 2026 | - if (remainingMinutes <= 0) { | |
| 2027 | - clearInterval(countdownInterval); | |
| 2028 | - $('#sendAuthToken').prop('disabled', false).text('🔄 Sync Now'); | |
| 2029 | - $('#sendAuthTokenTimestamp').text('Ready to sync'); | |
| 2030 | - $('#sendAuthTokenTimestamp').css({ color: 'green' }); | |
| 2031 | - } else { | |
| 2032 | - $('#sendAuthToken').text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2033 | - //$('#sendAuthTokenTimestamp').html('Please wait ' + remainingMinutes + ' minutes before syncing again'); | |
| 2034 | - } | |
| 2035 | - }, 60000); // Update every minute | |
| 2213 | + localStorage.setItem('metasync_manual_sync_throttle_expires', String(expiryMs)); | |
| 2214 | + refreshManualSyncCooldownUI(); | |
| 2036 | 2215 | } |
| 2037 | 2216 | |
| 2038 | - // Update header status to show throttling | |
| 2039 | - updateHeaderStatus(false, 'Throttled', 'Throttled - Please wait ' + remainingMinutes + ' minutes'); | |
| 2217 | + // Throttle is transient — don't change the connection badge. | |
| 2218 | + // The toast notification already informs the user. | |
| 2040 | 2219 | |
| 2041 | 2220 | if (showAlerts) { |
| 2042 | 2221 | showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.'); |
| 2043 | 2222 | } |
| @@ -2042,17 +2221,19 @@ | ||
| 2042 | 2221 | showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.'); |
| 2043 | 2222 | } |
| 2044 | 2223 | |
| 2045 | 2224 | } else if (response && response.detail) { |
| 2046 | - $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key'); | |
| 2047 | - $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2225 | + if (!is_hb) { | |
| 2226 | + $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key'); | |
| 2227 | + $('#sendAuthTokenTimestamp').css({ color: 'red' }); | |
| 2048 | 2228 | |
| 2049 | - // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server. | |
| 2050 | - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2229 | + // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server. | |
| 2230 | + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove(); | |
| 2231 | + } | |
| 2051 | 2232 | |
| 2052 | - // Update header status to "Not Synced" for invalid API key | |
| 2233 | + // No is_hb guard here — genuinely invalid keys should always flip the badge | |
| 2053 | 2234 | updateHeaderStatus(false, 'Not Synced', 'Not Synced - Invalid API key'); |
| 2054 | - | |
| 2235 | + | |
| 2055 | 2236 | if (showAlerts) { |
| 2056 | 2237 | showSyncError('❌ Invalid API Key', 'Please provide a valid ' + getPluginName() + ' API key.'); |
| 2057 | 2238 | } |
| 2058 | 2239 | |
| @@ -2057,9 +2238,11 @@ | ||
| 2057 | 2238 | } |
| 2058 | 2239 | |
| 2059 | 2240 | } else if (response === null || !response.id) { |
| 2060 | 2241 | // Update header status to "Not Synced" after failed sync |
| 2061 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed'); | |
| 2242 | + if (!is_hb) { | |
| 2243 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed'); | |
| 2244 | + } | |
| 2062 | 2245 | |
| 2063 | 2246 | if (showAlerts) { |
| 2064 | 2247 | showSyncError('❌ Sync Failed', 'Something went wrong during synchronization. Please check your connection and try again.'); |
| 2065 | 2248 | } |
| @@ -2068,8 +2251,16 @@ | ||
| 2068 | 2251 | } else { |
| 2069 | 2252 | var dateString = dateFormat(); |
| 2070 | 2253 | $('#sendAuthTokenTimestamp').html(dateString); |
| 2071 | 2254 | $('#sendAuthTokenTimestamp').css({ color: 'green' }); |
| 2255 | + // Server starts a 5-minute manual-sync cooldown after a successful sync. | |
| 2256 | + // Reflect that on the client so the button shows the cooldown across refreshes | |
| 2257 | + // instead of misleadingly saying "Sync Now" only to be rejected on next click. | |
| 2258 | + if (!is_hb) { | |
| 2259 | + var successCooldownMs = 5 * 60 * 1000; | |
| 2260 | + localStorage.setItem('metasync_manual_sync_throttle_expires', String(Date.now() + successCooldownMs)); | |
| 2261 | + refreshManualSyncCooldownUI(); | |
| 2262 | + } | |
| 2072 | 2263 | |
| 2073 | 2264 | // Update header status — check if UUID is present before declaring fully synced |
| 2074 | 2265 | var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== ''; |
| 2075 | 2266 | if (hasOttoUuid) { |
| @@ -2084,9 +2275,11 @@ | ||
| 2084 | 2275 | } |
| 2085 | 2276 | }, |
| 2086 | 2277 | error: function (xhr, status, error) { |
| 2087 | 2278 | // Update header status to "Not Synced" for network errors |
| 2088 | - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync'); | |
| 2279 | + if (!is_hb) { | |
| 2280 | + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync'); | |
| 2281 | + } | |
| 2089 | 2282 | |
| 2090 | 2283 | // Reset button state |
| 2091 | 2284 | if (showAlerts) { |
| 2092 | 2285 | $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now'); |
| @@ -2115,9 +2308,10 @@ | ||
| 2115 | 2308 | url: ajaxurl, |
| 2116 | 2309 | type: 'GET', |
| 2117 | 2310 | data: { |
| 2118 | 2311 | action: 'metasync_clear_otto_cache', |
| 2119 | - clear_otto_cache: 1 | |
| 2312 | + clear_otto_cache: 1, | |
| 2313 | + nonce: metaSync.clear_otto_cache_nonce || '' | |
| 2120 | 2314 | }, |
| 2121 | 2315 | success: function (response) { |
| 2122 | 2316 | const now = new Date(); |
| 2123 | 2317 | $('#clear_otto_caches').text('Cache Cleared ' + now.toLocaleTimeString()); |
| @@ -2125,8 +2319,65 @@ | ||
| 2125 | 2319 | } |
| 2126 | 2320 | }); |
| 2127 | 2321 | } |
| 2128 | 2322 | |
| 2323 | + // Holds the active manual-sync countdown interval so we can replace it | |
| 2324 | + // instead of stacking multiple timers if refreshManualSyncCooldownUI() | |
| 2325 | + // is called more than once (e.g. throttled response then refresh). | |
| 2326 | + var _manualSyncCountdownInterval = null; | |
| 2327 | + | |
| 2328 | + /** | |
| 2329 | + * Read the manual-sync throttle expiry (client-time ms) from localStorage | |
| 2330 | + * and apply UI state: disable the button, show "Throttled (Nm)", and tick | |
| 2331 | + * every 15s. Idempotent — safe to call repeatedly. | |
| 2332 | + */ | |
| 2333 | + function refreshManualSyncCooldownUI() { | |
| 2334 | + var $btn = $('#sendAuthToken'); | |
| 2335 | + if ($btn.length === 0) { | |
| 2336 | + return; | |
| 2337 | + } | |
| 2338 | + if (_manualSyncCountdownInterval) { | |
| 2339 | + clearInterval(_manualSyncCountdownInterval); | |
| 2340 | + _manualSyncCountdownInterval = null; | |
| 2341 | + } | |
| 2342 | + var stored = localStorage.getItem('metasync_manual_sync_throttle_expires'); | |
| 2343 | + if (!stored) { | |
| 2344 | + return; | |
| 2345 | + } | |
| 2346 | + var storedExpiry = parseInt(stored, 10); | |
| 2347 | + if (!storedExpiry || isNaN(storedExpiry)) { | |
| 2348 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2349 | + return; | |
| 2350 | + } | |
| 2351 | + var nowMs = Date.now(); | |
| 2352 | + if (storedExpiry <= nowMs) { | |
| 2353 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2354 | + return; | |
| 2355 | + } | |
| 2356 | + var remainingMinutes = Math.max(1, Math.ceil((storedExpiry - nowMs) / 60000)); | |
| 2357 | + // addClass('is-throttled') drives the visual state via CSS so it | |
| 2358 | + // doesn't depend on the disabled attribute being preserved by other | |
| 2359 | + // code paths in the AJAX callback chain (which strip it on success). | |
| 2360 | + $btn.prop('disabled', true).addClass('is-throttled').text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2361 | + _manualSyncCountdownInterval = setInterval(function () { | |
| 2362 | + var currentNowMs = Date.now(); | |
| 2363 | + if (storedExpiry <= currentNowMs) { | |
| 2364 | + clearInterval(_manualSyncCountdownInterval); | |
| 2365 | + _manualSyncCountdownInterval = null; | |
| 2366 | + localStorage.removeItem('metasync_manual_sync_throttle_expires'); | |
| 2367 | + $btn.prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now'); | |
| 2368 | + $('#sendAuthTokenTimestamp').text('Ready to sync').css({ color: 'green' }); | |
| 2369 | + // Cooldown is over — clear the lingering "Request Throttled" admin | |
| 2370 | + // notice at the top of the page. Without this the button resets to | |
| 2371 | + // "Sync Now" but the red throttled bar stays visible (QA). | |
| 2372 | + $('.metasync-sync-notice, .metasync-sync-error').remove(); | |
| 2373 | + return; | |
| 2374 | + } | |
| 2375 | + remainingMinutes = Math.max(1, Math.ceil((storedExpiry - currentNowMs) / 60000)); | |
| 2376 | + $btn.text('⏰ Throttled (' + remainingMinutes + 'm)'); | |
| 2377 | + }, 15000); | |
| 2378 | + } | |
| 2379 | + | |
| 2129 | 2380 | jQuery(document).ready(function () { |
| 2130 | 2381 | |
| 2131 | 2382 | // sendCustomerParams(); |
| 2132 | 2383 | $('#sendAuthToken').on('click', function (e) { |
| @@ -2131,11 +2382,14 @@ | ||
| 2131 | 2382 | // sendCustomerParams(); |
| 2132 | 2383 | $('#sendAuthToken').on('click', function (e) { |
| 2133 | 2384 | e.preventDefault(); |
| 2134 | 2385 | sendCustomerParams(); |
| 2135 | - | |
| 2386 | + | |
| 2136 | 2387 | }); |
| 2137 | 2388 | |
| 2389 | + // Restore manual-sync throttle countdown state across page refreshes. | |
| 2390 | + refreshManualSyncCooldownUI(); | |
| 2391 | + | |
| 2138 | 2392 | // handle otto clear cache button |
| 2139 | 2393 | $('#clear_otto_caches').on('click', function (e){ |
| 2140 | 2394 | e.preventDefault(); |
| 2141 | 2395 | clear_otto_caches(); |
| @@ -2157,8 +2411,27 @@ | ||
| 2157 | 2411 | return true; // Let the form submit normally |
| 2158 | 2412 | } |
| 2159 | 2413 | |
| 2160 | 2414 | e.preventDefault(); // Prevent the default form submission for regular settings |
| 2415 | + | |
| 2416 | + // Immediate client-side validation of the white-label branding | |
| 2417 | + // fields (the server-side contract stays authoritative): block the | |
| 2418 | + // submit and show an error beside each invalid field instead of | |
| 2419 | + // letting an invalid value reach the server and get rejected there. | |
| 2420 | + var brandingValidationErrors = validateBrandingFieldsInDom(); | |
| 2421 | + if (brandingValidationErrors.length > 0) { | |
| 2422 | + var $brandingErrorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap'); | |
| 2423 | + var $brandingErrorList = $('<ul>'); | |
| 2424 | + brandingValidationErrors.forEach(function (errorMessage) { | |
| 2425 | + $brandingErrorList.append($('<li>').text(errorMessage)); | |
| 2426 | + }); | |
| 2427 | + $brandingErrorNotice.append($brandingErrorList); | |
| 2428 | + $('.metasync-error-wrap').remove(); | |
| 2429 | + $('#metaSyncGeneralSetting').before($brandingErrorNotice); | |
| 2430 | + $('html, body').animate({ scrollTop: 0 }, 'slow'); | |
| 2431 | + return; | |
| 2432 | + } | |
| 2433 | + | |
| 2161 | 2434 | var actionField = $(this).find('input[name="action"]'); |
| 2162 | 2435 | var optionPage= $(this).find('input[name="option_page"]'); |
| 2163 | 2436 | var wpHttpReferer= $(this).find('input[name="_wp_http_referer"]'); |
| 2164 | 2437 | var wpnonce= $(this).find('input[name="_wpnonce"]'); |
| @@ -2190,18 +2463,39 @@ | ||
| 2190 | 2463 | data: formData + '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab), // Add the action and current tab |
| 2191 | 2464 | success: function (response) { |
| 2192 | 2465 | // Handle success response |
| 2193 | 2466 | if(response.success){ |
| 2467 | + // Per-field whitelabel validation issues: the save succeeded for | |
| 2468 | + // other fields, so show a warning notice and stay on the page so the | |
| 2469 | + // user can see which values were rejected. | |
| 2470 | + const wlErrors = response.data && response.data.whitelabel_errors; | |
| 2471 | + if (wlErrors && Object.keys(wlErrors).length > 0) { | |
| 2472 | + var $warnNotice = $('<div>').addClass('notice notice-warning metasync-error-wrap'); | |
| 2473 | + var $warnList = $('<ul>'); | |
| 2474 | + Object.keys(wlErrors).forEach(function (fieldKey) { | |
| 2475 | + $warnList.append($('<li>').text(wlErrors[fieldKey])); | |
| 2476 | + }); | |
| 2477 | + $warnNotice.append($('<p>').text('Settings saved, but some values were not applied:')); | |
| 2478 | + $warnNotice.append($warnList); | |
| 2479 | + $('.metasync-error-wrap').remove(); | |
| 2480 | + $('#metaSyncGeneralSetting').before($warnNotice); | |
| 2481 | + $('html, body').animate({ scrollTop: 0 }, 'slow'); | |
| 2482 | + return; | |
| 2483 | + } | |
| 2484 | + | |
| 2194 | 2485 | // get value of input field white_label_plugin_menu_slug |
| 2195 | 2486 | const whiteLableUrl = $('#metaSyncGeneralSetting input[name="metasync_options[general][white_label_plugin_menu_slug]"]').val(); |
| 2196 | 2487 | // check condition if it is empty or not and redirect it |
| 2197 | - | |
| 2488 | + | |
| 2198 | 2489 | // add the tag query to the window location |
| 2199 | 2490 | let tabParam = new URLSearchParams(window.location.search).get('tab'); |
| 2200 | 2491 | let tabQuery = tabParam ? '&tab=' + encodeURIComponent(tabParam) : ''; |
| 2201 | - | |
| 2202 | - // Handle undefined or empty white label URL — sanitize to valid slug characters only | |
| 2203 | - const rawSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas'; | |
| 2492 | + | |
| 2493 | + // Prefer the server-sanitized slug so the redirect matches the page | |
| 2494 | + // WordPress actually registered; fall back to the raw input field value. | |
| 2495 | + const savedSlug = response.data && typeof response.data.saved_menu_slug === 'string' ? response.data.saved_menu_slug : null; | |
| 2496 | + const inputSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas'; | |
| 2497 | + const rawSlug = (savedSlug !== null) ? (savedSlug || 'searchatlas') : inputSlug; | |
| 2204 | 2498 | const pageSlug = encodeURIComponent(rawSlug.replace(/[^a-zA-Z0-9_\-]/g, '')) || 'searchatlas'; |
| 2205 | 2499 | var redirectUrl = metaSync.admin_url + '?page=' + pageSlug + tabQuery; |
| 2206 | 2500 | try { |
| 2207 | 2501 | var parsedRedirect = new URL(redirectUrl, window.location.origin); |
| @@ -2213,12 +2507,13 @@ | ||
| 2213 | 2507 | } catch (e) { /* invalid URL */ } |
| 2214 | 2508 | }else { |
| 2215 | 2509 | // Handle error response |
| 2216 | 2510 | const errors = response.data?.errors || []; |
| 2511 | + const fieldErrors = response.data?.field_errors || {}; | |
| 2217 | 2512 | |
| 2218 | 2513 | // Build error notice using DOM methods to avoid XSS |
| 2219 | 2514 | var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap'); |
| 2220 | - if (Array.isArray(errors)) { | |
| 2515 | + if (Array.isArray(errors) && errors.length > 0) { | |
| 2221 | 2516 | var $ul = $('<ul>'); |
| 2222 | 2517 | errors.forEach(function (err) { |
| 2223 | 2518 | $ul.append($('<li>').text(err)); |
| 2224 | 2519 | }); |
| @@ -2230,8 +2525,15 @@ | ||
| 2230 | 2525 | |
| 2231 | 2526 | // Insert the error message before the form |
| 2232 | 2527 | $('#metaSyncGeneralSetting').before($errorNotice); |
| 2233 | 2528 | |
| 2529 | + // Field-specific feedback: show the server's per-field | |
| 2530 | + // error beside each rejected white-label branding input. | |
| 2531 | + clearBrandingFieldErrors(); | |
| 2532 | + Object.keys(fieldErrors).forEach(function (settingKey) { | |
| 2533 | + showBrandingFieldError(settingKey, fieldErrors[settingKey]); | |
| 2534 | + }); | |
| 2535 | + | |
| 2234 | 2536 | // Scroll to the top to ensure visibility |
| 2235 | 2537 | $('html, body').animate({ scrollTop: 0 }, 'slow'); |
| 2236 | 2538 | } |
| 2237 | 2539 | |
| @@ -2468,17 +2770,47 @@ | ||
| 2468 | 2770 | |
| 2469 | 2771 | // Save Changes function - use AJAX instead of form submission |
| 2470 | 2772 | window.saveChanges = function () { |
| 2471 | 2773 | isSaving = true; |
| 2472 | - | |
| 2473 | - // Completely remove the floating notification immediately when clicked | |
| 2774 | + | |
| 2775 | + var $form = $('#metaSyncGeneralSetting'); | |
| 2776 | + if ($form.length > 0) { | |
| 2777 | + // Immediate client-side validation of the white-label branding | |
| 2778 | + // fields — the floating save bypasses the form submit handler, | |
| 2779 | + // so the same contract is enforced here too. The server-side | |
| 2780 | + // validation stays authoritative. Runs BEFORE the notification | |
| 2781 | + // is removed so a blocked save keeps the persistent unsaved- | |
| 2782 | + // changes cue visible. | |
| 2783 | + var brandingProblems = validateBrandingFieldsInDom(); | |
| 2784 | + if (brandingProblems.length > 0) { | |
| 2785 | + var $brandingNotice = $('<div>').addClass('notice notice-error metasync-save-notice').css({ margin: '20px 0', padding: '12px' }); | |
| 2786 | + var $brandingList = $('<ul>'); | |
| 2787 | + brandingProblems.forEach(function (errorMessage) { | |
| 2788 | + $brandingList.append($('<li>').text(errorMessage)); | |
| 2789 | + }); | |
| 2790 | + $brandingNotice.append($brandingList); | |
| 2791 | + $('.metasync-save-notice').remove(); | |
| 2792 | + var $navWrapperForBranding = $('.metasync-nav-wrapper'); | |
| 2793 | + if ($navWrapperForBranding.length > 0) { | |
| 2794 | + $navWrapperForBranding.after($brandingNotice); | |
| 2795 | + } else { | |
| 2796 | + $('.metasync-dashboard-wrap').prepend($brandingNotice); | |
| 2797 | + } | |
| 2798 | + $('html, body').animate({ scrollTop: 0 }, 'slow'); | |
| 2799 | + isSaving = false; | |
| 2800 | + return; | |
| 2801 | + } | |
| 2802 | + } | |
| 2803 | + | |
| 2804 | + // Completely remove the floating notification immediately when the | |
| 2805 | + // save is actually proceeding (a blocked save left it in place). | |
| 2474 | 2806 | var $notification = $('.metasync-unsaved-notification'); |
| 2475 | 2807 | if ($notification.length > 0) { |
| 2476 | 2808 | $notification.remove(); // Completely remove from DOM, no animations |
| 2477 | 2809 | } |
| 2478 | - | |
| 2479 | - var $form = $('#metaSyncGeneralSetting'); | |
| 2810 | + | |
| 2480 | 2811 | if ($form.length > 0) { |
| 2812 | + | |
| 2481 | 2813 | // Get form data and submit via AJAX |
| 2482 | 2814 | var formData = $form.serialize(); |
| 2483 | 2815 | |
| 2484 | 2816 | // Ensure ALL whitelabel data is included using helper function |
| @@ -2517,11 +2849,12 @@ | ||
| 2517 | 2849 | } else if (response.data && response.data.api_key_validated === false) { |
| 2518 | 2850 | noticeType = 'notice-error'; |
| 2519 | 2851 | noticeMessage = $('<span/>').text(response.data.warning || 'The API key could not be verified. Your other settings were saved.').html(); |
| 2520 | 2852 | noAutoDismiss = true; |
| 2521 | - // Revert the input field to the previously saved valid key from the backend | |
| 2522 | - var revertKey = (response.data.previous_api_key !== undefined) ? response.data.previous_api_key : ''; | |
| 2523 | - $('#searchatlas-api-key').val(revertKey); | |
| 2853 | + // The server reverts the stored key to the last valid one on | |
| 2854 | + // verification failure, and the masked display is read-only, so | |
| 2855 | + // nothing in the DOM needs reverting here. The cleartext key is | |
| 2856 | + // never sent to the browser. | |
| 2524 | 2857 | } else if (response.data && response.data.api_key_removed === true) { |
| 2525 | 2858 | // API key was cleared — show warning, update status, page will reload |
| 2526 | 2859 | noticeType = 'notice-warning'; |
| 2527 | 2860 | noticeMessage = $('<span/>').text((response.data.warning || 'The API key has been removed.') + ' Reloading\u2026').html(); |
| @@ -2568,8 +2901,9 @@ | ||
| 2568 | 2901 | } |
| 2569 | 2902 | } else { |
| 2570 | 2903 | // Handle validation errors |
| 2571 | 2904 | var errors = response.data && response.data.errors ? response.data.errors : []; |
| 2905 | + var fieldErrors = (response.data && response.data.field_errors) ? response.data.field_errors : {}; | |
| 2572 | 2906 | |
| 2573 | 2907 | // Build error notice using DOM methods to avoid XSS |
| 2574 | 2908 | var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap').css({ margin: '20px', padding: '12px' }); |
| 2575 | 2909 | if (Array.isArray(errors)) { |
| @@ -2593,9 +2927,16 @@ | ||
| 2593 | 2927 | } else { |
| 2594 | 2928 | // Fallback: insert at top of plugin content area |
| 2595 | 2929 | $('.metasync-dashboard-wrap').prepend($errorNotice); |
| 2596 | 2930 | } |
| 2597 | - | |
| 2931 | + | |
| 2932 | + // Field-specific feedback: show the server's per-field | |
| 2933 | + // error beside each rejected white-label branding input. | |
| 2934 | + clearBrandingFieldErrors(); | |
| 2935 | + Object.keys(fieldErrors).forEach(function (settingKey) { | |
| 2936 | + showBrandingFieldError(settingKey, fieldErrors[settingKey]); | |
| 2937 | + }); | |
| 2938 | + | |
| 2598 | 2939 | // Scroll to the error message for better visibility |
| 2599 | 2940 | $('html, body').animate({ scrollTop: 0 }, 'slow'); |
| 2600 | 2941 | |
| 2601 | 2942 | setTimeout(function () { |
| @@ -3123,26 +3464,65 @@ | ||
| 3123 | 3464 | * @param {jQuery} $trigger - The trigger button |
| 3124 | 3465 | * @param {jQuery} $tooltip - The tooltip element |
| 3125 | 3466 | */ |
| 3126 | 3467 | function positionTooltip($trigger, $tooltip) { |
| 3127 | - // Skip positioning on mobile (uses fixed positioning) | |
| 3468 | + // Mobile uses the CSS fixed bottom-sheet layout; clear any inline desktop | |
| 3469 | + // styles so the stylesheet takes over. | |
| 3128 | 3470 | if (window.innerWidth <= 768) { |
| 3471 | + $tooltip.css({ position: '', left: '', top: '', right: '', bottom: '', transform: '', margin: '' }); | |
| 3129 | 3472 | return; |
| 3130 | 3473 | } |
| 3131 | 3474 | |
| 3132 | - var triggerRect = $trigger[0].getBoundingClientRect(); | |
| 3133 | - var tooltipWidth = $tooltip.outerWidth(); | |
| 3134 | - var viewportWidth = $(window).width(); | |
| 3135 | - var spaceRight = viewportWidth - triggerRect.right; | |
| 3475 | + // Move the tooltip up to the plugin wrapper so it escapes the | |
| 3476 | + // overflow:hidden on accordion sections / cards that was clipping it. | |
| 3477 | + // The wrapper carries data-theme (so the --dashboard-* CSS vars still | |
| 3478 | + // resolve) and is position:relative (not transformed), so the | |
| 3479 | + // position:fixed below stays relative to the viewport. Verified in a | |
| 3480 | + // headless-Chrome harness before shipping. | |
| 3481 | + var portal = document.querySelector('.metasync-dashboard-wrap') || document.body; | |
| 3482 | + if ($tooltip[0].parentNode !== portal) { | |
| 3483 | + portal.appendChild($tooltip[0]); | |
| 3484 | + } | |
| 3136 | 3485 | |
| 3137 | - // Check if tooltip would overflow on the right | |
| 3138 | - if (spaceRight < tooltipWidth + 20) { | |
| 3139 | - // Position on the left side | |
| 3140 | - $tooltip.attr('data-position', 'left'); | |
| 3141 | - } else { | |
| 3142 | - // Position on the right side (default) | |
| 3143 | - $tooltip.attr('data-position', 'right'); | |
| 3144 | - } | |
| 3486 | + var margin = 12; | |
| 3487 | + var rect = $trigger[0].getBoundingClientRect(); | |
| 3488 | + var vw = window.innerWidth; | |
| 3489 | + var vh = window.innerHeight; | |
| 3490 | + var tw = $tooltip.outerWidth(); | |
| 3491 | + var th = $tooltip.outerHeight(); | |
| 3492 | + | |
| 3493 | + // Horizontal: prefer to the right of the icon, else flip left; clamp to viewport. | |
| 3494 | + var left = rect.right + margin; | |
| 3495 | + if (left + tw + margin > vw) { left = rect.left - margin - tw; } | |
| 3496 | + if (left < margin) { left = margin; } | |
| 3497 | + if (left + tw + margin > vw) { left = Math.max(margin, vw - tw - margin); } | |
| 3498 | + | |
| 3499 | + // Vertical: center on the icon, then clamp so the top/bottom is never cut off. | |
| 3500 | + var top = rect.top + (rect.height / 2) - (th / 2); | |
| 3501 | + if (top < margin) { top = margin; } | |
| 3502 | + if (top + th + margin > vh) { top = Math.max(margin, vh - th - margin); } | |
| 3503 | + | |
| 3504 | + $tooltip.find('.metasync-tooltip-arrow').css('display', 'none'); | |
| 3505 | + // Place it, then play a small left-to-right slide-in. Position is | |
| 3506 | + // handled by left/top (fixed), so transform is free for the entrance | |
| 3507 | + // animation: start ~12px to the left and slide right into place. | |
| 3508 | + $tooltip.css({ | |
| 3509 | + position: 'fixed', | |
| 3510 | + left: Math.round(left) + 'px', | |
| 3511 | + top: Math.round(top) + 'px', | |
| 3512 | + right: 'auto', | |
| 3513 | + bottom: 'auto', | |
| 3514 | + margin: '0', | |
| 3515 | + transition: 'opacity 0.2s ease', | |
| 3516 | + transform: 'translateX(-12px)' | |
| 3517 | + }); | |
| 3518 | + // Force a reflow so the start position is committed before animating. | |
| 3519 | + void $tooltip[0].offsetWidth; | |
| 3520 | + $tooltip.css({ | |
| 3521 | + transition: 'opacity 0.2s ease, transform 0.2s ease', | |
| 3522 | + transform: 'translateX(0)' | |
| 3523 | + }); | |
| 3524 | + $tooltip.removeAttr('data-position'); | |
| 3145 | 3525 | } |
| 3146 | 3526 | |
| 3147 | 3527 | // Initialize tooltip system |
| 3148 | 3528 | initTooltipSystem(); |