PageSourceSearch

https://www.matterhackers.com/js/20260923t230207/public-product-page.js

js matterhackers.com collected 2026-09-24 08:38:19 UTC 32,841 bytes, 1,086 lines download raw bytes

1// JS that can be loaded on both single and multisku pages
2var isTestEnvironment = false;
3if (document.getElementById('jsTestEnvironment').value) {
4	isTestEnvironment = true;
5}
6
7$(function(){
8	loadProductQuestions();
9	overrideBreadcrumbToPreviousLocation();
10	getWishlistStatus();
11	getRequestAQuoteStatus();
12	initMhProductGallery();
13});
14
15function overrideBreadcrumbToPreviousLocation() {
16	// replace the breadcrumb to the search results page or collection if the user came from there
17}
18
19// Shared price-rule scroll-snap helpers. Both the single-SKU and multi-SKU pages drive the same
20// #js-product-price-rule-nav strip, so these low-level DOM helpers live once here.
21function scrollPriceRuleIntoView(index) {
22	var tile = $('#js-product-price-rule-nav').children().eq(index)[0];
23	if (tile && tile.scrollIntoView) {
24		tile.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
25	}
26}
27
28function scrollPriceRuleNavBy(nav, dir) {
29	if (!nav) {
30		return;
31	}
32	nav.scrollBy({left: dir * nav.clientWidth * 0.8, behavior: 'smooth'});
33}
34
35function getPriceRuleArrowState(nav) {
36	if (!nav) {
37		return {hasOverflow: false, prevDisabled: true, nextDisabled: true};
38	}
39	return {
40		hasOverflow: nav.scrollWidth > nav.clientWidth,
41		prevDisabled: nav.scrollLeft <= 0,
42		nextDisabled: nav.scrollLeft + nav.clientWidth >= nav.scrollWidth - 1
43	};
44}
45
46function enablePriceRuleDragScroll(nav) {
47	// Mouse drag-to-scroll for the strip; touch scrolling is native. Idempotent per element: the nav
48	// is re-created when the multi-SKU v-if flips, so repeat calls from a refresh path are free.
49	if (!nav || nav.mhPriceRuleDragBound) {
50		return;
51	}
52	nav.mhPriceRuleDragBound = true;
53
54	var startX = 0;
55	var startScrollLeft = 0;
56	var dragged = false;
57
58	function onMouseMove(e) {
59		if (e.buttons === 0) {
60			// mouseup landed outside the window and never reached us; end the drag
61			onMouseUp();
62			return;
63		}
64		var dx = e.pageX - startX;
65		if (!dragged && Math.abs(dx) < 5) {
66			return; // ignore jitter so plain tile clicks still register
67		}
68		if (!dragged) {
69			dragged = true;
70			// Kill snap + smooth scrolling while dragging so scrollLeft writes track the pointer 1:1.
71			nav.classList.add('product-price-rule-nav-dragging');
72		}
73		e.preventDefault();
74		nav.scrollLeft = startScrollLeft - dx;
75	}
76
77	function onMouseUp() {
78		document.removeEventListener('mousemove', onMouseMove);
79		document.removeEventListener('mouseup', onMouseUp);
80		nav.classList.remove('product-price-rule-nav-dragging');
81		// leave `dragged` set: the click that follows mouseup is suppressed below
82	}
83
84	nav.addEventListener('mousedown', function(e) {
85		if (e.button !== 0) {
86			return;
87		}
88		startX = e.pageX;
89		startScrollLeft = nav.scrollLeft;
90		dragged = false;
91		document.addEventListener('mousemove', onMouseMove);
92		document.addEventListener('mouseup', onMouseUp);
93	});
94
95	// Capture-phase so a drag never turns into a tile selection on either page.
96	nav.addEventListener('click', function(e) {
97		if (dragged) {
98			e.stopPropagation();
99			e.preventDefault();
100			dragged = false;
101		}
102	}, true);
103}
104
105// Pagination dot strips show a window of dots (CSS max-width: seven) rather than a row per seven
106// images. The strip is overflow: hidden with one dot per image inside, so this slides it by
107// scrollLeft to keep the active dot inside the window, one dot in from either edge, and marks a dot
108// on an edge that has more beyond it (.is-edge shrinks it) as the cue that the strip goes on.
109function syncGalleryDotStrip(strip, index) {
110	var dots = strip.children;
111	var count = dots.length;
112	var dotWidth = count ? dots[0].offsetWidth : 0;
113	if (!dotWidth) {
114		return; // display: none (the thumb rail is showing): nothing to measure or line up
115	}
116	var visible = Math.round(strip.clientWidth / dotWidth);
117	var maxStart = Math.max(0, count - visible);
118	var start = Math.round(strip.scrollLeft / dotWidth);
119	if (index < start + 1) {
120		start = index - 1;
121	} else if (index > start + visible - 2) {
122		start = index - visible + 2;
123	}
124	start = Math.max(0, Math.min(maxStart, start));
125	strip.scrollLeft = start * dotWidth;
126	for (var i = 0; i < count; i++) {
127		var isEdge = (i === start && start > 0) || (i === start + visible - 1 && start < maxStart);
128		dots[i].classList.toggle('is-edge', isEdge);
129	}
130}
131
132// Product image gallery: a horizontal scroll-snap stage with a static thumb rail beside it. Both
133// product pages share the markup, so the controller lives here. Pages still served from cache have
134// the old markup and none of these ids, so the gate below makes this a clean no-op there.
135function initMhProductGallery() {
136	var stage = document.getElementById('jsMhGalleryStage');
137	if (!stage) {
138		return;
139	}
140
141	var activeIndex = -1;
142	var slides = [];
143	var observer = null;
144	var ratios = {};
145
146	function setActive(index) {
147		if (index === activeIndex) {
148			return;
149		}
150		activeIndex = index;
151
152		// The rail is capped at 6 tiles, so any slide at or past the cap lights the last ("+N") tile.
153		var thumbs = document.querySelectorAll('.mh--gallery-thumb');
154		if (thumbs.length) {
155			var target = Math.min(index, thumbs.length - 1);
156			for (var i = 0; i < thumbs.length; i++) {
157				if (i === target) {
158					thumbs[i].classList.add('is-active');
159					thumbs[i].setAttribute('aria-current', 'true');
160				} else {
161					thumbs[i].classList.remove('is-active');
162					thumbs[i].removeAttribute('aria-current');
163				}
164			}
165		}
166
167		// The dots stand in for the rail on narrow viewports (CSS swaps them), so both track the slide.
168		var dots = document.querySelectorAll('.mh--gallery-dot');
169		for (var d = 0; d < dots.length; d++) {
170			if (d === index) {
171				dots[d].classList.add('is-active');
172				dots[d].setAttribute('aria-current', 'true');
173			} else {
174				dots[d].classList.remove('is-active');
175				dots[d].removeAttribute('aria-current');
176			}
177		}
178		var strip = document.getElementById('jsMhGalleryDots');
179		if (strip) {
180			syncGalleryDotStrip(strip, index);
181		}
182
183		// The sale badge sits over the stage, so it only belongs on the first image.
184		var $badge = $('#product-page-badge');
185		if ($badge.length) {
186			if (index === 0) {
187				$badge.fadeIn('fast');
188			} else {
189				$badge.fadeOut('fast');
190			}
191		}
192	}
193
194	function goTo(index, behavior) {
195		if (isNaN(index) || index < 0) {
196			return;
197		}
198		// Instant by default, not smooth: thumb hover previews would otherwise lag the pointer.
199		// 'instant' rather than 'auto': the stage carries scroll-behavior: smooth in CSS.
200		stage.scrollTo({left: index * stage.clientWidth, behavior: behavior || 'instant'});
201	}
202
203	// The templates render the dots, so this normally finds them in place and leaves them alone. Pages
204	// still served from cache predate them: there the dots are built here, so the narrow layout is
205	// never left with neither thumbs nor dots. Re-run on every SKU switch, since the image count
206	// changes with the SKU on those cached pages too.
207	function syncDots() {
208		var count = stage.querySelectorAll('.mh--gallery-slide').length;
209		var dots = document.getElementById('jsMhGalleryDots');
210		if (!dots) {
211			if (count < 2) {
212				return;
213			}
214			dots = document.createElement('div');
215			dots.className = 'mh--gallery-dots';
216			dots.id = 'jsMhGalleryDots';
217			// Directly after the stage: where the templates put it, and outside the v-for lists Vue patches.
218			stage.insertAdjacentElement('afterend', dots);
219		}
220		if (dots.querySelectorAll('.mh--gallery-dot').length === count) {
221			return;
222		}
223		dots.innerHTML = '';
224		for (var i = 0; i < count; i++) {
225			var dot = document.createElement('button');
226			dot.type = 'button';
227			dot.className = 'mh--gallery-dot';
228			dot.setAttribute('data-index', i);
229			dot.setAttribute('aria-label', 'View image ' + (i + 1) + ' of ' + count);
230			dots.appendChild(dot);
231		}
232	}
233
234	function observeSlides() {
235		// Re-read the slides every time: the multi-SKU page re-renders them whenever the SKU changes.
236		slides = [];
237		var slideNodes = stage.querySelectorAll('.mh--gallery-slide');
238		for (var i = 0; i < slideNodes.length; i++) {
239			slides.push(slideNodes[i]);
240		}
241		if (typeof IntersectionObserver === 'undefined') {
242			return;
243		}
244		if (observer) {
245			observer.disconnect();
246		}
247		ratios = {};
248		observer = new IntersectionObserver(function (entries) {
249			entries.forEach(function (entry) {
250				var index = slides.indexOf(entry.target);
251				if (index === -1) {
252					return;
253				}
254				ratios[index] = entry.isIntersecting ? entry.intersectionRatio : 0;
255			});
256			var best = -1;
257			var bestRatio = 0;
258			Object.keys(ratios).forEach(function (key) {
259				if (ratios[key] > bestRatio) {
260					bestRatio = ratios[key];
261					best = parseInt(key, 10);
262				}
263			});
264			// Once the stage is clamped at either end the centre band can no longer reach the first/last
265			// slide's middle, so pin those two positions.
266			if (stage.scrollLeft <= 1) {
267				best = 0;
268			} else if (stage.scrollLeft + stage.clientWidth >= stage.scrollWidth - 1) {
269				best = slides.length - 1;
270			}
271			if (best !== -1) {
272				setActive(best);
273			}
274		}, {
275			root: stage,
276			// Collapse the root to a thin band across its horizontal centre, so "active" means "the image
277			// crossing the middle", not "any visible".
278			rootMargin: '0px -45% 0px -45%',
279			threshold: 0
280		});
281		slides.forEach(function (slide) {
282			observer.observe(slide);
283		});
284	}
285
286	var startX = 0;
287	var startScrollLeft = 0;
288	var dragged = false;
289
290	function onMouseMove(e) {
291		if (e.buttons === 0) {
292			// mouseup landed outside the window and never reached us; end the drag
293			onMouseUp();
294			return;
295		}
296		var dx = e.pageX - startX;
297		if (!dragged && Math.abs(dx) < 5) {
298			return; // ignore jitter so plain image clicks still register
299		}
300		if (!dragged) {
301			dragged = true;
302			// Kill snap + smooth scrolling while dragging so scrollLeft writes track the pointer 1:1.
303			stage.classList.add('mh--gallery-dragging');
304		}
305		e.preventDefault();
306		stage.scrollLeft = startScrollLeft - dx;
307	}
308
309	function onMouseUp() {
310		document.removeEventListener('mousemove', onMouseMove);
311		document.removeEventListener('mouseup', onMouseUp);
312		stage.classList.remove('mh--gallery-dragging');
313		// leave `dragged` set: the click that follows mouseup is suppressed below
314	}
315
316	stage.addEventListener('mousedown', function(e) {
317		if (e.button !== 0) {
318			return;
319		}
320		startX = e.pageX;
321		startScrollLeft = stage.scrollLeft;
322		dragged = false;
323		document.addEventListener('mousemove', onMouseMove);
324		document.addEventListener('mouseup', onMouseUp);
325	});
326
327	// Suppress native HTML5 image dragging so a mouse drag scrolls the stage. Unconditional: the
328	// browser starts the native drag at ~3px, before our own 5px slop threshold has flipped `dragged`.
329	stage.addEventListener('dragstart', function(e) {
330		e.preventDefault();
331	});
332
333	// Capture-phase so a drag never opens the lightbox.
334	stage.addEventListener('click', function(e) {
335		if (dragged) {
336			e.stopPropagation();
337			e.preventDefault();
338			dragged = false;
339		}
340	}, true);
341
342	// Delegated from the document because the multi-SKU page re-renders the rail. The "+N" tile is
343	// deliberately excluded: the lightbox owns that one.
344	$(document).on('mouseenter click', '.mh--gallery-thumb:not(.mh--gallery-thumb-more)', function() {
345		var index = parseInt(this.getAttribute('data-index'), 10);
346		goTo(index);
347		setActive(index);
348	});
349
350	// Dots are tapped, never hovered, so the slide can glide over.
351	$(document).on('click', '.mh--gallery-dot', function() {
352		var index = parseInt(this.getAttribute('data-index'), 10);
353		goTo(index, 'smooth');
354		setActive(index);
355	});
356
357	// Keep the stage lined up with the active slide after a resize changes the slide width. The dot
358	// strip too: crossing 900px brings it out of display: none at scrollLeft 0.
359	var resizeTimer = null;
360	window.addEventListener('resize', function() {
361		clearTimeout(resizeTimer);
362		resizeTimer = setTimeout(function() {
363			if (activeIndex > 0) {
364				goTo(activeIndex);
365			}
366			var strip = document.getElementById('jsMhGalleryDots');
367			if (strip && activeIndex >= 0) {
368				syncGalleryDotStrip(strip, activeIndex);
369			}
370		}, 150);
371	});
372
373	if (typeof IntersectionObserver === 'undefined') {
374		// Fallback tracking: less precise, but the thumbs still follow the stage.
375		var scrollTimer = null;
376		stage.addEventListener('scroll', function() {
377			clearTimeout(scrollTimer);
378			scrollTimer = setTimeout(function() {
379				if (stage.clientWidth) {
380					setActive(Math.round(stage.scrollLeft / stage.clientWidth));
381				}
382			}, 100);
383		});
384	}
385
386	syncDots();
387	observeSlides();
388	setActive(0);
389
390	// The multi-SKU page swaps the whole image list when the SKU changes, so it calls this to put the
391	// gallery back on the first image.
392	window.mhProductGallery = {
393		reset: function() {
394			stage.scrollTo({left: 0, behavior: 'instant'});
395			syncDots();
396			observeSlides();
397			activeIndex = -1;
398			setActive(0);
399		}
400	};
401}
402
403function getRequestAQuoteStatus() {
404	$.ajax({
405		type: 'POST',
406		dataType: 'json',
407		url: '/handlers/request-quote/status',
408		data: {},
409		success: function (data) {
410			if(isTestEnvironment) {
411				console.log(data);
412			}
413			if(data.Status === 'success') {
414				if(data.IsOpen) {
415					$('.jsRequestQuoteCallUs').show();
416					$('.jsRequestQuoteCallUsText').hide();
417				} else {
418					$('.jsRequestQuoteCallUs').hide();
419				}
420
421				if(data.EmailAddress) {
422					$('#jsRequestQuoteEmail').val(data.EmailAddress);
423				}
424			}
425		},
426		error: function (xhr) {
427			console.log('Error setting up Request a Quote');
428		}
429	});
430}
431
432function getWishlistStatus() {
433	var skuKey = $('#sku-key').val();
434	let payload = {
435		ProductSkuKey: skuKey};
436	$.ajax({
437		type: 'POST',
438		dataType: 'json',
439		url: '/handlers/product-sku/wishlist-status',
440		data: payload,
441		success: function (data) {
442			if(isTestEnvironment) {
443				console.log(data);
444			}
445			if(data.Status === 'success') {
446				if(data.HasAccount) {
447					if(data.HasBeenAdded) {
448					setWishlistDisplay('added');
449					} else {
450						setWishlistDisplay('remove');
451					}
452				} else {
453					setWishlistDisplay('not-signed-in');
454				}
455			}
456		},
457		error: function (xhr) {
458			console.log('Error loading product wishlist')
459		}
460	});
461}
462
463function setWishlistDisplay(status = 'added') {
464	let $jsWishlistStatus = $('.jsWishlistStatus');
465	$jsWishlistStatus.text('');
466
467	if(status === 'not-signed-in') {
468		$jsWishlistStatus.html('<a href="/accounts/wishlists" class="css-link">Add to wishlist</a>').show();
469	} else {
470		if (status === 'added') {
471		$jsWishlistStatus.html('<a href="/accounts/wishlists" class="css-link">View Wishlist</a>').show();
472		$('.jsAddToWishlist').hide();
473		$('.jsWishlistHeart').off().on('click', function () {
474			if(isTestEnvironment) {
475				console.log('remove from wishlist');
476			}
477			let skuKey = $('#sku-key').val();
478			removeFromWishlist(skuKey);
479		});
480		$('.jsRemoveFromWishlist').show().text('Remove from wishlist');
481		$('.product-page-wishlist-heart').addClass('product-page-wishlist-is-animating product-page-wishlist-is-active').on('animationend', function(){
482			$(this).toggleClass('product-page-wishlist-is-animating');
483		});
484		} else {
485			$('.jsRemoveFromWishlist').hide();
486			$('.jsWishlistHeart').off().on('click', function () {
487				if(isTestEnvironment) {
488					console.log('add to wishlist');
489				}
490				let skuKey = $('#sku-key').val();
491				addToWishlist(skuKey);
492			});
493			$('.jsAddToWishlist').show().text('Add to wishlist');
494			$('.product-page-wishlist-heart').removeClass('product-page-wishlist-is-animating product-page-wishlist-is-active');
495		}
496
497		bindWishlistButtons();
498	}
499}
500
501function addToWishlist(skuKey) {
502	let payload = {
503		ProductSkuKey: skuKey};
504	$.ajax({
505		type: 'POST',
506		dataType: 'json',
507		url: '/handlers/wishlist/add',
508		data: payload,
509		success: function (data) {
510			if(isTestEnvironment) {
511				console.log(data);
512			}
513			if(data.Status === 'success') {
514				setWishlistDisplay('added');
515				snackbarNotify('Item added to wishlist', 'success', '/accounts/wishlists?ref=mh-toast-wishlist-add-success');
516
517			} else {
518			}
519		},
520		error: function (xhr) {
521			snackbarNotify('Oops! There was an error adding to your wishlist', 'error', '/accounts/wishlists?ref=mh-toast-wishlist-add-error');
522		}
523	});
524}
525
526function removeFromWishlist(skuKey){
527	let payload = {
528		ProductSkuKey: skuKey};
529	$.ajax({
530		type: 'POST',
531		dataType: 'json',
532		url: '/handlers/wishlist/remove',
533		data: payload,
534		success: function (data) {
535			if(isTestEnvironment) {
536				console.log(data);
537			}
538
539			if(data.Status === 'success') {
540				setWishlistDisplay('remove');
541				snackbarNotify('Item removed from wishlist', 'warning', '/accounts/wishlists?ref=mh-toast-wishlist-remove-success');
542			} else {
543			}
544		},
545		error: function (xhr) {
546			snackbarNotify('Oops! There was an error removing from your wishlist', 'error', '/accounts/wishlists?ref=mh-toast-wishlist-remove-error');
547		}
548	});
549}
550
551function bindWishlistButtons() {
552	$('.jsAddToWishlist').off().on('click', function () {
553		let skuKey = $('#sku-key').val();
554		addToWishlist(skuKey);
555	});
556
557	$('.jsRemoveFromWishlist').off().on('click', function () {
558		let skuKey = $('#sku-key').val();
559		removeFromWishlist(skuKey);
560	});
561}
562
563function loadProductQuestions() {
564	let $ajaxProductQuestions = $('#ajaxProductQuestions');
565	let payload = {
566		ProductReference: $ajaxProductQuestions.attr('data-product-reference'),
567		ProductTitle: $ajaxProductQuestions.attr('data-product-title'),
568		ProductSkuKey: $ajaxProductQuestions.attr('data-sku-key')};
569	$.ajax({
570		type: 'POST',
571		dataType: 'json',
572		url: '/handlers/retrieve-product-questions',
573		data: payload,
574		success: function (data) {
575			if(data.Status === 'success') {
576				$('#ajaxProductQuestions').html(data.Content);
577				initComments();
578				bindButtons();
579				bindForms();
580				scrollToQuestion();
581				bindVoting($('#user-is-mod').val());
582				bindLoadMoreQuestions();
583			} else {
584				$('#ajaxProductQuestions').html(data.ErrorMessage);
585			}
586		},
587		error: function (xhr) {
588			console.log('Error loading product questions')
589		}
590	});
591}
592
593function bindLoadMoreQuestions() {
594	$('#jsProductQuestionsLoadMore').off().on('click', function () {
595		let $ajaxProductQuestions = $('#ajaxProductQuestions');
596		let payload = {
597			StartingPage: $(this).attr('data-starting-page'),
598			ProductReference: $ajaxProductQuestions.attr('data-product-reference'),
599			ProductTitle: $ajaxProductQuestions.attr('data-product-title'),
600			ProductSkuKey: $ajaxProductQuestions.attr('data-sku-key')};
601		$('#jsProductQuestionsLoadMore').html(loader_element_svg_blue);
602		$.ajax({
603			type: 'POST',
604			dataType: 'json',
605			url: '/handlers/retrieve-product-questions',
606			data: payload,
607			success: function (data) {
608				if(data.Status === 'success') {
609					$('#jsProductQuestionsLoadMore').replaceWith(data.Content);
610					initComments();
611					bindButtons();
612					bindForms();
613					scrollToQuestion();
614					bindVoting($('#user-is-mod').val());
615					bindLoadMoreQuestions();
616				}
617			},
618			error: function (xhr) {
619				console.log('Error loading product questions')
620			}
621		});
622	});
623}
624
625function bindUpdateShadowban() {
626	$('.js-comment-user-update').off().on('click', function(){
627		var shadowBanFlag = $(this).attr('data-user-shadowban');
628
629		var payload = {
630			userName :  $(this).attr('data-user-name'),
631			shadowbanFlag : shadowBanFlag
632		};
633
634		var confirmText = 'Adding a shadowban to this user will hide this comment, all previous comments, as well as any future comments, and it will appear as normal for the user. Are you sure you want to shadowban this user?';
635		if(shadowBanFlag === "True") {
636			confirmText = 'Removing a shadowban from this user will unhide this comment, all previous comments, as well as any future comments. Are you sure you want to remove the shadowban on this user?';
637		}
638		var verify_shadow_ban = confirm(confirmText);
639
640		if (verify_shadow_ban) {
641			$.ajax({
642				type: 'POST',
643				dataType: 'json',
644				url: '/handlers/forum-update-shadowban',
645				data: payload,
646				success: function (data) {
647					if(!data.error_status) {
648						window.location.reload();
649					} else {
650						var error_message = "Oops! unable to update the user's data";
651						if(data.error_message) {
652							error_message = data.error_message;
653						}
654						snackbarNotify(error_message, 'error');
655					}
656				},
657				error: function (xhr) {
658					snackbarNotify("Oops! unable to update the user's data", 'error');
659				}
660			});
661		}
662	})
663}
664
665function submitProductQuestion() {
666	//Serialize form data to submit
667	var payload = $('#formAddProductQuestion').serialize();
668	$.ajax({
669		type: 'POST',
670		dataType: 'json',
671		url: '/handlers/forum-add-topic',
672		data: payload,
673		success: function (data) {
674			if (data.error_status == 'True') {
675				$('#errorContainer').html(data.error_message);
676				bindSubmitProductQuestion();
677			} else {
678				location.reload();
679			}
680		},
681		error: function (xhr) {
682			$('#errorContainer').html("Oops! We couldn't save your question, please try again later.");
683			bindSubmitProductQuestion();
684		}
685	});
686}
687
688function bindSubmitProductQuestion() {
689	$('#bSubmitProductQuestion').html('Submit').on('click', function () {
690		//Unbind click, so bindings don't accumulate
691		$(this).off('click');
692
693		//Show loader element on button
694		$(this).html(loader_element);
695
696		//Clear error container
697		$('#errorContainer').html('');
698
699		submitProductQuestion();
700	});
701}
702
703function scrollToQuestion() {
704	var hash = window.location.hash;
705
706	if (validateBlank(hash) && hash === "#questions") {  // if hash is questions, scroll to that sucka
707		setTimeout(function () {
708			scrollToLocation('questions');
709		}, 500);
710	}
711}
712
713function scrollToLocation(id) {
714	if($('#'+id).length) {
715		$('html,body').animate({scrollTop: $("#"+id).offset().top - 160}, 1000);
716	}
717}
718
719function bindForms() { // new topics and replies
720
721	// add new question
722	$('#formNewForumTopic').on('submit', function(e) {
723		e.preventDefault();
724
725		var form = $(this).serialize();
726		var key = $(this).find('ForumKey').val();
727
728		$(this).find('.status').html();
729		$(this).find('.button-submit').html('Processing...').addClass('fade-50');
730
731		if (validateBlank($(this).find('#TopicTitle').val())) {
732			createForumTopic(form, key);
733		} else {
734			$(this).find('.button-submit').html('Ask Question').removeClass('fade-50');
735			$(this).find('.status-question').html('Question cannot be blank.');
736			$(this).closest('.form-wrapper').shake();
737		}
738	});
739
740	// add a new reply
741	$('.formReplyForumTopic').on('submit', function(e) {
742		e.preventDefault();
743		var form = $(this).serialize();
744		var key = $(this).find('.TopicKey').val();
745
746		$(this).find('.status').html();
747		$(this).find('.btn-add-response').html('Processing...').addClass('fade-50');
748
749		if (validateBlank($(this).find('.TopicMessage').val())) {
750			replyForumTopic(form, key);
751		} else {
752			// shake it
753			$(this).find('.btn-add-response').html('Add Response').removeClass('fade-50');
754			$(this).find('.status-question').html('Response cannot be blank.');
755			$(this).closest('.form-wrapper').shake();
756		}
757	});
758}
759
760function bindButtons() {
761	// cta's
762	bindQuestionDelete();
763	bindCommentDelete();
764	bindViewAndReply();
765	bindViewAndReplyClose();
766
767	bindCommentsView();
768	bindCommentsHide();
769
770	bindCommentsViewAll();
771	bindAddReply();
772	bindCloseReply();
773	bindCloseReplyNoComments();
774
775	bindCommentFormattingGuide();
776
777	bindUpdateShadowban();
778
779	// close parent
780	$('.close-parent').off().on('click', function() {
781		$(this).parent().slideUp('medium');
782	});
783}
784
785function initComments() {
786	$('.comments').each(function(i, obj) {
787		var comment = $(this).find('.comment-single');
788		var numComments = comment.length;
789
790		$(this).closest('.comments').attr('data-number-comments', numComments);
791
792		if(numComments > 0) {
793
794			var response = ' Response'
795			// using correct english
796			if (numComments > 1) {
797				response += 's';
798			}
799
800			// hide the comment group if not curently set to open
801			if($(this).find('.comment-group').attr('data-view') == "open") {
802
803				// label the cta
804				$(this).find('.comment-cta-first').html('Hide ' + response).addClass('js-comments-hide');
805
806			} else{
807				$(this).find('.comment-group').hide().attr('data-view', 'closed');
808
809				// label the cta
810				$(this).find('.comment-cta-first').html('View ' + numComments + response).addClass('js-comments-view');
811			}
812
813			$(this).find('.button-cancel').addClass('js-comment-close-reply');
814
815		} else {
816			// label the cta and add appropriate class handler if no comments
817			$(this).find('.comment-cta-first').html('Add Response').addClass('js-comment-view-and-reply');
818			$(this).find('.comment-thread-cta').hide();
819			$(this).find('.comment-group').show().attr('data-view', 'open');
820			$(this).find('.button-cancel').addClass('js-comment-close-reply-no-comments');
821		}
822
823		$(this).find('.comment-reply').hide().attr('data-view', 'closed');
824
825	});
826}
827
828function bindCommentsViewAll() {
829	// add up all question responses
830	var $questionResponses = 0;
831	$('.comments').each(function(questionResponses) {
832		var num = parseInt($(this).attr('data-number-comments'));
833		$questionResponses += num;
834	});
835
836	// if there are responses, show button
837	if ($questionResponses > 0) {
838		$('#js-comments-view-all').off().on('click', function() {
839			$(this).fadeOut('fast', function(){
840
841				if ($(this).hasClass('show-close')) {
842					$(this).html('Hide All Responses x').removeClass('show-close').addClass('button-mini-orange').fadeIn();
843
844					$('.comment-group').each(function(i, obj) {
845						var numComments = $(this).closest('.comments').attr('data-number-comments');
846
847						if(numComments > 0) {
848							$(this).slideDown('10').attr('data-view', 'open');
849							$(this).closest('.comments').find('.comment-cta-first').hide();
850						}
851					});
852
853				} else {
854					$(this).html('View All Responses').addClass('show-close').removeClass('button-mini-orange').fadeIn();
855
856					$('.comment-group').each(function(i, obj){
857						var numComments = $(this).closest('.comments').attr('data-number-comments');
858
859						if(numComments > 0) {
860							$(this).slideUp('10').attr('data-view', 'closed');
861
862							if (numComments > 1) {
863								var response = ' Responses';
864							} else {
865								var response = ' Response';
866							}
867
868							$(this).closest('.comments').find('.comment-cta-first').html('View ' + numComments + response).addClass('js-comments-view').removeClass('js-comments-hide').show();
869							bindCommentsView();
870						}
871					});
872				}
873			});
874		});
875	} else {
876		// otherwise hide button
877		$('#js-comments-view-all').hide();
878	}
879}
880
881function bindCommentFormattingGuide() {
882	// text and js handlers for formatting guide
883	$('.comment-formatting-help').off().on('click', function(){
884		$(this).fadeOut('fast', function(){
885			if ($(this).hasClass('tag-close')) {
886				$(this).html('Show Formatting Guide').removeClass('tag-close').fadeIn('fast');
887				$(this).closest('.comment-formatting-group').attr('data-view', 'closed');
888			} else {
889				$(this).html('Hide Formatting Guide x').addClass('tag-close').fadeIn('fast');
890				$(this).closest('.comment-formatting-group').attr('data-view', 'open')
891			}
892		});
893
894		$(this).closest('.comment-formatting-group').find('.comment-formatting').slideToggle(350, function(){});
895	});
896}
897
898function bindCommentsView() {
899	$('.js-comments-view').off().on('click', function() {
900		$(this).html('Hide Responses').addClass('js-comments-hide').removeClass('js-comments-view').closest('.question-group').find('.comment-group').attr('data-view', 'open').slideDown('350');
901		bindCommentsHide();
902	});
903}
904
905function bindCommentsHide() {
906	$('.js-comments-hide').off().on('click', function() {
907		var numComments = $(this).closest('.comments').attr('data-number-comments');
908
909		if(numComments > 0) {
910			if (numComments > 1) {
911				var response = ' Responses'
912			} else {
913				var response = ' Response'
914			}
915
916			$(this).html('View ' + numComments + response).addClass('js-comments-view').removeClass('js-comments-hide').closest('.question-group').find('.comment-group').attr('data-view', 'closed').slide
916Up('350');
917		} else {
918
919		}
920
921		bindCommentsView();
922	});
923}
924
925function bindAddReply() {
926	$('.js-comment-add-reply').off().on('click', function() {
927		$(this).closest('.question-group').find('.comment-thread-cta').slideUp('10').closest('.question-group').find('.comment-reply').slideDown('10');
928		bindCloseReply();
929	});
930}
931
932function bindCloseReply() {
933	$('.js-comment-close-reply').off().on('click', function() {
934		$(this).closest('.question-group').find('.comment-thread-cta').slideDown('10').closest('.question-group').find('.comment-reply').slideUp('10');
935		bindAddReply();
936	});
937}
938
939function bindCloseReplyNoComments() {
940	$('.js-comment-close-reply-no-comments').off().on('click', function() {
941		$(this).closest('.question-group').find('.comment-reply').slideUp('10');
942		bindAddReply();
943
944		$(this).closest('.question-group').find('.comment-cta-first').removeClass('js-comment-view-and-reply-close').addClass('js-comment-view-and-reply').html('Add Response');
945		bindViewAndReply();
946	});
947}
948
949function bindViewAndReply() {
950	$('.js-comment-view-and-reply').off().on('click', function() {
951		$(this).removeClass('js-comment-view-and-reply').addClass('js-comment-view-and-reply-close').html('Hide Response').closest('.question-group').find('.comment-reply').slideDown('10');
952		bindViewAndReplyClose();
953	});
954}
955
956function bindViewAndReplyClose() {
957	$('.js-comment-view-and-reply-close').off().on('click', function() {
958		$(this).removeClass('js-comment-view-and-reply-close').addClass('js-comment-view-and-reply').html('Add Response').closest('.question-group').find('.comment-reply').slideUp('10');
959		bindViewAndReply();
960	});
961}
962
963function bindQuestionDelete() {
964	$('.js-question-delete').off().on('click', function () {
965		var key = $(this).attr('data-key');
966		$(this).html('Processing...').addClass('fade-50');
967		handlerDeleteQuestion(key);
968		$(this).closest('.question-group').fadeOut('slow');
969	});
970}
971
972function handlerDeleteQuestion(key) {
973	var payload = {topicKey: key}
974	$.ajax({
975		type: 'POST',
976		dataType: 'json',
977		url: '/handlers/forum-delete-topic',
978		data: payload,
979		success: function (data) {
980			if (data.error_status === 'True') {
981				snackbarNotify(data.error_message, 'error');
982			}
983		},
984		error: function (xhr) {
985			snackbarNotify("Oops! Couldn't delete the question, please try again.", 'error');
986		}
987	});
988}
989
990function bindCommentDelete() {
991	$('.js-comment-delete').on('click', function () {
992		var key = $(this).attr('data-key');
993		$(this).html('Processing...').addClass('fade-50');
994		handlerDeleteReply(key);
995		$(this).closest('.comment-thread').fadeOut('slow');
996	});
997}
998
999function handlerDeleteReply(key) {
1000	var payload = {postKey: key}
1001	$.ajax({
1002		type: 'POST',
1003		dataType: 'json',
1004		url: '/handlers/forum-delete-reply',
1005		data: payload,
1006		success: function (data) {
1007			if (data.error_status === 'True') {
1008				snackbarNotify(data.error_message, 'error');
1009			}
1010		},
1011		error: function (xhr) {
1012			snackbarNotify("Oops! Couldn't delete the question, please try again.", 'error');
1013		}
1014	});
1015}
1016
1017function createForumTopic(payload, key) {
1018	// payload is serialized form
1019	// key is id of the comment to show error message
1020	$.ajax({
1021		type: 'POST',
1022		dataType: 'json',
1023		url: '/handlers/forum-add-topic',
1024		data: payload,
1025		success: function (data) {
1026			if (data.error_status == 'True') {
1027				$('#question-add .status').html('');
1028				$('#question-add .js-ajax-response').html(data.error_message);
1029				$('#question-add').find('.button-submit').html('Ask Question').removeClass('fade-50');
1030				$('#question-add').closest('.form-wrapper').shake();
1031			} else {
1032				// success
1033				if (typeof(reload_callback) !== 'undefined')
1034				{
1035					setTimeout(function () { reload_callback(); }, 1000);
1036				} else {
1037					location.reload();
1038				}
1039			}
1040		},
1041		error: function (xhr) {
1042			$('.js-ajax-response', '#formNewForumTopic').html("Oops! We couldn't save your post, please try again.");
1043		}
1044	});
1045}
1046
1047function replyForumTopic(payload, key) {
1048	// payload is serialized form
1049	// key is id of the comment to show error message
1050	$.ajax({
1051		type: 'POST',
1052		dataType: 'json',
1053		url: '/handlers/forum-add-reply',
1054		data: payload,
1055		success: function (data) {
1056			if (data.error_status === 'True') {
1057				$('#'+ key + ' .status').html('').hide();
1058				$('#'+ key + ' .js-ajax-response').show().html(data.error_message);
1059				$('#'+ key).find('.button-submit').html('Ask Question').removeClass('fade-50');
1060				$('#' + key + '.form-wrapper').shake();
1061			} else {
1062				// success
1063				if (typeof(reload_callback) !== 'undefined')
1064				{
1065					setTimeout(function () { reload_callback(); }, 500);
1066				} else {
1067					location.reload();
1068				}
1069			}
1070		},
1071		error: function (xhr) {
1072			snackbarNotify("Oops! Couldn't delete the question, please try again.", 'error');
1073		}
1074	});
1075}
1076
1077function validateBlank(str) { // blank field validation
1078	if(str && str.length > 0) {
1079		return true;
1080	} else {
1081		return false;
1082	}
1083}
1084
1085var loader_element_svg = '<span class="isProcessing"><img src="/static/ajax-spin-white.svg" title="processing..." width="14" height="14" /></span>';
1086var loader_element_svg_blue = '<span class="isProcessing"><img src="/static/ajax-spin-blue.svg" title="processing..." width="14" height="14" /></span>';

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.