1(function($){ 2 'use strict'; 3 4 // Cloudflare Turnstile auto-render callbacks. They dispatch DOM events so 5 // the active advisor instance can safely enable, pause or retry submission. 6 window.voigtAiBrowserTurnstileReady = function(token) { 7 try { document.dispatchEvent(new CustomEvent('voigt:ai:turnstile-ready', {detail:{token:String(token || '')}})); } catch (e) {} 8 }; 9 window.voigtAiBrowserTurnstileExpired = function() { 10 try { document.dispatchEvent(new CustomEvent('voigt:ai:turnstile-expired')); } catch (e) {} 11 }; 12 window.voigtAiBrowserTurnstileError = function(code) { 13 try { document.dispatchEvent(new CustomEvent('voigt:ai:turnstile-error', {detail:{code:String(code || '')}})); } catch (e) {} 14 }; 15 16 var cfg = window.voigtAiBrowserConfig || {}; 17 if (!cfg.ajaxUrl) return; 18 19 function num(value) { 20 var parsed = parseFloat(String(value || '').replace(/[^0-9.-]/g, '')); 21 return isFinite(parsed) ? parsed : null; 22 } 23 24 function uniq(values) { 25 return Array.from(new Set((values || []).filter(Boolean))); 26 } 27 28 function isMobile() { 29 return window.matchMedia('(max-width: 767px)').matches; 30 } 31 32 $(function(){ 33 var $root = $('.voigt-ai-browser').first(); 34 if (!$root.length) return; 35 36 var $panel = $root.find('.voigt-ai-browser-panel').first(); 37 var $backdrop = $root.find('.voigt-ai-browser-backdrop').first(); 38 var $messages = $panel.find('.voigt-ai-browser-messages').first(); 39 var $form = $(); 40 var $listingHost = $(); 41 var strings = cfg.strings || {}; 42 var pendingSearch = false; 43 var pendingSearchId = ''; 44 var pendingSearchTimer = null; 45 var pendingSearchContext = {silent: false, scrollToResults: false}; 46 var loadingNode = null; 47 var loadingTimer = null; 48 var loadingSequence = []; 49 var loadingSequenceIndex = 0; 50 var savedScrollY = null; 51 var pendingCriteria = null; 52 var mobilePageLocked = false; 53 var bodyLockSnapshot = null; 54 var hiddenCompetingUi = []; 55 var turnstilePendingAction = null; 56 var turnstileRetryTimer = null; 57 var turnstilePollTimer = null; 58 var turnstileRefreshTimer = null; 59 var turnstileIssuedAt = 0; 60 var turnstileResetAttempts = 0; 61 var turnstileConsumed = false; 62 var $quotaNotice = $panel.find('.voigt-ai-browser-quota').first(); 63 var $accountButton = $panel.find('.voigt-ai-browser-account').first(); 64 var $memberStatus = $panel.find('[data-member-status]').first(); 65 var $authModal = $('.voigt-ai-auth-modal').first(); 66 var authBusy = false; 67 var authNonce = String(cfg.authNonce || ''); 68 var authNonceFetchedAt = 0; 69 var authNonceRequest = null; 70 var quotaBlocked = false; 71 var currentQuota = cfg.initialQuota && typeof cfg.initialQuota === 'object' ? cfg.initialQuota : {}; 72 73 // The language of the page being viewed is authoritative for the Advisor UI. 74 // TranslatePress changes the URL (e.g. /pl/), so a conversation opened on an 75 // English page must never overwrite the Polish UI after the visitor switches 76 // language. Keep an independent chat session per page language instead. 77 function detectPageLanguage() { 78 var path = String(window.location.pathname || ''); 79 var match = path.match(/^\/(pl|en|es|de|fr)(?:\/|$)/i); 80 if (match) return String(match[1] || 'en').toLowerCase(); 81 var htmlLang = String(document.documentElement.getAttribute('lang') || '').toLowerCase().slice(0, 2); 82 if (['pl','en','es','de','fr'].indexOf(htmlLang) !== -1) return htmlLang; 83 var configured = String($root.attr('data-lang') || cfg.lang || 'en').toLowerCase().slice(0, 2); 84 return ['pl','en','es','de','fr'].indexOf(configured) !== -1 ? configured : 'en'; 85 } 86 87 var pageLanguage = detectPageLanguage(); 88 $root.attr('data-lang', pageLanguage); 89 90 var storageKey = 'voigtAiBrowserSession:v6:' + pageLanguage; 91 var legacyGlobalKey = 'voigtAiBrowserSession:v5'; 92 var legacyKey = 'voigtAiBrowserSession:' + location.pathname; 93 var stored = {}; 94 try { 95 stored = JSON.parse(sessionStorage.getItem(storageKey) || '{}') || {}; 96 // Migrate the old global session only when it belongs to this exact page
97 // language. Never revive an English session on /pl/ (or vice versa). 98 if (!stored.id && !stored.token) { 99 var legacyStored = JSON.parse(sessionStorage.getItem(legacyGlobalKey) || sessionStorage.getItem(legacyKey) || '{}') || {}; 100 var legacyLang = String(legacyStored.lang || '').toLowerCase().slice(0, 2); 101 if (legacyStored && (legacyStored.id || legacyStored.token) && legacyLang === pageLanguage) { 102 stored = legacyStored; 103 sessionStorage.setItem(storageKey, JSON.stringify(stored)); 104 } 105 } 106 } catch (e) {} 107 var sessionId = stored.id || ''; 108 var sessionToken = stored.token || ''; 109 var turnstileSessionVerified = stored.turnstileVerified === true; 110 var trackedPropertyOpens = {}; 111 var conversationKey = 'voigtAiBrowserConversation:v3:' + pageLanguage; 112 var legacyConversationKey = 'voigtAiBrowserConversation:v2'; 113 var conversationHistory = []; 114 var currentSuggestions = []; 115 var restoredConversation = false; 116 var unreadStorageKey = 'voigtAiBrowserUnread:v2:' + pageLanguage; 117 // The teaser is a one-time discovery hint for the whole site, not a 118 // per-page/per-language prompt. Persist both exposure and an explicit 119 // dismissal so navigating to another property or returning later does not 120 // make the bubble reappear. 121 var teaserSeenKey = 'voigtAiTeaserSeen:v2'; 122 var teaserDismissedKey = 'voigtAiTeaserDismissed:v2'; 123 var unreadCount = 0; 124 try { unreadCount = Math.max(0, parseInt(sessionStorage.getItem(unreadStorageKey) || '0', 10) || 0); } catch (e) {} 125 126 function unreadLabel(count) { 127 var lang = currentLanguage(); 128 if (lang === 'pl') return count === 1 ? '1 nowa wiadomoÅÄ od AI' : count + ' nowe wiadomoÅci od AI'; 129 if (lang === 'es') return count === 1 ? '1 mensaje nuevo de IA' : count + ' mensajes nuevos de IA'; 130 if (lang === 'de') return count === 1 ? '1 neue KI-Nachricht' : count + ' neue KI-Nachrichten'; 131 return count === 1 ? '1 new AI message' : count + ' new AI messages'; 132 } 133 134 function renderUnreadBadge() { 135 $('.voigt-ai-browser-launcher').each(function(){ 136 var $launcher = $(this); 137 var $badge = $launcher.find('.voigt-ai-browser-unread').first(); 138 if (!$badge.length) { 139 $badge = $('<span class="voigt-ai-browser-unread" aria-l
139ive="polite" aria-atomic="true" hidden>0</span>').appendTo($launcher); 140 } 141 var visible = unreadCount > 0; 142 $badge.text(unreadCount > 9 ? '9+' : String(unreadCount)).prop('hidden', !visible); 143 $launcher.toggleClass('has-unread', visible); 144 if (visible) { 145 $badge.attr('aria-label', unreadLabel(unreadCount)); 146 $launcher.attr('data-unread-label', unreadLabel(unreadCount)); 147 } else { 148 $badge.removeAttr('aria-label'); 149 $launcher.removeAttr('data-unread-label'); 150 } 151 }); 152 } 153 154 function setUnreadCount(value) { 155 unreadCount = Math.max(0, parseInt(value, 10) || 0); 156 try { sessionStorage.setItem(unreadStorageKey, String(unreadCount)); } catch (e) {} 157 renderUnreadBadge(); 158 } 159 160 function incrementUnreadCount() { 161 setUnreadCount(unreadCount + 1); 162 } 163 164 function panelIsOpen() { 165 return $panel.hasClass('is-open'); 166 } 167 168 function readOpenPropertyContext() { 169 var node = document.getElementById('voigt-ai-open-property-context'); 170 if (!node) return {}; 171 try { 172 var data = JSON.parse(node.textContent || node.innerText || '{}'); 173 if (!data || typeof data !== 'object' || data.page_type !== 'property_detail') return {}; 174 return data; 175 } catch (e) { return {}; } 176 } 177 178 var openPropertyContext = readOpenPropertyContext(); 179 180 function hasOpenPropertyContext() { 181 return !!(openPropertyContext && (openPropertyContext.reference || openPropertyContext.title)); 182 } 183 184 function propertyContextPayload() { 185 if (!hasOpenPropertyContext()) return ''; 186 openPropertyContext.page_url = String(openPropertyContext.page_url || window.location.href || ''); 187 try { return JSON.stringify(openPropertyContext); } catch (e) { return ''; } 188 } 189 190 function trackAdvisorEvent(eventName, reference) { 191 if (!sessionId || !sessionToken || !eventName) return; 192 var ref = String(reference || '').trim().toUpperCase(); 193 var key = String(eventName) + ':' + ref; 194 if (trackedPropertyOpens[key]) return; 195 trackedPropertyOpens[key] = true; 196 $.post(cfg.ajaxUrl, { 197 action: 'voigt_ai_browser_event', 198 nonce: cfg.nonce, 199 session_id: sessionId, 200 session_token: sessionToken, 201 event: String(eventName), 202 reference: ref 203 }); 204 } 205 206 function initialGreeting() { 207 if (!hasOpenPropertyContext()) return String(strings.greeting || ''); 208 var ref = String(openPropertyContext.reference || '').trim(); 209 var title = String(openPropertyContext.title || '').trim(); 210 var label = title || ref; 211 if (label.length > 120) label = label.slice(0, 117) + 'â¦'; 212 var lang = currentLanguage(); 213 if (lang === 'pl') return 'WidzÄ, że oglÄ dasz ' + label + (ref && title ? ' (' + ref + ')' : '') + '. Możesz zapytaÄ mnie bezpoÅrednio o tÄ nieruchomoÅÄ â nie musisz podawaÄ numeru referencyjnego.'; 214 if (lang === 'es') return 'Veo que está viendo ' + label + (ref && title ? ' (' + ref + ')' : '') + '. Puede preguntarme directamente por esta propiedad sin indicar la referencia.'; 215 if (lang === 'de') return 'Ich sehe, dass Sie ' + label + (ref && title ? ' (' + ref + ')' : '') + ' ansehen. Sie können direkt nach dieser Immobilie fragen, ohne die Referenz zu nennen.'; 216 return 'I can see that you are viewing ' + label + (ref && title ? ' (' + ref + ')' : '') + '. You can ask me directly about this property without giving the reference.'; 217 } 218 219 function currentLanguage() { 220 // UI/session language follows the page, not the language remembered by a 221 // previous TranslatePress page or by an earlier AI response. 222 return pageLanguage; 223 } 224 225 function nativeUiFallback(en, de, pl) { 226 var lang = currentLanguage(); 227 if (lang === 'de') return de; 228 if (lang === 'pl') return pl || en; 229 return en; 230 } 231 232 function isAgentPanelContext() { 233 return !!document.querySelector('#vcs-agent-panel'); 234 } 235 236 function authenticatedAgentToken() { 237 // The backend validates the token, so the browser should not suppress it 238 // based on CSS classes or tab timing. Those guards caused valid Agent 239 // Panel requests to be silently treated as public traffic. 240 if (!isAgentPanelContext()) return '';
241 try { return localStorage.getItem('vcsAgentToken') || ''; } catch (e) { return ''; } 242 } 243 244 // Portal every floating UI element directly under <body>. This avoids 245 // stacking contexts created by Elementor, sticky headers and mobile menus. 246 $panel.addClass('voigt-ai-browser-panel-portal').appendTo(document.body); 247 $backdrop.addClass('voigt-ai-browser-backdrop-portal').appendTo(document.body); 248 if ($authModal.length) $authModal.appendTo(document.body); 249 $('.voigt-ai-browser-launcher, .voigt-ai-browser-teaser').appendTo(document.body); 250 if (isAgentPanelContext()) $panel.find('.voigt-ai-browser-account').prop('hidden', true).hide(); 251 renderUnreadBadge(); 252 253 function tapTapCandidates() { 254 var selectors = [ 255 '[id*="taptap" i]', '[class*="taptap" i]', 256 '[id*="tap-tap" i]', '[class*="tap-tap" i]', 257 '[id*="tap_menu" i]', '[class*="tap_menu" i]', 258 '[id*="tapmenu" i]', '[class*="tapmenu" i]', 259 '[id*="bonfire" i]', '[class*="bonfire" i]' 260 ]; 261 try { 262 return Array.prototype.slice.call(document.querySelectorAll(selectors.join(','))); 263 } catch (e) { 264 // Fallback for older browsers without case-insensitive attribute selectors. 265 return Array.prototype.slice.call(document.querySelectorAll('body *')).filter(function(element){ 266 var signature = ((element.id || '') + ' ' + (element.className || '') + ' ' + (element.getAttribute('aria-label') || '')).toLowerCase(); 267 return /taptap|tap-tap|tap_menu|tapmenu|bonfire/.test(signature); 268 }); 269 } 270 } 271 272 function hideCompetingMobileUi() { 273 if (!isMobile()) return; 274 restoreCompetingMobileUi(); 275 var seen = [];
276 tapTapCandidates().forEach(function(candidate){ 277 if (!candidate || candidate === document.body || candidate === document.documentElement) return; 278 if (candidate.closest('.voigt-ai-browser-panel-portal')) return; 279 280 // TapTap often places the identifying class on a child while the fixed 281 // stacking context lives on an ancestor. Hide that fixed/sticky root. 282 var element = candidate; 283 var node = candidate; 284 while (node && node !== document.body) { 285 var nodeStyle = window.getComputedStyle(node); 286 if (nodeStyle.position === 'fixed' || nodeStyle.position === 'sticky') { 287 element = node; 288 break; 289 } 290 node = node.parentElement; 291 } 292 if (seen.indexOf(element) !== -1) return; 293 seen.push(element); 294 295 hiddenCompetingUi.push({ 296 element: element, 297 opacity: element.style.opacity, 298 pointerEvents: element.style.pointerEvents, 299 visibility: element.style.visibility, 300 transition: element.style.transition, 301 ariaHidden: element.getAttribute('aria-hidden') 302 }); 303 element.style.transition = 'opacity .18s ease'; 304 element.style.opacity = '0'; 305 element.style.pointerEvents = 'none'; 306 element.style.visibility = 'hidden'; 307 element.setAttribute('aria-hidden', 'true'); 308 element.setAttribute('data-voigt-ai-temporarily-hidden', '1'); 309 }); 310 } 311 312 function restoreCompetingMobileUi() { 313 hiddenCompetingUi.forEach(function(entry){ 314 var element = entry.element; 315 if (!element || !document.documentElement.contains(element)) return; 316 element.style.opacity = entry.opacity; 317 element.style.pointerEvents = entry.pointerEvents; 318 element.style.visibility = entry.visibility; 319 element.style.transition = entry.transition; 320 if (entry.ariaHidden === null) element.removeAttribute('aria-hidden'); 321 else element.setAttribute('aria-hidden', entry.ariaHidden); 322 element.removeAttribute('data-voigt-ai-temporarily-hidden'); 323 }); 324 hiddenCompetingUi = []; 325 } 326 327 function resetListingBinding() { 328 $form = $(); 329 $listingHost = $(); 330 } 331 332 function getListingHost(force) { 333 if (force) resetListingBinding(); 334 if ($listingHost.length && document.documentElement.contains($listingHost.get(0))) return $listingHost; 335 336 // The advisor is portalled to body. Bind it to the native Property List 337 // that contains a real search form, prioritising the authenticated panel. 338 var $candidates = $('#vcs-agent-panel .voigt-ai-browser-listing-host[data-agent-mode="1"], .voigt-ai-browser-listing-host'); 339 var best = null; 340 var bestScore = -1; 341 $candidates.each(function(){ 342 var $candidate = $(this); 343 var $candidateForm = $candidate.find('form.mls-proplist-search-form').first(); 344 if (!$candidateForm.length) return; 345 var score = 0; 346 if ($candidate.closest('#vcs-agent-panel').length) score += 100; 347 if ($candidate.is(':visible')) score += 30; 348 if ($candidateForm.find('#mls_search_features_search_type').length) score += 20; 349 if ($candidateForm.find('input.mls-af-checkbox').length) score += 20; 350 if ($candidate.find('#mls-ajax-results').length) score += 10; 351 if (score > bestScore) { bestScore = score; best = this; } 352 }); 353 if (best) $listingHost = $(best); 354 return $listingHost; 355 } 356 357 function getForm(force) { 358 if (force) resetListingBinding(); 359 if ($form.length && document.documentElement.contains($form.get(0))) return $form; 360 361 var $host = getListingHost(); 362 var $forms = $host.length ? $host.find('form.mls-proplist-search-form') : $('form.mls-proplist-search-form'); 363 var best = null; 364 var bestScore = -1; 365 $forms.each(function(){ 366 var $candidate = $(this); 367 var score = 0; 368 if ($candidate.closest('#vcs-agent-panel').length) score += 100; 369 if ($candidate.is(':visible')) score += 30; 370 if ($candidate.find('#mls_search_features_search_type').length) score
370+= 30; 371 if ($candidate.find('input.mls-af-checkbox').length) score += 30; 372 if ($candidate.find('#mls_search_type').length) score += 10; 373 if (score > bestScore) { bestScore = score; best = this; } 374 }); 375 if (best) { 376 $form = $(best); 377 if (!$listingHost.length) $listingHost = $form.closest('.voigt-ai-browser-listing-host'); 378 } 379 return $form; 380 } 381 382 function getFilterScope(force) { 383 var $currentForm = getForm(force); 384 if ($currentForm.length) return $currentForm; 385 var $host = getListingHost(force); 386 return $host.length ? $host : $currentForm; 387 } 388 389 function normaliseFeatureId(value) { 390 var text = String(value || '').trim(); 391 try { text = decodeURIComponent(text); } catch (e) {} 392 return fold(text).replace(/[^a-z0-9]/g, ''); 393 } 394 395 function normaliseFeatureWords(value) { 396 return fold(value).replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim(); 397 } 398 399 function getFeatureInputs(force) { 400 var $currentForm = getForm(force); 401 var $inputs = $currentForm.find('input.mls-af-checkbox'); 402 if (!$inputs.length) { 403 var $scope = getListingHost(); 404 $inputs = $scope.find('form.mls-proplist-search-form input.mls-af-checkbox'); 405 } 406 return $inputs; 407 } 408 409 function featureLookup(param) { 410 var lookup = cfg.featureLookup || {}; 411 if (lookup[param]) return lookup[param]; 412 var wanted = normaliseFeatureId(param); 413 var found = null; 414 Object.keys(lookup).some(function(key){ 415 if (normaliseFeatureId(key) === wanted) { 416 found = lookup[key]; 417 return true; 418 } 419 return false; 420 }); 421 return found || {}; 422 } 423 424 function findFeatureInputs(params, force) { 425 var exact = new Set((params || []).map(function(value){ return String(value); })); 426 var normalised = new Set((params || []).map(normaliseFeatureId).filter(Boolean)); 427 var labels = new Set(); 428 (params || []).forEach(function(param){ 429 var meta = featureLookup(param); 430 if (meta && meta.name) labels.add(normaliseFeatureWords(meta.name)); 431 }); 432 return getFeatureInputs(force).filter(function(){ 433 var $input = $(this); 434 var value = String(this.value || ''); 435 var label = normaliseFeatureWords($input.next('label').text()); 436 return exact.has(value) || normalised.has(normaliseFeatureId(value)) || (label && labels.has(label)); 437 }); 438 } 439 440 function featureLabel(param) { 441 var $input = findFeatureInputs([param]).first(); 442 if ($input.length) { 443 var text = $.trim($input.next('label').text()); 444 if (text) return text; 445 } 446 var meta = featureLookup(param); 447 return String(meta.name || param || ''); 448 } 449 450 function setFeatureChecked($input, checked) { 451 $input.each(function(){ 452 var input = this; 453 var desired = !!checked; 454 // A native click follows exactly the same event path as a user click and 455 // lets main.js create/remove the visible badges. Fall back to explicit 456 // state + bubbling events if a browser refuses a click on a hidden panel. 457 if (!!input.checked !== desired) { 458 try { input.click(); } catch (e) {} 459 } 460 if (!!input.checked !== desired) { 461 input.checked = desired; 462 try { input.dispatchEvent(new Event('input', {bubbles: true})); } catch (e) { $(input).trigger('input'); } 463 try { input.dispatchEvent(new Event('change', {bubbles: true})); } catch (e) { $(input).trigger('change'); } 464 } 465 if (desired) input.setAttribute('checked', 'checked'); 466 else input.removeAttribute('checked'); 467 $(input).closest('li').toggleClass('mls-selected', desired).attr('aria-selected', desired ? 'true' : 'false'); 468 }); 469 } 470 471 function rebuildFeatureUi() { 472 var $scope = getFilterScope(); 473 $scope.find('.mls-af-sel-wrap').each(function(){ 474 var $wrap = $(this); 475 var $badges = $wrap.find('.mls-af-selected-labels').first().empty(); 476 $wrap.find('input.mls-af-checkbox:checked').each(function(){ 477 var $input = $(this); 478 var text = $.trim($input.next('label').text()); 479 if (!text) return; 480 $('<span class="mls-af-label-badge" data-voigt-ai-badge="1">') 481 .attr('data-value', String(this.value)) 482 .append(document.createTextNode(text)) 483 .append($('<span class="mls-af-close-btn">×</span>').attr('aria-label', nativeUiFallback('Remove', 'Entfernen', 'UsuÅ'))) 484 .appendTo($badges); 485 }); 486 }); 487 488 var $selectedFeatures = $scope.find('.mls_selected_features').first(); 489 if (!$selectedFeatures.length) $selectedFeatures = getForm().closest('.filter-form-wrapper').find('.mls_selected_features').first(); 490 $selectedFeatures.empty(); 491 getFeatureInputs().filter(':checked').each(function(){ 492 var text = $.trim($(this).next('label').text()); 493 if (text) $('<span>').text(text).appendTo($selectedFeatures); 494 }); 495 } 496 497 function featureCategoryField(category) { 498 var key = fold(category).replace(/[^a-z0-9]/g, ''); 499 var map = { 500 setting:'Setting', orientation:'Orientation', condition:'Condition', pool:'Pool', 501 climate:'Climate', climatecontrol:'Climate', views:'Views', features:'Features', 502 furniture:'Furniture', kitchen:'Kitchen', garden:'Garden', security:'Security', 503 parking:'Parking', utilities:'Utilities', category:'Category', rentals:'Rentals', 504 plots:'Plots', plotsandventures:'Plots' 505 }; 506 return map[key] || ''; 507 } 508 509 function ensureFeatureSubmissionMirrors(requestedParams) { 510 var $currentForm = getForm(); 511 if (!$currentForm.length) return; 512 $currentForm.find('input.voigt-ai-feature-mirror').remove(); 513
514 (requestedParams || []).forEach(function(param){ 515 var $input = findFeatureInputs([param]).first(); 516 if ($input.length && $currentForm.has($input).length) return; 517 518 var name = $input.attr('name') || ''; 519 if (!name) { 520 var meta = featureLookup(param); 521 var field = featureCategoryField(meta.category || ''); 522 if (field) name = 'mls_search_feature_' + field + '[]'; 523 } 524 if (!name) return; 525 $('<input type="hidden" class="voigt-ai-feature-mirror">') 526 .attr('name', name) 527 .val(String($input.length ? $input.val() : param)) 528 .appendTo($currentForm); 529 }); 530 } 531 532 function applyMoreFilters(criteria, force) { 533 criteria = criteria || {}; 534 var hardFeatures = uniq(criteria.must_have_features || []); 535 var preferredFeatures = uniq(criteria.preferred_features || []); 536 var appliedFeatures = hardFeatures.length ? hardFeatures : preferredFeatures; 537 var $currentForm = getForm(!!force); 538 var $all = getFeatureInputs(); 539 540 // Never clear checkboxes from another Property List on the same page. 541 setFeatureChecked($all.filter(':checked'), false); 542 var $matched = findFeatureInputs(appliedFeatures); 543 setFeatureChecked($matched, true); 544 545 var $mode = $currentForm.find('#mls_search_features_search_type').first(); 546 $mode.val(hardFeatures.length ? '2' : (preferredFeatures.length ? '1' : '2')); 547 nativeChange($mode); 548 549 rebuildFeatureUi(); 550 ensureFeatureSubmissionMirrors(appliedFeatures); 551 552 var appliedIds = []; 553 appliedFeatures.forEach(function(param){ 554 if (findFeatureInputs([param]).filter(':checked').length) appliedIds.push(param); 555 }); 556 window.__voigtAiFeatureSync = { 557 formFound: !!$currentForm.length, 558 availableCheckboxes: $all.length, 559 requested: appliedFeatures.slice(), 560 applied: uniq(appliedIds), 561 unresolved: appliedFeatures.filter(function(param){ return !findFeatureInputs([param]).filter(':checked').length; }), 562 mode: String($mode.val() || '') 563 }; 564 try { document.dispatchEvent(new CustomEvent('voigtAiMoreFiltersApplied', {detail: window.__voigtAiFeatureSync})); } catch (e) {} 565 return appliedFeatures; 566 } 567 568 function applyMoreFiltersWhenReady(criteria, callback) { 569 var requested = uniq([].concat(criteria.must_have_features || [], criteria.preferred_features || [])); 570 var attempts = 0; 571 var maxAttempts = 40; // up to four seconds for delayed/page-builder markup 572 573 function attempt() { 574 attempts++; 575 getForm(true); 576 var available = getFeatureInputs().length; 577 if (!requested.length || available > 0 || attempts >= maxAttempts) { 578 var applied = applyMoreFilters(criteria, false); 579 // Give delegated change handlers one animation frame to update their UI 580 // before serialising and submitting the native form. 581 window.requestAnimationFrame(function(){ 582 window.setTimeout(function(){ callback(applied); }, 20); 583 }); 584 return; 585 } 586 window.setTimeout(attempt, 100); 587 } 588 attempt(); 589 } 590 591 function getResultsRoot() { 592 var $host = getListingHost(); 593 var $results = $host.find('#mls-ajax-results').first(); 594 return $results.length ? $results : $('#mls-ajax-results').first(); 595 } 596 597 function turnstileRequired() { 598 return !!cfg.turnstileEnabled && !isAgentPanelContext() && !turnstileSessionVerified; 599 } 600 601 function tsToken() { 602 var input = $panel.find('input[name="cf-turnstile-response"]').get(0); 603 return input ? String(input.value || '') : ''; 604 } 605 606 function setTurnstileStatus(message, isError) { 607 var $status = $panel.find('.voigt-ai-browser-security-status').first(); 608 if (!$status.length) return; 609 if (!message || !turnstileRequired()) { 610 $status.attr('hidden', true).removeClass('is-error').text(''); 611 return; 612 } 613 $status.text(message).toggleClass('is-error', !!isError).removeAttr('hidden'); 614 } 615 616 function quotaTemplate(template, first, second) { 617 var firstValue = String(Math.max(0, parseInt(first, 10) || 0)); 618 var secondValue = String(Math.max(0, parseInt(second, 10) || 0)); 619 return String(template || '') 620 .replace(/%1\$d/g, firstValue) 621 .replace(/%2\$d/g, secondValue) 622 .replace(/%d/g, firstValue); 623 } 624 625 function accountBenefits($container, memberLimit, guestLimit) { 626 if (!$container || !$container.length) return; 627 var $benefits = $('<div class="voigt-ai-browser-quota-benefits">'); 628 $('<strong>
628').text(strings.account_benefits_title || nativeUiFallback('Free account benefits', 'Vorteile eines kostenlosen Kontos', 'KorzyÅci z bezpÅatnego konta')).appendTo($benefits); 629 var $list = $('<ul>').appendTo($benefits); 630 [ 631 quotaTemplate(strings.auth_benefit_limit, memberLimit, guestLimit), 632 strings.auth_benefit_searches || '', 633 strings.auth_benefit_favorites || '', 634 strings.auth_benefit_price_alerts || '' 635 ].filter(Boolean).forEach(function(item){ $('<li>').text(item).appendTo($list); }); 636 $benefits.appendTo($container); 637 } 638 639 function updateQuotaNotice(quota) { 640 quota = quota && typeof quota === 'object' ? quota : {}; 641 currentQuota = $.extend({}, currentQuota || {}, quota); 642 var tier = String(currentQuota.tier || (cfg.loggedInUser ? 'member' : 'guest')); 643 var reason = String(currentQuota.reason || ''); 644 var remaining = parseInt(currentQuota.remaining, 10); 645 var limit = parseInt(currentQuota.limit, 10); 646 var threshold = parseInt(currentQuota.warning_threshold, 10); 647 if (!isFinite(remaining)) remaining = 0; 648 if (!isFinite(limit)) limit = 0; 649 if (!isFinite(threshold) || threshold < 1) threshold = 5; 650 var guestDailyLimit = Math.max(1, parseInt(cfg.guestDailyLimit, 10) || 30); 651 var memberDailyLimit = Math.max(guestDailyLimit, parseInt(cfg.memberDailyLimit, 10) || 100); 652 653 if ($accountButton.length && tier === 'guest') { 654 $accountButton.text(strings.limit_login || nativeUiFallback('Sign in', 'Anmelden', 'Zaloguj siÄ')); 655 $accountButton.attr('title', strings.limit_login_more || ''); 656 } 657 if ($memberStatus.length && tier === 'member') { 658 $memberStatus.text(strings.member_status || nativeUiFallback('Signed in', 'Angemeldet', 'Zalogowano')); 659 } 660 661 if (isAgentPanelContext() || tier === 'agent') { 662 quotaBlocked = false; 663 if ($quotaNotice.length) $quotaNotice.attr('hidden', true).removeClass('is-warning is-blocked is-member is-rate'); 664 syncTurnstileControls(); 665 return; 666 } 667 668 var isRate = reason === 'rate'; 669 var exhausted = !isRate && limit > 0 && remaining <= 0; 670 var shouldWarn = !exhausted && !isRate && limit > 0 && remaining <= threshold; 671 quotaBlocked = exhausted; 672 673 if (!$quotaNotice.length || (!isRate && !exhausted && !shouldWarn)) { 674 if ($quotaNotice.length) { 675 $quotaNotice.attr('hidden', true).removeClass('is-warning is-blocked is-member is-rate'); 676 $quotaNotice.find('.voigt-ai-browser-quota-text').text(''); 677 $quotaNotice.find('.voigt-ai-browser-quota-actions').empty(); 678 } 679 syncTurnstileControls(); 680 return; 681 } 682 683 var text = ''; 684 if (isRate) { 685 text = strings.rate_limited || strings.error || ''; 686 } else if (exhausted) { 687 text = tier === 'member' 688 ? (strings.member_limit_exhausted || strings.error || '') 689 : (strings.limit_exhausted || strings.error || ''); 690 } else { 691 text = tier === 'member' 692 ? (strings.member_messages_left || '') 693 : (strings.messages_left || ''); 694 if (tier === 'guest' && strings.limit_login_more) text += ' ' + strings.limit_login_more; 695 } 696 697 $quotaNotice 698 .removeAttr('hidden') 699 .toggleClass('is-warning', shouldWarn) 700 .toggleClass('is-blocked', exhausted) 701 .toggleClass('is-member', tier === 'member') 702 .toggleClass('is-rate', isRate); 703 $quotaNotice.find('.voigt-ai-browser-quota-text').text(text); 704 705 var $actions = $quotaNotice.find('.voigt-ai-browser-quota-actions').empty(); 706 if (tier === 'guest' && (shouldWarn || exhausted)) { 707 accountBenefits($actions, memberDailyLimit, guestDailyLimit); 708 var loginUrl = String(currentQuota.login_url || cfg.loginUrl || ''); 709 var registerUrl = String(currentQuota.register_url || cfg.registerUrl || ''); 710 if (loginUrl) { 711 var $loginAction = cfg.authPopupEnabled && $authModal.length 712 ? $('<button type="button" class="voigt-ai-browser-quota-login" data-auth-open="login">') 713 : $('<a class="voigt-ai-browser-quota-login">').attr('href', loginUrl); 714 $loginAction.text(strings.limit_login || nativeUiFallback('Sign in', 'Anmelden', 'Zaloguj siÄ')).appendTo($actions); 715 } 716 if (registerUrl || (cfg.registrationEnabled && cfg.authPopupEnabled && $authModal.length)) { 717 var $registerAction = cfg.authPopupEnabled && $authModal.length 718 ? $('<button type="button" class="voigt-ai-browser-quota-register" data-auth-open="register">') 719 : $('<a class="voigt-ai-browser-quota-register">').attr('href', registerUrl); 720 $registerAction.text(strings.limit_register || nativeUiFallback('Create account', 'Konto erstellen', 'Utwórz konto')).appendTo($actions); 721 } 722 } 723 syncTurnstileControls(); 724 } 725 726 function syncTurnstileControls() { 727 var required = turnstileRequired(); 728 var waiting = required && (turnstileConsumed || !tsToken()); 729 var userWaiting = waiting && !!turnstilePendingAction; 730 $panel.find('.voigt-ai-browser-turnstile').toggle(required).attr('aria-hidden', required ? 'false' : 'true'); 731 // Pre-warm Turnstile silently. The composer stays usable while the token 732 // is being prepared; if the visitor submits early, ensureTurnstileReady() 733 // queues that action and sends it as soon as verification completes. 734 $panel.find('.voigt-ai-browser-composer button[type="submit"]').prop('disabled', quotaBlocked); 735 $panel.find('.voigt-ai-browser-composer textarea').prop('disabled', quotaBlocked); 736 var $lead = $panel.find('.voigt-ai-browser-send-lead'); 737 if (!$lead.data('voigtLeadSent')) $lead.prop('disabled', false); 738 if (userWaiting) setTurnstileStatus(strings.verifying || nativeUiFallback('Completing a security checkâ¦', 'Sicherheitsprüfung wird abgeschlossenâ¦', 'KoÅczenie weryfikacji bezpieczeÅstwaâ¦'), false); 739 else setTurnstileStatus('', false); 740 } 741 742 function runPendingTurnstileAction() { 743 if (!turnstilePendingAction || !tsToken() || turnstileConsumed) return; 744 var action = turnstilePendingAction; 745 turnstilePendingAction = null; 746 window.setTimeout(function(){ action(); }, 0); 747 } 748 749 function clearTurnstileRefreshTimer() { 750 if (turnstileRefreshTimer) window.clearTimeout(turnstileRefreshTimer); 751 turnstileRefreshTimer = null; 752 } 753
754 function scheduleTurnstileRefreshNearExpiry() { 755 clearTurnstileRefreshTimer(); 756 if (!turnstileRequired() || !tsToken() || turnstileConsumed) return; 757 // Cloudflare tokens expire after 300 seconds. Refresh an unused pre-warmed 758 // token shortly before that point, rather than after every chat message. 759 var elapsed = turnstileIssuedAt ? Math.max(0, Date.now() - turnstileIssuedAt) : 0; 760 var delay = Math.max(1000, 270000 - elapsed); 761 turnstileRefreshTimer = window.setTimeout(function(){ 762 turnstileRefreshTimer = null; 763 if (!turnstileRequired() || !tsToken() || turnstileConsumed) return; 764 turnstileConsumed = true; 765 try { 766 if (window.turnstile) { 767 var element = $panel.find('.voigt-ai-browser-turnstile').get(0); 768 if (element) { 769 window.turnstile.reset(element); 770 return; 771 } 772 } 773 } catch (e) {} 774 scheduleTurnstileReset(250, false); 775 }, delay); 776 } 777 778 function scheduleTurnstileReset(delay, retryMessage) { 779 if (!turnstileRequired()) return; 780 if (turnstileRetryTimer) window.clearTimeout(turnstileRetryTimer); 781 turnstileRetryTimer = window.setTimeout(function(){ 782 turnstileRetryTimer = null; 783 if (turnstilePendingAction) { 784 setTurnstileStatus(retryMessage ? (strings.verification_retry || strings.verifying || nativeUiFallback('Retrying the security checkâ¦', 'Sicherheitsprüfung wird erneut versuchtâ¦', 'Ponawianie weryfikacji bezpieczeÅstwaâ¦')) : (strings.verifying || nativeUiFallback('Completing a security checkâ¦', 'Sicherheitsprüfung wird abgeschlossenâ¦', 'KoÅczenie weryfikacji bezpieczeÅstwaâ¦')), false); 785 } else { 786 setTurnstileStatus('', false); 787 } 788 try { 789 if (window.turnstile) { 790 var element = $panel.find('.voigt-ai-browser-turnstile').get(0); 791 if (element) { 792 window.turnstile.reset(element); 793 turnstileResetAttempts = 0; 794 return; 795 } 796 } 797 } catch (e) {} 798 turnstileResetAttempts++; 799 if (!tsToken() && turnstileResetAttempts < 20) scheduleTurnstileReset(500, true); 800 }, Math.max(0, delay || 0)); 801 } 802 803 function tsReset() { 804 if (!turnstileRequired()) return; 805 turnstileConsumed = true; 806 syncTurnstileControls(); 807 scheduleTurnstileReset(50, false); 808 } 809 810 function ensureTurnstileReady(action) { 811 if (!turnstileRequired()) return true; 812 if (tsToken() && !turnstileConsumed) return true; 813 turnstilePendingAction = action; 814 syncTurnstileControls(); 815 // Handles delayed script loading and widgets that entered an error state. 816 scheduleTurnstileReset(250, false); 817 return false; 818 } 819 820 function initialiseTurnstileGate() { 821 if (!cfg.turnstileEnabled) return; 822 if (isAgentPanelContext()) { 823 $panel.find('.voigt-ai-browser-turnstile,.voigt-ai-browser-security-status').attr('hidden', true).hide(); 824 syncTurnstileControls(); 825 return; 826 } 827 turnstileConsumed = !tsToken(); 828 syncTurnstileControls(); 829 // Fallback for cached/optimised pages where auto-render callbacks can be 830 // registered after the widget has already populated its hidden input. 831 turnstilePollTimer = window.setInterval(function(){ 832 if (!turnstileRequired()) return; 833 var token = tsToken(); 834 if (token && turnstileConsumed) { 835 turnstileConsumed = false; 836 syncTurnstileControls(); 837 runPendingTurnstileAction(); 838 } else if (!token && !turnstileConsumed) { 839 turnstileConsumed = true; 840 syncTurnstileControls(); 841 } 842 }, 250); 843 } 844 845 document.addEventListener('voigt:ai:turnstile-ready', function(){ 846 if (!turnstileRequired()) return; 847 turnstileConsumed = false; 848 turnstileIssuedAt = Date.now(); 849 turnstileResetAttempts = 0; 850 syncTurnstileControls(); 851 scheduleTurnstileRefreshNearExpiry(); 852 runPendingTurnstileAction(); 853 }); 854 document.addEventListener('voigt:ai:turnstile-expired', function(){ 855 if (!turnstileRequired()) return; 856 clearTurnstileRefreshTimer(); 857 turnstileIssuedAt = 0; 858 turnstileConsumed = true; 859 syncTurnstileControls();
860 scheduleTurnstileReset(100, true); 861 }); 862 document.addEventListener('voigt:ai:turnstile-error', function(){ 863 if (!turnstileRequired()) return; 864 clearTurnstileRefreshTimer(); 865 turnstileIssuedAt = 0; 866 turnstileConsumed = true; 867 syncTurnstileControls(); 868 scheduleTurnstileReset(1200, true); 869 }); 870 871 function saveSession() { 872 stored = {id: sessionId, token: sessionToken, lang: pageLanguage, turnstileVerified: turnstileSessionVerified === true}; 873 try { 874 sessionStorage.setItem(storageKey, JSON.stringify(stored)); 875 } catch (e) {} 876 } 877 878 function serialisableOptions(options) { 879 options = options || {}; 880 var navigationCriteria = {}; 881 if (options.navigationCriteria && typeof options.navigationCriteria === 'object') { 882 try { 883 navigationCriteria = JSON.parse(JSON.stringify(options.navigationCriteria)); 884 } catch (e) { 885 navigationCriteria = {}; 886 } 887 } 888 return { 889 small: options.small ? String(options.small) : '', 890 cta: options.cta ? String(options.cta) : '', 891 href: options.href ? String(options.href) : '', 892 messageId: options.messageId ? String(options.messageId) : '', 893 feedback: options.feedback && typeof options.feedback === 'object' ? options.feedback : {}, 894 navigationCriteria: navigationCriteria 895 }; 896 } 897 898 function saveConversation() { 899 try { 900 sessionStorage.setItem(conversationKey, JSON.stringify({ 901 version: 2, 902 session_id: sessionId || '', 903 language: currentLanguage(), 904 messages: conversationHistory.slice(-40), 905 suggestions: currentSuggestions.slice(0, 12), 906 saved_at: Date.now() 907 })); 908 } catch (e) {} 909 } 910 911 function clearConversationStorage() { 912 conversationHistory = []; 913 currentSuggestions = [];
914 try { sessionStorage.removeItem(conversationKey); } catch (e) {} 915 } 916 917 function clearSession() { 918 sessionId = ''; 919 sessionToken = ''; 920 turnstileSessionVerified = false; 921 clearTurnstileRefreshTimer(); 922 turnstileIssuedAt = 0; 923 stored = {}; 924 window.__voigtAiLastCriteria = {}; 925 clearConversationStorage(); 926 try { 927 sessionStorage.removeItem(storageKey); 928 sessionStorage.removeItem(legacyKey); 929 sessionStorage.removeItem('voigtAiPendingCriteria'); 930 sessionStorage.removeItem('voigtAiPanelClosedForNavigation'); 931 } catch (e) {} 932 setUnreadCount(0); 933 } 934 935 function lockMobilePage() { 936 if (!isMobile() || mobilePageLocked) return; 937 savedScrollY = window.scrollY || window.pageYOffset || 0; 938 bodyLockSnapshot = { 939 position: document.body.style.position, 940 top: document.body.style.top, 941 left: document.body.style.left, 942 right: document.body.style.right, 943 width: document.body.style.width, 944 overflow: document.body.style.overflow 945 }; 946 document.body.style.position = 'fixed'; 947 document.body.style.top = (-savedScrollY) + 'px'; 948 document.body.style.left = '0'; 949 document.body.style.right = '0'; 950 document.body.style.width = '100%'; 951 document.body.style.overflow = 'hidden'; 952 mobilePageLocked = true; 953 } 954 955 function unlockMobilePage() { 956 if (!mobilePageLocked) return; 957 var y = savedScrollY || 0; 958 var snap = bodyLockSnapshot || {}; 959 document.body.style.position = snap.position || ''; 960 document.body.style.top = snap.top || ''; 961 document.body.style.left = snap.left || ''; 962 document.body.style.right = snap.right || ''; 963 document.body.style.width = snap.width || ''; 964 document.body.style.overflow = snap.overflow || ''; 965 mobilePageLocked = false; 966 bodyLockSnapshot = null; 967 savedScrollY = null; 968 window.scrollTo(0, y); 969 } 970 971 function applyVisualViewport() { 972 if (!isMobile()) { 973 $panel.css({'--vai-vh':'', '--vai-vtop':'', '--vai-vleft':'', '--vai-vwidth':''}).removeClass('is-keyboard-open'); 974 return; 975 } 976 var vv = window.visualViewport; 977 var height = vv ? vv.height : window.innerHeight; 978 var top = vv ? vv.offsetTop : 0; 979 var left = vv ? vv.offsetLeft : 0; 980 var width = vv ? vv.width : window.innerWidth; 981 $panel.css({ 982 '--vai-vh': Math.max(240, Math.round(height)) + 'px',
983 '--vai-vtop': Math.max(0, Math.round(top)) + 'px', 984 '--vai-vleft': Math.max(0, Math.round(left)) + 'px', 985 '--vai-vwidth': Math.max(280, Math.round(width)) + 'px' 986 }); 987 $panel.toggleClass('is-keyboard-open', (window.innerHeight - height) > 120); 988 } 989 990 function settleMobileViewport() { 991 applyVisualViewport(); 992 requestAnimationFrame(function(){ 993 applyVisualViewport(); 994 scrollBottom(true); 995 }); 996 setTimeout(function(){ applyVisualViewport(); scrollBottom(true); }, 80); 997 setTimeout(function(){ applyVisualViewport(); scrollBottom(true); }, 280); 998 } 999 1000 function refreshNativeListingLayout() { 1001 var refresh = function() { 1002 try { 1003 // The desktop advisor changes the available page width with an 1004 // animated body padding. Slick keeps the slide widths calculated 1005 // before that change unless it is explicitly repositioned. 1006 $(window).triggerHandler('resize.voigtAiAdvisorLayout'); 1007 $('.mls-project-listing-slider.slick-initialized:visible').each(function(){ 1008 var $slider = $(this); 1009 try { 1010 $slider.slick('setPosition'); 1011 var instance = $slider.slick('getSlick'); 1012 if (instance && typeof instance.resize === 'function') instance.resize(); 1013 } catch (e) {} 1014 }); 1015 $(document).trigger('voigt:advisor:layout-updated'); 1016 } catch (e) {} 1017 }; 1018 1019 window.requestAnimationFrame(refresh); 1020 window.setTimeout(refresh, 60); 1021 window.setTimeout(refresh, 180); 1022 // body padding-left transitions for .28s on desktop. 1023 window.setTimeout(refresh, 330); 1024 window.setTimeout(refresh, 520); 1025 } 1026 1027 function openPanel(focus) { 1028 setUnreadCount(0); 1029 if (isMobile()) lockMobilePage(); 1030 $root.addClass('is-open'); 1031 $panel.addClass('is-open').attr('aria-hidden', 'false'); 1032 $backdrop.addClass('is-open'); 1033 document.body.classList.add('voigt-ai-browser-panel-open'); 1034 document.body.classList.add('voigt-ai-open'); 1035 hideCompetingMobileUi(); 1036 document.documentElement.classList.toggle('voigt-ai-browser-lock', isMobile()); 1037 document.body.classList.toggle('voigt-ai-browser-lock', isMobile()); 1038 $('.voigt-ai-browser-teaser').prop('hidden', true); 1039 settleMobileViewport(); 1040 refreshNativeListingLayout(); 1041 if (focus !== false) { 1042 setTimeout(function(){ 1043 $panel.find('textarea').trigger('focus'); 1044 settleMobileViewport(); 1045 }, 180); 1046 } 1047 } 1048 1049 function closePanel() { 1050 $panel.find('textarea,input').trigger('blur'); 1051 $root.removeClass('is-open'); 1052 $panel.removeClass('is-open is-handoff-open').attr('aria-hidden', 'true'); 1053 $panel.find('.voigt-ai-browser-handoff').prop('hidden', true); 1054 $backdrop.removeClass('is-open'); 1055 document.body.classList.remove('voigt-ai-browser-panel-open'); 1056 document.body.classList.remove('voigt-ai-open'); 1057 restoreCompetingMobileUi(); 1058 document.documentElement.classList.remove('voigt-ai-browser-lock'); 1059 document.body.classList.remove('voigt-ai-browser-lock'); 1060 unlockMobilePage(); 1061 applyVisualViewport(); 1062 refreshNativeListingLayout(); 1063 } 1064 1065 function authStatus(message, isError) { 1066 if (!$authModal.length) return; 1067 var text = String(message || ''); 1068 var $status = $authModal.find('.voigt-ai-auth-status').first(); 1069 $status 1070 .text(text) 1071 .prop('hidden', !text) 1072 .toggleClass('is-error', !!isError) 1073 .toggleClass('is-success', !isError && !!text); 1074 if (text) { 1075 var dialog = $authModal.find('.voigt-ai-auth-dialog').get(0); 1076 if (dialog && $status.get(0)) { 1077 var top = $status.get(0).offsetTop - 16; 1078 if (dialog.scrollTop > top || isError) dialog.scrollTo({top: Math.max(0, top), behavior: 'smooth'}); 1079 } 1080 } 1081 } 1082 1083 function refreshAuthNonce(force) { 1084 var now = Date.now(); 1085 if (!force && authNonce && authNonceFetchedAt && (now - authNonceFetchedAt) < 5 * 60 * 1000) { 1086 return $.Deferred().resolve(authNonce).promise(); 1087 } 1088 if (authNonceRequest) return authNonceRequest; 1089 authNonceRequest = $.ajax({ 1090 url: cfg.ajaxUrl, 1091 method: 'POST', 1092 dataType: 'json', 1093 cache: false, 1094 timeout: 12000, 1095 data: {action: 'voigt_ai_browser_auth_nonce', _: now} 1096 }).then(function(response){ 1097 var fresh = response && response.success === true && response.data ? String(response.data.nonce || '') : ''; 1098 if (fresh) { 1099 authNonce = fresh; 1100 authNonceFetchedAt = Date.now(); 1101 } 1102 return authNonce; 1103 }, function(){ 1104 return authNonce; 1105 }).always(function(){ 1106 authNonceRequest = null; 1107 }); 1108 return authNonceRequest; 1109 } 1110 1111 function setAuthTab(mode) { 1112 if (!$authModal.length) return; 1113 var allowed = ['login', 'lost', 'reset']; 1114 if (cfg.registrationEnabled) allowed.push('register'); 1115 mode = allowed.indexOf(String(mode || 'login')) >= 0 ? String(mode || 'login') : 'login'; 1116 1117 var $dialog = $authModal.find('.voigt-ai-auth-dialog').first(); 1118 var special = mode === 'lost' || mode === 'reset'; 1119 var title = special 1120 ? String($dialog.attr(mode === 'reset' ? 'data-title-reset' : 'data-title-lost') || '') 1121 : String($dialog.attr('data-title-default') || ''); 1122 var subtitle = special 1123 ? String($dialog.attr(mode === 'reset' ? 'data-subtitle-reset' : 'data-subtitle-lost') || '') 1124 : String($dialog.attr('data-subtitle-default') || ''); 1125 var previousMode = String($dialog.attr('data-auth-mode') || ''); 1126 var modeChanged = previousMode !== mode; 1127 $dialog.attr('data-auth-mode', mode); 1128 if (modeChanged && $dialog.get(0)) $dialog.get(0).scrollTop = 0; 1129 $dialog.find('.voigt-ai-auth-title').text(title); 1130 $dialog.find('.voigt-ai-auth-subtitle').text(subtitle); 1131 $dialog.find('.voigt-ai-auth-benefits,.voigt-ai-auth-tabs').prop('hidden', special); 1132 1133 $authModal.find('.voigt-ai-auth-tab').each(function(){ 1134 var active = String($(this).attr('data-auth-tab') || '')
1134=== mode; 1135 $(this).toggleClass('is-active', active).attr('aria-selected', active ? 'true' : 'false'); 1136 }); 1137 $authModal.find('.voigt-ai-auth-form').each(function(){ 1138 var active = String($(this).attr('data-auth-form') || '') === mode; 1139 $(this).toggleClass('is-active', active).prop('hidden', !active); 1140 }); 1141 authStatus('', false); 1142 window.setTimeout(function(){ 1143 var activeElement = document.activeElement; 1144 var activeInsideModal = !!(activeElement && $authModal.get(0) && $authModal.get(0).contains(activeElement)); 1145 if (!modeChanged && activeInsideModal) return; 1146 var field = $authModal.find('.voigt-ai-auth-form.is-active input:not([type="checkbox"]):visible').first().get(0); 1147 if (!field) return; 1148 try { field.focus({preventScroll:true}); } catch (e) { field.focus(); } 1149 }, 80); 1150 } 1151 1152 function openAuthModal(mode) { 1153 if (!$authModal.length || cfg.loggedInUser || isAgentPanelContext()) return; 1154 mode = mode || 'login'; 1155 if (!$authModal.prop('hidden')) { 1156 var currentMode = String($authModal.find('.voigt-ai-auth-dialog').first().attr('data-auth-mode') || 'login'); 1157 if (currentMode !== mode) setAuthTab(mode); 1158 return; 1159 } 1160 if (mode === 'register' && turnstileRequired() && !turnstileSessionVerified && (!tsToken() || turnstileConsumed)) { 1161 ensureTurnstileReady(function(){ openAuthModal(mode); }); 1162 return; 1163 } 1164 setAuthTab(mode); 1165 $authModal.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open'); 1166 var dialog = $authModal.find('.voigt-ai-auth-dialog').get(0); 1167 if (dialog) dialog.scrollTop = 0; 1168 refreshAuthNonce(false); 1169 document.documentElement.classList.add('voigt-ai-auth-open'); 1170 document.body.classList.add('voigt-ai-auth-open'); 1171 } 1172 1173 function closeAuthModal() { 1174 if (!$authModal.length || authBusy) return; 1175 $authModal.removeClass('is-open').attr('aria-hidden', 'true').prop('hidden', true); 1176 document.documentElement.classList.remove('voigt-ai-auth-open'); 1177 document.body.classList.remove('voigt-ai-auth-open'); 1178 authStatus('', false); 1179 } 1180 1181 function authFormValue($form, name) { 1182 var $field = $form.find('[name="' + name + '"]').first(); 1183 if (!$field.length) return ''; 1184 if ($field.is(':checkbox')) return $field.is(':checked') ? '1' : ''; 1185 return String($field.val() || ''); 1186 } 1187 1188 function submitAuthForm($form, options) { 1189 options = options || {}; 1190 if (authBusy || !$form.length) return; 1191 var mode = String($form.attr('data-auth-form') || 'login'); 1192 var actionMap = { 1193 login: 'voigt_ai_browser_member_login', 1194 register: 'voigt_ai_browser_member_register', 1195 lost: 'voigt_ai_browser_member_lost_password', 1196 reset: 'voigt_ai_browser_member_reset_password' 1197 }; 1198 var action = actionMap[mode] || actionMap.login; 1199 var email = authFormValue($form, 'email').trim(); 1200 var password = authFormValue($form, 'password'); 1201 var confirmation = authFormValue($form, 'password_confirm'); 1202 1203 if ((mode === 'login' || mode === 'register' || mode === 'lost') && (!email || email.indexOf('@') < 1)) { 1204 authStatus(strings.auth_email_invalid || strings.error || '', true); 1205 return; 1206 } 1207 if ((mode === 'login' || mode === 'register' || mode === 'reset') && !password) { 1208 authStatus(strings.auth_invalid || strings.error || '', true); 1209 return; 1210 } 1211 if (mode === 'register' || mode === 'reset') { 1212 if (password.length < 8) { 1213 authStatus(strings.auth_password_short || strings.error || '', true); 1214 return; 1215 } 1216 if (password !== confirmation) { 1217 authStatus(strings.auth_password_mismatch || strings.error || '', true); 1218 return; 1219 } 1220 } 1221 if (mode === 'register' && !authFormValue($form, 'privacy')) { 1222 authStatus(strings.auth_privacy_required || strings.error || '', true); 1223 return; 1224 } 1225 if (mode === 'reset' && (!cfg.resetPassword || !cfg.resetPassword.key || !cfg.resetPassword.login)) { 1226 authStatus(strings.auth_reset_invalid || strings.error || '', true); 1227 return; 1228 } 1229 1230 // Registration also uses the Advisor's managed Turnstile session. A token 1231 // can expire while the account form is being completed, so reacquire it 1232 // before locking the form instead of failing silently on submit. 1233 if (mode === 'register' && turnstileRequired() && (!tsToken() || turnstileConsumed) && !options.turnstileRetried) { 1234 authStatus(strings.auth_working || nativeUiFallback('Please waitâ¦', 'Bitte wartenâ¦', 'ProszÄ czekaÄâ¦'), false); 1235 ensureTurnstileReady(function(){ 1236 submitAuthForm($form, $.extend({}, options, {turnstileRetried: true})); 1237 }); 1238 return; 1239 } 1240 1241 authBusy = true; 1242 authStatus(strings.auth_working || nativeUiFallback('Please waitâ¦', 'Bitte wartenâ¦', 'ProszÄ czekaÄâ¦'), false); 1243 $authModal.find('button,input').prop('disabled', true); 1244 1245 function unlockAuthForm() { 1246 authBusy = false;
1247 $authModal.find('button,input').prop('disabled', false); 1248 } 1249 1250 function requestAuth(nonceRetry) { 1251 return $.ajax({ 1252 url: cfg.ajaxUrl, 1253 method: 'POST', 1254 dataType: 'json', 1255 cache: false, 1256 timeout: 30000, 1257 data: { 1258 action: action, 1259 nonce: authNonce || String(cfg.authNonce || ''), 1260 lang: currentLanguage(), 1261 redirect_url: window.location.href, 1262 session_id: sessionId, 1263 session_token: sessionToken, 1264 turnstile: tsToken(), 1265 name: authFormValue($form, 'name'), 1266 email: email, 1267 password: password, 1268 password_confirm: confirmation, 1269 remember: authFormValue($form, 'remember'), 1270 privacy: authFormValue($form, 'privacy'), 1271 website: authFormValue($form, 'website'), 1272 key: cfg.resetPassword && cfg.resetPassword.key ? cfg.resetPassword.key : '', 1273 login: cfg.resetPassword && cfg.resetPassword.login ? cfg.resetPassword.login : '' 1274 } 1275 }).done(function(response){ 1276 if (!response || response.success !== true) { 1277 var payload = response && response.data ? response.data : {}; 1278 var code = String(payload.code || ''); 1279 var replacementNonce = String(payload.nonce || ''); 1280 if (code === 'auth_session_expired' && !nonceRetry) { 1281 if (replacementNonce) { 1282 authNonce = replacementNonce; 1283 authNonceFetchedAt = Date.now(); 1284 requestAuth(true); 1285 } else { 1286 refreshAuthNonce(true).always(function(){ requestAuth(true); }); 1287 } 1288 return; 1289 } 1290 authStatus(payload.message || strings.error || nativeUiFallback('The operation could not be completed.', 'Der Vorgang konnte nicht abgeschlossen werden.', 'Nie udaÅo siÄ wykonaÄ operacji.'), true); 1291 unlockAuthForm(); 1292 return; 1293 } 1294 var data = response.data || {}; 1295 if (mode === 'lost') { 1296 authStatus(data.message || strings.auth_reset_email_sent || '', false); 1297 $form.get(0).reset(); 1298 unlockAuthForm(); 1299 return; 1300 } 1301 var successMessage = mode === 'reset' 1302 ? strings.auth_reset_success 1303 : (mode === 'register' ? strings.auth_register_success : strings.auth_login_success); 1304 authStatus(data.message || successMessage || '', false); 1305 try { 1306 sessionStorage.setItem('voigtAiBrowserReopenAfterAuth', '1'); 1307 sessionStorage.setItem('voigtAiBrowserAuthSuccess', mode); 1308 } catch (e) {} 1309 window.setTimeout(function(){ 1310 window.location.assign(String(data.redirect || window.location.href)); 1311 }, 550); 1312 }).fail(function(xhr){ 1313 var payload = xhr && xhr.responseJSON && xhr.responseJSON.data ? xhr.responseJSON.data : {}; 1314 var code = String(payload.code || ''); 1315 var replacementNonce = String(payload.nonce || ''); 1316 if (code === 'auth_session_expired' && !nonceRetry) { 1317 if (replacementNonce) { 1318 authNonce = replacementNonce; 1319 authNonceFetchedAt = Date.now(); 1320 requestAuth(true); 1321 } else { 1322 refreshAuthNonce(true).always(function(){ requestAuth(true); }); 1323 } 1324 return; 1325 } 1326 authStatus(payload.message || strings.error || nativeUiFallback('The operation could not be completed.', 'Der Vorgang konnte nicht abgeschlossen werden.', 'Nie udaÅo siÄ wykonaÄ operacji.'), true); 1327 unlockAuthForm(); 1328 }); 1329 } 1330 1331 refreshAuthNonce(false).always(function(){ requestAuth(false); }); 1332 } 1333 1334 function scrollBottom(immediate) { 1335 if (!$messages.length || !$messages.get(0)) return; 1336 var node = $messages.get(0); 1337 if (immediate) { 1338 $messages.stop(true); 1339 node.scrollTop = node.scrollHeight; 1340 return; 1341 } 1342 $messages.stop(true).animate({scrollTop: node.scrollHeight}, 160); 1343 } 1344 1345 function sameListingDestination(href) { 1346 if (!href || !getForm().length) return false;
1347 try { 1348 var target = new URL(href, window.location.href); 1349 var current = new URL(window.location.href); 1350 return target.origin === current.origin && target.pathname.replace(/\/+$/, '') === current.pathname.replace(/\/+$/, ''); 1351 } catch (e) { return false; } 1352 } 1353 1354 function feedbackCategoryValues() { 1355 return ['misunderstood','wrong_filters','filters_not_applied','factual_error','other']; 1356 } 1357 1358 function submitFeedback(messageId, payload, $controls) { 1359 if (!messageId || !sessionId || !sessionToken) return; 1360 $controls.addClass('is-sending'); 1361 $.ajax({ 1362 url: cfg.ajaxUrl, 1363 type: 'POST', 1364 dataType: 'json', 1365 data: { 1366 action: 'voigt_ai_browser_feedback', 1367 nonce: cfg.nonce, 1368 session_id: sessionId, 1369 session_token: sessionToken, 1370 message_id: messageId, 1371 rating: payload.rating || 'down', 1372 category: payload.category || '', 1373 comment: payload.comment || '', 1374 correction: payload.correction || '' 1375 } 1376 }).done(function(response){ 1377 if (!response || !response.success) return; 1378 $controls.removeClass('is-sending').addClass('is-saved'); 1379 $controls.find('.voigt-ai-feedback-form').remove(); 1380 $controls.find('button').prop('disabled', true); 1381 $('<span class="voigt-ai-feedback-thanks">').text(strings.feedback_thanks || nativeUiFallback('Thank you.', 'Danke.', 'DziÄkujemy.')).appendTo($controls);
1382 conversationHistory.forEach(function(item){ 1383 if (item.options && String(item.options.messageId || '') === String(messageId)) { 1384 item.options.feedback = payload; 1385 } 1386 }); 1387 saveConversation(); 1388 }).fail(function(){ 1389 $controls.removeClass('is-sending'); 1390 }); 1391 } 1392 1393 function openFeedbackForm($controls, messageId) { 1394 if ($controls.find('.voigt-ai-feedback-form').length) return; 1395 var labels = Array.isArray(strings.feedback_categories) ? strings.feedback_categories : []; 1396 var values = feedbackCategoryValues(); 1397 var $form = $('<div class="voigt-ai-feedback-form">'); 1398 $('<strong>').text(strings.feedback_title || nativeUiFallback('What was wrong?', 'Was war falsch?', 'Co byÅo nie tak?')).appendTo($form); 1399 var $select = $('<select>').attr('aria-label', strings.feedback_category || nativeUiFallback('Feedback category', 'Problemtyp', 'Rodzaj problemu')).appendTo($form); 1400 values.forEach(function(value, index){ $('<option>').val(value).text(labels[index] || value).appendTo($select); }); 1401 $('<textarea rows="2" class="voigt-ai-feedback-comment">').attr('placeholder', strings.feedback_comment || '').appendTo($form); 1402 $('<textarea rows="2" class="voigt-ai-feedback-correction">').attr('placeholder', strings.feedback_correction || '').appendTo($form); 1403 var $actions = $('<div class="voigt-ai-feedback-form-actions">').appendTo($form); 1404 $('<button type="button" class="voigt-ai-feedback-submit">').text(strings.feedback_send || nativeUiFallback('Send', 'Senden', 'WyÅlij')).on('click', function(){ 1405 submitFeedback(messageId, { 1406 rating: 'down', 1407 category: String($select.val() || 'other'), 1408 comment: String($form.find('.voigt-ai-feedback-comment').val() || ''), 1409 correction: String($form.find('.voigt-ai-feedback-correction').val() || '') 1410 }, $controls); 1411 }).appendTo($actions); 1412 $('<button type="button" class="voigt-ai-feedback-cancel">').text(strings.feedback_cancel || nativeUiFallback('Cancel', 'Abbrechen', 'Anuluj')).on('click', function(){ $form.remove(); }).appendTo($actions); 1413 $controls.append($form); 1414 scrollBottom(); 1415 } 1416 1417 function appendFeedbackControls($message, messageId, existing) { 1418 if (!messageId) return; 1419 existing = existing && typeof existing === 'object' ? existing : {}; 1420 var $controls = $('<div class="voigt-ai-feedback">').attr('data-message-id', messageId); 1421 if (existing.rating) { 1422 $controls.addClass('is-saved'); 1423 $('<span class="voigt-ai-feedback-thanks">').text(strings.feedback_thanks || nativeUiFallback('Thank you.', 'Danke.', 'DziÄkujemy.')).appendTo($controls); 1424 $message.append($controls); 1425 return; 1426 } 1427 $('<button type="button" class="voigt-ai-feedback-up">').attr('aria-label', nativeUiFallback('Good answer', 'Gute Antwort', 'Dobra odpowiedź')).html('<span aria-hidden="true">ð</span><em></em>') 1428 .find('em').text(strings.feedback_good || nativeUiFallback('Good answer', 'Gute Antwort', 'Dobra odpowiedź')).end() 1429 .on('click', function(){ submitFeedback(messageId, {rating:'up'}, $controls); }) 1430 .appendTo($controls); 1431 $('<button type="button" class="voigt-ai-feedback-down">').attr('aria-label', nativeUiFallback('Report wrong answer', 'Falsche Antwort melden', 'ZgÅoÅ bÅÄdnÄ odpowiedź')).html('<span aria-hidden="true">ð</span><em></em>') 1432 .find('em').text(strings.feedback_bad || nativeUiFallback('Report', 'Melden', 'ZgÅoÅ')).end() 1433 .on('click', function(){ openFeedbackForm($controls, messageId); }) 1434 .appendTo($controls); 1435 $message.append($controls); 1436 } 1437 1438 function addMessage(body, role, options) { 1439 options = options || {}; 1440 var displayOptions = serialisableOptions(options); 1441 if (displayOptions.href && sameListingDestination(displayOptions.href)) displayOptions.href = ''; 1442 var $message = $('<div class="voigt-ai-browser-message">').addClass(role === 'user' ? 'is-user' : 'is-ai'); 1443 $('<div>').text(body || '').appendTo($message); 1444 if (displayOptions.small) $('<small>').text(displayOptions.small).appendTo($message); 1445 if (displayOptions.cta) { 1446 $('<button type="button" class="voigt-ai-browser-results-cta">') 1447 .text(displayOptions.cta) 1448 .on('click', function(){ 1449 saveConversation(); 1450 saveSession(); 1451 if (displayOptions.href) { 1452 // Store cross-page criteria only after the user deliberately clicks 1453 // âView propertiesâ. Previously they were stored as soon as the AI 1454 // answered, so a later manual visit to the listing page unexpectedly 1455 // launched a second search and looked like a double page reload. 1456 if ( 1457 displayOptions.navigationCriteria 1458 && typeof displayOptions.navigationCriteria === 'object' 1459 && Object.keys(displayOptions.navigationCriteria).length 1460 ) { 1461 try { 1462 var navigationId = 'vai-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); 1463 var navigationTarget = new URL(displayOptions.href, window.location.href); 1464 sessionStorage.setItem('voigtAiPendingCriteria', JSON.stringify({ 1465 version: 4, 1466 source: 'results_cta', 1467 navigationId: navigationId, 1468 targetPath: navigationTarget.pathname, 1469 criteria: displayOptions.navigationCriteria, 1470 silent: true, 1471 scrollToResults: true, 1472 createdAt: Date.now() 1473 }));
1474 displayOptions.navigationId = navigationId; 1475 } catch (e) {} 1476 } 1477 // Keep the session and transcript, close the panel, then navigate. 1478 // The destination applies the pending filters silently and remains closed. 1479 closePanel(); 1480 try { sessionStorage.setItem('voigtAiPanelClosedForNavigation', '1'); } catch (e) {} 1481 window.setTimeout(function(){ 1482 var destination = new URL(displayOptions.href, window.location.href); 1483 if (displayOptions.navigationId) { 1484 destination.hash = 'voigt-ai-search=' + encodeURIComponent(displayOptions.navigationId); 1485 } 1486 if (typeof window.voigtReloadTraceLog === 'function') { 1487 window.voigtReloadTraceLog('advisor-results-navigation', { targetUrl: destination.toString() }); 1488 } 1489 window.location.assign(destination.toString()); 1490 }, 20); 1491 return; 1492 } 1493 closePanel(); 1494 scrollToResults(); 1495 }) 1496 .appendTo($message); 1497 } 1498 if (role !== 'user' && displayOptions.messageId) { 1499 appendFeedbackControls($message, displayOptions.messageId, displayOptions.feedback); 1500 } 1501 $messages.append($message); 1502 if (options.persist !== false) { 1503 conversationHistory.push({ 1504 body: String(body || ''), 1505 role: role === 'user' ? 'user' : 'ai', 1506 options: displayOptions 1507 }); 1508 conversationHistory = conversationHistory.slice(-40); 1509 saveConversation(); 1510 } 1511 scrollBottom(); 1512 if ( 1513 role !== 'user' && 1514 options.persist !== false && 1515 !panelIsOpen() && 1516 conversationHistory.some(function(item){ return item && item.role === 'user'; }) 1517 ) { 1518 incrementUnreadCount(); 1519 } 1520 return $message; 1521 } 1522 1523 function restoreConversation() { 1524 var payload = null; 1525 try { 1526 payload = JSON.parse(sessionStorage.getItem(conversationKey) || 'null'); 1527 if (!payload) { 1528 var legacyPayload = JSON.parse(sessionStorage.getItem(legacyConversationKey) || 'null'); 1529 var legacyPayloadLang = String(legacyPayload && legacyPayload.language || '').toLowerCase().slice(0, 2); 1530 if (legacyPayload && legacyPayloadLang === pageLanguage) { 1531 payload = legacyPayload; 1532 sessionStorage.setItem(conversationKey, JSON.stringify(legacyPayload)); 1533 } 1534 } 1535 } catch (e) {} 1536 if (!payload || !Array.isArray(payload.messages) || !payload.messages.length) { 1537 $messages.empty(); 1538 conversationHistory = []; 1539 addMessage(initialGreeting(), 'ai', {small: strings.hint || ''}); 1540 return false; 1541 } 1542 // If the user explicitly reset the server session, do not revive an unrelated transcript. 1543 if (payload.session_id && sessionId && String(payload.session_id) !== String(sessionId)) { 1544 clearConversationStorage(); 1545 $messages.empty(); 1546 addMessage(initialGreeting(), 'ai', {small: strings.hint || ''}); 1547 return false; 1548 } 1549 // A transcript is namespaced by page language. Never allow an old 1550 // transcript to change the language of the current TranslatePress page. 1551 conversationHistory = payload.messages.slice(-40).map(function(item){ 1552 return { 1553 body: String(item && item.body || ''), 1554 role: item && item.role === 'user' ? 'user' : 'ai', 1555 options: serialisableOptions(item && item.options || {}) 1556 }; 1557 }); 1558 currentSuggestions = Array.isArray(payload.suggestions) ? payload.suggestions.slice(0, 12) : []; 1559 $messages.empty();
1560 conversationHistory.forEach(function(item){ 1561 addMessage(item.body, item.role, $.extend({}, item.options, {persist: false})); 1562 }); 1563 return true; 1564 } 1565 1566 1567 function restoreConversationFromServer() { 1568 if (!sessionId || !sessionToken) return; 1569 $.ajax({ 1570 url: cfg.ajaxUrl, 1571 type: 'POST', 1572 dataType: 'json', 1573 data: { 1574 action: 'voigt_ai_browser_restore', 1575 nonce: cfg.nonce, 1576 session_id: sessionId, 1577 session_token: sessionToken 1578 } 1579 }).done(function(response){ 1580 if (!response || !response.success || !response.data) return; 1581 var data = response.data; 1582 if (data.turnstile_session_verified === true) { 1583 turnstileSessionVerified = true; 1584 clearTurnstileRefreshTimer(); 1585 turnstileIssuedAt = 0; 1586 saveSession(); 1587 syncTurnstileControls(); 1588 } 1589 if (!Array.isArray(data.messages) || !data.messages.length) return; 1590 var serverLang = String(data.language || pageLanguage).toLowerCase().slice(0, 2); 1591 if (serverLang && serverLang !== pageLanguage) return; 1592 conversationHistory = data.messages.slice(-40).map(function(item){ 1593 return { 1594 body: String(item && item.body || ''), 1595 role: item && item.role === 'user' ? 'user' : 'ai', 1596 options: serialisableOptions(item && item.options || {}) 1597 }; 1598 }); 1599 // The server transcript starts with the first user turn. Restore the 1600 // introductory greeting locally when it is not present in the session. 1601 if (conversationHistory.length && conversationHistory[0].role === 'user') { 1602 conversationHistory.unshift({ 1603 body: String(initialGreeting()), 1604 role: 'ai', 1605 options: serialisableOptions({small: strings.hint || ''}) 1606 }); 1607 } 1608 currentSuggestions = Array.isArray(data.suggestions) ? data.suggestions.slice(0, 12) : []; 1609 window.__voigtAiLastCriteria = data.criteria || window.__voigtAiLastCriteria || {}; 1610 $messages.empty();
1611 conversationHistory.forEach(function(item){ 1612 addMessage(item.body, item.role, $.extend({}, item.options, {persist: false})); 1613 }); 1614 restoredConversation = true; 1615 $('.voigt-ai-browser-teaser').prop('hidden', true); 1616 renderSuggestions(currentSuggestions, {persist: false}); 1617 saveSession(); 1618 saveConversation(); 1619 refreshSummary(window.__voigtAiLastCriteria); 1620 }); 1621 } 1622 1623 function stopLoadingSequence() { 1624 if (loadingTimer) { 1625 window.clearTimeout(loadingTimer); 1626 loadingTimer = null; 1627 } 1628 loadingSequence = []; 1629 loadingSequenceIndex = 0; 1630 } 1631 1632 function startLoadingSequence(labels) { 1633 stopLoadingSequence(); 1634 loadingSequence = (labels || []).filter(Boolean); 1635 if (!loadingNode || loadingSequence.length < 2) return; 1636 loadingSequenceIndex = 0; 1637 var advance = function(){ 1638 if (!loadingNode || !loadingSequence.length) return; 1639 loadingSequenceIndex = Math.min(loadingSequenceIndex + 1, loadingSequence.length - 1); 1640 loadingNode.find('em').text(loadingSequence[loadingSequenceIndex] || ''); 1641 if (loadingSequenceIndex < loadingSequence.length - 1) loadingTimer = window.setTimeout(advance, 1850); 1642 }; 1643 loadingTimer = window.setTimeout(advance, 1650); 1644 } 1645 1646 function loadingLabelsForMessage(message, quickAction) { 1647 var raw = String(message || ''); 1648 var text = raw.toLowerCase(); 1649 try { text = text.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } catch (e) {} 1650 var labels = []; 1651 var has = function(pattern){ return pattern.test(text); }; 1652 if (quickAction === 'contact' || has(/advisor|doradc|asesor|berater|human|contact/)) { 1653 labels = [strings.thinking || '', strings.thinking_prepare || '']; 1654 } else if (has(/compare|comparison|porown|vergleich|comparar|ref[ ._-]*id|\br[0-9]{3,}\b/)) { 1655 labels = [strings.thinking_compare || strings.thinking || '', strings.thinking_property || '', strings.thinking_prepare || '']; 1656 } else if (has(/alert|notify|notification|powiadom|zapisz wyszukiwanie|favorite|favourite|ulubion|price drop|obniz|bajada de precio|preissenkung/)) { 1657 labels = [strings.thinking_alert || strings.thinking || '', strings.thinking_prepare || '']; 1658 } else if (has(/mortgage|financ|kredyt|hipotec|hypothek|tax|podatek|impuesto|steuer|cost|koszt|gastos|kosten/)) { 1659 labels = [strings.thinking_financing || strings.thinking || '', strings.thinking_process || '', strings.thinking_prepare || '']; 1660 } else if (has(/location|area|district|neighbourhood|neighborhood|lokaliz|okolica|dzielnic|zona|gebiet|marbella|estepona|mijas|malaga|benahavis|sotogrande/)) { 1661 labels = [strings.thinking_location || strings.thinking || '', strings.thinking_prepare || '']; 1662 } else if (has(/budget|price|cena|eur|euro|\b[0-9]+\s*[km]\b|presupuesto|precio|budget|preis/)) { 1663 labels = [strings.thinking_budget || strings.thinking || '', strings.thinking_property || '', strings.thinking_prepare || '']; 1664 } else if (has(/buy|purchase|property|villa|apartment|house|kup|nieruchom|willa|apartament|comprar|propiedad|immobilie|kaufen/)) { 1665 labels = [strings.thinking_property || strings.thinking || '', strings.thinking_prepare || '']; 1666 } else { 1667 labels = [strings.thinking || '', strings.thinking_prepare || '']; 1668 } 1669 return labels.filter(Boolean); 1670 } 1671 1672 function addLoading(label) { 1673 removeLoading(); 1674 loadingNode = $('<div class="voigt-ai-browser-message is-ai is-loading" role="status" aria-live="polite" aria-atomic="true"><span></span><span></span><span></span><em></em></div>'); 1675 var labels = Array.isArray(label) ? label.filter(Boolean) : [label || strings.thinking || '']; 1676 loadingNode.find('em').text(labels[0] || ''); 1677 $messages.append(loadingNode); 1678 startLoadingSequence(labels); 1679 scrollBottom(); 1680 } 1681 1682 function updateLoading(label) { 1683 if (!loadingNode) return; 1684 var labels = Array.isArray(label) ? label.filter(Boolean) : [label || '']; 1685 loadingNode.find('em').text(labels[0] || ''); 1686 startLoadingSequence(labels); 1687 } 1688 1689 function removeLoading() { 1690 stopLoadingSequence(); 1691 if (loadingNode) { 1692 loadingNode.remove(); 1693 loadingNode = null; 1694 } 1695 } 1696 1697 function selectedValues($element) { 1698 if (!$element || !$element.length) return []; 1699 if ($element.first().is(':checkbox,:radio')) { 1700 return $element.filter(':checked').map(function(){ return this.value; }
1700).get().filter(Boolean); 1701 } 1702 var value = $element.val(); 1703 return Array.isArray(value) ? value : (value ? [value] : []); 1704 } 1705 1706 function nativeChange($element) { 1707 if (!$element || !$element.length) return; 1708 $element.each(function(){ 1709 try { 1710 this.dispatchEvent(new Event('change', {bubbles: true})); 1711 } catch (e) { 1712 $(this).trigger('change'); 1713 } 1714 }); 1715 } 1716 1717 function syncSelectWidget($select) { 1718 if (!$select || !$select.length) return; 1719 $select.each(function(){ 1720 var select = this; 1721 var selected = new Set(Array.prototype.slice.call(select.selectedOptions || []).map(function(o){ return String(o.value); })); 1722 var $select = $(select); 1723 var $simpleWrapper = $select.closest('.mls-multi-wrapper'); 1724 if ($simpleWrapper.length) { 1725 $simpleWrapper.find('.mls-multi-list input[type="checkbox"]').each(function(){ this.checked = selected.has(String(this.value)); }); 1726 $simpleWrapper.find('.mls-multi-list li[data-value]').each(function(){ 1727 $(this).toggleClass('mls-selected', selected.has(String($(this).attr('data-value')))); 1728 }); 1729 } 1730 var $typeWrapper = $select.closest('.mls-select-wrapper'); 1731 if ($typeWrapper.length) { 1732 $typeWrapper.find('.mls-options input[type="checkbox"]').each(function(){ this.checked = selected.has(String(this.value)); }); 1733 } 1734 }); 1735 } 1736 1737 function setSelectValues($select, values) { 1738 values = (values || []).map(String); 1739 if (!$select || !$select.length) return; 1740 $select.find('option').each(function(){ 1741 this.selected = values.indexOf(String(this.value)) !== -1; 1742 }); 1743 nativeChange($select); 1744 syncSelectWidget($select); 1745 } 1746 1747 function rateData() { 1748 var $wrapper = getForm().find('.slider-range-wrapper').first(); 1749 return { 1750 rate: parseFloat($wrapper.data('rate')) || 1, 1751 currency: String($wrapper.data('currency') || 'EUR'), 1752 min: num($wrapper.data('min')), 1753 max: num($wrapper.data('max')) 1754 }; 1755 } 1756 1757 function currentCriteria() { 1758 if (!getForm(true).length) { 1759 return window.__voigtAiLastCriteria || pendingCriteria || {}; 1760 } 1761 1762 var locations = selectedValues($form.find('#mls_search_parent_area')); 1763 var $hidden = $form.find('#mls_search_area_hidden'); 1764 if (!locations.length && $hidden.length && $hidden.val()) { 1765 locations = String($hidden.val()).split(',').map(function(item){ return $.trim(item); }).filter(Boolean); 1766 } 1767 if (!locations.length) locations = selectedValues($form.find('#mls_search_area')); 1768 1769 var currency = rateData(); 1770 var minPrice = num($form.find('.min_price').val()); 1771 var maxPrice = num($form.find('.max_price').val()); 1772 if (currency.currency !== 'EUR' && currency.rate !== 1) { 1773 if (minPrice !== null) minPrice = Math.round(minPrice / currency.rate); 1774 if (maxPrice !== null) maxPrice = Math.round(maxPrice / currency.rate); 1775 } 1776 if (currency.min !== null && minPrice === currency.min) minPrice = null; 1777 if (currency.max !== null && maxPrice === currency.max) maxPrice = null; 1778 1779 var filterType = $form.find('input[name="filter_type"]:checked').val() || $form.find('input[name="filter_type"]').val() || 'sales'; 1780 var newMode = String($form.find('input[name="newdevelopment"]').val() || 'include'); 1781 if (filterType === 'new_development') newMode = 'only'; 1782 var features = selectedValues(getFeatureInputs().filter(':checked')); 1783 features = uniq(features.concat(selectedValues($form.find('input.voigt-ai-feature-mirror')))); 1784 var $featureMode = getFilterScope().find('#mls_search_features_search_type').first(); 1785 var featureMode = String(($featureMode.length ? $featureMode.val() : $form.find('#mls_search_features_search_type').val()) || '2'); 1786 1787 return { 1788 locations: uniq(locations), 1789 excluded_locations: [], 1790 area_scope: locations.length ? 'selected' : 'all_costa_del_sol', 1791 province: null, 1792 property_types: uniq(selectedValues($form.find('#mls_search_type'))), 1793 must_have_features: featureMode === '2' ? uniq(features) : [], 1794 preferred_features: featureMode === '2' ? [] : uniq(features), 1795 soft_criteria: [], 1796 min_bedrooms: num($form.find('#mls_search_beds').val()), 1797 exact_bedrooms: null, 1798 min_bathrooms: num($form.find('#mls_search_baths').val()), 1799 exact_bathrooms: null, 1800 min_price_eur: minPrice, 1801 max_price_eur: maxPrice, 1802 min_built_m2: null, 1803 max_built_m2: null, 1804 min_plot_m2: null, 1805 max_plot_m2: null, 1806 energy_rating: 'any', 1807 include_half_bathrooms: false, 1808 include_rented: false, 1809 rental_license: 'any', 1810 rental_date_from: null, 1811 rental_date_to: null, 1812 decree218: 'any', 1813 reference_ids: [], 1814 transaction_type: filterType === 'long_rentals' ? 'long_rental' : (filterType === 'short_rentals' ? 'short_rental' : 'sale'), 1815 new_development_mode: newMode, 1816 new_development_only: newMode === 'only', 1817 summary: '' 1818 }; 1819 } 1820 1821 function fold(value) { 1822 var text = String(value || '').trim().toLowerCase();
1823 try { return text.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } catch (e) { return text; } 1824 } 1825 1826 function optionMatch($select, values) { 1827 values = values || []; 1828 var queries = values.map(fold).filter(Boolean); 1829 var matched = []; 1830 $select.find('option').each(function(){ 1831 var optionValue = String(this.value); 1832 var valueFolded = fold(optionValue); 1833 var labelFolded = fold($(this).text()); 1834 queries.forEach(function(query){ 1835 if (valueFolded === query || labelFolded === query) matched.push(optionValue); 1836 }); 1837 }); 1838 return uniq(matched); 1839 } 1840 1841 function applyLocations(criteria) { 1842 var locations = criteria.area_scope === 'all_costa_del_sol' ? [] : (criteria.locations || []); 1843 var $container = $form.find('.mls-location-group-container'); 1844 if (!$container.length) { 1845 var $area = $form.find('#mls_search_area'); 1846 setSelectValues($area, optionMatch($area, locations)); 1847 return; 1848 } 1849 1850 var groups = $container.data('groups') || []; 1851 if (typeof groups === 'string') { 1852 try { groups = JSON.parse(groups); } catch (e) { groups = []; } 1853 } 1854 var parents = []; 1855 var children = []; 1856 var wholeParents = []; 1857 locations.forEach(function(location){ 1858 var found = false; 1859 groups.forEach(function(group){ 1860 if (fold(group.parent) === fold(location)) { 1861 parents.push(group.parent); 1862 wholeParents.push(group.parent); 1863 children = children.concat(group.children || []); 1864 found = true; 1865 return; 1866 } 1867 (group.children || []).forEach(function(child){ 1868 if (fold(child) === fold(location)) { 1869 parents.push(group.parent); 1870 children.push(child); 1871 found = true; 1872 } 1873 }); 1874 }); 1875 if (!found) children.push(location); 1876 }); 1877 parents = uniq(parents); 1878 children = uniq(children); 1879 wholeParents = uniq(wholeParents); 1880 1881 var $parent = $form.find('#mls_search_parent_area'); 1882 var $child = $form.find('#mls_search_child_area'); 1883 1884 // Use a real DOM change event. The custom multiselect listens through 1885 // addEventListener, so jQuery.trigger('change') alone does not reliably 1886 // refresh the visible parent tags. 1887 var matchedParents = optionMatch($parent, parents); 1888 setSelectValues($parent, matchedParents); 1889 1890 // main.js rebuilds child options from the selected parent(s). Re-query the 1891 // child select after that operation, then select the provider locations 1892 // used by the API while keeping the visible parent tag selected. 1893 $child = $form.find('#mls_search_child_area'); 1894 if ($child.length) { 1895 var desiredChildren = children.slice(); 1896 if (!locations.length) desiredChildren = []; 1897 setSelectValues($child, optionMatch($child, desiredChildren)); 1898 } 1899 1900 $container.attr('data-selected-parents', JSON.stringify(matchedParents)).data('selected-parents', matchedParents); 1901 $container.attr('data-selected-children', JSON.stringify(children)).data('selected-children', children); 1902 $form.find('#mls_search_area_hidden').val(children.join(',')).trigger('change'); 1903 nativeChange($parent); 1904 } 1905 1906 function setPrice(eurMin, eurMax) { 1907 var currency = rateData(); 1908 var minValue = eurMin !== null && eurMin !== undefined ? Math.round(eurMin * currency.rate) : ''; 1909 var maxValue = eurMax !== null && eurMax !== undefined ? Math.round(eurMax * currency.rate) : ''; 1910 var $group = $form.find('.slider-range-wrapper').first().closest('.mls-form-group'); 1911 var $min = $group.find('.min_price').first(); 1912 var $max = $group.find('.max_price').first(); 1913 1914 $min.val(minValue).trigger('input').trigger('change').trigger('blur'); 1915 $max.val(maxValue).trigger('input').trigger('change').trigger('blur'); 1916 1917 var $slider = $group.find('.slider-range').first(); 1918 if ($slider.length && $.fn.slider && $slider.hasClass('ui-slider')) { 1919 var lower = minValue !== '' ? minValue : parseInt($slider.slider('option', 'min'), 10); 1920 var upper = maxValue !== '' ? maxValue : parseInt($slider.slider('option', 'max'), 10); 1921 try { $slider.slider('values', [lower, upper]); } catch (e) {} 1922 } 1923 1924 // Reuse the native Done handler. It performs the same validation and 1925 // currency/locale formatting as a real user leaving the price fields. 1926 var $done = $group.find('.price-range-done').first(); 1927 if ($done.length) $done.trigger('click'); 1928 else { 1929 var display = []; 1930 if (minValue !== '') display.push(Number(minValue).toLocaleStr
1930ing()); 1931 if (maxValue !== '') display.push(Number(maxValue).toLocaleString()); 1932 $group.find('.pricerangeResults,.pricerangeDisplay').val(display.join(' - ')).trigger('change'); 1933 } 1934 } 1935 1936 function applyCriteria(criteria, options) { 1937 criteria = criteria || {}; 1938 options = options || {}; 1939 window.__voigtAiLastCriteria = criteria; 1940 pendingCriteria = criteria; 1941 1942 if (!getForm().length) { 1943 // On pages without the native property-search form we can still keep the 1944 // Advisor's criteria in memory for the next conversational turn. An 1945 // update-only turn (for example: the Advisor asks for a missing location 1946 // after an already explicit "find me..." request) must NOT pretend that 1947 // a search has started or add a misleading "View properties" CTA. 1948 // The CTA is reserved for an actual search/navigation request. 1949 refreshSummary(criteria); 1950 removeLoading(); 1951 if (options.submit === false) return; 1952 addMessage(strings.applied_generic || '', 'ai', { 1953 cta: (strings.view_results || nativeUiFallback('View properties', 'Immobilien ansehen', 'Zobacz nieruchomoÅci')).replace('%d', '').trim(), 1954 href: cfg.listingUrl, 1955 navigationCriteria: criteria 1956 }); 1957 return; 1958 } 1959 1960 var filterType = criteria.transaction_type === 'long_rental' ? 'long_rentals' : (criteria.transaction_type === 'short_rental' ? 'short_rentals' : 'sales'); 1961 var $radio = $form.find('input[name="filter_type"][value="' + filterType + '"]'); 1962 if ($radio.length) $radio.prop('checked', true).trigger('change'); 1963 else $form.find('input[name="filter_type"]').val(filterType); 1964 1965 $form.find('input[name="newdevelopment"]').val(criteria.new_development_mode || 'include'); 1966 applyLocations(criteria); 1967 var $type = $form.find('#mls_search_type'); 1968 setSelectValues($type, optionMatch($type, criteria.property_types || [])); 1969 var beds = criteria.exact_bedrooms || criteria.min_bedrooms || ''; 1970 var baths = criteria.exact_bathrooms || criteria.min_bathrooms || ''; 1971 setSelectValues($form.find('#mls_search_beds'), beds ? [String(beds)] : []); 1972 setSelectValues($form.find('#mls_search_baths'), baths ? [String(baths)] : []); 1973 setPrice(criteria.min_price_eur, criteria.max_price_eur); 1974 1975 // More Filters may be injected after the Agent Panel tab becomes visible. 1976 // The final native submit is therefore delayed until the real checkbox 1977 // controls exist and have followed their normal click/change path. 1978 1979 refreshSummary(criteria); 1980 if (options.submit === false) { 1981 applyMoreFiltersWhenReady(criteria, function(appliedFeatures){ 1982 ensureFeatureSubmissionMirrors(appliedFeatures); 1983 pendingCriteria = criteria; 1984 window.__voigtAiLastCriteria = criteria; 1985 refreshSummary(criteria); 1986 removeLoading(); 1987 }); 1988 return; 1989 } 1990 pendingSearch = true; 1991 pendingSearchContext = { 1992 silent: options.silent === true, 1993 scrollToResults: options.scrollToResults === true 1994 }; 1995 pendingSearchId = 'vai-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); 1996 $form.find('input[name="_voigt_ai_request_id"]').remove(); 1997 $('<input type="hidden" name="_voigt_ai_request_id">').val(pendingSearchId).appendTo($form); 1998 if (pendingSearchTimer) window.clearTimeout(pendingSearchTimer); 1999 pendingSearchTimer = window.setTimeout(function(){ 2000 if (!pendingSearch) return; 2001 var context = pendingSearchContext || {}; 2002 pendingSearch = false; 2003 pendingSearchId = ''; 2004 pendingSearchContext = {silent: false, scrollToResults: false}; 2005 $form.find('input[name="_voigt_ai_request_id"]').remove(); 2006 removeLoading(); 2007 if (!context.silent) addMessage(strings.results_ready || str
2007ings.applied_generic || '', 'ai'); 2008 refreshSummary(); 2009 if (context.scrollToResults) window.setTimeout(scrollToResults, 100); 2010 }, 30000); 2011 if (!options.silent) updateLoading(strings.searching || ''); 2012 applyMoreFiltersWhenReady(criteria, function(appliedFeatures){ 2013 ensureFeatureSubmissionMirrors(appliedFeatures); 2014 var $submitForm = getForm(); 2015 if ($submitForm.length) $submitForm.trigger('submit'); 2016 }); 2017 } 2018 2019 function labelForSelect($select, value) { 2020 var $option = $select.find('option[value="' + String(value).replace(/"/g, '\\"') + '"]'); 2021 return $option.length ? $.trim($option.text()) : String(value); 2022 } 2023 2024 function criteriaLabels(criteria) { 2025 criteria = criteria || {}; 2026 var output = []; 2027 if (criteria.transaction_type === 'long_rental') output.push('Long-term rent'); 2028 else if (criteria.transaction_type === 'short_rental') output.push('Holiday rent'); 2029 if (criteria.new_development_mode === 'only') output.push('New development'); 2030 (criteria.locations || []).slice(0, 2).forEach(function(item){ output.push(item); }); 2031 (criteria.property_types || []).slice(0, 2).forEach(function(item){ 2032 output.push(getForm().length ? labelForSelect($form.find('#mls_search_type'), item) : item); 2033 }); 2034 if (criteria.min_bedrooms) output.push(criteria.min_bedrooms + '+ beds'); 2035 if (criteria.min_bathrooms) output.push(criteria.min_bathrooms + '+ baths'); 2036 if (criteria.min_price_eur) output.push('⥠â¬' + Number(criteria.min_price_eur).toLocaleString()); 2037 if (criteria.max_price_eur) output.push('⤠â¬' + Number(criteria.max_price_eur).toLocaleString()); 2038 [].concat(criteria.must_have_features || [], criteria.preferred_features || []).slice(0, 3).forEach(function(param){ 2039 if (!getForm().length) { 2040 output.push(param); 2041 return; 2042 } 2043 var label = featureLabel(param); 2044 if (label) output.push(label); 2045 }); 2046 return uniq(output).slice(0, 10); 2047 } 2048 2049 function refreshSummary(criteria) { 2050 criteria = criteria || currentCriteria(); 2051 var labels = criteriaLabels(criteria); 2052 var $wrappers = $('.voigt-ai-browser-applied'); 2053 $wrappers.each(function(){ 2054 var $wrapper = $(this); 2055 var $chips = $wrapper.find('.voigt-ai-browser-chips').empty(); 2056 labels.forEach(function(label){ $('<span>').text(label).appendTo($chips); }); 2057 $wrapper.prop('hidden', !labels.length); 2058 }); 2059 } 2060
2061 function renderSuggestions(items, options) { 2062 options = options || {}; 2063 currentSuggestions = (items || []).map(function(item){ 2064 return { 2065 label: String(item && (item.label || item.message) || ''), 2066 message: String(item && (item.message || item.label) || ''), 2067 action: String(item && item.action || '') 2068 }; 2069 }).filter(function(item){ return item.label || item.message; }).slice(0, 12); 2070 var $box = $panel.find('.voigt-ai-browser-suggestions'); 2071 var $inner = $box.find('div').empty(); 2072 currentSuggestions.forEach(function(item){ 2073 $('<button type="button">') 2074 .text(item.label || item.message) 2075 .on('click', function(){ 2076 if (item.action === 'agency_contact_form') { focusAgencyContactForm(); return; } 2077 sendMessage(item.message || item.label, item.action || ''); 2078 }) 2079 .appendTo($inner); 2080 }); 2081 $box.toggle(!!$inner.children().length); 2082 if (options.persist !== false) saveConversation(); 2083 } 2084 2085 function focusAgencyContactForm() { 2086 // Use the existing property enquiry form, with the chat's agency form 2087 // as a fallback on pages that do not contain a property contact section. 2088 var target = document.getElementById('contact-from'); 2089 if (target && target.getClientRects().length) { 2090 closePanel(); 2091 window.setTimeout(function(){ 2092 target.style.scrollMarginTop = '110px'; 2093 target.scrollIntoView({behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'start'}); 2094 var field = target.querySelector('input:not([type="hidden"]):not([disabled]), textarea:not([disabled])'); 2095 if (field) { field.focus({preventScroll: true}); } 2096 }, 100); 2097 } else { 2098 openHandoff(); 2099 var field = $panel.find('.voigt-ai-browser-handoff input:visible:not([type="hidden"])').get(0); 2100 if (field) { field.focus(); } 2101 } 2102 } 2103 2104 function openHandoff() { 2105 var $handoff = $panel.find('.voigt-ai-browser-handoff'); 2106 $handoff.prop('hidden', false); 2107 $panel.addClass('is-handoff-open'); 2108 settleMobileViewport(); 2109 } 2110 2111 function closeHandoff() { 2112 $panel.find('.voigt-ai-browser-handoff').prop('hidden', true); 2113 $panel.removeClass('is-handoff-open'); 2114 $panel.find('.voigt-ai-browser-lead-status').text(''); 2115 settleMobileViewport(); 2116 } 2117 2118 function displayedPropertyRefs() { 2119 var refs = []; 2120 var seen = new Set(); 2121 var addRef = function(value){ 2122 var text = String(value || '').toUpperCase(); 2123 var matches = text.match(/\bR\d{3,}\b/g) || []; 2124 matches.forEach(function(ref){ 2125 if (!seen.has(ref)) { seen.add(ref); refs.push(ref); } 2126 }); 2127 }; 2128 2129 // The visible listing cards are authoritative for questions such as 2130 // "which of these is closest to the beach?". Read only cards currently 2131 // rendered in the list; do not infer refs from old chat results. 2132 $('.mls-pro-list-wrapper .mls-property-box:visible').each(function(){ 2133 var $card = $(this); 2134 addRef($card.attr('data-ref')); 2135 addRef($card.attr('data-property-ref')); 2136 $card.find('.mls-pyc-right span,[data-ref],[data-property-ref]').each(function(){ 2137 addRef($(this).attr('data-ref')); 2138 addRef($(this).attr('data-property-ref')); 2139 addRef($(this).text()); 2140 }); 2141 }); 2142 2143 // Some list templates expose the reference outside .mls-pyc-right. 2144 if (!refs.length) { 2145 $('.mls-pro-list-wrapper:visible').find('[data-ref],[data-property-ref],.mls-pyc-right span').each(function(){ 2146 addRef($(this).attr('data-ref')); 2147 addRef($(this).attr('data-property-ref')); 2148 addRef($(this).text()); 2149 }); 2150 } 2151 return refs.slice(0, 40); 2152 } 2153 2154 function availableFeatureDictionary() { 2155 var rows = []; 2156 var seen = new Set(); 2157 2158 // Prefer the exact controls rendered by the current native MLS form. 2159 getFeatureInputs(true).each(function(){ 2160 var $input = $(this); 2161 var id = String(this.value || '').trim(); 2162 var name = $.trim($input.next('label').text()); 2163 var $section = $input.closest('.mls-af-accodian-cnts'); 2164 var category = $.trim($section.prev('.mls-af-accodian-title').text()); 2165 if (!id || !name || seen.has(id)) return; 2166 seen.add(id); 2167 rows.push({id: id, name: name, category: category}); 2168 }); 2169 2170 // Agent Panel/page-builder tabs can initialise the advisor before the 2171 // hidden More Filters markup is visible. The server-localised registry 2172 // contains the same native checkbox IDs, so the AI still receives a 2173 // complete closed catalogue instead of an empty list. DOM rows above 2174 // always take precedence. 2175 Object.keys(cfg.featureLookup || {}
2175).forEach(function(id){ 2176 id = String(id || '').trim(); 2177 var meta = cfg.featureLookup[id] || {}; 2178 var name = String(meta.name || '').trim(); 2179 if (!id || !name || seen.has(id)) return; 2180 seen.add(id); 2181 rows.push({id: id, name: name, category: String(meta.category || '').trim()}); 2182 }); 2183 2184 return rows.slice(0, 300); 2185 } 2186 2187 function sendMessage(message, quickAction, turnstileConfirmed, retryExistingMessage) { 2188 message = $.trim(message || ''); 2189 if (!message) return; 2190 if (quotaBlocked) { 2191 updateQuotaNotice(currentQuota); 2192 return; 2193 } 2194 if (!turnstileConfirmed && !ensureTurnstileReady(function(){ sendMessage(message, quickAction, true, retryExistingMessage); })) return; 2195 openPanel(false); 2196 if (!retryExistingMessage) { 2197 addMessage(message, 'user'); 2198 $panel.find('textarea').val('').css('height', '48px'); 2199 } 2200 addLoading(loadingLabelsForMessage(message, quickAction)); 2201 var requestTurnstileToken = tsToken(); 2202 if (turnstileRequired()) { 2203 $panel.find('input[name="cf-turnstile-response"]').val(''); 2204 turnstileConsumed = true; 2205 syncTurnstileControls(); 2206 } 2207 2208 $.ajax({ 2209 url: cfg.ajaxUrl, 2210 type: 'POST', 2211 dataType: 'json', 2212 data: { 2213 action: 'voigt_ai_browser_chat', 2214 nonce: cfg.nonce, 2215 message: message, 2216 quick_action: quickAction || '', 2217 lang: currentLanguage(), 2218 current_criteria: JSON.stringify(currentCriteria()), 2219 available_features: JSON.stringify(availableFeatureDictionary()), 2220 displayed_refs: JSON.stringify(displayedPropertyRefs()), 2221 property_context: propertyContextPayload(), 2222 session_id: sessionId, 2223 session_token: sessionToken, 2224 agent_token: authenticatedAgentToken(), 2225 agent_context: isAgentPanelContext() ? '1' : '0', 2226 origin_url: location.href, 2227 turnstile: requestTurnstileToken, 2228 website: $panel.find('[name="website"]').val() || '' 2229 } 2230 }).done(function(response){ 2231 removeLoading(); 2232 if (!response || !response.success) { 2233 tsReset(); 2234 var failedData = response && response.data ? response.data : {}; 2235 if (failedData.quota) { 2236 failedData.quota.login_url = failedData.login_url || cfg.loginUrl || ''; 2237 failedData.quota.register_url = failedData.register_url || cfg.registerUrl || ''; 2238 updateQuotaNotice(failedData.quota); 2239 } 2240 addMessage(failedData.message || strings.error, 'ai'); 2241 return; 2242 } 2243 var data = response.data || {}; 2244 if (data.quota) { 2245 data.quota.login_url = data.login_url || cfg.loginUrl || ''; 2246 data.quota.register_url = data.register_url || cfg.registerUrl || ''; 2247 updateQuotaNotice(data.quota); 2248 } 2249 sessionId = data.session_id || sessionId; 2250 sessionToken = data.session_token || sessionToken; 2251 if (data.turnstile_session_verified === true) { 2252 turnstileSessionVerified = true; 2253 turnstilePendingAction = null; 2254 turnstileConsumed = false; 2255 clearTurnstileRefreshTimer(); 2256 turnstileIssuedAt = 0; 2257 syncTurnstileControls(); 2258 } else { 2259 tsReset(); 2260 } 2261 saveSession(); 2262 var aiFailureReason = data.ai_failure_reason || data.ai_diagnostic || ''; 2263 if (aiFailureReason && window.console && console.error) { console.error('[voigt-ai] Agent diagnostic:', aiFailureReason); } 2264 addMessage(data.assistant_message || '', 'ai', {messageId: data.assistant_message_id || ''}); 2265 renderSuggestions(data.suggestions || []); 2266 // The backend returns open_handoff only after an explicit request for a human advisor. 2267 if (data.open_handoff === true) openHandoff(); 2268 if (data.timings && isAgentPanelContext() && window.console && console.info) { 2269 console.info('[voigt-ai] Timings:', data.timings, 'pending:', data.pending_question || 'none'); 2270 } 2271 if (data.should_apply || data.apply_mode === 'search') { 2272 addLoading(strings.searching || ''); 2273 applyCriteria(data.criteria || {}); 2274 } else if (data.should_update_form || data.apply_mode === 'update') { 2275 applyCriteria(data.criteria || {}, {submit: false, silent: true}); 2276 } else { 2277 window.__voigtAiLastCriteria = data.criteria || currentCriteria(); 2278 refreshSummary(window.__voigtAiLastCriteria); 2279 } 2280 }).fail(function(xhr){ 2281 removeLoading(); 2282 var data = xhr.responseJSON && xhr.responseJSON.data ? xhr.responseJSON.data : {}; 2283 if (data.code === 'turnstile_required') { 2284 turnstileSessionVerified = false;
2285 stored.turnstileVerified = false; 2286 saveSession(); 2287 turnstileConsumed = true; 2288 syncTurnstileControls(); 2289 scheduleTurnstileReset(50, true); 2290 turnstilePendingAction = function(){ sendMessage(message, quickAction, true, true); }; 2291 return; 2292 } 2293 tsReset(); 2294 if (data.code === 'agent_session_invalid') { 2295 try { localStorage.removeItem('vcsAgentToken'); } catch (e) {} 2296 try { document.documentElement.classList.remove('vcs-agent-panel-authenticated'); } catch (e) {} 2297 } 2298 if (data.diagnostic && window.console && console.error) { 2299 console.error('[voigt-ai] Agent diagnostic:', data.diagnostic); 2300 } 2301 if (data.quota) { 2302 data.quota.login_url = data.login_url || cfg.loginUrl || ''; 2303 data.quota.register_url = data.register_url || cfg.registerUrl || ''; 2304 updateQuotaNotice(data.quota); 2305 } 2306 addMessage(data.message || strings.error, 'ai'); 2307 }); 2308 } 2309 2310 function localFeatureLabel(param) { 2311 return featureLabel(param); 2312 } 2313 2314 function formatEuro(value) { 2315 value = parseInt(value, 10) || 0; 2316 try { return new Intl.NumberFormat(currentLanguage(), {style:'currency', currency:'EUR', maximumFractionDigits:0}).format(value); } 2317 catch (e) { return 'â¬' + Number(value).toLocaleString(); } 2318 } 2319 2320 function relaxationButtonLabel(item) { 2321 var count = parseInt(item.count, 10) || 0; 2322 var base = ''; 2323 if (item.type === 'feature') base = String(strings.relax_feature || nativeUiFallback('Remove requirement: %s', 'Anforderung entfernen: %s', 'UsuÅ wymaganie: %s')).replace('%s', localFeatureLabel(item.feature)); 2324 else if (item.type === 'type') base = strings.relax_type || nativeUiFallback('Broaden the property type', 'Immobilientyp erweitern', 'Poszerz typ nieruchomoÅci'); 2325 else if (item.type === 'beds') base = String(strings.relax_beds || nativeUiFallback('Reduce minimum bedrooms to %d', 'Mindestanzahl der Schlafzimmer auf %d reduzieren', 'Zmniejsz minimalnÄ liczbÄ sypialni do %d')).replace('%d', parseInt(item.beds, 10) || ''); 2326 else if (item.type === 'budget') base = String(strings.relax_budget || nativeUiFallback('Increase budget to %s', 'Budget auf %s erhöhen', 'ZwiÄksz budżet do %s')).replace('%s', formatEuro(item.max_price_eur)); 2327 else if (item.type === 'location') base = strings.relax_location || nativeUiFallback('Search the whole Costa del Sol', 'An der gesamten Costa del Sol suchen', 'Szukaj na caÅym Costa del Sol'); 2328 else if (item.type === 'all_features') base = strings.relax_all_features || nativeUiFallback('Treat additional features as preferences', 'Zusätzliche Merkmale als Präferenzen behandeln', 'Traktuj dodatkowe cechy jako preferencje'); 2329 else base = item.label || ''; 2330 return count > 0 ? base + ' (' + count + ')' : base; 2331 } 2332 2333 function renderRelaxationSuggestions(items) { 2334 var $box = $panel.find('.voigt-ai-browser-suggestions'); 2335 var $inner = $box.find('div').empty(); 2336 currentSuggestions = []; 2337 (items || []).forEach(function(item){ 2338 if (!item || !item.criteria) return; 2339 var label = relaxationButtonLabel(item); 2340 $('<button type="button" class="is-relaxation">') 2341 .text(label) 2342 .on('click', function(){ 2343 addMessage(label, 'user'); 2344 addMessage(strings.relax_apply || strings.applied_generic || '', 'ai'); 2345 $inner.empty(); 2346 $box.hide(); 2347 addLoading(strings.searching || ''); 2348 applyCriteria(item.criteria || {}); 2349 }) 2350 .appendTo($inner); 2351 }); 2352 $box.toggle(!!$inner.children().length); 2353 } 2354 2355 function requestRelaxations(criteria) { 2356 addMessage(strings.no_results || '', 'ai'); 2357 addLoading(strings.thinking || ''); 2358 $.ajax({ 2359 url: cfg.ajaxUrl, 2360 type: 'POST', 2361 dataType: 'json', 2362 data: { 2363 action: 'voigt_ai_browser_relaxations', 2364 nonce: cfg.nonce, 2365 lang: currentLanguage(), 2366 session_id: sessionId, 2367 session_token: sessionToken, 2368 criteria: JSON.stringify(criteria || currentCriteria()) 2369 } 2370 }).done(function(response){ 2371 removeLoading(); 2372 var candidates = response && response.success && response.data ? (response.data.candidates || []) : []; 2373 if (candidates.length) { 2374 addMessage(strings.relax_intro || '', 'ai'); 2375 renderRelaxationSuggestions(candidates); 2376 } else { 2377 addMessage(strings.relax_none || strings.no_results || '', 'ai'); 2378 } 2379 }).fail(function(){ 2380 removeLoading(); 2381 addMessage(strings.relax_none || strings.no_results || '', 'ai'); 2382 }); 2383 } 2384 2385 function finishPendingSearch(payload) { 2386 payload = payload || {}; 2387 if (!pendingSearch || !pendingSearchId) return; 2388 if (String(payload.request_id || '') !== String(pendingSearchId)) return; 2389 2390 var count = payload.total_count; 2391 if (count !== null && count !== undefined && count !== '') count = parseInt(count, 10); 2392 else count = null; 2393 2394 var context = pendingSearchContext || {}; 2395 pendingSearch = false;
2396 pendingSearchId = ''; 2397 pendingSearchContext = {silent: false, scrollToResults: false}; 2398 if (pendingSearchTimer) window.clearTimeout(pendingSearchTimer); 2399 pendingSearchTimer = null; 2400 $form.find('input[name="_voigt_ai_request_id"]').remove(); 2401 removeLoading(); 2402 2403 if (count === 0) { 2404 // Also analyse silent searches made after navigation from another page. 2405 // The panel remains closed, but the explanation and choices are ready 2406 // when the user opens the existing conversation. 2407 requestRelaxations(window.__voigtAiLastCriteria || currentCriteria()); 2408 } else if (!context.silent) { 2409 if (count !== null && isFinite(count) && count > 0) { 2410 addMessage((strings.results || '').replace('%d', count), 'ai', { 2411 cta: (strings.view_results || '').replace('%d', count) 2412 }); 2413 } else { 2414 addMessage(strings.results_ready || strings.applied_generic || '', 'ai'); 2415 } 2416 } 2417 refreshSummary(); 2418 if (context.scrollToResults) window.setTimeout(scrollToResults, 100); 2419 } 2420 2421 function scrollToResults() { 2422 var element = getResultsRoot().get(0); 2423 if (element) element.scrollIntoView({behavior: 'smooth', block: 'start'}); 2424 } 2425 2426 // Accept a result count only from the exact MLS request created by the AI. 2427 // DOM observers and previously rendered counters are intentionally ignored. 2428 $(document).on('voigt:mls:results-updated', function(event, payload){ 2429 finishPendingSearch(payload || {}); 2430 }); 2431 2432 $(document).on('click', '[data-auth-open]', function(event){ 2433 if (!cfg.authPopupEnabled || !$authModal.length) return; 2434 // A modal can originate inside a launcher marked data-auth-open before it 2435 // is moved to <body>. Never interpret clicks on fields or checkboxes in 2436 // the already-open dialog as another request to reopen and refocus it. 2437 if ($(event.target).closest('.voigt-ai-auth-modal').length) return; 2438 event.preventDefault(); 2439 openAuthModal(String($(this).attr('data-auth-open') || 'login')); 2440 }); 2441 $authModal.on('click', '[data-auth-close]', function(event){ event.preventDefault(); closeAuthModal(); }); 2442 $authModal.on('click', '.voigt-ai-auth-form input, .voigt-ai-auth-form label', function(event){ event.stopPropagation(); }); 2443 $authModal.on('click', '.voigt-ai-auth-privacy a', function(event){ event.stopPropagation(); }); 2444 $authModal.on('click', '.voigt-ai-auth-tab', function(){ setAuthTab(String($(this).attr('data-auth-tab') || 'login')); }); 2445 $authModal.on('click', '[data-auth-mode]', function(event){ event.preventDefault(); setAuthTab(String($(this).attr('data-auth-mode') || 'login')); }); 2446 $authModal.on('submit', '.voigt-ai-auth-form', function(event){ event.preventDefault(); submitAuthForm($(this)); }); 2447 // Some themes and optimisation plugins install document-level form 2448 // handlers that cancel popup submissions. Capture the primary submit-button 2449 // click before those handlers, then invoke the same validated AJAX path. 2450 var authModalNode = $authModal.get(0); 2451 if (authModalNode && !authModalNode.__voigtAuthCaptureBound) { 2452 authModalNode.__voigtAuthCaptureBound = true; 2453 authModalNode.addEventListener('click', function(event){ 2454 var button = event.target && event.target.closest ? event.target.closest('.voigt-ai-auth-submit') : null; 2455 if (!button || !authModalNode.contains(button)) return; 2456 event.preventDefault(); 2457 event.stopPropagation(); 2458 submitAuthForm($(button).closest('.voigt-ai-auth-form')); 2459 }, true); 2460 } 2461 $(document).on('voigt:ai:open-auth', function(event, payload){ 2462 var mode = payload && payload.mode ? payload.mode : 'login'; 2463 openAuthModal(mode); 2464 }); 2465 $(document).on('keydown.voigtAiAuth', function(event){ 2466 if (event.key === 'Escape' && $authModal.length && !$authModal.prop('hidden')) closeAuthModal(); 2467 }); 2468 2469 $panel.on('click', '.voigt-ai-browser-close', closePanel); 2470 $panel.on('click', '.voigt-ai-card a[href], .voigt-ai-browser-property-link[href]', function(){ 2471 var $link = $(this); 2472 var $card = $link.closest('.voigt-ai-card'); 2473 var ref = String($card.attr('data-reference') || $card.data('reference') || $link.attr('data-reference') || '').trim(); 2474 if (!ref) { 2475 var text = String($card.text() || ''); 2476 var match = text.match(/\bR\s*([0-9]{4,})\b/i); 2477 if (match) ref = 'R' + match[1]; 2478 } 2479 if (ref) trackAdvisorEvent('property_open', ref); 2480 }); 2481 2482 $panel.on('click', '.voigt-ai-browser-handoff-close', closeHandoff); 2483 $backdrop.on('click', closePanel); 2484 $(document).on('click', '.voigt-ai-browser-launcher,.voigt-ai-browser-edit', function(){ openPanel
2484(); }); 2485 2486 /* Public API so other modules (calculators, landing pages) can open the 2487 advisor and seed it with context. Additive only, nothing else changes. */ 2488 window.voigtAiAdvisor = window.voigtAiAdvisor || {}; 2489 window.voigtAiAdvisor.open = function(){ openPanel(); }; 2490 window.voigtAiAdvisor.openAuth = function(mode){ openAuthModal(mode || 'login'); }; 2491 window.voigtAiAdvisor.openHandoff = function(){ openPanel(false); openHandoff(); }; 2492 window.voigtAiAdvisor.ask = function(message){ 2493 if (!$.trim(message || '')) { openPanel(); return; } 2494 sendMessage(message, ''); 2495 }; 2496 window.voigtAiAdvisor.ready = true; 2497 try { $(document).trigger('voigt-ai-advisor-ready'); } catch (e) {} 2498 if (cfg.resetPassword && cfg.resetPassword.active) { 2499 window.setTimeout(function(){ openAuthModal('reset'); }, 80); 2500 } 2501 $panel.on('click', '.voigt-ai-browser-reset', function(){ 2502 clearSession(); 2503 $messages.empty(); 2504 addMessage(initialGreeting(), 'ai', {small: strings.hint || ''}); 2505 closeHandoff(); 2506 renderSuggestions(cfg.startSuggestions || []); 2507 }); 2508 $panel.find('.voigt-ai-browser-composer').on('submit', function(event){ 2509 event.preventDefault(); 2510 sendMessage($(this).find('textarea').val(), ''); 2511 }); 2512 function resizeComposerTextarea(element) { 2513 if (!element) return; 2514 element.style.height = 'auto'; 2515 element.style.height = Math.min(112, Math.max(48, element.scrollHeight)) + 'px'; 2516 } 2517 2518 $panel.find('.voigt-ai-browser-composer textarea') 2519 .on('input', function(){ resizeComposerTextarea(this); settleMobileViewport(); }) 2520 .on('focus', function(){ resizeComposerTextarea(this); settleMobileViewport(); }) 2521 .on('keydown', function(event){ 2522 if (event.key === 'Enter' && !event.shiftKey) { 2523 event.preventDefault(); 2524 $(this).closest('form').trigger('submit'); 2525 } 2526 }); 2527 $panel.on('focusin', 'textarea,input', function(){ settleMobileViewport(); }); 2528 $(document).on('click.voigtAiMoreFilters', '.voigt-ai-browser-listing-host .mls-af-selected-labels .mls-af-close-btn', function(event){ 2529 var $badge = $(this).closest('.mls-af-label-badge'); 2530 var value = String($badge.attr('data-value') || ''); 2531 if (!value) return; 2532 event.preventDefault(); 2533 event.stopPropagation(); 2534 setFeatureChecked(findFeatureInputs([value]), false); 2535 rebuildFeatureUi(); 2536 ensureFeatureSubmissionMirrors(getFeatureInputs().filter(':checked').map(function(){ return String(this.value); }).get()); 2537 refreshSummary(); 2538 }); 2539 2540 $(document).on('change.voigtAiMoreFilters', '.voigt-ai-browser-listing-host input.mls-af-checkbox', function(event){ 2541 if (event.originalEvent && event.originalEvent.isTrusted && window.__voigtAiFeatureSync) { 2542 window.__voigtAiFeatureSync.userModified = true; 2543 } 2544 rebuildFeatureUi(); 2545 ensureFeatureSubmissionMirrors(getFeatureInputs().filter(':checked').map(function(){ return String(this.value); }).get()); 2546 }); 2547 2548 $(document).on('click.voigtAiMoreFilters', '.voigt-ai-browser-listing-host .mls-add-feat-btn', function(){ 2549 var sync = window.__voigtAiFeatureSync || {}; 2550 if (!sync.userModified && Array.isArray(sync.requested) && sync.requested.length) { 2551 window.setTimeout(function(){ 2552 var checkedCount = findFeatureInputs(sync.requested).filter(':checked').length; 2553 if (checkedCount < sync.requested.length) applyMoreFilters(window.__voigtAiLastCriteria || {}); 2554 }, 40); 2555 } 2556 }); 2557 2558 $(document).on('change input', '.mls-proplist-search-form select,.mls-proplist-search-form input', function(){ 2559 if (!pendingSearch) refreshSummary(); 2560 }); 2561 2562 $panel.on('click', '.voigt-ai-browser-send-lead', function(){ 2563 if (!ensureTurnstileReady(function(){ $panel.find('.voigt-ai-browser-send-lead').trigger('click'); })) return; 2564 var $handoff = $panel.find('.voigt-ai-browser-handoff'); 2565 var $status = $handoff.find('.voigt-ai-browser-lead-status').text(''); 2566 var handoffTurnstileToken = tsToken(); 2567 if (turnstileRequired()) { 2568 $panel.find('input[name="cf-turnstile-response"]').val(''); 2569 turnstileConsumed = true; 2570 syncTurnstileControls(); 2571 } 2572 $.post(cfg.ajaxUrl, { 2573 action: 'voigt_ai_handoff', 2574 nonce: cfg.handoffNonce, 2575 session_id: sessionId, 2576 session_token: sessionToken, 2577 turnstile: handoffTurnstileToken, 2578 current_criteria: JSON.stringify(currentCriteria()), 2579 name: $handoff.find('[name="name"]').val(), 2580 email: $handoff.find('[name="email"]').val(), 2581 phone: $handoff.find('[name="phone"]').val(), 2582 consent: $handoff.find('[name="consent"]').is(':checked') ? 1 : 0 2583 }).done(function(response){ 2584 tsReset(); 2585 $status.text(response && response.success ? strings.lead_ok : ((response && response.data && response.data.message) || str
2585ings.error)); 2586 if (response && response.success) { $handoff.find('.voigt-ai-browser-send-lead').data('voigtLeadSent', 1); $handoff.find('input,.voigt-ai-browser-send-lead').prop('disabled', true); } 2587 }).fail(function(){ 2588 tsReset(); 2589 $status.text(strings.error); 2590 }); 2591 }); 2592 2593 updateQuotaNotice(currentQuota); 2594 initialiseTurnstileGate(); 2595 try { 2596 if (sessionStorage.getItem('voigtAiBrowserReopenAfterAuth') === '1') { 2597 sessionStorage.removeItem('voigtAiBrowserReopenAfterAuth'); 2598 window.setTimeout(function(){ openPanel(false); }, 180); 2599 } 2600 } catch (e) {} 2601 2602 document.body.addEventListener('transitionend', function(event){ 2603 if (event && event.propertyName === 'padding-left') refreshNativeListingLayout(); 2604 }); 2605 2606 if (window.visualViewport) { 2607 window.visualViewport.addEventListener('resize', settleMobileViewport); 2608 window.visualViewport.addEventListener('scroll', settleMobileViewport); 2609 } 2610 window.addEventListener('resize', settleMobileViewport); 2611 window.addEventListener('orientationchange', function(){ setTimeout(settleMobileViewport, 120); }); 2612 2613 $(document).on('keydown', function(event){ 2614 if (event.key !== 'Escape' || !$root.hasClass('is-open')) return; 2615 if (!$panel.find('.voigt-ai-browser-handoff').prop('hidden')) closeHandoff(); 2616 else closePanel(); 2617 }); 2618 2619 var $teaser = $('.voigt-ai-browser-teaser'); 2620 2621 function teaserStorageGet(key) { 2622 try { return localStorage.getItem(key) || ''; } catch (e) {} 2623 try { return sessionStorage.getItem(key) || ''; } catch (e2) {} 2624 return ''; 2625 } 2626 2627 function teaserStorageSet(key, value) { 2628 var stored = false; 2629 try { localStorage.setItem(key, value); stored = true; } catch (e) {} 2630 // Keep a session fallback for privacy modes/browsers that block localStorage. 2631 if (!stored) { try { sessionStorage.setItem(key, value); } catch (e2) {} } 2632 } 2633 2634 function teaserWasSeenOrDismissed() { 2635 if (teaserStorageGet(teaserSeenKey) || teaserStorageGet(teaserDismissedKey
2635)) return true; 2636 2637 // Promote the previous session-only close marker to the new persistent, 2638 // language-independent preference so existing visitors are not prompted 2639 // again merely because this behaviour was upgraded. 2640 try { 2641 var legacyLangs = ['pl', 'en', 'es', 'de', 'fr']; 2642 for (var i = 0; i < legacyLangs.length; i++) { 2643 if (sessionStorage.getItem('voigtAiTeaserShown:' + legacyLangs[i])) { 2644 teaserStorageSet(teaserSeenKey, '1'); 2645 return true; 2646 } 2647 } 2648 } catch (e) {} 2649 return false; 2650 } 2651 2652 if ($teaser.length && !teaserWasSeenOrDismissed()) { 2653 setTimeout(function(){ 2654 // Re-check storage after the delay in case another tab/page has already 2655 // displayed or dismissed the hint in the meantime. 2656 if (!teaserWasSeenOrDismissed() && !restoredConversation && !$panel.hasClass('is-open')) { 2657 $teaser.prop('hidden', false); 2658 // Mark on actual display: the discovery bubble is intentionally shown 2659 // only once per browser profile, even when the visitor simply ignores it. 2660 teaserStorageSet(teaserSeenKey, '1'); 2661 } 2662 }, 4000); 2663 } 2664 $(document).on('click', '.voigt-ai-browser-teaser-close', function(event){ 2665 event.preventDefault(); 2666 event.stopPropagation(); 2667 $teaser.prop('hidden', true); 2668 teaserStorageSet(teaserSeenKey, '1'); 2669 teaserStorageSet(teaserDismissedKey, '1'); 2670 }); 2671 $(document).on('click', '.voigt-ai-browser-teaser', function(event){ 2672 if ($(event.target).closest('.voigt-ai-browser-teaser-close').length) return; 2673 $teaser.prop('hidden', true); 2674 teaserStorageSet(teaserSeenKey, '1'); 2675 openPanel(); 2676 }); 2677 2678 $panel.on('click', '.voigt-ai-browser-contacts a[data-type="whatsapp"]', function(){ 2679 var criteria = window.__voigtAiLastCriteria || currentCriteria(); 2680 var labels = criteriaLabels(criteria); 2681 var brief; 2682 if (currentLanguage() === 'pl') { 2683 brief = 'DzieÅ dobry, szukam: ' + (labels.join(', ') || 'nieruchomoÅci na Costa del Sol') + ' (przez ' + (cfg.advisorName || 'Voigt AI Advisor') + ').'; 2684 } else if (currentLanguage() === 'de') { 2685 brief = 'Guten Tag, ich suche: ' + (labels.join(', ') || 'eine Immobilie an der Costa del Sol') + ' (über ' + (cfg.advisorName || 'Voigt AI Advisor') + ').'; 2686 } else { 2687 brief = 'Hello, I am looking for: ' + (labels.join(', ') || 'a property on the Costa del Sol') + ' (via ' + (cfg.advisorName || 'Voigt AI Advisor') + ' AI).'; 2688 } 2689 var base = (this.href || '').split('?')[0]; 2690 this.href = base + '?text=' + encodeURIComponent(brief); 2691 }); 2692 2693 try { 2694 // Consume a pending cross-page search exactly once. Only the v3 payload 2695 // written by an explicit results CTA is accepted; old v2 payloads could 2696 // be created merely by chatting and are discarded as stale automation. 2697 if (!window.__voigtAiPendingCriteriaConsumed) { 2698 window.__voigtAiPendingCriteriaConsumed = true; 2699 var savedCriteriaRaw = sessionStorage.getItem('voigtAiPendingCriteria'); 2700 if (savedCriteriaRaw && getForm().length) { 2701 sessionStorage.removeItem('voigtAiPendingCriteria'); 2702 var savedPayload = JSON.parse(savedCriteriaRaw); 2703 var isExplicitCta = savedPayload 2704 && Number(savedPayload.version || 0) >= 4 2705 && String(savedPayload.source || '') === 'results_cta'; 2706 var criteriaToApply = isExplicitCta && savedPayload.criteria ? savedPayload.criteria : null; 2707 var createdAt = savedPayload && savedPayload.createdAt ? Number(savedPayload.createdAt) : 0; 2708 var expired = !createdAt || (Date.now() - createdAt > 2 * 60 * 1000); 2709 var hashMatch = String(window.location.hash || '').match(/^#voigt-ai-search=([^&]+)$/); 2710 var hashNavigationId = hashMatch ? decodeURIComponent(hashMatch[1] || '') : ''; 2711 var navigationMatches = isExplicitCta 2712 && hashNavigationId !== '' 2713 && hashNavigationId === String(savedPayload.navigationId || '') 2714 && String(window.location.pathname || '') === String(savedPayload.targetPath || ''); 2715 2716 if (hashMatch && history.replaceState) { 2717 history.replaceState(history.state, '', window.location.pathname + window.location.search); 2718 } 2719 2720 if (!expired && navigationMatches && criteriaToApply && typeof criteriaToApply === 'object') { 2721 // Apply only when the destination carries the one-time navigation 2722 // token generated by the explicit âView propertiesâ click. A stale 2723 // sessionStorage payload can no longer trigger a later manual visit. 2724 closePanel(); 2725 applyCriteria(criteriaToApply, {silent: true, scrollToResults: true}); 2726 } 2727 } 2728 } 2729 sessionStorage.removeItem('voigtAiPanelClosedForNavigation'); 2730 } catch (e) {
2731 try { sessionStorage.removeItem('voigtAiPendingCriteria'); } catch (ignore) {} 2732 } 2733 2734 restoredConversation = restoreConversation(); 2735 if (restoredConversation) { 2736 renderSuggestions(currentSuggestions, {persist: false}); 2737 } else { 2738 renderSuggestions(cfg.startSuggestions || []); 2739 restoreConversationFromServer(); 2740 } 2741 saveSession(); 2742 refreshSummary(); 2743 }); 2744})(jQuery);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.