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 +374 -143 2.6.6 → 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);
@@ -243,9 +280,9 @@
243 280 $('#linkgraph_customer_id').val(response.customer_id);
244 281 $('.input.lguser,#lgerror').addClass('hidden');
245 282 localStorage.setItem('token', response.token);
246 283 } else {
247 - $('#lgerror').html(`${response.detail} (${response.kind})`).removeClass('hidden');
284 + $('#lgerror').text(response.detail + ' (' + response.kind + ')').removeClass('hidden');
248 285 }
249 286 }
250 287 );
251 288 }
@@ -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,14 +644,15 @@
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: {
611 653 action: 'metasync_track_one_click_activation',
654 + nonce: metaSync.nonce,
612 655 auth_method: 'searchatlas_connect',
613 656 is_reconnection: hasExistingApiKey
614 657 }
615 658 });
@@ -736,9 +779,9 @@
736 779 var timeElapsed = currentPoll * 5;
737 780 var timeRemaining = (maxPolls - currentPoll) * 5;
738 781 $progressTime.text(timeElapsed + 's elapsed, ' + timeRemaining + 's remaining');
739 782
740 - // 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)
741 784 var progressMessages = [
742 785 'Establishing connection and opening authentication window...',
743 786 'Please complete authentication in the popup window...',
744 787 'Almost done! Finalizing your authentication...'
@@ -861,10 +904,19 @@
861 904 '⚠️ Website Not Registered',
862 905 'Your website hasn\'t been registered with ' + getPluginName() + ' yet. Registration is required to enable 1-click connect to retrieve your Search Atlas API key and Otto UUID.',
863 906 [{
864 907 text: '🌐 Register Website',
865 - action: function () {
866 - window.open(registerUrl, '_blank');
908 + action: function () {
909 + try {
910 + var parsedRegister = new URL(registerUrl);
911 + if (parsedRegister.protocol === 'https:' || parsedRegister.protocol === 'http:') {
912 + var a = document.createElement('a');
913 + a.href = parsedRegister.href;
914 + a.target = '_blank';
915 + a.rel = 'noopener';
916 + a.click();
917 + }
918 + } catch (e) { /* invalid URL */ }
867 919 },
868 920 primary: true
869 921 }, {
870 922 text: '📚 Learn More About Registration',
@@ -869,9 +921,18 @@
869 921 }, {
870 922 text: '📚 Learn More About Registration',
871 923 action: function () {
872 924 var docDomain = metaSync.documentation_domain || 'https://searchatlas.com';
873 - 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 */ }
874 935 }
875 936 }, {
876 937 text: '🔄 Try Authentication Again',
877 938 action: function () {
@@ -953,9 +1014,9 @@
953 1014 'Check if firewall is blocking the request'
954 1015 ];
955 1016 } else if (context === 'timeout') {
956 1017 recoverySuggestions = [
957 - 'Complete authentication within 60 seconds',
1018 + 'Complete authentication within 2 minutes',
958 1019 'Check if the popup window needs attention',
959 1020 'Ensure you have your ' + getPluginName() + ' login ready',
960 1021 'Try the authentication process again',
961 1022 'Contact support if timeouts persist'
@@ -1085,17 +1146,17 @@
1085 1146
1086 1147 // Add connection status indicator
1087 1148 function updateConnectionStatus() {
1088 1149 var $button = $('#connect-searchatlas-btn');
1089 - var $apiKeyField = $('#searchatlas-api-key');
1090 -
1091 - // 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)
1092 1153 // On other pages, preserve the PHP-determined status in the header
1093 1154 if ($apiKeyField.length === 0) {
1094 - 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
1095 1156 }
1096 -
1097 - var hasApiKey = $apiKeyField.val() && $apiKeyField.val().trim() !== '';
1157 +
1158 + var hasApiKey = searchAtlasKeyConfigured();
1098 1159 var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== '';
1099 1160 var isFullyConnected = hasApiKey && hasOttoUuid;
1100 1161
1101 1162 // Update button text based on connection state
@@ -1108,11 +1169,24 @@
1108 1169 $button.html('🔗 Connect to ' + getPluginName());
1109 1170 }
1110 1171 }
1111 1172
1112 - // Update header status indicator (only on General Settings page)
1113 - 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) {
1114 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.');
1115 1189 } else if (hasApiKey && !hasOttoUuid) {
1116 1190 updateHeaderStatus(false, 'Warning', 'Connected but ' + getOttoName() + ' UUID is missing — deploys will not work. Please reconnect.');
1117 1191 } else {
1118 1192 updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID');
@@ -1118,11 +1192,11 @@
1118 1192 updateHeaderStatus(false, 'Not Synced', 'Missing ' + getPluginName() + ' API key or ' + getOttoName() + ' UUID');
1119 1193 }
1120 1194 }
1121 1195
1122 - // Monitor API key field changes
1123 - $('#searchatlas-api-key').on('input', updateConnectionStatus);
1124 -
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 +
1125 1199 // Initial status update
1126 1200 updateConnectionStatus();
1127 1201
1128 1202 // Initialize dashboard iframe functionality
@@ -1131,13 +1205,12 @@
1131 1205 // Settings dropdown now handled by inline script in HTML
1132 1206
1133 1207 // Add debug function for connection status (accessible in console)
1134 1208 window.debugConnectionStatus = function () {
1135 - var apiKey = $('#searchatlas-api-key').val();
1136 - var hasApiKey = apiKey.trim() !== '';
1137 -
1209 + var hasApiKey = searchAtlasKeyConfigured();
1210 +
1138 1211 console.log('🔍 Connection Status Debug:', {
1139 - searchatlas_api_key: hasApiKey ? (apiKey.substring(0, 8) + '...') : 'EMPTY',
1212 + searchatlas_credential_state: hasApiKey ? 'CONFIGURED (masked)' : 'EMPTY',
1140 1213 otto_pixel_uuid: metaSync.otto_pixel_uuid || 'NOT SET',
1141 1214 connection_state: hasApiKey && metaSync.otto_pixel_uuid ? 'CONNECTED' :
1142 1215 hasApiKey ? 'PARTIAL (Missing ' + getOttoName() + ' UUID)' : 'NOT CONNECTED',
1143 1216 dashboard_tab_visible: hasApiKey && metaSync.otto_pixel_uuid ? 'YES' : 'NO',
@@ -1259,9 +1332,8 @@
1259 1332 }
1260 1333
1261 1334 var $resetButton = $('#reset-searchatlas-auth');
1262 1335 var $connectButton = $('#connect-searchatlas-btn');
1263 - var $apiKeyField = $('#searchatlas-api-key');
1264 1336
1265 1337 // Show loading state (prevent dashboard.js conflicts)
1266 1338 $resetButton.prop('disabled', true)
1267 1339 .addClass('no-loading') // Prevent dashboard.js interference
@@ -1280,11 +1352,11 @@
1280 1352 nonce: metaSync.reset_auth_nonce
1281 1353 },
1282 1354 success: function (response) {
1283 1355 if (response.success) {
1284 - // Clear the API key field
1285 - $apiKeyField.val('');
1286 -
1356 + // Reset the masked display to the "Not configured" state
1357 + setSearchAtlasMaskedDisplay('');
1358 +
1287 1359 // Update button states
1288 1360 $connectButton.html('🔗 Connect to ' + getPluginName());
1289 1361 $resetButton.remove(); // Remove reset button since no longer connected
1290 1362
@@ -1379,10 +1451,18 @@
1379 1451 /**
1380 1452 * Update UI elements when account is disconnected
1381 1453 */
1382 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 +
1383 1462 // Update API key field placeholder
1384 - $('#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('');
1385 1465
1386 1466 // ✅ Clear OTTO Pixel UUID field
1387 1467 $('input[name="metasync_options[general][otto_pixel_uuid]"]').val('');
1388 1468
@@ -1432,14 +1512,13 @@
1432 1512 /**
1433 1513 * Update UI elements when account is connected/authenticated
1434 1514 * Complementary function to updateUIForDisconnectedState()
1435 1515 */
1436 - function updateUIForConnectedState(apiKey, ottoPixelUuid) {
1437 - // Update API key field
1438 - if (apiKey) {
1439 - $('#searchatlas-api-key').val(apiKey);
1440 - }
1441 -
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 +
1442 1521 // ✅ Update OTTO Pixel UUID field
1443 1522 if (ottoPixelUuid) {
1444 1523 $('input[name="metasync_options[general][otto_pixel_uuid]"]').val(ottoPixelUuid);
1445 1524 }
@@ -1843,8 +1922,9 @@
1843 1922 method: 'POST',
1844 1923 url: 'admin-ajax.php',
1845 1924 data: {
1846 1925 action: 'metasync_send_giapi',
1926 + nonce: metaSync.nonce,
1847 1927 metasync_giapi_url: url.val(),
1848 1928 metasync_giapi_action: action.val()
1849 1929 }
1850 1930 })
@@ -1965,8 +2045,9 @@
1965 2045
1966 2046 // DEBUG: Log request parameters
1967 2047 const requestData = {
1968 2048 action: 'metasync_send_customer_params',
2049 + nonce: metaSync.nonce,
1969 2050 is_heart_beat : is_hb
1970 2051 };
1971 2052
1972 2053 jQuery.ajax({
@@ -1984,17 +2065,18 @@
1984 2065 if (showAlerts) {
1985 2066 $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now');
1986 2067 }
1987 2068
1988 - if ($('#searchatlas-api-key') && $('#searchatlas-api-key').val() === '') {
1989 - $('#sendAuthTokenTimestamp').html('Please save your ' + getPluginName() + ' API key');
1990 - $('#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' });
1991 2073
1992 - // Remove stale PHP-rendered "✓ Synced" label.
1993 - $('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();
1994 2076
1995 - // Update header status to "Not Synced" for missing API key
1996 - updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required');
2077 + updateHeaderStatus(false, 'Not Synced', 'Not Synced - API key required');
2078 + }
1997 2079
1998 2080 if (showAlerts) {
1999 2081 showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.');
2000 2082 }
@@ -1999,34 +2081,23 @@
1999 2081 showSyncError('⚠️ API Key Required', 'Please save your ' + getPluginName() + ' API key in the settings above before syncing.');
2000 2082 }
2001 2083
2002 2084 } else if (response && response.throttled) {
2003 - // Handle throttling response
2004 - var remainingMinutes = response.remaining_minutes || 5;
2005 - //$('#sendAuthTokenTimestamp').html('Please wait ' + remainingMinutes + ' minutes before syncing again');
2006 - //$('#sendAuthTokenTimestamp').css({ color: 'orange' });
2007 -
2008 - // 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 +
2009 2093 if (showAlerts) {
2010 - $('#sendAuthToken').prop('disabled', true).html('⏰ Throttled (' + remainingMinutes + 'm)');
2011 -
2012 - // Start countdown timer
2013 - var countdownInterval = setInterval(function () {
2014 - remainingMinutes--;
2015 - if (remainingMinutes <= 0) {
2016 - clearInterval(countdownInterval);
2017 - $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now');
2018 - $('#sendAuthTokenTimestamp').html('Ready to sync');
2019 - $('#sendAuthTokenTimestamp').css({ color: 'green' });
2020 - } else {
2021 - $('#sendAuthToken').html('⏰ Throttled (' + remainingMinutes + 'm)');
2022 - //$('#sendAuthTokenTimestamp').html('Please wait ' + remainingMinutes + ' minutes before syncing again');
2023 - }
2024 - }, 60000); // Update every minute
2094 + localStorage.setItem('metasync_manual_sync_throttle_expires', String(expiryMs));
2095 + refreshManualSyncCooldownUI();
2025 2096 }
2026 2097
2027 - // Update header status to show throttling
2028 - 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.
2029 2100
2030 2101 if (showAlerts) {
2031 2102 showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.');
2032 2103 }
@@ -2031,17 +2102,19 @@
2031 2102 showSyncError('⏰ Request Throttled', response.message || 'Please wait ' + remainingMinutes + ' minutes before making another sync request.');
2032 2103 }
2033 2104
2034 2105 } else if (response && response.detail) {
2035 - $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key');
2036 - $('#sendAuthTokenTimestamp').css({ color: 'red' });
2106 + if (!is_hb) {
2107 + $('#sendAuthTokenTimestamp').html('Please provide a valid ' + getPluginName() + ' API key');
2108 + $('#sendAuthTokenTimestamp').css({ color: 'red' });
2037 2109
2038 - // Remove stale PHP-rendered "✓ Synced" label — key is invalid on the server.
2039 - $('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 + }
2040 2113
2041 - // Update header status to "Not Synced" for invalid API key
2114 + // No is_hb guard here — genuinely invalid keys should always flip the badge
2042 2115 updateHeaderStatus(false, 'Not Synced', 'Not Synced - Invalid API key');
2043 -
2116 +
2044 2117 if (showAlerts) {
2045 2118 showSyncError('❌ Invalid API Key', 'Please provide a valid ' + getPluginName() + ' API key.');
2046 2119 }
2047 2120
@@ -2046,9 +2119,11 @@
2046 2119 }
2047 2120
2048 2121 } else if (response === null || !response.id) {
2049 2122 // Update header status to "Not Synced" after failed sync
2050 - updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed');
2123 + if (!is_hb) {
2124 + updateHeaderStatus(false, 'Not Synced', 'Not Synced - Data synchronization failed');
2125 + }
2051 2126
2052 2127 if (showAlerts) {
2053 2128 showSyncError('❌ Sync Failed', 'Something went wrong during synchronization. Please check your connection and try again.');
2054 2129 }
@@ -2057,8 +2132,16 @@
2057 2132 } else {
2058 2133 var dateString = dateFormat();
2059 2134 $('#sendAuthTokenTimestamp').html(dateString);
2060 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 + }
2061 2144
2062 2145 // Update header status — check if UUID is present before declaring fully synced
2063 2146 var hasOttoUuid = metaSync.otto_pixel_uuid && metaSync.otto_pixel_uuid.trim() !== '';
2064 2147 if (hasOttoUuid) {
@@ -2073,9 +2156,11 @@
2073 2156 }
2074 2157 },
2075 2158 error: function (xhr, status, error) {
2076 2159 // Update header status to "Not Synced" for network errors
2077 - 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 + }
2078 2163
2079 2164 // Reset button state
2080 2165 if (showAlerts) {
2081 2166 $('#sendAuthToken').prop('disabled', false).html('🔄 Sync Now');
@@ -2104,9 +2189,10 @@
2104 2189 url: ajaxurl,
2105 2190 type: 'GET',
2106 2191 data: {
2107 2192 action: 'metasync_clear_otto_cache',
2108 - clear_otto_cache: 1
2193 + clear_otto_cache: 1,
2194 + nonce: metaSync.clear_otto_cache_nonce || ''
2109 2195 },
2110 2196 success: function (response) {
2111 2197 const now = new Date();
2112 2198 $('#clear_otto_caches').text('Cache Cleared ' + now.toLocaleTimeString());
@@ -2114,8 +2200,65 @@
2114 2200 }
2115 2201 });
2116 2202 }
2117 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 +
2118 2261 jQuery(document).ready(function () {
2119 2262
2120 2263 // sendCustomerParams();
2121 2264 $('#sendAuthToken').on('click', function (e) {
@@ -2120,11 +2263,14 @@
2120 2263 // sendCustomerParams();
2121 2264 $('#sendAuthToken').on('click', function (e) {
2122 2265 e.preventDefault();
2123 2266 sendCustomerParams();
2124 -
2267 +
2125 2268 });
2126 2269
2270 + // Restore manual-sync throttle countdown state across page refreshes.
2271 + refreshManualSyncCooldownUI();
2272 +
2127 2273 // handle otto clear cache button
2128 2274 $('#clear_otto_caches').on('click', function (e){
2129 2275 e.preventDefault();
2130 2276 clear_otto_caches();
@@ -2179,39 +2325,68 @@
2179 2325 data: formData + '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab), // Add the action and current tab
2180 2326 success: function (response) {
2181 2327 // Handle success response
2182 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 +
2183 2347 // get value of input field white_label_plugin_menu_slug
2184 2348 const whiteLableUrl = $('#metaSyncGeneralSetting input[name="metasync_options[general][white_label_plugin_menu_slug]"]').val();
2185 2349 // check condition if it is empty or not and redirect it
2186 -
2350 +
2187 2351 // add the tag query to the window location
2188 2352 let tabParam = new URLSearchParams(window.location.search).get('tab');
2189 2353 let tabQuery = tabParam ? '&tab=' + encodeURIComponent(tabParam) : '';
2190 -
2191 - // Handle undefined or empty white label URL
2192 - const pageSlug = (whiteLableUrl && whiteLableUrl !== '') ? whiteLableUrl : 'searchatlas';
2193 - window.location = metaSync.admin_url + '?page=' + pageSlug + tabQuery;
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;
2360 + const pageSlug = encodeURIComponent(rawSlug.replace(/[^a-zA-Z0-9_\-]/g, '')) || 'searchatlas';
2361 + var redirectUrl = metaSync.admin_url + '?page=' + pageSlug + tabQuery;
2362 + try {
2363 + var parsedRedirect = new URL(redirectUrl, window.location.origin);
2364 + if (parsedRedirect.origin === window.location.origin) {
2365 + var navLink = document.createElement('a');
2366 + navLink.href = parsedRedirect.href;
2367 + navLink.click();
2368 + }
2369 + } catch (e) { /* invalid URL */ }
2194 2370 }else {
2195 2371 // Handle error response
2196 2372 const errors = response.data?.errors || [];
2197 2373
2198 - // Create a notice element to display the errors
2199 - let html = '<div class="notice notice-error metasync-error-wrap">';
2374 + // Build error notice using DOM methods to avoid XSS
2375 + var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap');
2200 2376 if (Array.isArray(errors)) {
2201 - html += '<ul>';
2377 + var $ul = $('<ul>');
2202 2378 errors.forEach(function (err) {
2203 - html += '<li>' + err + '</li>';
2379 + $ul.append($('<li>').text(err));
2204 2380 });
2205 - html += '</ul>';
2381 + $errorNotice.append($ul);
2206 2382 }
2207 - html += '</div>';
2208 2383
2209 2384 // Remove previous error notices
2210 2385 $('.metasync-error-wrap').remove();
2211 -
2386 +
2212 2387 // Insert the error message before the form
2213 - $('#metaSyncGeneralSetting').before(html);
2388 + $('#metaSyncGeneralSetting').before($errorNotice);
2214 2389
2215 2390 // Scroll to the top to ensure visibility
2216 2391 $('html, body').animate({ scrollTop: 0 }, 'slow');
2217 2392 }
@@ -2425,11 +2600,11 @@
2425 2600 // Form submission handler already exists above, so we just need to listen for successful response
2426 2601 var originalAjaxHandler = $(this).data('events') && $(this).data('events').submit;
2427 2602 });
2428 2603
2429 - // Listen for successful form submission to clear the unsaved changes flag
2430 - $(document).ajaxSuccess(function (event, xhr, settings) {
2431 - if (settings.data && typeof settings.data === 'string' && settings.data.indexOf('action=meta_sync_save_settings') > -1) {
2604 + // Listen for successful form submission to clear the unsaved changes flag
2605 + $(document).ajaxSuccess(function (event, xhr, settings) {
2606 + if (settings.data && typeof settings.data === 'string' && settings.data.indexOf('action=meta_sync_save_settings') > -1) {
2432 2607 try {
2433 2608 var response = JSON.parse(xhr.responseText);
2434 2609 if (response.success) {
2435 2610 hasUnsavedChanges = false;
@@ -2475,11 +2650,8 @@
2475 2650 var currentTab = urlParams.get('tab') || 'general';
2476 2651
2477 2652 formData += '&action=meta_sync_save_settings&active_tab=' + encodeURIComponent(currentTab);
2478 2653
2479 - // Capture the current API key value so we can restore it client-side if validation fails
2480 - var savedApiKeyValue = $('#searchatlas-api-key').val();
2481 -
2482 2654 $.ajax({
2483 2655 url: metaSync.ajax_url,
2484 2656 type: 'POST',
2485 2657 data: formData,
@@ -2501,10 +2673,17 @@
2501 2673 } else if (response.data && response.data.api_key_validated === false) {
2502 2674 noticeType = 'notice-error';
2503 2675 noticeMessage = $('<span/>').text(response.data.warning || 'The API key could not be verified. Your other settings were saved.').html();
2504 2676 noAutoDismiss = true;
2505 - // Revert the input field to the value before save attempt
2506 - $('#searchatlas-api-key').val(savedApiKeyValue);
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.
2681 + } else if (response.data && response.data.api_key_removed === true) {
2682 + // API key was cleared — show warning, update status, page will reload
2683 + noticeType = 'notice-warning';
2684 + noticeMessage = $('<span/>').text((response.data.warning || 'The API key has been removed.') + ' Reloading\u2026').html();
2685 + updateHeaderStatus(false, 'Disconnected', getPluginName() + ' disconnected');
2507 2686 } else if (response.data && response.data.warning) {
2508 2687 // Network failure — key saved but unverified
2509 2688 noticeType = 'notice-warning';
2510 2689 noticeMessage = $('<span/>').text(response.data.warning).html();
@@ -2526,11 +2705,11 @@
2526 2705
2527 2706 // Scroll to the notice for better visibility
2528 2707 $('html, body').animate({ scrollTop: 0 }, 'slow');
2529 2708
2530 - // Reload page when API key was validated so server-rendered
2709 + // Reload page when API key status changed so server-rendered
2531 2710 // sections (One-Click Authentication, promo sidebar) update
2532 - if (response.data && response.data.api_key_validated === true) {
2711 + if (response.data && (response.data.api_key_validated === true || response.data.api_key_removed === true)) {
2533 2712 setTimeout(function () {
2534 2713 location.reload();
2535 2714 }, 1500);
2536 2715 return;
@@ -2546,31 +2725,32 @@
2546 2725 }
2547 2726 } else {
2548 2727 // Handle validation errors
2549 2728 var errors = response.data && response.data.errors ? response.data.errors : [];
2550 - var errorHtml = '<div class="notice notice-error metasync-error-wrap" style="margin: 20px; padding: 12px;">';
2729 +
2730 + // Build error notice using DOM methods to avoid XSS
2731 + var $errorNotice = $('<div>').addClass('notice notice-error metasync-error-wrap').css({ margin: '20px', padding: '12px' });
2551 2732 if (Array.isArray(errors)) {
2552 - errorHtml += '<ul>';
2733 + var $ul = $('<ul>');
2553 2734 for (var i = 0; i < errors.length; i++) {
2554 - errorHtml += '<li>' + errors[i] + '</li>';
2735 + $ul.append($('<li>').text(errors[i]));
2555 2736 }
2556 - errorHtml += '</ul>';
2737 + $errorNotice.append($ul);
2557 2738 } else {
2558 2739 var message = 'An error occurred while saving settings.';
2559 2740 if (response.data && response.data.message) {
2560 2741 message = response.data.message;
2561 2742 }
2562 - errorHtml += '<p>' + message + '</p>';
2743 + $errorNotice.append($('<p>').text(message));
2563 2744 }
2564 - errorHtml += '</div>';
2565 -
2745 +
2566 2746 // Insert error notice in plugin area
2567 2747 var $navWrapper = $('.metasync-nav-wrapper');
2568 2748 if ($navWrapper.length > 0) {
2569 - $navWrapper.after(errorHtml);
2749 + $navWrapper.after($errorNotice);
2570 2750 } else {
2571 2751 // Fallback: insert at top of plugin content area
2572 - $('.metasync-dashboard-wrap').prepend(errorHtml);
2752 + $('.metasync-dashboard-wrap').prepend($errorNotice);
2573 2753 }
2574 2754
2575 2755 // Scroll to the error message for better visibility
2576 2756 $('html, body').animate({ scrollTop: 0 }, 'slow');
@@ -2658,13 +2838,14 @@
2658 2838 dataType: 'json',
2659 2839 success: function (response) {
2660 2840 if (response.success) {
2661 2841 // Show success message above Indexation Control section
2662 - $('#seo-controls-messages').html(
2663 - '<div class="notice notice-success is-dismissible" style="margin-bottom: 20px;">' +
2664 - '<p><strong>✅ Success!</strong> ' + response.data.message + '</p>' +
2665 - '</div>'
2666 - );
2842 + var $successNotice = $('<div>').addClass('notice notice-success is-dismissible').css('margin-bottom', '20px');
2843 + var $successP = $('<p>');
2844 + $successP.append($('<strong>').text('✅ Success! '));
2845 + $successP[0].appendChild(document.createTextNode(response.data.message));
2846 + $successNotice.append($successP);
2847 + $('#seo-controls-messages').empty().append($successNotice);
2667 2848
2668 2849 // Clear unsaved changes state
2669 2850 hasUnsavedChanges = false;
2670 2851 if (typeof updateUnsavedChangesIndicator === 'function') {
@@ -2680,13 +2861,14 @@
2680 2861 $('#seo-controls-messages .notice-success').fadeOut();
2681 2862 }, 5000);
2682 2863 } else {
2683 2864 // Show error message above Indexation Control section
2684 - $('#seo-controls-messages').html(
2685 - '<div class="notice notice-error is-dismissible" style="margin-bottom: 20px;">' +
2686 - '<p><strong>❌ Error!</strong> ' + (response.data.message || 'Failed to save settings') + '</p>' +
2687 - '</div>'
2688 - );
2865 + var $errorNoticeCtrl = $('<div>').addClass('notice notice-error is-dismissible').css('margin-bottom', '20px');
2866 + var $errorPCtrl = $('<p>');
2867 + $errorPCtrl.append($('<strong>').text('❌ Error! '));
2868 + $errorPCtrl[0].appendChild(document.createTextNode(response.data.message || 'Failed to save settings'));
2869 + $errorNoticeCtrl.append($errorPCtrl);
2870 + $('#seo-controls-messages').empty().append($errorNoticeCtrl);
2689 2871 }
2690 2872
2691 2873 // Make dismiss buttons work
2692 2874 $('#seo-controls-messages .notice-dismissible').each(function () {
@@ -2712,9 +2894,9 @@
2712 2894
2713 2895 // Don't clear unsaved changes on network error
2714 2896 // User may want to retry or fix the issue
2715 2897 },
2716 - complete: function() {
2898 + complete: function () {
2717 2899 // Re-enable save button if it was disabled
2718 2900 $('.metasync-save-button').prop('disabled', false);
2719 2901 }
2720 2902 });
@@ -3098,26 +3280,65 @@
3098 3280 * @param {jQuery} $trigger - The trigger button
3099 3281 * @param {jQuery} $tooltip - The tooltip element
3100 3282 */
3101 3283 function positionTooltip($trigger, $tooltip) {
3102 - // 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.
3103 3286 if (window.innerWidth <= 768) {
3287 + $tooltip.css({ position: '', left: '', top: '', right: '', bottom: '', transform: '', margin: '' });
3104 3288 return;
3105 3289 }
3106 3290
3107 - var triggerRect = $trigger[0].getBoundingClientRect();
3108 - var tooltipWidth = $tooltip.outerWidth();
3109 - var viewportWidth = $(window).width();
3110 - 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 + }
3111 3301
3112 - // Check if tooltip would overflow on the right
3113 - if (spaceRight < tooltipWidth + 20) {
3114 - // Position on the left side
3115 - $tooltip.attr('data-position', 'left');
3116 - } else {
3117 - // Position on the right side (default)
3118 - $tooltip.attr('data-position', 'right');
3119 - }
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');
3120 3341 }
3121 3342
3122 3343 // Initialize tooltip system
3123 3344 initTooltipSystem();
@@ -3123,11 +3344,15 @@
3123 3344 initTooltipSystem();
3124 3345
3125 3346 // PR3: Burst ping — 10 min polling when UNREGISTERED or KEY_PENDING. Stop after 5 failed attempts.
3126 3347 (function () {
3127 - if (typeof metaSync === 'undefined' || !metaSync.heartbeat_state) return;
3348 + if (typeof metaSync === 'undefined' || !metaSync.heartbeat_state) {
3349 + return;
3350 + }
3128 3351 var state = metaSync.heartbeat_state;
3129 - if (state !== 'UNREGISTERED' && state !== 'KEY_PENDING') return;
3352 + if (state !== 'UNREGISTERED' && state !== 'KEY_PENDING') {
3353 + return;
3354 + }
3130 3355
3131 3356 var INTERVAL_MS = 10 * 60 * 1000;
3132 3357 var MAX_ATTEMPTS = 5;
3133 3358 var attempts = 0;
@@ -3148,9 +3373,11 @@
3148 3373 })
3149 3374 .done(function (res) {
3150 3375 if (!res || !res.data) {
3151 3376 attempts++;
3152 - if (attempts >= MAX_ATTEMPTS) stop();
3377 + if (attempts >= MAX_ATTEMPTS) {
3378 + stop();
3379 + }
3153 3380 return;
3154 3381 }
3155 3382 var data = res.data;
3156 3383 var newState = data.state || lastState;
@@ -3170,13 +3397,17 @@
3170 3397 }
3171 3398 return;
3172 3399 }
3173 3400 attempts++;
3174 - if (attempts >= MAX_ATTEMPTS) stop();
3401 + if (attempts >= MAX_ATTEMPTS) {
3402 + stop();
3403 + }
3175 3404 })
3176 3405 .fail(function () {
3177 3406 attempts++;
3178 - if (attempts >= MAX_ATTEMPTS) stop();
3407 + if (attempts >= MAX_ATTEMPTS) {
3408 + stop();
3409 + }
3179 3410 });
3180 3411 }, INTERVAL_MS);
3181 3412 })();
3182 3413