PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/js/metasync-admin.js +295 -99 2.6.9 → 2.7.1 View file →
@@ -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
@@ -235,9 +271,10 @@
235 271
236 272 function metasyncLGLogin(user, pass) {
237 273 jQuery.post(ajaxurl, {
238 274 action: 'metasync_lglogin',
239 - username: user, password: pass
275 + username: user, password: pass,
276 + nonce: metaSync.lglogin_nonce || ''
240 277 }, function (response) {
241 278 if (typeof response.token !== 'undefined') {
242 279 $('#linkgraph_token').val(response.token);
243 280 $('#linkgraph_customer_id').val(response.customer_id);
@@ -470,9 +507,9 @@
470 507
471 508 function resetConnectButton() {
472 509 var $button = $('#connect-searchatlas-btn');
473 510 var $progressContainer = $('.metasync-sa-connect-progress');
474 - var hasApiKey = $('#searchatlas-api-key').val().trim() !== '';
511 + var hasApiKey = searchAtlasKeyConfigured();
475 512
476 513 $button.prop('disabled', false)
477 514 .removeClass('connecting authenticating success dashboard-loading') // Remove all loading classes
478 515 .html(hasApiKey ? '🔄 Re-authenticate with ' + getPluginName() : '🔗 Connect to ' + getPluginName());
@@ -480,9 +517,14 @@
480 517 }
481 518
482 519 function startSearchAtlasPolling(nonceToken) {
483 520 var pollCount = 0;
484 - var maxPolls = 12; // Poll for 60 seconds (12 * 5 seconds)
521 + // WP-558 (Option A): poll for 2 minutes (24 * 5s). Each poll also drives
522 + // the outbound pull-based connect fallback server-side, which needs a
523 + // slightly longer window than the legacy push-only 60s to ride out the
524 + // human SSO-authorization delay. ~24 requests stays inside the CA
525 + // endpoint's 120/hour per-domain budget.
526 + var maxPolls = 24; // Poll for 120 seconds (24 * 5 seconds)
485 527 var $progressContainer = $('.metasync-sa-connect-progress');
486 528 var $progressFill = $('.metasync-sa-connect-progress-fill');
487 529 var $progressText = $('.metasync-sa-connect-progress-text');
488 530 var $button = $('#connect-searchatlas-btn');
@@ -521,20 +563,20 @@
521 563
522 564 // ✅ Reset the authentication flow while keeping the timeout component
523 565 resetConnectButton();
524 566
525 - showConnectError('⏰ Authentication Timeout',
526 - 'The authentication process timed out after 60 seconds. Please complete the authentication more quickly or check for network issues.',
567 + showConnectError('⏰ Authentication Timeout',
568 + 'The authentication process timed out after 2 minutes. Please complete the authentication more quickly or check for network issues.',
527 569 [{
528 570 text: '🔄 Try Again',
529 571 action: function () {
530 - handleSearchAtlasConnect();
572 + handleSearchAtlasConnect();
531 573 }
532 574 }, {
533 575 text: '💬 Contact Support',
534 - action: function () {
576 + action: function () {
535 577 var supportEmail = metaSync.support_email || '[email protected]';
536 - window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (30s)', '_blank');
578 + window.open('mailto:' + supportEmail + '?subject=Connect Authentication Timeout (120s)', '_blank');
537 579 }
538 580 }]
539 581 );
540 582 return;
@@ -569,9 +611,9 @@
569 611
570 612 // Handle different status codes with enhanced UX
571 613 if (statusCode === 200) {
572 614 // Success: Update all UI elements to reflect connected state
573 - updateUIForConnectedState(response.data.api_key, response.data.otto_pixel_uuid);
615 + updateUIForConnectedState(response.data.masked_key, response.data.otto_pixel_uuid);
574 616
575 617 // Refresh Plugin Auth Token field to show the auto-generated token
576 618 // This will either show the existing token or the newly auto-generated one
577 619 $.ajax({
@@ -602,9 +644,9 @@
602 644 $button.closest('.metasync-sa-connect-container').removeClass('metasync-sa-connect-success-animation');
603 645 }, 600);
604 646
605 647 // Track 1-click activation in GA4
606 - var hasExistingApiKey = $('#searchatlas-api-key').val() && $('#searchatlas-api-key').val().trim() !== '';
648 + var hasExistingApiKey = searchAtlasKeyConfigured();
607 649 $.ajax({
608 650 url: metaSync.ajax_url,
609 651 type: 'POST',
610 652 data: {
@@ -737,9 +779,9 @@
737 779 var timeElapsed = currentPoll * 5;
738 780 var timeRemaining = (maxPolls - currentPoll) * 5;
739 781 $progressTime.text(timeElapsed + 's elapsed, ' + timeRemaining + 's remaining');
740 782
741 - // Update progress text based on time elapsed (optimized for 60-second timeout)
783 + // Update progress text based on time elapsed (optimized for 120-second timeout)
742 784 var progressMessages = [
743 785 'Establishing connection and opening authentication window...',
744 786 'Please complete authentication in the popup window...',
745 787 'Almost done! Finalizing your authentication...'
@@ -879,9 +921,18 @@
879 921 }, {
880 922 text: '📚 Learn More About Registration',
881 923 action: function () {
882 924 var docDomain = metaSync.documentation_domain || 'https://searchatlas.com';
883 - window.open(docDomain, '_blank');
925 + try {
926 + var parsedDoc = new URL(docDomain);
927 + if (parsedDoc.protocol === 'https:' || parsedDoc.protocol === 'http:') {
928 + var a = document.createElement('a');
929 + a.href = parsedDoc.href;
930 + a.target = '_blank';
931 + a.rel = 'noopener';
932 + a.click();
933 + }
934 + } catch (e) { /* invalid URL */ }
884 935 }
885 936 }, {
886 937 text: '🔄 Try Authentication Again',
887 938 action: function () {
@@ -963,9 +1014,9 @@
963 1014 'Check if firewall is blocking the request'
964 1015 ];
965 1016 } else if (context === 'timeout') {
966 1017 recoverySuggestions = [
967 - 'Complete authentication within 60 seconds',
1018 + 'Complete authentication within 2 minutes',
968 1019 'Check if the popup window needs attention',
969 1020 'Ensure you have your ' + getPluginName() + ' login ready',
970 1021 'Try the authentication process again',
971 1022 'Contact support if timeouts persist'
@@ -1095,17 +1146,17 @@
1095 1146
1096 1147 // Add connection status indicator
1097 1148 function updateConnectionStatus() {
1098 1149 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)
1150 + var $apiKeyField = $('#searchatlas-api-key-display');
1151 +
1152 + // Only update status if we're on a page with the API key display (General Settings)
1102 1153 // On other pages, preserve the PHP-determined status in the header
1103 1154 if ($apiKeyField.length === 0) {
1104 - return; // Don't update status on pages without the API key field
1155 + return; // Don't update status on pages without the API key display
1105 1156 }
1106 -
1107 - var hasApiKey = $apiKeyField.val() && $apiKeyField.val().trim() !== '';
1157 +
1158 + var hasApiKey = searchAtlasKeyConfigured();
1108 1159 var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== '';
1109 1160 var isFullyConnected = hasApiKey && hasOttoUuid;
1110 1161
1111 1162 // Update button text based on connection state
@@ -1118,11 +1169,24 @@
1118 1169 $button.html('🔗 Connect to ' + getPluginName());
1119 1170 }
1120 1171 }
1121 1172
1122 - // Update header status indicator (only on General Settings page)
1123 - if (isFullyConnected) {
1173 + // Update header status indicator (only on General Settings page).
1174 + // the header badge must reflect the confirmed heartbeat
1175 + // round-trip (metaSync.is_connected), NOT mere API-key/UUID presence —
1176 + // otherwise a revoked/invalid key still shows "Connected" because the
1177 + // key string and UUID remain stored locally.
1178 + // NOTE: wp_localize_script stringifies PHP booleans (true -> "1",
1179 + // false -> ""), so normalise to a real boolean rather than ===.
1180 + var heartbeatConnected = metaSync.is_connected === true
1181 + || metaSync.is_connected === '1'
1182 + || metaSync.is_connected === 1;
1183 + if (isFullyConnected && heartbeatConnected) {
1124 1184 updateHeaderStatus(true, 'Synced', getPluginName() + ' API key and ' + getOttoName() + ' UUID are configured');
1185 + } else if (isFullyConnected) {
1186 + // Key + UUID are configured but the heartbeat is not succeeding
1187 + // (e.g. the key was revoked on the Search Atlas side).
1188 + updateHeaderStatus(false, 'Not Connected', getPluginName() + ' API key is set but the heartbeat is not responding — the connection may be revoked or unreachable.');
1125 1189 } else if (hasApiKey && !hasOttoUuid) {
1126 1190 updateHeaderStatus(false, 'Warning', 'Connected but ' + getOttoName() + ' UUID is missing — deploys will not work. Please reconnect.');
1127 1191 } else {
1128 1192 updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID');
@@ -1128,11 +1192,11 @@
1128 1192 updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID');
1129 1193 }
1130 1194 }
1131 1195
1132 - // Monitor API key field changes
1133 - $('#searchatlas-api-key').on('input', updateConnectionStatus);
1134 -
1196 + // The API key is no longer manually editable (one-click auth only), so there
1197 + // is no input event to monitor; status changes are driven by connect/disconnect.
1198 +
1135 1199 // Initial status update
1136 1200 updateConnectionStatus();
1137 1201
1138 1202 // Initialize dashboard iframe functionality
@@ -1141,13 +1205,12 @@
1141 1205 // Settings dropdown now handled by inline script in HTML
1142 1206
1143 1207 // Add debug function for connection status (accessible in console)
1144 1208 window.debugConnectionStatus = function () {
1145 - var apiKey = $('#searchatlas-api-key').val();
1146 - var hasApiKey = apiKey.trim() !== '';
1147 -
1209 + var hasApiKey = searchAtlasKeyConfigured();
1210 +
1148 1211 console.log('🔍 Connection Status Debug:', {
1149 - searchatlas_api_key: hasApiKey ? (apiKey.substring(0, 8) + '...') : 'EMPTY',
1212 + searchatlas_credential_state: hasApiKey ? 'CONFIGURED (masked)' : 'EMPTY',
1150 1213 otto_pixel_uuid: metaSync.otto_pixel_uuid || 'NOT SET',
1151 1214 connection_state: hasApiKey && metaSync.otto_pixel_uuid ? 'CONNECTED' :
1152 1215 hasApiKey ? 'PARTIAL (Missing ' + getOttoName() + ' UUID)' : 'NOT CONNECTED',
1153 1216 dashboard_tab_visible: hasApiKey && metaSync.otto_pixel_uuid ? 'YES' : 'NO',
@@ -1269,9 +1332,8 @@
1269 1332 }
1270 1333
1271 1334 var $resetButton = $('#reset-searchatlas-auth');
1272 1335 var $connectButton = $('#connect-searchatlas-btn');
1273 - var $apiKeyField = $('#searchatlas-api-key');
1274 1336
1275 1337 // Show loading state (prevent dashboard.js conflicts)
1276 1338 $resetButton.prop('disabled', true)
1277 1339 .addClass('no-loading') // Prevent dashboard.js interference
@@ -1290,11 +1352,11 @@
1290 1352 nonce: metaSync.reset_auth_nonce
1291 1353 },
1292 1354 success: function (response) {
1293 1355 if (response.success) {
1294 - // Clear the API key field
1295 - $apiKeyField.val('');
1296 -
1356 + // Reset the masked display to the "Not configured" state
1357 + setSearchAtlasMaskedDisplay('');
1358 +
1297 1359 // Update button states
1298 1360 $connectButton.html('🔗 Connect to ' + getPluginName());
1299 1361 $resetButton.remove(); // Remove reset button since no longer connected
1300 1362
@@ -1389,10 +1451,18 @@
1389 1451 /**
1390 1452 * Update UI elements when account is disconnected
1391 1453 */
1392 1454 function updateUIForDisconnectedState() {
1455 + // a cached manual-sync cooldown must not survive disconnect —
1456 + // it keeps the Sync Now button locked after the user reconnects even
1457 + // though the server-side cooldown stamp was cleared with the auth data.
1458 + localStorage.removeItem('metasync_manual_sync_throttle_expires');
1459 + refreshManualSyncCooldownUI();
1460 + $('#sendAuthToken').prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now');
1461 +
1393 1462 // Update API key field placeholder
1394 - $('#searchatlas-api-key').attr('placeholder', 'Your API key will appear here after authentication');
1463 + // Reset the masked API key display to the "Not configured" state
1464 + setSearchAtlasMaskedDisplay('');
1395 1465
1396 1466 // ✅ Clear OTTO Pixel UUID field
1397 1467 $('input[name="metasync_options[general][otto_pixel_uuid]"]').val('');
1398 1468
@@ -1442,14 +1512,13 @@
1442 1512 /**
1443 1513 * Update UI elements when account is connected/authenticated
1444 1514 * Complementary function to updateUIForDisconnectedState()
1445 1515 */
1446 - function updateUIForConnectedState(apiKey, ottoPixelUuid) {
1447 - // Update API key field
1448 - if (apiKey) {
1449 - $('#searchatlas-api-key').val(apiKey);
1450 - }
1451 -
1516 + function updateUIForConnectedState(maskedKey, ottoPixelUuid) {
1517 + // Update the read-only masked API key display. Only the masked value is
1518 + // ever shown — the cleartext key is never rendered into the DOM.
1519 + setSearchAtlasMaskedDisplay(maskedKey || '••••••••');
1520 +
1452 1521 // ✅ Update OTTO Pixel UUID field
1453 1522 if (ottoPixelUuid) {
1454 1523 $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(ottoPixelUuid);
1455 1524 }
@@ -1853,8 +1922,9 @@
1853 1922 method: 'POST',
1854 1923 url: 'admin-ajax.php',
1855 1924 data: {
1856 1925 action: 'metasync_send_giapi',
1926 + nonce: metaSync.nonce,
1857 1927 metasync_giapi_url: url.val(),
1858 1928 metasync_giapi_action: action.val()
1859 1929 }
1860 1930 })
@@ -1995,17 +2065,18 @@
1995 2065 if (showAlerts) {
1996 2066 $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now');
1997 2067 }
1998 2068
1999 - if ($('#searchatlas-api-key') && $('#searchatlas-api-key').val() === '') {
2000 - $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key');
2001 - $('#sendAuthTokenTimestamp').css({ color: 'red' });
2069 + if ($('#searchatlas-api-key-display').length > 0 && !searchAtlasKeyConfigured()) {
2070 + if (!is_hb) {
2071 + $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key');
2072 + $('#sendAuthTokenTimestamp').css({ color: 'red' });
2002 2073
2003 - // Remove stale PHP-rendered "✓ Synced" label.
2004 - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove();
2074 + // Remove stale PHP-rendered "✓ Synced" label.
2075 + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove();
2005 2076
2006 - // Update header status to "Not Synced" for missing API key
2007 - updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required');
2077 + updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required');
2078 + }
2008 2079
2009 2080 if (showAlerts) {
2010 2081 showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.');
2011 2082 }
@@ -2010,34 +2081,23 @@
2010 2081 showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.');
2011 2082 }
2012 2083
2013 2084 } 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
2085 + // Compute expiry locally from a duration (remaining_seconds) so server/client
2086 + // clock drift cannot lock the user out or let them bypass the throttle.
2087 + var remainingSeconds = (typeof response.remaining_seconds === 'number')
2088 + ? response.remaining_seconds
2089 + : ((response.remaining_minutes || 5) * 60);
2090 + var expiryMs = Date.now() + (remainingSeconds * 1000);
2091 + var remainingMinutes = Math.max(1, Math.ceil(remainingSeconds / 60));
2092 +
2020 2093 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
2094 + localStorage.setItem('metasync_manual_sync_throttle_expires', String(expiryMs));
2095 + refreshManualSyncCooldownUI();
2036 2096 }
2037 2097
2038 - // Update header status to show throttling
2039 - updateHeaderStatus(false, 'Throttled', 'Throttled - Please wait ' + remainingMinutes + ' minutes');
2098 + // Throttle is transient — don't change the connection badge.
2099 + // The toast notification already informs the user.
2040 2100
2041 2101 if (showAlerts) {
2042 2102 showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.');
2043 2103 }
@@ -2042,17 +2102,19 @@
2042 2102 showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.');
2043 2103 }
2044 2104
2045 2105 } else if (response && response.detail) {
2046 - $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key');
2047 - $('#sendAuthTokenTimestamp').css({ color: 'red' });
2106 + if (!is_hb) {
2107 + $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key');
2108 + $('#sendAuthTokenTimestamp').css({ color: 'red' });
2048 2109
2049 - // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server.
2050 - $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove();
2110 + // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server.
2111 + $('label[for="searchatlas-api-key"]').find('span:contains("Synced")').remove();
2112 + }
2051 2113
2052 - // Update header status to "Not Synced" for invalid API key
2114 + // No is_hb guard here — genuinely invalid keys should always flip the badge
2053 2115 updateHeaderStatus(false, 'Not Synced', 'Not Synced - Invalid API key');
2054 -
2116 +
2055 2117 if (showAlerts) {
2056 2118 showSyncError('❌ Invalid API Key', 'Please provide a valid ' + getPluginName() + ' API key.');
2057 2119 }
2058 2120
@@ -2057,9 +2119,11 @@
2057 2119 }
2058 2120
2059 2121 } else if (response === null || !response.id) {
2060 2122 // Update header status to "Not Synced" after failed sync
2061 - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed');
2123 + if (!is_hb) {
2124 + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed');
2125 + }
2062 2126
2063 2127 if (showAlerts) {
2064 2128 showSyncError('❌ Sync Failed', 'Something went wrong during synchronization. Please check your connection and try again.');
2065 2129 }
@@ -2068,8 +2132,16 @@
2068 2132 } else {
2069 2133 var dateString = dateFormat();
2070 2134 $('#sendAuthTokenTimestamp').html(dateString);
2071 2135 $('#sendAuthTokenTimestamp').css({ color: 'green' });
2136 + // Server starts a 5-minute manual-sync cooldown after a successful sync.
2137 + // Reflect that on the client so the button shows the cooldown across refreshes
2138 + // instead of misleadingly saying "Sync Now" only to be rejected on next click.
2139 + if (!is_hb) {
2140 + var successCooldownMs = 5 * 60 * 1000;
2141 + localStorage.setItem('metasync_manual_sync_throttle_expires', String(Date.now() + successCooldownMs));
2142 + refreshManualSyncCooldownUI();
2143 + }
2072 2144
2073 2145 // Update header status — check if UUID is present before declaring fully synced
2074 2146 var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== '';
2075 2147 if (hasOttoUuid) {
@@ -2084,9 +2156,11 @@
2084 2156 }
2085 2157 },
2086 2158 error: function (xhr, status, error) {
2087 2159 // Update header status to "Not Synced" for network errors
2088 - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync');
2160 + if (!is_hb) {
2161 + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Network error during sync');
2162 + }
2089 2163
2090 2164 // Reset button state
2091 2165 if (showAlerts) {
2092 2166 $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now');
@@ -2115,9 +2189,10 @@
2115 2189 url: ajaxurl,
2116 2190 type: 'GET',
2117 2191 data: {
2118 2192 action: 'metasync_clear_otto_cache',
2119 - clear_otto_cache: 1
2193 + clear_otto_cache: 1,
2194 + nonce: metaSync.clear_otto_cache_nonce || ''
2120 2195 },
2121 2196 success: function (response) {
2122 2197 const now = new Date();
2123 2198 $('#clear_otto_caches').text('Cache Cleared ' + now.toLocaleTimeString());
@@ -2125,8 +2200,65 @@
2125 2200 }
2126 2201 });
2127 2202 }
2128 2203
2204 + // Holds the active manual-sync countdown interval so we can replace it
2205 + // instead of stacking multiple timers if refreshManualSyncCooldownUI()
2206 + // is called more than once (e.g. throttled response then refresh).
2207 + var _manualSyncCountdownInterval = null;
2208 +
2209 + /**
2210 + * Read the manual-sync throttle expiry (client-time ms) from localStorage
2211 + * and apply UI state: disable the button, show "Throttled (Nm)", and tick
2212 + * every 15s. Idempotent — safe to call repeatedly.
2213 + */
2214 + function refreshManualSyncCooldownUI() {
2215 + var $btn = $('#sendAuthToken');
2216 + if ($btn.length === 0) {
2217 + return;
2218 + }
2219 + if (_manualSyncCountdownInterval) {
2220 + clearInterval(_manualSyncCountdownInterval);
2221 + _manualSyncCountdownInterval = null;
2222 + }
2223 + var stored = localStorage.getItem('metasync_manual_sync_throttle_expires');
2224 + if (!stored) {
2225 + return;
2226 + }
2227 + var storedExpiry = parseInt(stored, 10);
2228 + if (!storedExpiry || isNaN(storedExpiry)) {
2229 + localStorage.removeItem('metasync_manual_sync_throttle_expires');
2230 + return;
2231 + }
2232 + var nowMs = Date.now();
2233 + if (storedExpiry <= nowMs) {
2234 + localStorage.removeItem('metasync_manual_sync_throttle_expires');
2235 + return;
2236 + }
2237 + var remainingMinutes = Math.max(1, Math.ceil((storedExpiry - nowMs) / 60000));
2238 + // addClass('is-throttled') drives the visual state via CSS so it
2239 + // doesn't depend on the disabled attribute being preserved by other
2240 + // code paths in the AJAX callback chain (which strip it on success).
2241 + $btn.prop('disabled', true).addClass('is-throttled').text('⏰ Throttled (' + remainingMinutes + 'm)');
2242 + _manualSyncCountdownInterval = setInterval(function () {
2243 + var currentNowMs = Date.now();
2244 + if (storedExpiry <= currentNowMs) {
2245 + clearInterval(_manualSyncCountdownInterval);
2246 + _manualSyncCountdownInterval = null;
2247 + localStorage.removeItem('metasync_manual_sync_throttle_expires');
2248 + $btn.prop('disabled', false).removeClass('is-throttled').text('🔄 Sync Now');
2249 + $('#sendAuthTokenTimestamp').text('Ready to sync').css({ color: 'green' });
2250 + // Cooldown is over — clear the lingering "Request Throttled" admin
2251 + // notice at the top of the page. Without this the button resets to
2252 + // "Sync Now" but the red throttled bar stays visible (QA).
2253 + $('.metasync-sync-notice, .metasync-sync-error').remove();
2254 + return;
2255 + }
2256 + remainingMinutes = Math.max(1, Math.ceil((storedExpiry - currentNowMs) / 60000));
2257 + $btn.text('⏰ Throttled (' + remainingMinutes + 'm)');
2258 + }, 15000);
2259 + }
2260 +
2129 2261 jQuery(document).ready(function () {
2130 2262
2131 2263 // sendCustomerParams();
2132 2264 $('#sendAuthToken').on('click', function (e) {
@@ -2131,11 +2263,14 @@
2131 2263 // sendCustomerParams();
2132 2264 $('#sendAuthToken').on('click', function (e) {
2133 2265 e.preventDefault();
2134 2266 sendCustomerParams();
2135 -
2267 +
2136 2268 });
2137 2269
2270 + // Restore manual-sync throttle countdown state across page refreshes.
2271 + refreshManualSyncCooldownUI();
2272 +
2138 2273 // handle otto clear cache button
2139 2274 $('#clear_otto_caches').on('click', function (e){
2140 2275 e.preventDefault();
2141 2276 clear_otto_caches();
@@ -2190,18 +2325,39 @@
2190 2325 data: formData + '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab), // Add the action and current tab
2191 2326 success: function (response) {
2192 2327 // Handle success response
2193 2328 if(response.success){
2329 + // Per-field whitelabel validation issues: the save succeeded for
2330 + // other fields, so show a warning notice and stay on the page so the
2331 + // user can see which values were rejected.
2332 + const wlErrors = response.data && response.data.whitelabel_errors;
2333 + if (wlErrors && Object.keys(wlErrors).length > 0) {
2334 + var $warnNotice = $('<div>').addClass('notice notice-warning metasync-error-wrap');
2335 + var $warnList = $('<ul>');
2336 + Object.keys(wlErrors).forEach(function (fieldKey) {
2337 + $warnList.append($('<li>').text(wlErrors[fieldKey]));
2338 + });
2339 + $warnNotice.append($('<p>').text('Settings saved, but some values were not applied:'));
2340 + $warnNotice.append($warnList);
2341 + $('.metasync-error-wrap').remove();
2342 + $('#metaSyncGeneralSetting').before($warnNotice);
2343 + $('html, body').animate({ scrollTop: 0 }, 'slow');
2344 + return;
2345 + }
2346 +
2194 2347 // get value of input field white_label_plugin_menu_slug
2195 2348 const whiteLableUrl = $('#metaSyncGeneralSetting input[name="metasync_options[general][white_label_plugin_menu_slug]"]').val();
2196 2349 // check condition if it is empty or not and redirect it
2197 -
2350 +
2198 2351 // add the tag query to the window location
2199 2352 let tabParam = new URLSearchParams(window.location.search).get('tab');
2200 2353 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';
2354 +
2355 + // Prefer the server-sanitized slug so the redirect matches the page
2356 + // WordPress actually registered; fall back to the raw input field value.
2357 + const savedSlug = response.data && typeof response.data.saved_menu_slug === 'string' ? response.data.saved_menu_slug : null;
2358 + const inputSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas';
2359 + const rawSlug = (savedSlug !== null) ? (savedSlug || 'searchatlas') : inputSlug;
2204 2360 const pageSlug = encodeURIComponent(rawSlug.replace(/[^a-zA-Z0-9_\-]/g, '')) || 'searchatlas';
2205 2361 var redirectUrl = metaSync.admin_url + '?page=' + pageSlug + tabQuery;
2206 2362 try {
2207 2363 var parsedRedirect = new URL(redirectUrl, window.location.origin);
@@ -2517,11 +2673,12 @@
2517 2673 } else if (response.data && response.data.api_key_validated === false) {
2518 2674 noticeType = 'notice-error';
2519 2675 noticeMessage = $('<span/>').text(response.data.warning || 'The API key could not be verified. Your other settings were saved.').html();
2520 2676 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);
2677 + // The server reverts the stored key to the last valid one on
2678 + // verification failure, and the masked display is read-only, so
2679 + // nothing in the DOM needs reverting here. The cleartext key is
2680 + // never sent to the browser.
2524 2681 } else if (response.data && response.data.api_key_removed === true) {
2525 2682 // API key was cleared — show warning, update status, page will reload
2526 2683 noticeType = 'notice-warning';
2527 2684 noticeMessage = $('<span/>').text((response.data.warning || 'The API key has been removed.') + ' Reloading\u2026').html();
@@ -3123,26 +3280,65 @@
3123 3280 * @param {jQuery} $trigger - The trigger button
3124 3281 * @param {jQuery} $tooltip - The tooltip element
3125 3282 */
3126 3283 function positionTooltip($trigger, $tooltip) {
3127 - // Skip positioning on mobile (uses fixed positioning)
3284 + // Mobile uses the CSS fixed bottom-sheet layout; clear any inline desktop
3285 + // styles so the stylesheet takes over.
3128 3286 if (window.innerWidth <= 768) {
3287 + $tooltip.css({ position: '', left: '', top: '', right: '', bottom: '', transform: '', margin: '' });
3129 3288 return;
3130 3289 }
3131 3290
3132 - var triggerRect = $trigger[0].getBoundingClientRect();
3133 - var tooltipWidth = $tooltip.outerWidth();
3134 - var viewportWidth = $(window).width();
3135 - var spaceRight = viewportWidth - triggerRect.right;
3291 + // Move the tooltip up to the plugin wrapper so it escapes the
3292 + // overflow:hidden on accordion sections / cards that was clipping it.
3293 + // The wrapper carries data-theme (so the --dashboard-* CSS vars still
3294 + // resolve) and is position:relative (not transformed), so the
3295 + // position:fixed below stays relative to the viewport. Verified in a
3296 + // headless-Chrome harness before shipping.
3297 + var portal = document.querySelector('.metasync-dashboard-wrap') || document.body;
3298 + if ($tooltip[0].parentNode !== portal) {
3299 + portal.appendChild($tooltip[0]);
3300 + }
3136 3301
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 - }
3302 + var margin = 12;
3303 + var rect = $trigger[0].getBoundingClientRect();
3304 + var vw = window.innerWidth;
3305 + var vh = window.innerHeight;
3306 + var tw = $tooltip.outerWidth();
3307 + var th = $tooltip.outerHeight();
3308 +
3309 + // Horizontal: prefer to the right of the icon, else flip left; clamp to viewport.
3310 + var left = rect.right + margin;
3311 + if (left + tw + margin > vw) { left = rect.left - margin - tw; }
3312 + if (left < margin) { left = margin; }
3313 + if (left + tw + margin > vw) { left = Math.max(margin, vw - tw - margin); }
3314 +
3315 + // Vertical: center on the icon, then clamp so the top/bottom is never cut off.
3316 + var top = rect.top + (rect.height / 2) - (th / 2);
3317 + if (top < margin) { top = margin; }
3318 + if (top + th + margin > vh) { top = Math.max(margin, vh - th - margin); }
3319 +
3320 + $tooltip.find('.metasync-tooltip-arrow').css('display', 'none');
3321 + // Place it, then play a small left-to-right slide-in. Position is
3322 + // handled by left/top (fixed), so transform is free for the entrance
3323 + // animation: start ~12px to the left and slide right into place.
3324 + $tooltip.css({
3325 + position: 'fixed',
3326 + left: Math.round(left) + 'px',
3327 + top: Math.round(top) + 'px',
3328 + right: 'auto',
3329 + bottom: 'auto',
3330 + margin: '0',
3331 + transition: 'opacity 0.2s ease',
3332 + transform: 'translateX(-12px)'
3333 + });
3334 + // Force a reflow so the start position is committed before animating.
3335 + void $tooltip[0].offsetWidth;
3336 + $tooltip.css({
3337 + transition: 'opacity 0.2s ease, transform 0.2s ease',
3338 + transform: 'translateX(0)'
3339 + });
3340 + $tooltip.removeAttr('data-position');
3145 3341 }
3146 3342
3147 3343 // Initialize tooltip system
3148 3344 initTooltipSystem();