PageSourceSearch

https://cats.com/wp-content/plugins/cliverse-ai-search/assets/js/cliverse-ai-search.js?ver=1783945659

js cats.com collected 2026-10-02 03:21:25 UTC 18,302 bytes, 487 lines download raw bytes

1/* Cliverse AI Search — overlay controller */
2( function ( $ ) {
3	'use strict';
4
5	var cfg = window.cliverseAISearch || {};
6	var RECENT_KEY = 'cliverse_ai_recent_searches';
7	var RECENT_MAX = 6;
8
9	var $overlay, $views, lastQuery = '', lastArticles = [], chatThreadId = '', chatSeeded = false, chatSession = 0;
10	var curPage = 1, totalPages = 0, pageReq = 0;
11
12	/* ---------- helpers ---------- */
13	function esc( s ) {
14		return $( '<div/>' ).text( s == null ? '' : s ).html();
15	}
16
17	function getRecent() {
18		try { return JSON.parse( localStorage.getItem( RECENT_KEY ) ) || []; }
19		catch ( e ) { return []; }
20	}
21	function pushRecent( q ) {
22		q = $.trim( q );
23		if ( ! q ) { return; }
24		var list = getRecent().filter( function ( x ) { return x.toLowerCase() !== q.toLowerCase(); } );
25		list.unshift( q );
26		list = list.slice( 0, RECENT_MAX );
27		try { localStorage.setItem( RECENT_KEY, JSON.stringify( list ) ); } catch ( e ) {}
28	}
29
30	/* ---------- view switching ---------- */
31	function showView( name ) {
32		$views.removeClass( 'is-active' );
33		$overlay.find( '.cas-view--' + name ).addClass( 'is-active' );
34	}
35
36	var lockedScrollY = 0;
37
38	// Fully lock the background page so only the overlay scrolls. body{overflow:
39	// hidden} alone doesn't hold — this theme scrolls on <html>, and iOS ignores
40	// it — so we pin the body with position:fixed (CSS) at its current offset and
41	// restore the scroll position on unlock.
42	function lockBackground() {
43		if ( $( 'body' ).hasClass( 'cas-locked' ) ) { return; }
44		lockedScrollY = window.pageYOffset || document.documentElement.scrollTop || 0;
45		document.body.style.top = ( -lockedScrollY ) + 'px';
46		$( 'body' ).addClass( 'cas-locked' );
47	}
48	function unlockBackground() {
49		if ( ! $( 'body' ).hasClass( 'cas-locked' ) ) { return; }
50		$( 'body' ).removeClass( 'cas-locked' );
51		document.body.style.top = '';
52		window.scrollTo( 0, lockedScrollY );
53	}
54
55	function openOverlay() {
56		$overlay.addClass( 'is-open' ).attr( 'aria-hidden', 'false' );
57		lockBackground();
58		// Each fresh open starts a clean session — otherwise a previous chat
59		// (messages + n8n thread) lingers hidden and resurfaces on the next
60		// "Continue chatting", ignoring the new query.
61		resetChat();
62		showView( 'hero' );
63		setTimeout( function () { $overlay.find( '.cas-view--hero .cas-input' ).trigger( 'focus' ); }, 50 );
64	}
65	function closeOverlay() {
66		$overlay.removeClass( 'is-open' ).attr( 'aria-hidden', 'true' );
67		unlockBackground();
68	}
69
70	/* ---------- rendering: results ---------- */
71	function renderRecent() {
72		var $ul = $overlay.find( '.cas-recent' ).empty();
73		var list = getRecent();
74		if ( ! list.length ) {
75			$ul.append( '<li class="cas-empty">No recent searches yet.</li>' );
76			return;
77		}
78		list.forEach( function ( q ) {
79			var parts = q.split( ' ' );
80			var first = esc( parts.shift() );
81			var rest = parts.length ? ' ' + esc( parts.join( ' ' ) ) : '';
82			$( '<li/>' ).html( '<b>' + first + '</b>' + rest ).data( 'q', q ).appendTo( $ul );
83		} );
84	}
85
86	function renderArticles( articles ) {
87		var $wrap = $overlay.find( '.cas-articles' ).empty();
88		if ( ! articles.length ) {
89			$wrap.append( '<div class="cas-empty">' + esc( $wrap.attr( 'data-empty' ) ) + '</div>' );
90			return;
91		}
92		articles.forEach( function ( a ) {
93			var thumb = a.thumb ? '<img class="cas-article-thumb" src="' + esc( a.thumb ) + '" alt="" loading="lazy">'
94								 : '<span class="cas-article-thumb"></span>';
95			var author = a.author
96				? '<div class="cas-article-author">' +
97					( a.avatar ? '<img src="' + esc( a.avatar ) + '" alt="">' : '' ) +
98					'<span>by ' + esc( a.author ) + '</span></div>'
99				: '';
100			$( '<a class="cas-article" target="_blank" rel="noopener"></a>' )
101				.attr( 'href', a.url )
102				.html(
103					thumb +
104					'<div class="cas-article-body">' +
105						( a.category ? '<p class="cas-article-cat">' + esc( a.category ) + '</p>' : '' ) +
106						'<h4 class="cas-article-title">' + esc( a.title ) + '</h4>' +
107						author +
108					'</div>'
109				)
110				.appendTo( $wrap );
111		} );
112	}
113
114	/* Numbered pagination for the Related Articles list (Google-style: Prev,
115	 * a windowed set of page numbers with ellipses, Next). Hidden for a single
116	 * page. Buttons carry data-page; the delegated handler loads that page. */
117	function renderPagination( page, pages ) {
118		var $nav = $overlay.find( '.cas-pagination' ).empty();
119		if ( !pages || pages < 2 ) { return; }
120
121		function btn( label, target, opts ) {
122			opts = opts || {};
123			var $b = $( '<button type="button" class="cas-page"></button>' ).html( label );
124			if ( opts.current ) { $b.addClass( 'is-current' ).attr( 'aria-current', 'page' ); }
125			if ( opts.disabled ) { $b.prop( 'disabled', true ).addClass( 'is-disabled' ); }
126			else { $b.attr( 'data-page', target ); }
127			$nav.append( $b );
128		}
129		function gap() { $nav.append( '<span class="cas-page-gap">…</span>' ); }
130
131		btn( '‹', page - 1, { disabled: page <= 1 } );
132
133		// Window of page numbers around the current page, always showing first/last.
134		var win = 1, list = [];
135		for ( var i = 1; i <= pages; i++ ) {
136			if ( i === 1 || i === pages || ( i >= page - win && i <= page + win ) ) { list.push( i ); }
137		}
138		var prev = 0;
139		list.forEach( function ( n ) {
140			if ( prev && n - prev > 1 ) { gap(); }
141			btn( String( n ), n, { current: n === page } );
142			prev = n;
143		} );
144
145		btn( '›', page + 1, { disabled: page >= pages } );
146	}
147
148	/* Load a specific page of Related Articles for the current query. Page 1 is
149	 * the primary render (owns lastArticles for chat seeding, tools, discussions);
150	 * pages > 1 refresh only the article list + pagination and scroll to the top.
151	 * A pageReq token discards stale responses when the user clicks quickly. */
152	function loadArticlesPage( q, page ) {
153		var token = ++pageReq;
154		$overlay.find( '.cas-articles' ).html( '<div class="cas-empty">Searching…</div>' );
155		$overlay.find( '.cas-pagination' ).empty();
156
157		$.ajax( {
158			url: cfg.searchUrl,
159			method: 'GET',
160			data: { q: q, page: page },
161			headers: { 'X-WP-Nonce': cfg.nonce }
162		} ).done( function ( res ) {
163			if ( token !== pageReq || q !== lastQuery ) { return; } // superseded
164			curPage = res.page || page;
165			totalPages = res.pages || 0;
166			var articles = res.articles || [];
167			if ( curPage === 1 ) { lastArticles = articles; }
168			renderArticles( articles );
169			renderPagination( curPage, totalPages );
170			if ( page > 1 ) {
171				var ov = $overlay[ 0 ];
172				if ( ov ) { ov.scrollTop = 0; }
173			}
174		} ).fail( function () {
175			if ( token !== pageReq ) { return; }
176			$overlay.find( '.cas-articles' ).html( '<div class="cas-empty">Something went wrong. Please try again.</div>' );
177		} );
178	}
179
180	function renderTools( tools ) {
181		var $block = $overlay.find( '.cas-tools-block' );
182		var $wrap = $overlay.find( '.cas-tools' ).empty();
183		if ( ! tools.length ) { $block.hide(); return; }
184		$block.show();
185		var fallback = '<svg viewBox="0 0 24 24" width="22" height="22"><path d="M21 5h-7l-2-2H3v16h18V5zM3 19V7h6.2l2 2H21" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
186		tools.forEach( function ( t ) {
187			var icon = t.icon
188				? '<span class="cas-tool-icon cas-tool-icon--img"><img src="' + esc( t.icon ) + '" alt="" loading="lazy"></span>'
189				: '<span class="cas-tool-icon">' + fallback + '</span>';
190			$( '<a class="cas-tool" target="_blank" rel="noopener"></a>' )
191				.attr( 'href', t.url )
192				.html( icon + '<span>' + esc( t.label ) + '</span>' )
193				.appendTo( $wrap );
194		} );
195	}
196
197	function renderDiscussions( items ) {
198		var $block = $overlay.find( '.cas-discussions-block' );
199		var $wrap = $overlay.find( '.cas-discussions' ).empty();
200		if ( ! items.length ) { $block.hide(); return; }
201		$block.show();
202		items.forEach( function ( d ) {
203			var meta = '<div class="cas-discussion-meta"><svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 4h16v12H8l-4 4V4z" fill="currentColor"/></svg><span>' + ( d.posts || 0 ) + '</span></div>';
204			$( '<a class="cas-discussion" target="_blank" rel="noopener"></a>' )
205				.attr( 'href', d.url || '#' )
206				.html( '<div class="cas-discussion-title">' + esc( d.title ) + '</div>' + meta )
207				.appendTo( $wrap );
208		} );
209	}
210
211	/* Toggle the clear (×) button visibility for a search field. */
212	function syncClear( $input ) {
213		$input.closest( '.cas-searchbar' )
214			.find( '.cas-clear' )
215			.toggleClass( 'is-visible', $.trim( $input.val() ) !== '' );
216	}
217
218	/* Reset the chat session (messages, n8n thread, seeded flag) so the next
219	 * "Continue chatting" starts fresh for the current query. */
220	function resetChat() {
221		chatSeeded = false;
222		chatThreadId = '';
223		chatSession++; // invalidate any in-flight chat request from the old session
224		$overlay.find( '.cas-messages' ).empty();
225	}
226
227	/* ---------- search ---------- */
228	function runSearch( q ) {
229		q = $.trim( q );
230		if ( ! q ) { return; }
231		// A new topic invalidates any chat seeded for the previous query.
232		if ( q !== lastQuery ) { resetChat(); }
233		lastQuery = q;
234		pushRecent( q );
235
236		showView( 'results' );
237		$overlay.find( '.cas-view--results .cas-input' ).val( q );
238		syncClear( $overlay.find( '.cas-view--results .cas-input' ) );
239		renderRecent();
240		curPage = 1; totalPages = 0;
241		var token = ++pageReq;
242		$overlay.find( '.cas-articles' ).html( '<div class="cas-empty">Searching…</div>' );
243		$overlay.find( '.cas-pagination' ).empty();
244		$overlay.find( '.cas-tools, .cas-discussions' ).empty();
245
246		$.ajax( {
247			url: cfg.searchUrl,
248			method: 'GET',
249			data: { q: q, page: 1 },
250			headers: { 'X-WP-Nonce': cfg.nonce }
251		} ).done( function ( res ) {
252			if ( token !== pageReq ) { return; }
253			curPage = res.page || 1;
254			totalPages = res.pages || 0;
255			lastArticles = res.articles || [];
256			renderArticles( lastArticles );
257			renderPagination( curPage, totalPages );
258			renderTools( res.tools || [] );
259			renderDiscussions( res.discussions || [] );
260		} ).fail( function () {
261			if ( token !== pageReq ) { return; }
262			$overlay.find( '.cas-articles' ).html( '<div class="cas-empty">Something went wrong. Please try again.</div>' );
263		} );
264	}
265
266	/* ---------- chat ---------- */
267	function appendMsg( who, html, extraClass ) {
268		var $msg = $( '<div class="cas-msg"></div>' )
269			.addClass( 'cas-msg--' + who )
270			.addClass( extraClass || '' )
271			.html( '<span class="cas-msg-avatar"></span><div class="cas-msg-bubble">' + html + '</div>' );
272		$overlay.find( '.cas-messages' ).append( $msg );
273		var scroll = $overlay.find( '.cas-chat-scroll' )[ 0 ];
274		if ( scroll ) { scroll.scrollTop = scroll.scrollHeight; }
275		return $msg;
276	}
277
278	// The assistant returns Markdown-ish text (### headings, blank-line
279	// paragraphs, - / 1. lists, **bold**) with inline <a> links already
280	// sanitised server-side (wp_kses_post). Render the block structure to HTML
281	// so long answers read as paragraphs/lists instead of one wall of text;
282	// existing <a> tags are passed through untouched.
283	function formatAnswer( html ) {
284		if ( ! html ) { return ''; }
285
286		var inline = function ( s ) {
287			return s
288				.replace( /\*\*([^*]+)\*\*/g, '<strong>$1</strong>' ) // **bold**
289				.replace( /\n/g, '<br>' );                            // soft line break
290		};
291
292		var text = String( html ).replace( /\r\n/g, '\n' ).trim();
293		var blocks = text.split( /\n{2,}/ ); // blank line = paragraph boundary
294		var out = '';
295
296		blocks.forEach( function ( block ) {
297			block = block.replace( /^\n+|\n+$/g, '' );
298			if ( ! block ) { return; }
299			var lines = block.split( '\n' );
300
301			// Whole block is a bullet / numbered list.
302			var nonEmpty = lines.filter( function ( l ) { return l.trim() !== ''; } );
303			var allBullets = nonEmpty.length && nonEmpty.every( function ( l ) { return /^\s*[-*]\s+/.test( l ); } );
304			var allNumbers = nonEmpty.length && nonEmpty.every( function ( l ) { return /^\s*\d+[.)]\s+/.test( l ); } );
305			if ( allBullets || allNumbers ) {
306				var tag = allNumbers ? 'ol' : 'ul';
307				out += '<' + tag + ' class="cas-msg-list">' + nonEmpty.map( function ( l ) {
308					return '<li>' + inline( l.replace( /^\s*(?:[-*]|\d+[.)])\s+/, '' ) ) + '</li>';
309				} ).join( '' ) + '</' + tag + '>';
310				return;
311			}
312
313			// Heading line (### Title), optionally followed by body text.
314			var hm = /^#{1,6}\s+(.*)$/.exec( lines[ 0 ] );
315			if ( hm ) {
316				out += '<p class="cas-msg-heading">' + inline( hm[ 1 ] ) + '</p>';
317				var rest = lines.slice( 1 ).join( '\n' ).trim();
318				if ( rest ) { out += '<p>' + inline( rest ) + '</p>'; }
319				return;
320			}
321
322			out += '<p>' + inline( block ) + '</p>';
323		} );
324
325		return out || '<p>' + inline( text ) + '</p>';
326	}
327
328	function chatCardFromArticle( a ) {
329		if ( ! a ) { return ''; }
330		var thumb = a.thumb ? '<img src="' + esc( a.thumb ) + '" alt="">' : '<img alt="">';
331		return '<a class="cas-chat-card" target="_blank" rel="noopener" href="' + esc( a.url ) + '">' +
332			thumb +
333			'<div>' +
334				( a.category ? '<p class="cas-article-cat">' + esc( a.category ) + '</p>' : '' ) +
335				'<div class="cas-chat-card-title">' + esc( a.title ) + '</div>' +
336			'</div></a>';
337	}
338
339	// Build the preview card from the article the assistant actually recommends
340	// (the first link in its answer), not the top search result — those differ
341	// once answers are localised by country. Only show a card when the
342	// recommended URL is among the search results (so we have its thumbnail);
343	// otherwise show no card rather than a mismatched one.
344	function chatCardForAnswer( answerHtml ) {
345		var m = /<a[^>]*href="([^"]+)"[^>]*>/i.exec( answerHtml || '' );
346		if ( ! m ) { return ''; }
347		var norm = function ( u ) { return ( u || '' ).replace( /\/+$/, '' ).toLowerCase(); };
348		var target = norm( m[ 1 ] );
349		for ( var i = 0; i < lastArticles.length; i++ ) {
350			if ( norm( lastArticles[ i ].url ) === target ) {
351				return chatCardFromArticle( lastArticles[ i ] );
352			}
353		}
354		return '';
355	}
356
357	function sendToChat( prompt, attachCard ) {
358		prompt = $.trim( prompt );
359		if ( ! prompt ) { return; }
360		appendMsg( 'user', esc( prompt ) );
361
362		if ( ! cfg.chatEnabled ) {
363			appendMsg( 'bot', 'The AI chat assistant isn’t configured on this site yet, but you can explore the related articles from your search.' );
364			return;
365		}
366
367		var $thinking = appendMsg( 'bot', 'Thinking…', 'cas-msg-thinking' );
368		var session = chatSession;
369
370		$.ajax( {
371			url: cfg.chatUrl,
372			method: 'POST',
373			contentType: 'application/json',
374			headers: { 'X-WP-Nonce': cfg.nonce },
375			data: JSON.stringify( { prompt: prompt, thread_id: chatThreadI
375d } )
376		} ).done( function ( res ) {
377			if ( session !== chatSession ) { return; } // session was reset mid-flight — discard
378			$thinking.remove();
379			if ( res && res.answer ) {
380				if ( res.thread_id ) { chatThreadId = res.thread_id; }
381				var card = attachCard ? chatCardForAnswer( res.answer ) : '';
382				appendMsg( 'bot', formatAnswer( res.answer ) + card );
383			} else {
384				appendMsg( 'bot', 'Sorry, I couldn’t answer that. ' + esc( ( res && res.message ) || '' ) );
385			}
386		} ).fail( function () {
387			if ( session !== chatSession ) { return; } // session was reset mid-flight — discard
388			$thinking.remove();
389			appendMsg( 'bot', 'Sorry, the assistant is unavailable right now. Please try again.' );
390		} );
391	}
392
393	function openChat() {
394		showView( 'chat' );
395		$overlay.find( '.cas-chat-search' ).val( lastQuery );
396		$overlay.find( '.cas-searched-label' ).text( lastQuery ? 'You’ve searched “' + lastQuery + '”' : '' );
397
398		if ( ! chatSeeded ) {
399			$overlay.find( '.cas-messages' ).empty();
400			appendMsg( 'bot', 'Hello, cat lovers! 👋 I’m your smart search assistant. The more detailed your question is, the better I can help you! 😊' );
401			if ( lastQuery ) {
402				// Preview card temporarily disabled (pass true to re-enable).
403				sendToChat( lastQuery, false );
404			}
405			chatSeeded = true;
406		}
407		setTimeout( function () { $overlay.find( '.cas-chat-input' ).trigger( 'focus' ); }, 50 );
408	}
409
410	/* ---------- wiring ---------- */
411	$( function () {
412		$overlay = $( '#cas-overlay' );
413		if ( ! $overlay.length ) { return; }
414		$views = $overlay.find( '.cas-view' );
415
416		// Intercept the theme's header search icon.
417		$( document ).on( 'click', '.toggle-search', function ( e ) {
418			e.preventDefault();
419			e.stopImmediatePropagation();
420			$( '.wrapper-search-top-bar' ).hide(); // suppress legacy search bar
421			openOverlay();
422		} );
423
424		// Hero submit
425		$overlay.on( 'submit', '.cas-searchbar--hero', function ( e ) {
426			e.preventDefault();
427			runSearch( $( this ).find( '.cas-input' ).val() );
428		} );
429
430		// Results top-bar submit
431		$overlay.on( 'submit', '.cas-view--results .cas-searchbar--top', function ( e ) {
432			e.preventDefault();
433			runSearch( $( this ).find( '.cas-input' ).val() );
434		} );
435		$overlay.on( 'click', '.cas-clear', function () {
436			var $input = $( this ).closest( '.cas-searchbar' ).find( '.cas-input' ).val( '' ).trigger( 'focus' );
437			syncClear( $input );
438		} );
439
440		// Show/hide the clear button as the user types.
441		$overlay.on( 'input', '.cas-input', function () {
442			syncClear( $( this ) );
443		} );
444
445		// Recent search click
446		$overlay.on( 'click', '.cas-recent li', function () {
447			var q = $( this ).data( 'q' );
448			if ( q ) { runSearch( q ); }
449		} );
450
451		// Related Articles pagination
452		$overlay.on( 'click', '.cas-pagination .cas-page[data-page]', function () {
453			var target = parseInt( $( this ).attr( 'data-page' ), 10 );
454			if ( target && target !== curPage && lastQuery ) {
455				loadArticlesPage( lastQuery, target );
456			}
457		} );
458
459		// Continue chatting
460		$overlay.on( 'click', '.cas-chat-btn', function () { openChat(); } );
461		$overlay.on( 'click', '.cas-back-results', function () { showView( 'results' ); } );
462
463		// Chat-view search box re-runs a search (runSearch resets the chat when
464		// the query changes, so a fresh "Continue chatting" re-seeds correctly).
465		$overlay.on( 'submit', '.cas-view--chat .cas-searchbar--top', function ( e ) {
466			e.preventDefault();
467			var q = $( this ).find( '.cas-input' ).val();
468			if ( $.trim( q ) ) { runSearch( q ); }
469		} );
470
471		// Chat send
472		$overlay.on( 'submit', '.cas-chat-form', function ( e ) {
473			e.preventDefault();
474			var $in = $( this ).find( '.cas-chat-input' );
475			var v = $in.val();
476			$in.val( '' );
477			sendToChat( v, false );
478		} );
479
480		// Close
481		$overlay.on( 'click', '.cas-close', closeOverlay );
482		$( document ).on( 'keydown', function ( e ) {
483			if ( e.key === 'Escape' && $overlay.hasClass( 'is-open' ) ) { closeOverlay(); }
484		} );
485	} );
486
487} )( 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.