PageSourceSearch

https://wheelofitem.com/js/retention.js?v=20260928-signin

js wheelofitem.com collected 2026-10-08 13:52:36 UTC 18,682 bytes, 256 lines download raw bytes

1// retention.js — the free-save funnel: sign-in gate modal, "don't lose it" banner,
2// slot meter, and local->cloud migration toasts. Shared by index / gachapon /
3// claw-machine / photo-booth across all four locales.
4//
5// 2026-08-20: cloud saves went FREE (3 slots) on gacha / claw / photo booth — they were
6// Pro-only, which left free users with nothing stored and nothing to come back for
7// (93 accounts in 3 months). This file turns a signed-out "save" click into an account
8// instead of a dead-end toast, and tells device-bound users their setup can be kept.
9//
10// Every entry point is a no-op on OBS / embed / preview surfaces.
11(function () {
12  'use strict';
13  var Q = new URLSearchParams(location.search);
14  var MUTED = Q.get('obs') === '1' || Q.get('obs') === 'true'
15           || Q.get('embed') === '1' || Q.get('preview') === '1';
16  var LANG = (location.pathname.match(/^\/(ja|th|ko)\//) || [0, 'en'])[1];
17
18  var STR = {
19    en: {
20      nounwheel: 'wheel', noungacha: 'gachapon', nounclaw: 'claw machine', nounphotobooth: 'photo booth', nounthankyou: 'thank-you card',
21      gateTitle: 'Save it — free',
22      gateSub: 'Sign in with Google to keep this {noun} in your account.',
23      b1: '3 saved slots, free forever', b2: 'Opens on any device — nothing dies with this browser',
24      b3: 'Ready for OBS & donation triggers later',
25      googleBtn: 'Continue with Google', noCard: 'Free · no card needed', gateSuccess: 'Signed in ✓',
26      bnwheel: "This wheel isn't saved anywhere yet — sign in free to keep it.",
27      bn: 'Your {noun} lives only on this device — sign in free so it can’t be lost.',
28      bnBtn: 'Sign in free',
29      migrated: '☁️ Found the {noun} you set up on this device — saved to your account.',
30      meterFree: '{n}/3 saved · Pro = unlimited', meterFull: '3/3 full — Pro = unlimited', meterPro: '{n} saved',
31      boothPrompt: '{n} photos live only on this device — save your booth to keep them.', boothPromptBtn: 'Save booth'
32    },
33    th: {
34      nounwheel: 'วงล้อ', noungacha: 'ตู้กาชา', nounclaw: 'ตู้คีบ', nounphotobooth: 'ตู้ถ่ายรูป', nounthankyou: 'การ์ดขอบคุณ',
35      gateTitle: 'เซฟเก็บไว้ — ฟรี',
36      gateSub: 'Sign in ด้วย Google เพื่อเก็บ{noun}นี้ไว้ในบัญชีของคุณ',
37      b1: 'เซฟได้ 3 ช่อง ฟรีตลอด', b2: 'เปิดได้ทุกเครื่อง ไม่หายไปกับเบราว์เซอร์',
38      b3: 'พร้อมต่อ OBS และโดเนทได้ทีหลัง',
39      googleBtn: 'ดำเนินการต่อด้วย Google', noCard: 'ฟรี · ไม่ต้องใช้บัตร', gateSuccess: 'เข้าสู่ระบบแล้ว ✓',
40      bnwheel: 'วงล้อนี้ยังไม่ได้เซฟไว้ที่ไหนเลย — sign in ฟรีเพื่อเก็บไว้',
41      bn: '{noun}ของคุณอยู่แค่ในเครื่องนี้ — sign in ฟรี กันไม่ให้หาย',
42      bnBtn: 'Sign in ฟรี',
43      migrated: '☁️ เจอ{noun}ที่ตั้งไว้ในเครื่องนี้ — เซฟเข้าบัญชีให้แล้ว',
44      meterFree: 'เซฟแล้ว {n}/3 · Pro ไม่จำกัด', meterFull: 'ครบ 3/3 — Pro ไม่จำกัด', meterPro: 'เซฟแล้ว {n}',
45      boothPrompt: 'มีรูป {n} รูปอยู่แค่ในเครื่องนี้ — เซฟตู้เพื่อเก็บรูปไว้', boothPromptBtn: 'เซฟตู้'
46    },
47    ja: {
48      nounwheel: 'ホイール', noungacha: 'ガチャ', nounclaw: 'クレーンゲーム', nounphotobooth: 'フォトブース', nounthankyou: 'サンクスカード',
49      gateTitle: '無料で保存',
50      gateSub: 'Googleでサインインして、この{noun}をアカウントに保存しましょう',
51      b1: '無料で3枠まで保存', b2: 'どの端末でも開ける — ブラウザと一緒に消えない',
52      b3: 'OBS・投げ銭連携はあとから設定可能',
53      googleBtn: 'Googleで続行', noCard: '無料 · カード不要', gateSuccess: 'サインインしました ✓',
54      bnwheel: 'このホイールはまだ保存されていません — 無料サインインで保存できます',
55      bn: 'この{noun}はこの端末にしかありません — 無料サインインで消えないように',
56      bnBtn: '無料でサインイン',
57      migrated: '☁️ この端末の{noun}を見つけました — アカウントに保存済み',
58      meterFree: '保存 {n}/3 · Proは無制限', meterFull: '3/3 いっぱい — Proは無制限', meterPro: '保存 {n}',
59      boothPrompt: '写真{n}枚がこの端末のみに — ブースを保存して残しましょう', boothPromptBtn: 'ブースを保存'
60    },
61    ko: {
62      nounwheel: '룰렛', noungacha: '가챠', nounclaw: '인형뽑기', nounphotobooth: '포토부스', nounthankyou: '감사 카드',
63      gateTitle: '무료로 저장',
64      gateSub: 'Google로 로그인하고 이 {noun}을(를) 계정에 저장하세요',
65      b1: '무료로 3개까지 저장', b2: '어느 기기에서나 열림 — 브라우저와 함께 사라지지 않음',
66      b3: 'OBS·후원 연동은 나중에 설정 가능',
67      googleBtn: 'Google로 계속하기', noCard: '무료 · 카드 불필요', gateSuccess: '로그인 완료 ✓',
68      bnwheel: '이 룰렛은 아직 저장되지 않았습니다 — 무료 로그인으로 보관하세요',
69      bn: '{noun}이(가) 이 기기에만 있습니다 — 무료 로그인으로 잃어버리지 않게 하세요',
70      bnBtn: '무료 로그인',
71      migrated: '☁️ 이 기기에 설정된 {noun}을(를) 찾았습니다 — 계정에 저장했습니다',
72      meterFree: '저장 {n}/3 · Pro 무제한', meterFull: '3/3 가득 참 — Pro 무제한', meterPro: '저장 {n}',
73      boothPrompt: '사진 {n}장이 이 기기에만 있습니다 — 부스를 저장해 보관하세요', boothPromptBtn: '부스 저장'
74    }
75  };
76  function S(k) { return (STR[LANG] && STR[LANG][k]) || STR.en[k] || k; }
77  function fmt(k, m) {
78    var t = S(k);
79    Object.keys(m || {}).forEach(function (x) { t = t.split('{' + x + '}').join(m[x]); });
80    return t;
81  }
82  function noun(p) { return S('noun' + p); }
83  function track(ev, params) { try { if (window._track) window._track(ev, params || {}); } catch (e) {} }
84  function signedIn() { try { return !!(window.Cloud && Cloud.currentUser); } catch (e) { return false; } }
85  function getFlag(k) { try { return localStorage.getItem('woi_ret_' + k) === '1'; } catch (e) { return true; } }
86  function setFlag(k) { try { localStorage.setItem('woi_ret_' + k, '1'); } catch (e) {} }
87
88  var cssDone = false;
89  function ensureCss() {
90    if (cssDone) return; cssDone = true;
91    var st = document.createElement('style');
92    st.textContent =
93      '#woiretOv{position:fixed;inset:0;background:rgba(30,10,22,.55);backdrop-filter:blur(3px);z-index:100000;display:flex;align-items:center;justify-content:center;padding:18px}' +
94      '#woiretOv .wr-card{background:#fff;border-radius:20px;max-width:400px;width:100%;padding:28px 26px 22px;text-align:center;box-shadow:0 24px 70px rgba(0,0,0,.35);font-family:inherit;position:relative}' +
95      '#woiretOv .wr-x{position:absolute;top:10px;right:12px;border:0;background:none;font-size:20px;color:#b895a8;cursor:pointer;line-height:1;padding:6px}' +
96      '#woiretOv .wr-emoji{font-size:38px;line-height:1;margin-bottom:8px}' +
97      '#woiretOv h3{margin:0 0 6px;font-size:20px;color:#4a2338}' +
98      '#woiretOv .wr-sub{font-size:14px;color:#8a6478;margin:0 0 14px;line-height:1.45}' +
99      '#woiretOv ul{list-style:none;margin:0 0 16px;padding:0;text-align:left;display:inline-block}' +
100      '#woiretOv li{font-size:13.5px;color:#5d3a4e;margin:5px 0}' +
101      '#woiretOv li::before{content:"\\2713";color:#2fae6b;font-weight:900;margin-right:8px}' +
102      '#woiretOv .wr-g{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;padding:12px 16px;border:1px solid #dcd0d8;border-radius:999px;background:#fff;font-size:15px;font-weight:800;color:#3c4043;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.08);font-family:inherit}' +
103      '#woiretOv .wr-g:hover{background:#f8f5f7}' +
104      '#woiretOv .wr-note{font-size:12px;color:#a98a9c;margin-top:10px}' +
105      '#woiretBn{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);z-index:99990;background:linear-gradient(120deg,#fff,#fff6fa);border:1.5px solid #f0b9dc;border-radius:16px;box-shadow:0 10px 34px rgba(160,60,110,.25);padding:12px 16px;display:flex;gap:12px;align-items:center;max-width:min(94vw,560px);font-family:inherit}' +
106      '#woiretBn .wb-t{font-size:13.5px;color:#5d3a4e;line-height:1.4;text-align:left}' +
107      '#woiretBn .wb-b{flex:none;background:linear-gradient(120deg,#e05080,#ff7ba6);color:#fff;border:0;border-radius:999px;padding:9px 16px;font-size:13px;font-weight:900;cursor:pointer;white-space:nowrap;font-family:inherit}' +
108      '#woiretBn .wb-x{flex:none;border:0;background:none;color:#b895a8;font-size:18px;
108cursor:pointer;padding:4px;min-width:40px;min-height:40px}' +
109      '.woiret-meter{display:inline-block;margin:6px 0 0;padding:3px 11px;border-radius:999px;font-size:11.5px;font-weight:800;background:#f6ecf2;color:#8a6478;border:1px solid #eddce6}' +
110      '.woiret-meter.full{background:#fff1e4;color:#b25a00;border-color:#f4cba0;cursor:pointer}' +
111      '#woiretToast{position:fixed;left:50%;top:18px;transform:translateX(-50%);z-index:100001;background:rgba(40,16,30,.94);color:#fff;padding:11px 20px;border-radius:999px;font-size:14px;font-weight:700;box-shadow:0 8px 26px rgba(0,0,0,.35);max-width:92vw}';
112    document.head.appendChild(st);
113  }
114
115  function toast(msg, ms) {
116    ensureCss();
117    var t = document.getElementById('woiretToast');
118    if (!t) { t = document.createElement('div'); t.id = 'woiretToast'; document.body.appendChild(t); }
119    t.textContent = msg; t.style.display = 'block';
120    clearTimeout(t._to); t._to = setTimeout(function () { t.style.display = 'none'; }, ms || 4200);
121  }
122
123  var G_SVG = '<svg width="19" height="19" viewBox="0 0 48 48" aria-hidden="true"><path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.7 32.7 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3l5.7-5.7C34.3 6.1 29.4 4 24 4 13 4 4 13 4 24s9 20 20 20 20-9 20-20c0-1.3-.1-2.6-.4-3.9z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.9 1.2 8 3l5.7-5.7C34.3 6.1 29.4 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-8l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.3-2.3 4.3-4.1 5.7l6.2 5.2C36.9 40.4 44 35 44 24c0-1.3-.1-2.6-.4-3.9z"/></svg>';
124  var EMOJI = { wheel: '🎡', gacha: '🎰', claw: '🧸', photobooth: '📸' };
125
126  function closeGate() { var ov = document.getElementById('woiretOv'); if (ov) ov.remove(); }
127
128  function gate(product, from) {
129    if (MUTED) return;
130    ensureCss(); closeGate();
131    var ov = document.createElement('div'); ov.id = 'woiretOv';
132    ov.innerHTML =
133      '<div class="wr-card" role="dialog" aria-modal="true">' +
134        '<button class="wr-x" type="button" aria-label="close">' + '<svg class="wi" aria-hidden="true"><use href="/img/wi.svg?v=20260905-1#wi-close"></use></svg>' + '</button>' +
135        '<div class="wr-emoji">' + (EMOJI[product] || '✨') + '</div>' +
136        '<h3>' + S('gateTitle') + '</h3>' +
137        '<p class="wr-sub">' + fmt('gateSub', { noun: noun(product) }) + '</p>' +
138        '<ul><li>' + S('b1') + '</li><li>' + S('b2') + '</li><li>' + S('b3') + '</li></ul>' +
139        '<button class="wr-g" type="button">' + G_SVG + '<span>' + S('googleBtn') + '</span></button>' +
140        '<div class="wr-note">' + S('noCard') + '</div>' +
141      '</div>';
142    document.body.appendChild(ov);
143    track('signin_gate_shown', { product: product, from: from || 'save' });
144    ov.addEventListener('click', function (e) { if (e.target === ov) closeGate(); });
145    ov.querySelector('.wr-x').addEventListener('click', closeGate);
146    ov.querySelector('.wr-g').addEventListener('click', function () {
147      track('signin_gate_click', { product: product });
148      var btn = ov.querySelector('.wr-g'); btn.disabled = true; btn.style.opacity = '.6';
149      if (!(window.Cloud && Cloud.signInGoogle)) { location.href = (LANG === 'en' ? '' : '/' + LANG) + '/dashboard.html'; return; }
150      // signInGoogle already falls back to a full-page redirect when the popup is blocked.
151      Cloud.signInGoogle().then(function (res) {
152        // null = the visitor closed the Google window (2026-09-28): keep the gate, re-enable the button.
153        if (!res || !res.user) { btn.disabled = false; btn.style.opacity = ''; track('signin_gate_cancel', { product: product }); return; }
154        closeGate(); toast(S('gateSuccess'));
155        track('signin_gate_success', { product: product });
156      }).catch(function () { btn.disabled = false; btn.style.opacity = ''; });
157    });
158  }
159
160
161  // The banner is pinned 14px from the bottom, which on a phone lands exactly on top of a
162  // bottom-fixed product bar (the wheel page's .tabs, the mobile dock on the other pages).
163  // Measured live 2026-09-02 on a 375x667 viewport: it covered 42 of the 56px bar and
164  // elementFromPoint returned the banner for 4 of the 6 product tabs — they could not be
165  // tapped at all while it showed. Measure whatever bottom bar is actually there and sit
166  // above it, so this keeps working if a page changes its dock height.
167  function liftAboveBottomBar(bn) {
168    try {
169      var vh = document.documentElement.clientHeight || window.innerHeight, best = 0;
170      var nodes = document.body.querySelectorAll('*');
171      for (var i = 0; i < nodes.length; i++) {
172        var e = nodes[i];
173        if (e === bn || bn.contains(e)) continue;
174        var cs = getComputedStyle(e);
175        if (cs.position !== 'fixed' || cs.display === 'none' || cs.visibility === 'hidden') continue;
176        if (parseFloat(cs.opacity || '1') < 0.05 || cs.pointerEvents === 'none') continue;
177        var r = e.getBoundingClientRect();
178        if (r.height < 8 || r.width < 60) continue;
179        if (Math.abs(r.bottom - vh) > 2) continue;      // anchored to the bottom edge
180        if (r.height > vh * 0.45) continue;             // a full-screen overlay is not a bar
181        if (r.height > best) best = r.height;
182      }
183      if (best > 0) bn.style.bottom = Math.round(best + 12) + 'px';
184    } catch (e) {}
185  }
186
187  // "You'll lose this" banner — fires on the 2nd real use by a signed-out visitor.
188  function showBanner(product, text, btnText, onClick) {
189    if (MUTED || document.getElementById('woiretBn')) return;
190    ensureCss();
191    var bn = document.createElement('div'); bn.id = 'woiretBn';
192    bn.innerHTML = '<div class="wb-t"></div><button class="wb-b" type="button"></button><button class="wb-x" type="button" aria-label="dismiss">✕</button>';
193    bn.querySelector('.wb-t').textContent = text;
194    bn.querySelector('.wb-b').textContent = btnText;
195    document.body.appendChild(bn);
196    liftAboveBottomBar(bn);
197    track('keep_banner_shown', { product: product });
198    bn.querySelector('.wb-x').addEventListener('click', function () { setFlag('bn_' + product); bn.remove(); });
199    bn.querySelector('.wb-b').addEventListener('click', function () {
200      track('keep_banner_click', { product: product });
201      bn.remove();
202      if (onClick) onClick(); else gate(product, 'banner');
203    });
204  }
205
206  function use(product) {
207    if (MUTED || signedIn() || getFlag('bn_' + product)) return;
208    var n = 0;
209    try {
210      n = (parseInt(localStorage.getItem('woi_ret_use_' + product), 10) || 0) + 1;
211      localStorage.setItem('woi_ret_use_' + product, String(n));
212    } catch (e) { return; }
213    var sess;
214    try { sess = sessionStorage.getItem('woi_ret_bns_' + product) === '1'; } catch (e) { sess = true; }
215    if (n >= 2 && !sess) {
216      try { sessionStorage.setItem('woi_ret_bns_' + product, '1'); } catch (e) {}
217      var text = product === 'wheel' ? S('bnwheel') : fmt('bn', { noun: noun(product) });
218      showBanner(product, text, S('bnBtn'));
219    }
220  }
221
222  // Slot meter chip, rendered right after each page's template picker / list host.
223  function meter(target, n, isPro) {
224    if (MUTED) return;
225    ensureCss();
226    var host = typeof target === 'string' ? document.querySelector(target) : target;
227    if (!host || !host.parentNode) return;
228    var isSelect = host.tagName === 'SELECT' || host.tagName === 'INPUT';
229    var scope = isSelect ? host.parentNode : host;
230    var chip = scope.querySelector('.woiret-meter');
231    if (!chip) {
232      chip = document.createElement('span'); chip.className = 'woiret-meter';
233      if (isSelect) host.parentNode.insertBefore(chip, host.nextSibling);
234      else host.insertBefore(chip, host.firstChild);
235    }
236    if (isPro) { chip.textContent = fmt('meterPro', { n: n }); chip.classList.remove('full'); chip.onclick = null; return; }
237    if (n >= 3) {
238      chip.textContent = S('meterFull'); chip.classList.add('full');
239      chip.onclick = function () { location.href = (LANG === 'en' ? '' : '/' + LANG) + '/pricing.html'; };
240    } else {
241      chip.textContent = fmt('meterFree', { n: n }); chip.classList.remove('full'); chip.onclick = null;
242    }
243  }
244
245  // Photo-booth variant: photos are personal, so nothing is uploaded silently —
246  // the prompt opens the save modal instead (unlike the config-only auto-migrations).
247  function boothPrompt(photoCount, openSave) {
248    if (MUTED || getFlag('bn_photobooth')) return;
249    showBanner('photobooth', fmt('boothPrompt', { n: photoCount }), S('boothPromptBtn'), openSave);
250  }
251
252  function migratedToast(product) { toast(fmt('migrated', { noun: noun(product) })); track('local_migrated', { product: product }); }
253
254  window.WOIRET = { gate: gate, use: use, meter: meter, toast: toast, migratedToast: migratedToast,
255                    boothPrompt: boothPrompt, flag: getFlag, setFlag: setFlag, str: S, fmt: fmt, muted: MUTED };
256})();

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.