PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 +506 -126 2.6.9 → 2.7.2 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
@@ -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();