@privacyscrubber/sdk 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/shared-ui.js ADDED
@@ -0,0 +1,533 @@
1
+ import '../scripts/ps-license-manager.js';
2
+ /**
3
+ * PrivacyScrubber Global UI & Licensing Module
4
+ * Source of truth for user tier status across the entire platform.
5
+ */
6
+
7
+ import { openModal, closeModal, hydrateLazyModal } from './ui-modals.js';
8
+
9
+ export const UI_STATE = {
10
+ isPro: false,
11
+ isTeam: false,
12
+ tier: 'FREE'
13
+ };
14
+
15
+ /**
16
+ * ZTDS License Generation Logic (v1.4.2 Hardened)
17
+ * Generates a cryptographically verified key with a salted position-aware checksum.
18
+ * Exclusively used by app.js for local auto-activation and ztds-keygen-ops.html.
19
+ */
20
+ export const generateLicenseKey = (tier = "PRO") => {
21
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Avoid ambiguous O/0/I/1
22
+ let core = '';
23
+ const cryptoObj = window.crypto || window.msCrypto;
24
+ const randomValues = new Uint32Array(8);
25
+ cryptoObj.getRandomValues(randomValues);
26
+
27
+ for (let i = 0; i < 8; i++) {
28
+ core += chars.charAt(randomValues[i] % chars.length);
29
+ }
30
+
31
+ const salt = "ZTDS_SALT_2026_!@#";
32
+ const input = core + salt + tier;
33
+ let sum = 0;
34
+ // Position-aware weighted checksum
35
+ for (let i = 0; i < input.length; i++) {
36
+ sum += input.charCodeAt(i) * (i + 1);
37
+ }
38
+
39
+ const checksum = (sum % 9999).toString().padStart(4, '0');
40
+ return `PS-${tier}-${core}-${checksum}`;
41
+ };
42
+
43
+ /**
44
+ * Standard license validator used site-wide.
45
+ * Hardened in v1.4.2 to use salted, weighted checksums.
46
+ */
47
+ export const validateLicenseKey = (key, mockTime = null) => {
48
+ if (typeof globalThis.LicenseManager === 'undefined') {
49
+ console.error('[PrivacyScrubber] LicenseManager not loaded!');
50
+ return null;
51
+ }
52
+
53
+ let result = globalThis.LicenseManager.validate(key, {
54
+ mockTime: mockTime,
55
+ storage: typeof localStorage !== 'undefined' ? localStorage : null,
56
+ onAnomaly: () => {
57
+ if (typeof window.PrivacyScrubberUI?.showStatus === 'function') {
58
+ window.PrivacyScrubberUI.showStatus("System clock anomaly detected. License locked.", "error", 10000);
59
+ }
60
+ },
61
+ onExpired: (type) => {
62
+ if (typeof window.PrivacyScrubberUI?.showStatus === 'function' && mockTime === null) {
63
+ window.PrivacyScrubberUI.showStatus("Your License/Pilot has expired.", "error", 10000);
64
+ }
65
+ }
66
+ });
67
+
68
+ return result.valid ? result.tier : null;
69
+ };
70
+
71
+ /**
72
+ * Synchronizes navigation buttons and site-wide badges with current license state.
73
+ * Targets all platform navigation variations (Desktop, Mobile, Drawer).
74
+ */
75
+ export const updateGlobalTierUI = () => {
76
+ const btnConfigs = [
77
+ { id: 'psNavProBtn', tSuffix: '1', proText: 'Dashboard', teamText: 'Dashboard' },
78
+ { id: 'psNavProBtnMobile', tSuffix: 'Mobile', proText: 'Dashboard', teamText: 'Dashboard' },
79
+ { id: 'psNavProBtnDrawer', tSuffix: 'Drawer', proText: 'Dashboard', teamText: 'Dashboard' }
80
+ ];
81
+
82
+ const isSdk = UI_STATE.isSdk || !!localStorage.getItem('ps_sdk_key') || localStorage.getItem('ps_license_type') === 'SDK';
83
+ const isTeam = isSdk || UI_STATE.isTeam || !!localStorage.getItem('ps_team_key');
84
+ const isPro = UI_STATE.isPro || isTeam || isSdk || !!localStorage.getItem('ps_pro_key') || !!localStorage.getItem('ps_pro_sub');
85
+
86
+ btnConfigs.forEach(cfg => {
87
+ const btn = document.getElementById(cfg.id);
88
+ if (!btn) return;
89
+
90
+ if (isTeam || isPro) {
91
+ // Emerald/Teal Theme for Active Licenses (except desktop link)
92
+ if (cfg.id === 'psNavProBtn') {
93
+ btn.style.background = 'transparent';
94
+ btn.style.color = isSdk ? '#06b6d4' : (isTeam ? '#10b981' : '#3b82f6');
95
+ } else {
96
+ btn.style.background = isSdk ? 'linear-gradient(to right, #06b6d4, #3b82f6)' : (isTeam ? 'linear-gradient(to right, #10b981, #14b8a6)' : 'linear-gradient(to right, #10b981, #3b82f6)');
97
+ }
98
+ btn.classList.add('ps-pro-active-theme');
99
+
100
+
101
+ const dot = document.getElementById('psLiveDot' + cfg.tSuffix);
102
+ const icon = document.getElementById('psProIcon' + cfg.tSuffix);
103
+ const text = document.getElementById('psProText' + cfg.tSuffix);
104
+
105
+ if (dot) dot.style.display = 'none';
106
+ if (icon) {
107
+ icon.style.display = 'inline-block';
108
+ icon.className = '';
109
+ icon.innerHTML = isSdk ? '<svg class="w-4 h-4 mr-1.5 inline-block"><use href="#icon-code"></use></svg>' : (isTeam ? '<svg class="w-4 h-4 mr-1.5 inline-block"><use href="#icon-users"></use></svg>' : '<svg class="w-4 h-4 mr-1.5 inline-block"><use href="#icon-crown"></use></svg>');
110
+ }
111
+ if (text) text.innerText = isSdk ? 'SDK' : (isTeam ? cfg.teamText : cfg.proText);
112
+ }
113
+ });
114
+
115
+ const toolTierBadge = document.getElementById('tool-tier-badge');
116
+ if (toolTierBadge) {
117
+ if (isSdk) {
118
+ toolTierBadge.textContent = 'SDK';
119
+ toolTierBadge.className = 'px-2 py-0.5 rounded text-tiny font-black bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/30 uppercase tracking-tighter';
120
+ } else if (isTeam) {
121
+ toolTierBadge.textContent = 'TEAM';
122
+ toolTierBadge.className = 'px-2 py-0.5 rounded text-tiny font-black bg-neon-purple/20 text-neon-purple border border-neon-purple/30 uppercase tracking-tighter';
123
+ } else if (isPro) {
124
+ toolTierBadge.textContent = 'PRO';
125
+ toolTierBadge.className = 'px-2 py-0.5 rounded text-tiny font-black bg-neon-blue/20 text-neon-blue border border-neon-blue/30 uppercase tracking-tighter';
126
+ }
127
+ }
128
+ };
129
+
130
+ /**
131
+ * Calculates SHA-256 hash using native Web Crypto API.
132
+ */
133
+ const getSHA256 = async (text) => {
134
+ if (!text) return '';
135
+ try {
136
+ const msgBuffer = new TextEncoder().encode(text.trim());
137
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
138
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
139
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
140
+ } catch (e) {
141
+ console.error('[PrivacyScrubber] SHA-256 hashing failed:', e);
142
+ return '';
143
+ }
144
+ };
145
+
146
+ /**
147
+ * Checks all active license keys against the Key Revocation List (KRL).
148
+ * Runs asynchronously in the background.
149
+ */
150
+ const checkKeyRevocation = async () => {
151
+ const keys = [];
152
+ ['ps_pro_key', 'ps_pro_sub', 'ps_team_key'].forEach(k => {
153
+ const val = localStorage.getItem(k);
154
+ if (val) keys.push({ key: k, val: val.trim() });
155
+ });
156
+ if (keys.length === 0) return;
157
+
158
+ // Silent key upgrade for legacy formats
159
+ if (typeof navigator !== 'undefined' && navigator.onLine) {
160
+ for (const item of keys) {
161
+ const val = item.val.toUpperCase();
162
+ if (!val.startsWith('PS-RSA-') && !val.startsWith('PS-PILOT-')) {
163
+ try {
164
+ const upgradeUrl = window.location.origin + '/api/upgrade-key';
165
+ const resUpgrade = await fetch(upgradeUrl, {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({ oldKey: item.val })
169
+ });
170
+ if (resUpgrade.ok) {
171
+ const dataUpgrade = await resUpgrade.json();
172
+ if (dataUpgrade && dataUpgrade.success && dataUpgrade.key) {
173
+ localStorage.setItem(item.key, dataUpgrade.key);
174
+ setTimeout(() => {
175
+ initSharedUI();
176
+ }, 50);
177
+ return; // Stop current check, re-triggered by initSharedUI()
178
+ }
179
+ }
180
+ } catch (e) {
181
+ console.warn('[PrivacyScrubber] Failed to silently upgrade key:', e.message);
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ let revokedHashes = [];
188
+ const now = Date.now();
189
+ const lastFetch = parseInt(localStorage.getItem('ps_krl_last_fetch') || '0', 10);
190
+ const cachedKRL = localStorage.getItem('ps_krl_cache');
191
+
192
+ if (cachedKRL) {
193
+ try {
194
+ revokedHashes = JSON.parse(cachedKRL);
195
+ } catch (e) {}
196
+ }
197
+
198
+ // Refresh KRL once every 24 hours if online
199
+ if ((now - lastFetch > 86400000 || !cachedKRL) && typeof navigator !== 'undefined' && navigator.onLine) {
200
+ try {
201
+ const url = window.location.origin + '/revoked-keys.json';
202
+ const response = await fetch(url);
203
+ if (response.ok) {
204
+ const data = await response.json();
205
+ if (data && Array.isArray(data.revoked)) {
206
+ revokedHashes = data.revoked;
207
+ localStorage.setItem('ps_krl_cache', JSON.stringify(data.revoked));
208
+ localStorage.setItem('ps_krl_last_fetch', now.toString());
209
+ }
210
+ }
211
+ } catch (err) {
212
+ console.warn('[PrivacyScrubber] Failed to refresh revocation list:', err.message);
213
+ }
214
+ }
215
+
216
+ let revokedAny = false;
217
+ for (const item of keys) {
218
+ const hash = await getSHA256(item.val);
219
+ if (hash && revokedHashes.includes(hash)) {
220
+ localStorage.removeItem(item.key);
221
+ revokedAny = true;
222
+ }
223
+ }
224
+
225
+ if (revokedAny) {
226
+ UI_STATE.isPro = false;
227
+ UI_STATE.isTeam = false;
228
+ UI_STATE.tier = 'FREE';
229
+ window.isPro = false;
230
+ window.isTeam = false;
231
+ updateGlobalTierUI();
232
+ alert('⚠️ [PrivacyScrubber] Your license key has been deactivated. Please contact support@privacyscrubber.com if you believe this is an error.');
233
+ }
234
+ };
235
+
236
+ /**
237
+ * Bootstraps the UI state from storage and environment.
238
+ */
239
+ export const initSharedUI = () => {
240
+ // 1. Resolve Initial State from Storage
241
+ const proKey = localStorage.getItem('ps_pro_key');
242
+ const proSub = localStorage.getItem('ps_pro_sub');
243
+ const teamKey = localStorage.getItem('ps_team_key');
244
+
245
+ // Purge legacy insecure flags (v1.4.2 Cleanup)
246
+ ['isPro', 'ps_pro_v1', 'ps_pro_lifetime', 'ps_team_v1'].forEach(f => {
247
+ if (localStorage.getItem(f)) localStorage.removeItem(f);
248
+ });
249
+
250
+ let pTier = validateLicenseKey(proKey) || validateLicenseKey(proSub);
251
+ let tTier = validateLicenseKey(teamKey);
252
+
253
+ // 2. Update Source of Truth (UI_STATE)
254
+ const hasTeamKey = !!teamKey;
255
+ const hasProKey = !!proKey || !!proSub;
256
+ UI_STATE.isTeam = (tTier === 'TEAMS' || pTier === 'TEAMS' || hasTeamKey);
257
+ UI_STATE.isPro = (UI_STATE.isTeam || !!pTier || hasProKey);
258
+
259
+ UI_STATE.tier = UI_STATE.isTeam ? 'TEAMS' : (UI_STATE.isPro ? 'PRO' : 'FREE');
260
+
261
+ // Sync to legacy window globals
262
+ window.isPro = UI_STATE.isPro;
263
+ window.isTeam = UI_STATE.isTeam;
264
+
265
+ // 2. Override from URL (Query or Hash)
266
+ const urlParams = new URLSearchParams(window.location.search);
267
+ let keyParam = urlParams.get('key');
268
+ if (!keyParam && window.location.hash) {
269
+ const hashStr = window.location.hash.substring(1);
270
+ const match = hashStr.match(/(?:^|&)key=([^&]+)/);
271
+ if (match) keyParam = decodeURIComponent(match[1]);
272
+ }
273
+ if (keyParam) {
274
+ let tier = validateLicenseKey(keyParam);
275
+ if (/^(I-|sub_|txn_|chk_)/.test(keyParam)) {
276
+ tier = 'TEAMS';
277
+ }
278
+ if (tier === 'TEAMS') {
279
+ UI_STATE.isTeam = true;
280
+ UI_STATE.isPro = true;
281
+ localStorage.setItem('ps_team_key', keyParam.trim());
282
+ } else if (tier === 'PRO') {
283
+ UI_STATE.isPro = true;
284
+ localStorage.setItem('ps_pro_key', keyParam.trim());
285
+ }
286
+ // Clear the key from URL to prevent history/proxy leaks
287
+ if (urlParams.has('key')) {
288
+ urlParams.delete('key');
289
+ const newSearch = urlParams.toString() ? '?' + urlParams.toString() : '';
290
+ window.history.replaceState({}, '', window.location.pathname + newSearch + window.location.hash);
291
+ } else if (window.location.hash.includes('key=')) {
292
+ const cleanHash = window.location.hash.replace(/#?key=[^&]+&?/, '').replace(/&$/, '');
293
+ window.history.replaceState({}, '', window.location.pathname + window.location.search + (cleanHash ? '#' + cleanHash : ''));
294
+ }
295
+
296
+ // Update globals again
297
+ window.isPro = UI_STATE.isPro;
298
+ window.isTeam = UI_STATE.isTeam;
299
+
300
+ setTimeout(() => {
301
+ if (typeof window.showStatus === 'function') {
302
+ window.showStatus(`🛡️ ${tier === 'TEAMS' ? 'TEAMS Governance' : 'PRO Edition'} License Activated!`, 'success');
303
+ }
304
+ if ((tier === 'TEAMS' || tier === 'PRO') && typeof window.openTeamModal === 'function') {
305
+ window.openTeamModal(true);
306
+ }
307
+ }, 400);
308
+ }
309
+
310
+ updateGlobalTierUI();
311
+
312
+ if (typeof window !== 'undefined') {
313
+ const handleUrlTriggers = () => {
314
+ const hash = window.location.hash.toLowerCase();
315
+ const search = window.location.search.toLowerCase();
316
+
317
+ if (hash === '#teams' || search.includes('tier=teams') || search.includes('action=teams')) {
318
+ setTimeout(() => {
319
+ if (typeof window.openTeamsModal === 'function') window.openTeamsModal();
320
+ else if (typeof window.openTierModal === 'function') window.openTierModal('TEAMS');
321
+ }, 200);
322
+ } else if (hash === '#pro' || search.includes('tier=pro') || search.includes('action=pro')) {
323
+ setTimeout(() => {
324
+ if (typeof window.openTierModal === 'function') window.openTierModal('PRO');
325
+ }, 200);
326
+ } else if (hash === '#dashboard' || search.includes('dashboard=1') || search.includes('action=dashboard')) {
327
+ setTimeout(() => {
328
+ if (UI_STATE.isTeam || UI_STATE.isPro) {
329
+ if (typeof window.openTeamModal === 'function') window.openTeamModal(true);
330
+ } else {
331
+ if (typeof window.openTierModal === 'function') window.openTierModal('TEAMS');
332
+ }
333
+ }, 200);
334
+ } else if (hash === '#ai') {
335
+ setTimeout(() => {
336
+ const tabAI = document.getElementById('modeTabAI');
337
+ if (tabAI) {
338
+ tabAI.click();
339
+ window.scrollTo({ top: 0, behavior: 'smooth' });
340
+ }
341
+ }, 100);
342
+ } else if (hash === '#doc') {
343
+ setTimeout(() => {
344
+ const tabDoc = document.getElementById('modeTabDoc');
345
+ if (tabDoc) {
346
+ tabDoc.click();
347
+ window.scrollTo({ top: 0, behavior: 'smooth' });
348
+ }
349
+ }, 100);
350
+ }
351
+ };
352
+
353
+ handleUrlTriggers();
354
+ window.addEventListener('hashchange', handleUrlTriggers);
355
+ }
356
+ if (typeof window !== 'undefined' && window.crypto && window.crypto.subtle) {
357
+ checkKeyRevocation().catch(err => console.error('[PrivacyScrubber] Revocation check error:', err));
358
+ }
359
+
360
+ // Expose refreshAppState for PayPal post-payment re-init
361
+ window.refreshAppState = () => {
362
+ initSharedUI();
363
+ };
364
+ };
365
+
366
+ // Expose to window for legacy scripts (app.js, nav.js)
367
+ if (typeof window !== 'undefined') {
368
+ window.PrivacyScrubberUI = {
369
+ UI_STATE,
370
+ validateLicenseKey,
371
+ generateLicenseKey,
372
+ updateGlobalTierUI,
373
+ initSharedUI,
374
+ openModal,
375
+ closeModal,
376
+ hydrateLazyModal
377
+ };
378
+
379
+ // Expose generation directly to window for app.js integration
380
+ window.generateLicenseKey = generateLicenseKey;
381
+ window.validateLicenseKey = validateLicenseKey;
382
+
383
+ // Auto-init on load
384
+ if (document.readyState === 'loading') {
385
+ document.addEventListener('DOMContentLoaded', initSharedUI);
386
+ } else {
387
+ initSharedUI();
388
+ }
389
+
390
+ // Global Wrappers for Legacy/External Scripts
391
+ window.openTierModal = (tier) => {
392
+ window.PrivacyScrubberUI.openModal('tierModal', 'tierModalContent');
393
+ if (tier && typeof window.switchTierView === 'function') {
394
+ window.switchTierView('checkout', tier);
395
+ }
396
+ };
397
+
398
+ window.closeTierModal = () => {
399
+ window.PrivacyScrubberUI.closeModal('tierModal', 'tierModalContent');
400
+ const banner = document.getElementById('checkoutContextBanner');
401
+ if (banner) banner.classList.add('hidden');
402
+ };
403
+
404
+ window.applyModalContext = (context = {}) => {
405
+ const banner = document.getElementById('checkoutContextBanner');
406
+ const bannerTitle = document.getElementById('contextBannerTitle');
407
+ const bannerSubtitle = document.getElementById('contextBannerSubtitle');
408
+ const bannerIcon = document.getElementById('contextBannerIcon');
409
+ const title = document.getElementById('checkoutTitle');
410
+ const subtitle = document.getElementById('checkoutSubtitle');
411
+ const mTitle = document.getElementById('checkoutMobileTitle');
412
+
413
+ if (!context || !context.type) {
414
+ if (banner) banner.classList.add('hidden');
415
+ return;
416
+ }
417
+
418
+ if (banner) banner.classList.remove('hidden');
419
+
420
+ if (context.type === 'profile') {
421
+ const prof = (window.INDUSTRY_PROFILES || []).find(p => p.id === context.profileId) || { label: context.profileId || 'Specialized Profile', title: 'Custom PII pattern protection.' };
422
+ if (banner) {
423
+ banner.className = 'mb-6 p-4 rounded-xl border border-amber-500/30 bg-amber-500/10 backdrop-blur-md';
424
+ }
425
+ if (bannerIcon) {
426
+ bannerIcon.className = 'w-8 h-8 rounded-lg bg-amber-500/20 text-amber-400 flex items-center justify-center shrink-0';
427
+ bannerIcon.innerHTML = '<i class="fa-solid fa-shield-halved text-sm"></i>';
428
+ }
429
+ if (bannerTitle) bannerTitle.textContent = `${prof.label} — 5,000 Chars Quota Limit`;
430
+ if (bannerSubtitle) bannerSubtitle.textContent = `Free 24-hour trial quota for ${prof.label} reached. Upgrade to PRO for unlimited redaction.`;
431
+ if (title) title.textContent = `${prof.label.toUpperCase()} UNLOCK`;
432
+ if (subtitle) subtitle.textContent = `Unlimited ${prof.label} Processing`;
433
+ if (mTitle) mTitle.textContent = `${prof.label.toUpperCase()} UNLOCK`;
434
+ } else if (context.type === 'audit_receipt') {
435
+ if (banner) {
436
+ banner.className = 'mb-6 p-4 rounded-xl border border-blue-500/30 bg-blue-500/10 backdrop-blur-md';
437
+ }
438
+ if (bannerIcon) {
439
+ bannerIcon.className = 'w-8 h-8 rounded-lg bg-blue-500/20 text-neon-blue flex items-center justify-center shrink-0';
440
+ bannerIcon.innerHTML = '<i class="fa-solid fa-file-shield text-sm"></i>';
441
+ }
442
+ if (bannerTitle) bannerTitle.textContent = 'Trial Audit Receipt Generated';
443
+ if (bannerSubtitle) bannerSubtitle.textContent = 'Remove the "TRIAL / UNVERIFIED" watermark and activate tamper-evident SHA-256 compliance proofs.';
444
+ if (title) title.textContent = 'CISO AUDIT CERTIFICATE UNLOCK';
445
+ if (subtitle) subtitle.textContent = 'Official Compliance Proofs (SOC 2, GDPR, HIPAA)';
446
+ if (mTitle) mTitle.textContent = 'CISO AUDIT UNLOCK';
447
+ } else if (context.type === 'bulk_files') {
448
+ const count = context.fileCount || 'Batch';
449
+ if (banner) {
450
+ banner.className = 'mb-6 p-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 backdrop-blur-md';
451
+ }
452
+ if (bannerIcon) {
453
+ bannerIcon.className = 'w-8 h-8 rounded-lg bg-emerald-500/20 text-neon-green flex items-center justify-center shrink-0';
454
+ bannerIcon.innerHTML = '<i class="fa-solid fa-layer-group text-sm"></i>';
455
+ }
456
+ if (bannerTitle) bannerTitle.textContent = `${count} Files Detected · 1 File Free Limit`;
457
+ if (bannerSubtitle) bannerSubtitle.textContent = 'Sanitize 50+ batch documents, Excel spreadsheets, and scanned PDFs simultaneously in local RAM.';
458
+ if (title) title.textContent = 'BULK & OFFLINE OCR UNLOCK';
459
+ if (subtitle) subtitle.textContent = 'Unlimited Batch & OCR Processing';
460
+ if (mTitle) mTitle.textContent = 'BULK & OCR UNLOCK';
461
+ } else if (context.type === 'teams_handoff') {
462
+ if (banner) {
463
+ banner.className = 'mb-6 p-4 rounded-xl border border-purple-500/30 bg-purple-500/10 backdrop-blur-md';
464
+ }
465
+ if (bannerIcon) {
466
+ bannerIcon.className = 'w-8 h-8 rounded-lg bg-purple-500/20 text-neon-purple flex items-center justify-center shrink-0';
467
+ bannerIcon.innerHTML = '<i class="fa-solid fa-users text-sm"></i>';
468
+ }
469
+ if (bannerTitle) bannerTitle.textContent = 'Teams Cryptographic Session Handoff';
470
+ if (bannerSubtitle) bannerSubtitle.textContent = 'Zero-server session handoff via XChaCha20-Poly1305 + centralized regex blueprints for your team.';
471
+ if (title) title.textContent = 'TEAMS HUB UNLOCK';
472
+ if (subtitle) subtitle.textContent = 'Unlimited Seats · $99/mo Flat Rate';
473
+ if (mTitle) mTitle.textContent = 'TEAMS HUB UNLOCK';
474
+ }
475
+ };
476
+
477
+ window.openContextualUpgradeModal = (context = {}) => {
478
+ const tier = (context && context.type === 'teams_handoff') ? 'TEAMS' : 'PRO';
479
+ window.openTierModal(tier);
480
+ setTimeout(() => {
481
+ if (typeof window.applyModalContext === 'function') {
482
+ window.applyModalContext(context);
483
+ }
484
+ }, 50);
485
+ };
486
+
487
+ /* ── v1.5.3 Performance: Lightweight tab visibility pause (no scroll thrashing) ── */
488
+ if (typeof window !== 'undefined') {
489
+ const CONTINUOUS_ANIM_SELECTOR = '.animate-pulse,.animate-ping,.animate-float-slow,.animate-handoff-flow,.border-beam,.demo-fo,.demo-fr,.btn-shine,#psContactFab';
490
+
491
+ // Visibility change (pause heavy continuous animations only when tab is backgrounded)
492
+ document.addEventListener('visibilitychange', () => {
493
+ if (document.hidden) {
494
+ document.querySelectorAll(CONTINUOUS_ANIM_SELECTOR).forEach(el => {
495
+ el.dataset.psPausedTab = '1';
496
+ el.style.animationPlayState = 'paused';
497
+ });
498
+ } else {
499
+ document.querySelectorAll('[data-ps-paused-tab]').forEach(el => {
500
+ el.style.animationPlayState = '';
501
+ delete el.dataset.psPausedTab;
502
+ });
503
+ }
504
+ }, { passive: true });
505
+
506
+ // ── Airplane Mode / Offline Test Challenge Modal ──
507
+ window.showOfflineModal = function() {
508
+ const m = document.getElementById('offlineModal');
509
+ const c = document.getElementById('offlineModalCard');
510
+ if (!m || !c) return;
511
+ m.classList.remove('hidden');
512
+ m.classList.add('flex');
513
+ void c.offsetWidth;
514
+ requestAnimationFrame(() => {
515
+ c.classList.remove('scale-90', 'opacity-0');
516
+ c.classList.add('scale-100', 'opacity-100');
517
+ });
518
+ };
519
+ window.hideOfflineModal = function() {
520
+ window.offlineChallengeActive = true;
521
+ const m = document.getElementById('offlineModal');
522
+ const c = document.getElementById('offlineModalCard');
523
+ if (!m || !c) return;
524
+ c.classList.remove('scale-100', 'opacity-100');
525
+ c.classList.add('scale-90', 'opacity-0');
526
+ setTimeout(() => {
527
+ m.classList.remove('flex');
528
+ m.classList.add('hidden');
529
+ }, 300);
530
+ };
531
+ }
532
+ }
533
+