PageSourceSearch

https://www.theivnews.com/wp-content/plugins/paywall-project/asset…/js/restriction-resolver.js?ver=3.1.0

js theivnews.com collected 2026-10-04 10:06:51 UTC 15,002 bytes, 428 lines download raw bytes

1/**
2 * PPAccess restriction resolver.
3 *
4 * Server ships full content (hidden via inline visibility:hidden CSS for
5 * non-subscribers), this script reads viewed_content from localStorage, asks
6 * the REST endpoint for a
7	 * decision, then either reveals the container or replaces it with the paywall prompt.
8	 *
9	 * Two layouts are supported:
10	 *   - inline: paywall prompt replaces the article container (default).
11	 *   - modal:  paywall prompt mounts in a fixed-position overlay; the article stays
12 *             visible behind the modal and JS locks page scroll.
13 *
14 * @package PaywallProjectAccess
15 */
16(function () {
17	'use strict';
18
19	var cfg = window.ppaccessResolver || {};
20	if (!cfg.rest_url) { return; }
21
22	// Refresh nonce on load so cached pages don't send a stale one.
23	// Only needed for logged-in users; anonymous requests skip the nonce anyway.
24	if (document.body && document.body.classList.contains('logged-in') && cfg.nonce) {
25		var nonceBase = cfg.rest_url.replace(/\/public\/.*$/, '');
26		fetch(nonceBase + '/public/nonce', { method: 'GET', credentials: 'same-origin' })
27			.then(function (r) { return r.ok ? r.json() : null; })
28			.then(function (d) { if (d && d.nonce) { cfg.nonce = d.nonce; } })
29			.catch(function () {});
30	}
31
32	var STORAGE_KEY = 'ppaccess_viewed_content';
33
34	function getPostIdFromBody() {
35		var body = document.body;
36		if (!body) { return null; }
37		var classes = body.className.split(/\s+/);
38		for (var i = 0; i < classes.length; i++) {
39			var cls = classes[i];
40			if (cls.indexOf('postid-') === 0) {
41				var pid = parseInt(cls.substring(7), 10);
42				if (pid > 0) { return pid; }
43			}
44			if (cls.indexOf('page-id-') === 0) {
45				var pageId = parseInt(cls.substring(8), 10);
46				if (pageId > 0) { return pageId; }
47			}
48		}
49		return null;
50	}
51
52	function getViewedContent() {
53		try {
54			var raw = localStorage.getItem(STORAGE_KEY);
55			if (raw) { return JSON.parse(raw) || {}; }
56		} catch (e) { /* unavailable / corrupt */ }
57		return {};
58	}
59
60	function saveViewedContent(content) {
61		try { localStorage.setItem(STORAGE_KEY, JSON.stringify(content || {})); }
62		catch (e) { /* unavailable */ }
63	}
64
65	function cleanExpired(content) {
66		var now = Math.floor(Date.now() / 1000);
67		var changed = false;
68		for (var pt in content) {
69			if (!Object.prototype.hasOwnProperty.call(content, pt)) { continue; }
70			var bucket = content[pt];
71			for (var pid in bucket) {
72				if (Object.prototype.hasOwnProperty.call(bucket, pid) && bucket[pid] < now) {
73					delete bucket[pid];
74					changed = true;
75				}
76			}
77			if (Object.keys(bucket).length === 0) {
78				delete content[pt];
79				changed = true;
80			}
81		}
82		if (changed) { saveViewedContent(content); }
83		return content;
84	}
85
86	function getContainers() {
87		var body = document.body;
88		var selector;
89		if (body && body.className.indexOf('page-id-') !== -1) {
90			selector = cfg.page_container || '';
91		} else {
92			selector = cfg.post_container || '';
93		}
94		if (!selector) { return []; }
95		var selectors = selector.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
96		var nodes = [];
97		selectors.forEach(function (sel) {
98			var found = document.querySelectorAll(sel);
99			for (var i = 0; i < found.length; i++) { nodes.push(found[i]); }
100		});
101		return nodes;
102	}
103
104	// Trim a node's children to a remaining word budget, mirroring the server's
105	// truncate_to_lead_in so the teaser matches and doesn't shift on load. Text
106	// nodes consume the budget; the node it runs out on is cut at a word boundary
107	// and everything after is removed. remaining is a one-element array (by ref).
108	function truncateNodeToWords(node, remaining) {
109		var children = Array.prototype.slice.call(node.childNodes);
110		for (var i = 0; i < children.length; i++) {
111			var child = children[i];
112			if (remaining[0] <= 0) { node.removeChild(child); continue; }
113			if (child.nodeType === 3) { // text node
114				var words = (child.textContent || '').split(/\s+/).filter(Boolean);
115				if (words.length <= remaining[0]) {
116					remaining[0] -= words.length;
117				} else {
118					child.textContent = words.slice(0, remaining[0]).join(' ') + '…';
119					remaining[0] = 0;
120				}
121			} else if (child.nodeType === 1) { // element
122				truncateNodeToWords(child, remaining);
123			}
124		}
125	}
126
127	function leadInHtml(container, n) {
128		if (!n || n <= 0) { return ''; }
129		var clone = container.cloneNode(true);
130		truncateNodeToWords(clone, [n]);
131		return clone.innerHTML;
132	}
133
134	function reveal(containers) {
135		containers.forEach(function (c) { c.style.visibility = 'visible'; });
136	}
137
138	// Assign HTML and re-run any <script> tags it contains. innerHTML never
139	// executes injected scripts, so inline initializers delivered in
140	// full_content (map tokens, data-table setup, embeds) would otherwise stay
141	// dead — leaving loading skeletons and "x is not defined" errors. Cloning
142	// each script into a fresh element makes the browser execute it in order.
143	function setHtmlWithScripts(container, html) {
144		container.innerHTML = html;
145		var scripts = container.querySelectorAll('script');
146		for (var i = 0; i < scripts.length; i++) {
147			var old = scripts[i];
148			var s = document.createElement('script');
149			for (var j = 0; j < old.attributes.length; j++) {
150				s.setAttribute(old.attributes[j].name, old.attributes[j].value);
151			}
152			s.text = old.textContent || '';
153			old.parentNode.replaceChild(s, old);
154		}
155	}
156
157	function isSlideupPaywall(paywallHtml) {
158		return paywallHtml.indexOf('ppaccess-paywall--slideup') !== -1;
159	}
160
161	function showInlinePaywall(containers, paywallHtml) {
162		var leadCount = parseInt(cfg.lead_in_words, 10);
163		if (isNaN(leadCount)) { leadCount = 75; }
164		var fade      = cfg.fade_color || '#ffffff';
165		containers.forEach(function (c, idx) {
166			if (containers.length > 1 && idx > 0) {
167				c.innerHTML = '';
168			} else {
169				var leadHtml = leadInHtml(c, leadCount);
170				// Wrap lead-in so a gradient overlay can fade the last paragraph
171				// into the page background. Color is admin-configurable so it
172				// matches the theme — important for sites with off-white or
173				// dark backgrounds.
174				var wrapped = leadHtml
175					? '<div class="ppaccess-paywall-leadin" style="--ppaccess-fade-color:' + fade + '">' + leadHtml + '</div>'
176					: '';
177					c.innerHTML = wrapped + paywallHtml;
178			}
179			c.style.visibility = 'visible';
180		});
181	}
182
183	function showSlideupPaywall(containers, paywallHtml) {
184		// Bottom-sheet pattern: article stays behind the locked offer.
185		reveal(containers);
186
187		var existing = document.querySelector('.ppaccess-slideup-root');
188		if (existing && existing.parentNode) {
189			existing.parentNode.removeChild(existing);
190		}
191
192		var root = document.createElement('div');
193		root.className = 'ppaccess-slideup-root';
194		root.innerHTML = '<div class="ppaccess-slideup-mask" aria-hidden="true"></div><div class="ppaccess-slideup-portal">' + paywallHtml + '</div>';
195		document.body.classList.add('ppaccess-slideup-open');
196		document.documentElement.classList.add('ppaccess-slideup-open');
197		document.body.appendChild(root);
198
199		// Show the sheet immediately in its starting position.
200		root.classList.add('is-visible');
201		root.classList.add('is-active');
202
203		var slideupEl = root.querySelector('.ppaccess-paywall--slideup');
204		// Resolve the resting top to pixels. The CSS var may be in vh (mobile)
205		// or px (desktop); parseInt('38vh') would wrongly yield 38px and trap
206		// the sheet near the top when sliding back down. The element's computed
207		// `top` is always resolved to px, so prefer that, with a vh-aware
208		// fallback on the raw custom property.
209		var startTop = slideupEl ? parseInt(getComputedStyle(slideupEl).top, 10) : NaN;
210		if (!startTop || isNaN(startTop)) {
211			var rawTop = getComputedStyle(root).getPropertyValue('--ppaccess-slideup-top').trim();
212			startTop = rawTop.indexOf('vh') !== -1
213				? Math.round(parseFloat(rawTop) * window.innerHeight / 100)
214				: (parseInt(rawTop, 10) || 350);
215		}
216		var currentTop = startTop;
217
218		// Drive the fade color from the panel's real background so the article
219		// fades into whatever color the panel is — white, a preset (e.g. the
220		// ft-classic cream), or a custom Box background color.
221		if (slideupEl) {
222			var panelBg = getComputedStyle(slideupEl).backgroundColor;
223			if (panelBg && panelBg !== 'rgba(0, 0, 0, 0)' && panelBg !== 'transparent') {
224				root.style.setProperty('--ppaccess-slideup-bg', panelBg);
225			}
226		}
227
228		// Disable CSS transition so gesture-driven movement is instant.
229		if (slideupEl) { slideupEl.style.transition = 'none'; }
230
231		function setTop(t) {
232			currentTop = Math.max(0, Math.min(startTop, t));
233			if (slideupEl) { slideupEl.style.top = currentTop + 'px'; }
234		}
235
236		// Drive sheet position from wheel events (body scroll is locked).
237		window.addEventListener('wheel', function (e) {
238			setTop(currentTop - e.deltaY);
239		}, { passive: true });
240
241		// Touch support.
242		var touchStartY = null;
243		window.addEventListener('touchstart', function (e) {
244			touchStartY = e.touches[0].clientY;
245		}, { passive: true });
246		window.addEventListener('touchmove', function (e) {
247			if (touchStartY === null) { return; }
248			var delta = touchStartY - e.touches[0].clientY;
249			touchStartY = e.touches[0].clientY;
250			setTop(currentTop - delta);
251		}, { passive: true });
252	}
253
254	function showModalPaywall(containers, paywallHtml) {
255		// Reveal the article behind the modal — modal layout does not hide content.
256		reveal(containers);
257
258		var root = document.createElement('div');
259		root.className = 'ppaccess-modal-root';
260			root.innerHTML = paywallHtml;
261		document.body.appendChild(root);
262
263		var modal = root.querySelector('.ppaccess-paywall-modal');
264		if (!modal) { return; }
265
266		var trigger = modal.getAttribute('data-trigger') || 'load';
267		var delay   = parseInt(modal.getAttribute('data-delay'), 10) || 0;
268
269		function open() {
270			modal.removeAttribute('hidden');
271			document.body.classList.add('ppaccess-modal-open');
272		}
273		function close() {
274			modal.setAttribute('hidden', 'hidden');
275			document.body.classList.remove('ppaccess-modal-open');
276		}
277
278		// Dismiss via backdrop click. The Escape key is intentionally not wired
279		// since this is a paywall — letting users dismiss with Esc would defeat
280		// the purpose. Backdrop dismiss exists so a user who already subscribed
281		// can re-read without page reload.
282		root.addEventListener('click', function (ev) {
283			if (ev.target && ev.target.hasAttribute('data-ppaccess-modal-close')) { close(); }
284		});
285
286		if ('load' === trigger) {
287			if (delay > 0) { setTimeout(open, delay * 1000); }
288			else { open(); }
289			return;
290		}
291
292		if ('scroll' === trigger) {
293			var fired = false;
294			var onScroll = function () {
295				if (fired) { return; }
296				var docH  = document.documentElement.scrollHeight - window.innerHeight;
297				var ratio = docH > 0 ? (window.scrollY / docH) : 0;
298				if (ratio >= 0.4) {
299					fired = true;
300					window.removeEventListener('scroll', onScroll);
301					open();
302				}
303			};
304			window.addEventListener('scroll', onScroll, { passive: true });
305			return;
306		}
307
308		if ('exit' === trigger) {
309			var fired2 = false;
310			document.addEventListener('mouseleave', function (ev) {
311				if (fired2) { return; }
312				if (ev.clientY <= 0) {
313					fired2 = true;
314					open();
315				}
316			});
317			return;
318		}
319	}
320
321	function dispatch(name, detail) {
322		var ev;
323		try { ev = new CustomEvent(name, { detail: detail, bubbles: true, cancelable: true }); }
324		catch (e) { return; }
325		document.dispatchEvent(ev);
326	}
327
328	function getGiftToken() {
329		try {
330			var value = new URLSearchParams(window.location.search).get('ppgift') || '';
331			return value.replace(/[^A-Za-z0-9]/g, '');
332		} catch (e) {
333			return '';
334		}
335	}
336
337	function resolve(postId) {
338		// When IP metering is enabled (cfg.ip_meter), always send empty viewed_content
339		// so the server IP meter is the sole gate — localStorage can be bypassed
340		// by clearing browser data or opening incognito, so we never rely on it.
341		var viewed = cfg.ip_meter ? {} : cleanExpired(getViewedContent());
342		var headers = { 'Content-Type': 'application/json' };
343
344		// Match LP's behavior: only send the nonce for logged-in users.
345		// Anonymous visitors hitting a cached page may have a stale nonce that
346		// would 403 the request — omitting it lets the public endpoint succeed.
347		if (cfg.nonce && document.body.classList.contains('logged-in')) {
348			headers['X-WP-Nonce'] = cfg.nonce;
349		}
350
351		var payload = { post_id: postId, viewed_content: viewed };
352
353		// Gift links carry their token in the query string. Read it from the live
354		// URL rather than from localized config: this page may have been served
355		// from a full-page cache, and a token baked into that HTML would belong
356		// to whoever populated the cache, not to the person reading now.
357		var giftToken = getGiftToken();
358		if (giftToken) { payload.gift_token = giftToken; }
359
360		fetch(cfg.rest_url, {
361			method: 'POST',
362			headers: headers,
363			credentials: 'same-origin',
364			body: JSON.stringify(payload)
365		})
366			.then(function (r) {
367				if (!r.ok) { throw new Error('resolve_failed_' + r.status); }
368				return r.json();
369			})
370			.then(function (data) {
371				var containers = getContainers();
372
373					var paywallContent = data ? data.paywall_content : '';
374					if (data && data.show_paywall && paywallContent) {
375						setNagLocCookie(postId);
376						var layout = (data.layout || cfg.layout || 'inline');
377						if (!containers.length) {
378							// No matching container - custom post type template. Fall back to
379							// slideup overlay so the paywall still appears.
380							showSlideupPaywall([], paywallContent);
381						} else if (isSlideupPaywall(paywallContent)) {
382							showSlideupPaywall(containers, paywallContent);
383						} else if ('modal' === layout) {
384							showModalPaywall(containers, paywallContent);
385						} else {
386							showInlinePaywall(containers, paywallContent);
387						}
388					dispatch('ppaccess:paywall_shown', { postId: postId, layout: layout, response: data });
389					return;
390				}
391
392				if (!containers.length) { return; }
393				if (data && data.full_content) {
394					containers.forEach(function (c) { setHtmlWithScripts(c, data.full_content); });
395				}
396				reveal(containers);
397				clearNagLocCookie();
398				if (data && data.viewed_content && !cfg.ip_meter) { saveViewedContent(data.viewed_content); }
399				dispatch('ppaccess:access_granted', { postId: postId, response: data });
400			})
401			.catch(function () {
402				// On error, reveal so we never silently hide content.
403				reveal(getContainers());
404			});
405	}
406
407	function setNagLocCookie(postId) {
408		if (!postId) { return; }
409		var expires = new Date(Date.now() + 30 * 60 * 1000).toUTCString();
410		document.cookie = 'ppaccess_nag_loc=' + encodeURIComponent(postId) + '; expires=' + expires + '; path=/; SameSite=Lax';
411	}
412
413	function clearNagLocCookie() {
414		document.cookie = 'ppaccess_nag_loc=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';
415	}
416
417	function init() {
418		var postId = cfg.post_id || getPostIdFromBody();
419		if (!postId) { return; }
420		resolve(postId);
421	}
422
423	if (document.readyState === 'loading') {
424		document.addEventListener('DOMContentLoaded', init);
425	} else {
426		init();
427	}
428})();

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.