PageSourceSearch

https://academy.zhaket.com/wp-content/themes/zhaket-academy/assets/js/single-course.js?ver=1.1.12

js zhaket.com collected 2026-10-02 07:04:36 UTC 23,372 bytes, 865 lines download raw bytes

1/**
2 * Single course page interactions.
3 *
4 * Accordion, sticky buy bar, intro/lesson video playback,
5 * and native lesson progress tracking.
6 *
7 * @package ZhaketAcademy
8 * @since   1.0.22
9 */
10(function () {
11	'use strict';
12
13	var cfg = window.zhaketAcademy || {};
14	var course = window.zhaketCourse || {};
15
16	/**
17	 * Convert Latin digits to Persian for progress UI updates.
18	 *
19	 * @param {string|number} value Raw value.
20	 * @return {string}
21	 */
22	function toFa(value) {
23		return String(value).replace(/\d/g, function (d) {
24			return 'Û°Û±Û²Û³Û´ÛµÛ¶Û·Û¸Û¹'[d];
25		});
26	}
27
28	/**
29	 * Accordion open/close for curriculum and FAQ.
30	 *
31	 * @return {void}
32	 */
33	function initAccordion() {
34		document.addEventListener('click', function (e) {
35			var top = e.target.closest('.acc-top');
36			if (!top) {
37				return;
38			}
39			var acc = top.parentElement;
40			if (acc && acc.classList.contains('acc')) {
41				acc.classList.toggle('open');
42			}
43		});
44	}
45
46	/**
47	 * Wire clickable 5-star rating widgets -- writes the picked value into
48	 * the accompanying hidden `name="rating"` input so the existing review
49	 * submit handler (below) needs no changes.
50	 *
51	 * @return {void}
52	 */
53	function initStarRating() {
54		var widgets = document.querySelectorAll('[data-star-rate]');
55
56		widgets.forEach(function (widget) {
57			var input = widget.parentElement.querySelector('[data-star-rate-input]');
58			var stars = Array.prototype.slice.call(
59				widget.querySelectorAll('[data-value]')
60			);
61
62			function render(value) {
63				stars.forEach(function (s) {
64					var on = Number(s.getAttribute('data-value')) <= value;
65					s.classList.toggle('on', on);
66					s.setAttribute('aria-pressed', on ? 'true' : 'false');
67				});
68			}
69
70			stars.forEach(function (s) {
71				s.addEventListener('click', function () {
72					var v = s.getAttribute('data-value');
73					if (input) {
74						input.value = v;
75						input.dispatchEvent(new Event('change', { bubbles: true }));
76					}
77					render(Number(v));
78				});
79				s.addEventListener('mouseenter', function () {
80					render(Number(s.getAttribute('data-value')));
81				});
82			});
83
84			widget.addEventListener('mouseleave', function () {
85				render(Number((input && input.value) || 0));
86			});
87		});
88	}
89
90	/**
91	 * Sticky mobile buy bar -- always on (matches the reference site), except
92	 * right above the footer where it would overlap it.
93	 *
94	 * Used to only reveal itself once `.buy-card` scrolled out of view, back
95	 * when the buy-box sat at the very top of the page on mobile. Since the
96	 * buy-box now sits further down in source order (see single-product.php,
97	 * matching the reference site's rhythm), that anchor could take a full
98	 * page of scrolling to pass, leaving mobile visitors with no purchase CTA
99	 * at all until then.
100	 *
101	 * @return {void}
102	 */
103	function initBuyBar() {
104		var bar = document.querySelector('.buy-bar');
105		var foot = document.querySelector('footer.site');
106
107		if (!bar) {
108			return;
109		}
110
111		// Reveal the bar element (hidden until JS boots).
112		bar.hidden = false;
113
114		/**
115		 * Toggle visibility based on scroll position.
116		 *
117		 * @return {void}
118		 */
119		function onScroll() {
120			var near = foot
121				? foot.getBoundingClientRect().top < window.innerHeight - 40
122				: false;
123			var show = !near;
124			bar.classList.toggle('on', show);
125			document.body.classList.toggle('has-buybar', show);
126		}
127
128		window.addEventListener('scroll', onScroll, { passive: true });
129		window.addEventListener('resize', onScroll);
130		onScroll();
131	}
132
133	/**
134	 * Ensure a shared lesson video modal exists in the DOM.
135	 *
136	 * @return {{modal: HTMLElement, video: HTMLVideoElement}}
137	 */
138	function ensureModal() {
139		var modal = document.getElementById('zhaketLessonModal');
140		if (modal) {
141			return {
142				modal: modal,
143				video: modal.querySelector('video')
144			};
145		}
146
147		modal = document.createElement('div');
148		modal.id = 'zhaketLessonModal';
149		modal.className = 'zhaket-lesson-modal';
150		modal.innerHTML =
151			'<div class="zhaket-lesson-modal__box">' +
152			'<button type="button" class="zhaket-lesson-modal__close" aria-label="Close">&times;</button>' +
153			'<video controls playsinline></video>' +
154			'</div>';
155		document.body.appendChild(modal);
156
157		var video = modal.querySelector('video');
158		var closeBtn = modal.querySelector('.zhaket-lesson-modal__close');
159
160		/**
161		 * Close modal and pause playback.
162		 *
163		 * @return {void}
164		 */
165		function close() {
166			modal.classList.remove('on');
167			video.pause();
168			video.removeAttribute('src');
169			video.load();
170		}
171
172		closeBtn.addEventListener('click', close);
173		modal.addEventListener('click', function (e) {
174			if (e.target === modal) {
175				close();
176			}
177		});
178
179		return { modal: modal, video: video };
180	}
181
182	/**
183	 * Mark a lesson watched via AJAX when native progress tracking is enabled.
184	 *
185	 * @param {HTMLElement} lesson Lesson row element.
186	 * @return {void}
187	 */
188	function trackProgress(lesson) {
189		if (!lesson || lesson.getAttribute('data-track-progress') !== '1') {
190			return;
191		}
192		if (!cfg.ajaxUrl || !course.progressNonce) {
193			return;
194		}
195
196		var body = new FormData();
197		body.append('action', 'zhaket_mark_lesson_watched');
198		body.append('nonce', course.progressNonce);
199		body.append('product_id', lesson.getAttribute('data-product-id') || '');
200		body.append('section_index', lesson.getAttribute('data-section-index') || '');
201		body.append('lesson_index', lesson.getAttribute('data-lesson-index') || '');
202
203		fetch(cfg.ajaxUrl, {
204			method: 'POST',
205			credentials: 'same-origin',
206			body: body
207		})
208			.then(function (res) {
209				return res.json();
210			})
211			.then(function (json) {
212				if (!json || !json.success || !json.data || !json.data.progress) {
213					return;
214				}
215				updateProgressUi(json.data.progress);
216			})
217			.catch(function () {
218				/* Silent: progress is best-effort. */
219			});
220	}
221
222	/**
223	 * Update sidebar / buy-bar progress numbers after a watched event.
224	 *
225	 * @param {{watched:number,total:number,percent:number}} progress Progress payload.
226	 * @return {void}
227	 */
228	function updateProgressUi(progress) {
229		var percentEl = document.querySelector('[data-progress-percent]');
230		var barEl = document.querySelector('[data-progress-bar]');
231		var metaEl = document.querySelector('[data-progress-meta]');
232		var buybar = document.querySelector('[data-buybar-progress]');
233
234		if (percentEl) {
235			percentEl.textContent = toFa(progress.percent) + 'Ùª';
236		}
237		if (barEl) {
238			barEl.style.width = String(progress.percent) + '%';
239		}
240		if (metaEl) {
241			metaEl.textContent =
242				'جلسه ' + toFa(progress.watched) + ' از ' + toFa(progress.total);
243		}
244		if (buybar) {
245			buybar.innerHTML =
246				toFa(progress.percent) +
247				'Ùª <small>' +
248				((course.i18n && course.i18n.completed) || '') +
249				'</small>';
250		}
251	}
252
253	/**
254	 * Resolve a lesson's video into a playable URL.
255	 *
256	 * Migrated `video_url` values are bucket-relative storage keys, not real
257	 * URLs, even when they carry a stray scheme -- deciding "is this already
258	 * public" from the string alone is unreliable (see class-sign-lesson-video.php).
259	 * That decision now lives entirely server-side (host-based, re-derived
260	 * from the lesson data, not from this attribute), so every lookup always
261	 * goes through the AJAX endpoint instead of duplicating the logic here.
262	 *
263	 * @param {HTMLElement} lesson Lesson element with data-video-url.
264	 * @return {Promise<string>} Resolves to a playable URL, rejects on failure.
265	 */
266	function resolveLessonUrl(lesson) {
267		if (!cfg.ajaxUrl && !course.ajaxUrl) {
268			return Promise.reject(new Error('no ajax url'));
269		}
270		if (!course.signNonce) {
271			return Promise.reject(new Error('no sign nonce'));
272		}
273
274		var body = new FormData();
275		body.append('action', 'zhaket_sign_lesson_video');
276		body.append('nonce', course.signNonce);
277		body.append('product_id', lesson.getAttribute('data-product-id') || '');
278		body.append('section_index', lesson.getAttribute('data-section-index') || '');
279		body.append('lesson_index', lesson.getAttribute('data-lesson-index') || '');
280
281		return fetch(course.ajaxUrl || cfg.ajaxUrl, {
282			method: 'POST',
283			credentials: 'same-origin',
284			body: body
285		})
286			.then(function (res) {
287				return res.json();
288			})
289			.then(function (json) {
290				if (!json || !json.success || !json.data || !json.data.url) {
291					throw new Error((json && json.data && json.data.message) || 'sign failed');
292				}
293				return json.data.url;
294			});
295	}
296
297	/**
298	 * Open a lesson/preview video in the modal player.
299	 *
300	 * @param {HTMLElement} lesson Lesson element with data-video-url.
301	 * @return {void}
302	 */
303	function playLesson(lesson) {
304		if (!lesson.getAttribute('data-video-url')) {
305			return;
306		}
307		var parts = ensureModal();
308		parts.modal.classList.add('on', 'is-loading');
309
310		resolveLessonUrl(lesson)
311			.then(function (url) {
312				parts.modal.classList.remove('is-loading');
313				parts.video.src = url;
314				parts.video.play();
315
316				parts.video.onended = function () {
317					trackProgress(lesson);
318				};
319			})
320			.catch(function () {
321				parts.modal.classList.remove('on', 'is-loading');
322			});
323	}
324
325	/**
326	 * Wire playable lesson rows and intro player.
327	 *
328	 * @return {void}
329	 */
330	function initVideo() {
331		document.addEventListener('click', function (e) {
332			var lesson = e.target.closest('.lesson--playable');
333			if (lesson) {
334				e.preventDefault();
335				playLesson(lesson);
336				return;
337			}
338
339			var play = e.target.closest('.cp-player--has-video .play');
340			if (!play) {
341				return;
342			}
343
344			var player = play.closest('.cp-player');
345			var url = player.getAttribute('data-video-url');
346			var video = player.querySelector('.cp-player__video');
347			if (!url || !video) {
348				return;
349			}
350
351			video.hidden = false;
352			video.muted = false;
353			video.src = url;
354			player.classList.add('is-playing');
355			video.play();
356		});
357
358		document.addEventListener('keydown', function (e) {
359			if (e.key !== 'Enter' && e.key !== ' ') {
360				return;
361			}
362			var lesson = e.target.closest('.lesson--playable');
363			if (!lesson) {
364				return;
365			}
366			e.preventDefault();
367			playLesson(lesson);
368		});
369	}
370
371	/**
372	 * When no poster/gallery cover exists, park the intro video on its first frame.
373	 *
374	 * @return {void}
375	 */
376	function initFrameFallback() {
377		var players = document.querySelectorAll('.cp-player[data-frame-fallback="1"]');
378		players.forEach(function (player) {
379			var url = player.getAttribute('data-video-url');
380			var video = player.querySelector('.cp-player__video');
381			if (!url || !video) {
382				return;
383			}
384
385			// Show the video under the play control so the first frame acts as cover.
386			video.muted = true;
387			video.playsInline = true;
388			video.preload = 'metadata';
389			video.hidden = false;
390			video.setAttribute('src', url);
391
392			var onSeeked = function () {
393				try {
394					video.pause();
395				} catch (err) {
396					// Ignore pause errors on unfinished loads.
397				}
398				video.removeEventListener('seeked', onSeeked);
399			};
400
401			video.addEventListener('loadeddata', function () {
402				try {
403					video.currentTime = 0.05;
404				} catch (err) {
405					// Some hosts block seeking until more data arrives.
406				}
407			});
408			video.addEventListener('seeked', onSeeked);
409		});
410	}
411
412	/**
413	 * AJAX pagination for product reviews (uses Discussion → comments_per_page).
414	 *
415	 * @return {void}
416	 */
417	function initReviewsPager() {
418		var box = document.getElementById('reviews');
419		if (!box) {
420			return;
421		}
422
423		var list = box.querySelector('[data-reviews-list]');
424		var pagerWrap = box.querySelector('[data-reviews-pager]');
425		var productId = box.getAttribute('data-product-id');
426		if (!list || !pagerWrap || !productId) {
427			return;
428		}
429
430		var loading = false;
431
432		/**
433		 * Fetch one reviews page and swap list + pager markup.
434		 *
435		 * @param {number} page Target page number.
436		 * @return {void}
437		 */
438		function loadPage(page) {
439			if (loading || page < 1) {
440				return;
441			}
442			loading = true;
443			box.classList.add('is-loading-reviews');
444
445			var body = new URLSearchParams();
446			body.set('action', 'zhaket_load_reviews');
447			body.set('product_id', productId);
448			body.set('page', String(page));
449
450			fetch(course.ajaxUrl || cfg.ajaxUrl || '/wp-admin/admin-ajax.php', {
451				method: 'POST',
452				credentials: 'same-origin',
453				headers: {
454					'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
455				},
456				body: body.toString(),
457			})
458				.then(function (res) {
459					return res.json();
460				})
461				.then(function (json) {
462					if (!json || !json.success || !json.data) {
463						throw new Error('reviews');
464					}
465					list.innerHTML = json.data.html || '';
466					pagerWrap.innerHTML = json.data.pager || '';
467					list.scrollIntoView({ behavior: 'smooth', block: 'start' });
468				})
469				.catch(function () {
470					window.alert(
471						(course.i18n && course.i18n.reviewsError) ||
472							'Reviews load failed'
473					);
474				})
475				.finally(function () {
476					loading = false;
477					box.classList.remove('is-loading-reviews');
478				});
479		}
480
481		pagerWrap.addEventListener('click', function (e) {
482			var btn = e.target.closest('.cmt-pager__btn[data-page]');
483			if (!btn || btn.getAttribute('aria-current') === 'page') {
484				return;
485			}
486			e.preventDefault();
487			var page = parseInt(btn.getAttribute('data-page'), 10);
488			if (!isNaN(page)) {
489				loadPage(page);
490			}
491		});
492	}
493
494	/**
495	 * Convert Latin digits to Persian for rating summary refresh.
496	 *
497	 * @param {string|number} value Raw value.
498	 * @return {string}
499	 */
500	function toFaDigits(value) {
501		return toFa(value);
502	}
503
504	/**
505	 * Build a 5-star glyph string from an average rating.
506	 *
507	 * @param {number} rating Average rating.
508	 * @return {string}
509	 */
510	function starsGlyph(rating) {
511		var full = Math.round(Number(rating) || 0);
512		full = Math.max(0, Math.min(5, full));
513		return '★★★★★'.slice(0, full) + '☆☆☆☆☆'.slice(0, 5 - full);
514	}
515
516	/**
517	 * Refresh the rating summary block from AJAX breakdown data.
518	 *
519	 * @param {HTMLElement} box Reviews root.
520	 * @param {Object} breakdown Rating payload from the server.
521	 * @return {void}
522	 */
523	function updateRateSum(box, breakdown) {
524		if (!box || !breakdown) {
525			return;
526		}
527		var avgEl = box.querySelector('.rate-big b');
528		var stEl = box.querySelector('.rate-big .st');
529		var countEl = box.querySelector('.rate-big > span');
530		if (avgEl) {
531			avgEl.textContent = toFaDigits(
532				Number(breakdown.average || 0).toFixed(1)
533			);
534		}
535		if (stEl) {
536			stEl.textContent = starsGlyph(breakdown.average);
537		}
538		if (countEl) {
539			countEl.textContent =
540				'از ' + toFaDigits(String(breakdown.count || 0)) + ' دیدگاه';
541		}
542		var bars = Array.isArray(breakdown.bars) ? breakdown.bars : [];
543		var rows = box.querySelectorAll('.rate-bars .rb');
544		rows.forEach(function (row, i) {
545			var bar = bars[i];
546			if (!bar) {
547				return;
548			}
549			var fill = row.querySelector('.bar i');
550			var n = row.querySelector('.n');
551			if (fill) {
552				fill.style.width = String(bar.percent || 0) + '%';
553			}
554			if (n) {
555				n.textContent = toFaDigits(String(bar.count || 0));
556			}
557		});
558	}
559
560	/**
561	 * AJAX submit for product review forms.
562	 *
563	 * @return {void}
564	 */
565	function initReviewSubmit() {
566		var box = document.getElementById('reviews');
567		if (!box) {
568			return;
569		}
570
571		var forms = box.querySelectorAll('[data-review-form]');
572		if (!forms.length) {
573			return;
574		}
575
576		/**
577		 * Point the form at a review (or back at a fresh review when id is 0).
578		 * While replying, the rating row is hidden: a reply carries no stars.
579		 *
580		 * @param {HTMLFormElement} form   Review form.
581		 * @param {number|string}   id     Top-level review id, 0 for none.
582		 * @param {string}          author Name shown in the "replying to" bar.
583		 * @return {void}
584		 */
585		function setReplyTarget(form, id, author) {
586			var parent = form.querySelector('[name="comment_parent"]');
587			var bar = form.querySelector('[data-reply-bar]');
588			var text = form.querySelector('[data-reply-text]');
589			var ratingRow = form.querySelector('.review-rating-row');
590			if (parent) {
591				parent.value = String(id || 0);
592			}
593			if (bar) {
594				bar.hidden = !id;
595			}
596			if (text) {
597				text.textContent = id ? (bar.getAttribute('data-reply-label') || '') + ' ' + author : '';
598			}
599			if (ratingRow) {
600				ratingRow.hidden = !!id;
601			}
602		}
603
604		// The list is re-rendered by AJAX, so listen on the box rather than on each button.
605		box.addEventListener('click', function (e) {
606			var form = box.querySelector('[data-review-form]:not([data-requires-login="1"])');
607			if (!form) {
608				return;
609			}
610			var replyBtn = e.target.closest('[data-reply-to]');
611			if (replyBtn) {
612				setReplyTarget(form, replyBtn.getAttribute('data-reply-to'), replyBtn.getAttribute('data-reply-author') || '');
613				form.scrollIntoView({ behavior: 'smooth', block: 'center' });
614				var field = form.querySelector('[name="comment"]');
615				if (field) {
616					field.focus({ preventScroll: true });
617				}
618				return;
619			}
620			if (e.target.closest('[data-reply-cancel]')) {
621				setReplyTarget(form, 0, '');
622			}
623		});
624
625		forms.forEach(function (form) {
626			form.addEventListener('submit', function (e) {
627				e.preventDefault();
628
629				var btn = form.querySelector('[data-review-submit]');
630				var msg = form.querySelector('[data-review-msg]');
631				var list = box.querySelector('[data-reviews-list]');
632				var pagerWrap = box.querySelector('[data-reviews-pager]');
633				var sending = false;
634
635				if (form.getAttribute('data-sending') === '1') {
636					return;
637				}
638
639				var comment = form.querySelector('[name="comment"]');
640				var rating = form.querySelector('[name="rating"]');
641				var parentField = form.querySelector('[name="comment_parent"]');
642				var replyTo = parentField && parentField.value !== '0' ? parentField.value : '';
643				if (comment && !String(comment.value || '').trim()) {
644					comment.focus();
645					return;
646				}
647				// A reply has no rating.
648				if (rating && !replyTo && !rating.value) {
649					rating.focus();
650					return;
651				}
652
653				// Guest form: a review is tied to a real account. Stash what
654				// was typed so it survives the round trip to login and back,
655				// then send them there instead of calling the AJAX endpoint
656				// (which would just 403 with no author identity anyway).
657				if (form.getAttribute('data-requires-login') === '1') {
658					var productIdForDraft =
659						(form.querySelector('[name="product_id"]') || {}).value ||
660						box.getAttribute('data-product-id') ||
661						'';
662					try {
663						localStorage.setItem(
664							'zhaket_review_draft_' + productIdForDraft,
665							JSON.stringify({
666								comment: comment ? comment.value : '',
667								rating: rating ? rating.value : '',
668							})
669						);
670					} catch (err) {
671						// Private mode / storage disabled -- draft just won't survive the redirect.
672					}
673					window.location.href = form.getAttribute('data-login-url') || '#';
674					return;
675				}
676
677				form.setAttribute('data-sending', '1');
678				sending = true;
679				if (btn) {
680					btn.disabled = true;
681					btn.textContent =
682						(course.i18n && course.i18n.submitSending) ||
683						'Sending…';
684				}
685				if (msg) {
686					msg.hidden = true;
687					msg.className = 'cmt-form-msg';
688					msg.textContent = '';
689				}
690
691				var body = new URLSearchParams();
692				body.set('action', 'zhaket_submit_review');
693				body.set('nonce', course.submitNonce || '');
694				body.set(
695					'product_id',
696					form.querySelector('[name="product_id"]').value ||
697						box.getAttribute('data-product-id') ||
698						''
699				);
700				body.set('comment', comment ? comment.value : '');
701				body.set('rating', rating && !replyTo ? rating.value : '');
702				if (replyTo) {
703					body.set('comment_parent', replyTo);
704				}
705
706				var author = form.querySelector('[name="author"]');
707				var email = form.querySelector('[name="email"]');
708				if (author) {
709					body.set('author', author.value || '');
710				}
711				if (email) {
712					body.set('email', email.value || '');
713				}
714
715				fetch(course.ajaxUrl || cfg.ajaxUrl || '/wp-admin/admin-ajax.php', {
716					method: 'POST',
717					credentials: 'same-origin',
718					headers: {
719						'Content-Type':
720							'application/x-www-form-urlencoded; charset=UTF-8',
721					},
722					body: body.toString(),
723				})
724					.then(function (res) {
725						return res.json();
726					})
727					.then(function (json) {
728						if (!json || !json.success || !json.data) {
729							var errMsg =
730								json && json.data && json.data.message
731									? json.data.message
732									: (course.i18n && course.i18n.reviewsError) ||
733									  'Error';
734							throw new Error(errMsg);
735						}
736
737						if (msg) {
738							msg.hidden = false;
739							msg.className = 'cmt-form-msg is-ok';
740							msg.textContent = json.data.message || '';
741						}
742
743						setReplyTarget(form, 0, '');
744						if (comment) {
745							comment.value = '';
746						}
747						if (rating) {
748							rating.value = '';
749							var stars = form.querySelectorAll('[data-star-rate] [data-value]');
750							stars.forEach(function (s) {
751								s.classList.remove('on');
752								s.setAttribute('aria-pressed', 'false');
753							});
754						}
755
756						if (json.data.approved) {
757							if (list) {
758								list.innerHTML = json.data.html || '';
759							}
760							if (pagerWrap) {
761								pagerWrap.innerHTML = json.data.pager || '';
762							}
763							updateRateSum(box, json.data.breakdown);
764						}
765					})
766					.catch(function (err) {
767						if (msg) {
768							msg.hidden = false;
769							msg.className = 'cmt-form-msg is-err';
770							msg.textContent =
771								(err && err.message) ||
772								(course.i18n && course.i18n.reviewsError) ||
773								'Error';
774						}
775					})
776					.finally(function () {
777						form.removeAttribute('data-sending');
778						sending = false;
779						if (btn) {
780							btn.disabled = false;
781							btn.textContent =
782								(course.i18n && course.i18n.submitLabel) ||
783								'Submit';
784						}
785					});
786			});
787		});
788	}
789
790	/**
791	 * After returning from the login redirect above, refill the (now
792	 * logged-in) review form from the stashed draft and submit it right
793	 * away, so the visitor never has to retype their review.
794	 *
795	 * @return {void}
796	 */
797	function restoreReviewDraft() {
798		var box = document.getElementById('reviews');
799		if (!box) {
800			return;
801		}
802
803		var productId = box.getAttribute('data-product-id') || '';
804		var key = 'zhaket_review_draft_' + productId;
805		var raw = null;
806		try {
807			raw = localStorage.getItem(key);
808		} catch (err) {
809			return;
810		}
811		if (!raw) {
812			return;
813		}
814		try {
815			localStorage.removeItem(key);
816		} catch (err) {
817			// Nothing to clean up if storage isn't writable.
818		}
819
820		var draft = null;
821		try {
822			draft = JSON.parse(raw);
823		} catch (err) {
824			return;
825		}
826		if (!draft || !draft.comment) {
827			return;
828		}
829
830		var form = box.querySelector(
831			'[data-review-form]:not([data-requires-login="1"])'
832		);
833		if (!form) {
834			// Still logged out (or comments closed) -- leave the draft consumed;
835			// nothing sensible to restore it into.
836			return;
837		}
838
839		var comment = form.querySelector('[name="comment"]');
840		var rating = form.querySelector('[name="rating"]');
841		if (comment) {
842			comment.value = draft.comment;
843		}
844		if (rating && draft.rating) {
845			rating.value = draft.rating;
846			var stars = form.querySelectorAll('[data-star-rate] [data-value]');
847			stars.forEach(function (s) {
848				var on = Number(s.getAttribute('data-value')) <= Number(draft.rating);
849				s.classList.toggle('on', on);
850				s.setAttribute('aria-pressed', on ? 'true' : 'false');
851			});
852		}
853
854		form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
855	}
856
857	initAccordion();
858	initBuyBar();
859	initVideo();
860	initFrameFallback();
861	initReviewsPager();
862	initReviewSubmit();
863	initStarRating();
864	restoreReviewDraft();
865})();

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.