PageSourceSearch

https://fortune-cookie.tokyo/assets/js/pwa-install.js?v=1.0.610

js fortune-cookie.tokyo collected 2026-10-07 17:58:22 UTC 13,480 bytes, 320 lines download raw bytes

1/**
2 * PWA インストール検知 + GA4 起動イベント発火 + DB 保存
3 *
4 * spec: docs/features/pwa-installation/spec.md §2.3 / §2.8 / §5.10
5 *
6 * - standalone モードかつ sessionStorage が空(= 起動時)に GA4 イベント発火
7 * - ログイン済みなら DB POST、未ログインなら次回ログイン後に状態変化検知で POST
8 * - localStorage は使用しない(sessionStorage のみ、iOS ITP 影響を受けない)
9 */
10(function () {
11    var SESSION_KEY = 'pwa_session_state';
12
13    function isStandalone() {
14        return (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches)
15            || window.navigator.standalone === true;
16    }
17
18    function detectDeviceType() {
19        var ua = window.navigator.userAgent || '';
20        if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
21        if (/Android/i.test(ua)) return 'android';
22        return 'other';
23    }
24
25    function getPrevState() {
26        try {
27            return JSON.parse(sessionStorage.getItem(SESSION_KEY) || '{}');
28        } catch (e) {
29            return {};
30        }
31    }
32
33    function setState(state) {
34        try {
35            sessionStorage.setItem(SESSION_KEY, JSON.stringify(state));
36        } catch (e) {
37            // sessionStorage 利用不可(プライベートブラウジング等)の場合は黙って失敗
38        }
39    }
40
41    // GA4 イベント発火の共通関数。GA4Events.php の定数と一致させる
42    function sendGa4Event(eventName, params) {
43        params = params || {};
44        if (typeof window.gtag === 'function') {
45            window.gtag('event', eventName, params);
46        } else if (window.dataLayer && Array.isArray(window.dataLayer)) {
47            var payload = { event: eventName };
48            for (var k in params) {
49                if (Object.prototype.hasOwnProperty.call(params, k)) payload[k] = params[k];
50            }
51            window.dataLayer.push(payload);
52        }
53    }
54
55    function sendGa4LaunchEvent() {
56        // standalone 起動時イベント。device は GA4 自動収集の operating_system で集計可能
57        sendGa4Event('pwa_launch_standalone');
58    }
59
60    function sendInstallationPost(url, deviceType, token) {
61        return fetch(url, {
62            method: 'POST',
63            credentials: 'same-origin',
64            headers: {
65                'Accept': 'application/json',
66                'Content-Type': 'application/json',
67                'X-CSRF-TOKEN': token,
68            },
69            body: JSON.stringify({ device_type: deviceType }),
70        });
71    }
72
73    // 419 リトライ用: 現在セッションの fresh CSRF token を取得。
74    // meta タグ token が stale(session 再生成後・iOS PWA cookie 分離コンテキスト等)でも復旧できる。
75    // 失敗系(404 / 5xx / JSON parse 失敗 / token が文字列でない・空)は null を返してリトライ中止。
76    function fetchFreshCsrfToken() {
77        var url = window.CSRF_TOKEN_URL;
78        if (!url) return Promise.resolve(null);
79        return fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
80            .then(function (res) { return res.ok ? res.json() : null; })
81            .then(function (data) {
82                if (!data || typeof data.token !== 'string' || data.token === '') return null;
83                return data.token;
84            })
85            .catch(function () { return null; });
86    }
87
88    function postInstallation(deviceType) {
89        var url = window.PWA_INSTALLATIONS_URL;
90        if (!url) return;
91        var csrfTokenMeta = document.querySelector('meta[name="csrf-token"]');
92        var csrfToken = csrfTokenMeta ? csrfTokenMeta.getAttribute('content') : '';
93
94        sendInstallationPost(url, deviceType, csrfToken).then(function (res) {
95            // 419 の場合のみ fresh token を取り直して 1 度だけリトライする。
96            // res.status===419 は「meta タグの token が stale」のシグナル。
97            // 200 / 401 / 500 等の非 419 はリトライしても結果が変わらないため既存挙動どおり終了。
98            if (res.status !== 419) return;
99            // return で Promise チェーンを繋ぐ(Unhandled Promise Rejection 防止)
100            return fetchFreshCsrfToken().then(function (freshToken) {
101                if (!freshToken) {
102                    console.warn('[PWA] installation POST 419; fresh token unavailable');
103                    return;
104                }
105                return sendInstallationPost(url, deviceType, freshToken).then(function (retr
105yRes) {
106                    // リトライは 1 回まで。再度失敗しても次回のページロード(新 sessionStorage)で再試行される
107                    if (!retryRes.ok) {
108                        console.warn('[PWA] installation POST retry failed with status', retryRes.status);
109                    }
110                });
111            });
112        }).catch(function (err) {
113            // ネットワークエラー等。次回 page load で再判定される
114            console.warn('[PWA] installation POST failed', err);
115        });
116    }
117
118    // -----------------------------------------------------
119    // ① standalone 起動検知 + GA4 イベント + DB POST
120    // -----------------------------------------------------
121    if (isStandalone()) {
122        // body に .pwa-standalone クラス付与(CSS でマスコット・モーダルを非表示にする)
123        // @media (display-mode: standalone) と JS フォールバックの二重防御
124        if (document.body) {
125            document.body.classList.add('pwa-standalone');
126        } else {
127            document.addEventListener('DOMContentLoaded', function () {
128                document.body.classList.add('pwa-standalone');
129            });
130        }
131
132        var prev = getPrevState();
133        var isLoggedIn = window.PWA_IS_USER_LOGGED_IN === true;
134        var deviceType = detectDeviceType();
135        var shouldPost = false;
136
137        if (!prev.initialized) {
138            // 起動時(セッション初回): GA4 発火
139            sendGa4LaunchEvent();
140            if (isLoggedIn) {
141                shouldPost = true;
142            }
143        } else if (prev.isLoggedIn === false && isLoggedIn === true) {
144            // ログイン時(状態変化検知): GA4 は発火せず DB POST のみ
145            shouldPost = true;
146        }
147
148        setState({ initialized: true, isLoggedIn: isLoggedIn });
149
150        if (shouldPost) {
151            postInstallation(deviceType);
152        }
153    }
154
155    // -----------------------------------------------------
156    // ② マスコット導線 + インストールモーダル制御
157    //    (standalone でない場合も実行: マスコットは未インストール時にも表示される)
158    // -----------------------------------------------------
159    var deferredPrompt = null;
160
161    document.addEventListener('DOMContentLoaded', function () {
162        initMascot();
163        initInstallModal();
164    });
165
166    // エンゲージメント連動 Popup(TOP)から開くための公開 API。
167    // resetStep 込みで開くため、マスコット経由で途中まで進めた状態が残らない。
168    window.openPwaInstallModal = openInstallModal;
169
170    window.addEventListener('beforeinstallprompt', function (e) {
171        // Android Chrome 用: prompt() を後から呼ぶためにイベントを保持
172        e.preventDefault();
173        deferredPrompt = e;
174    });
175
176    window.addEventListener('appinstalled', function () {
177        // インストール完了したらモーダルを閉じる
178        closeInstallModal();
179    });
180
181    function initMascot() {
182        var mascot = document.querySelector('[data-pwa-mascot]');
183        if (!mascot) return;
184
185        // マスコット画像に文言が埋め込まれているため、タップで直接モーダルを開く(吹き出しなし)
186        var addBtn = mascot.querySelector('[data-pwa-mascot-add]');
187        if (addBtn) {
188            addBtn.addEventListener('click', function () {
189                sendGa4Event('pwa_mascot_clicked');
190                openInstallModal();
191            });
192        }
193    }
194
195    function initInstallModal() {
196        var modal = document.getElementById('pwa-install-modal');
197        if (!modal) return;
198
199        // 閉じるボタン群
200        var closers = modal.querySelectorAll('[data-pwa-modal-close]');
201        for (var i = 0; i < closers.length; i++) {
202            closers[i].addEventListener('click', closeInstallModal);
203        }
204
205        // Android「ホーム画面に追加する」→ beforeinstallprompt の prompt() 呼ぶ
206        var androidAdd = modal.querySelector('[data-pwa-modal-android-add]');
207        if (androidAdd) {
208            androidAdd.addEventListener('click', function () {
209                sendGa4Event('pwa_install_cta_clicked');
210                if (!deferredPrompt) {
211                    // beforeinstallprompt が未捕捉(既インストール or 非対応)→ 静かに閉じる
212                    closeInstallModal();
213                    return;
214                }
215                deferredPrompt.prompt();
216                deferredPrompt.userChoice.finally(function () {
217                    deferredPrompt = null;
218                    closeInstallModal();
219                });
220            });
221        }
222
223        // iOS Safari ガイド: ステップ進む/戻る
224        var nextBtns = modal.querySelectorAll('[data-pwa-step-next]');
225        for (var j = 0; j < nextBtns.length; j++) {
226            nextBtns[j].addEventListener('click', function () {
227                changeStep(modal, +1);
228            });
229        }
230        var prevBtns = modal.querySelectorAll('[data-pwa-step-prev]');
231        for (var k = 0; k < prevBtns.length; k++) {
232            prevBtns[k].addEventListener('click', function () {
233                changeStep(modal, -1);
234            });
235        }
236
237        // モーダルが開いた瞬間に Esc キーで閉じれるよう
238        document.addEventListener('keydown', function (e) {
239            if (e.key === 'Escape' && !modal.hidden) closeInstallModal();
240        });
241    }
242
243    function openInstallModal() {
244        var modal = document.getElementById('pwa-install-modal');
245        if (!modal) return;
246        modal.hidden = false;
247        // 最初の step(intro=0 または step=1)にリセット(iOS Safari ガイド用)
248        resetStep(modal);
249    }
250
251    function closeInstallModal() {
252        var modal = document.getElementById('pwa-install-modal');
253        if (!modal) return;
254        modal.hidden = true;
255    }
256
257    function resetStep(modal) {
258        var steps = modal.querySelectorAll('[data-pwa-step]');
259        if (steps.length === 0) {
260            updateProgress(modal, 1);
261            return;
262        }
263        // 最小の step(iOS ガイドは intro=0、それ以外は 1)を初期表示にする
264        var min = null;
265        for (var i = 0; i < steps.length; i++) {
266            var n = parseInt(steps[i].getAttribute('data-pwa-step'), 10);
267            if (isNaN(n)) continue;
268            if (min === null || n < min) min = n;
269        }
270        if (min === null) min = 1;
271        for (var k = 0; k < steps.length; k++) {
272            steps[k].hidden = (parseInt(steps[k].getAttribute('data-pwa-step'), 10) !== min);
273        }
274        updateProgress(modal, min);
275    }
276
277    function changeStep(modal, delta) {
278        var steps = modal.querySelectorAll('[data-pwa-step]');
279        if (steps.length === 0) return;
280        // 実在する step 番号から最小・最大を求める(0 始まり連続前提に依存しない)
281        var minStep = null, maxStep = null;
282        for (var i = 0; i < steps.length; i++) {
283            var sn = parseInt(steps[i].getAttribute('data-pwa-step'), 10);
284            if (isNaN(sn)) continue;
285            if (minStep === null || sn < minStep) minStep = sn;
286            if (maxStep === null || sn > maxStep) maxStep = sn;
287        }
288        if (minStep === null) return;
289        // 表示中の step を current とする(全 hidden の DOM 異常時は minStep にフォールバック)
290        var current = minStep;
291        for (var j = 0; j < steps.length; j++) {
292            if (!steps[j].hidden) {
293                // step="0"(intro)も正しく取得するため || 1 は使わない
294                var c = parseInt(steps[j].getAttribute('data-pwa-step'), 10);
295                if (!isNaN(c)) current = c;
296                break;
297            }
298        }
299        var next = current + delta;
300        if (next < minStep || next > maxStep) return;
301        for (var k = 0; k < steps.length; k++) {
302            steps[k].hidden = (parseInt(steps[k].getAttribute('data-pwa-step'), 10) !== next);
303        }
304        updateProgress(modal, next);
305    }
306
307    function updateProgress(modal, step) {
308        var progress = modal.querySelector('[data-pwa-progress]');
309        // intro(step < 1)ではプログレスバーを隠す
310        if (step < 1) {
311            if (progress) progress.hidden = true;
312            return;
313        }
314        if (progress) progress.hidden = false;
315        var label = modal.querySelector('[data-pwa-step-label]');
316        if (label) label.textContent = 'ステップ' + step + '/3';
317        var fill = modal.querySelector('[data-pwa-progress-fill]');
318        if (fill) fill.style.width = (step / 3 * 100).toFixed(2) + '%';
319    }
320})();

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.