PageSourceSearch

https://coinkeeper.me/static/js/landing_new_payment.js?v=1790584696

js coinkeeper.me collected 2026-10-02 03:43:35 UTC 20,630 bytes, 511 lines download raw bytes

1/**
2 * CloudPayments integration for new.coinkeeper.me/landing/ (template `landing_new.html`).
3 *
4 * Page structure:
5 *   - 6 buy buttons `.checkout-btn` with `data-product-key` + `data-amount`
6 *   - `#checkout-modal` popup with `#checkout-email` input (+ `#checkout-email-error`)
7 *     and `#checkout-modal-form` (submit triggers payment)
8 *   - `window.LANDING_PRODUCTS` map (keyed by product key) injected from Jinja
9 *   - `window.CP_PUBLIC_ID`, `window.LANDING_SLUG`
10 *
11 * Per-click flow:
12 *   1. click `.checkout-btn` → stash productKey, open `#checkout-modal`
13 *   2. submit form → validate email
14 *   3. track('checkout_open', {productKey, amount})
15 *   4. POST /api/abandoned-checkout
16 *   5. close modal, cp.charge(...) — recurrent params derived from product.duration if `recurrent=true`
17 *   6. onSuccess → track('purchase_success') → POST /api/finalize-purchase → redirect to /thank-you
18 *   7. onFail → track('purchase_fail')
19 */
20(function () {
21    'use strict';
22
23    var pendingProductKey = null;
24
25    function currentLang() {
26        try { return (document.documentElement.lang || '').toLowerCase().slice(0, 8); }
27        catch (e) { return ''; }
28    }
29
30    function getUtm() {
31        try {
32            var params = new URLSearchParams(window.location.search);
33            var s = params.get('utm_source');
34            var m = params.get('utm_medium');
35            var c = params.get('utm_campaign');
36            var ct = params.get('utm_content');
37            if (s || m || c || ct) {
38                var fresh = { utmSource: s || '', utmMedium: m || '', utmCampaign: c || '', utmContent: ct || '' };
39                try { sessionStorage.setItem('ck_utm', JSON.stringify(fresh)); } catch (e) {}
40                return fresh;
41            }
42            var raw = sessionStorage.getItem('ck_utm');
43            return raw ? JSON.parse(raw) : {};
44        } catch (e) { return {}; }
45    }
46
47    // SRV-2040: возврат в веб-учёт после оплаты. Хост из query проверяем точным
48    // совпадением со списком, а не includes()/regex — иначе "evilweb.coinkeeper.me.attacker.com"
49    // или похожий трюк с поддоменом прошёл бы как валидный.
50    var RETURN_URL_HOST_WHITELIST = ['web.coinkeeper.me', 'web.coinkeeper.org'];
51
52    function resolveReturnUrl() {
53        try {
54            var params = new URLSearchParams(window.location.search);
55            var raw = params.get('return_url');
56            if (!raw) return null;
57            var parsed = new URL(raw);
58            if (RETURN_URL_HOST_WHITELIST.indexOf(parsed.hostname) === -1) return null;
59            return parsed.href;
60        } catch (e) { return null; }
61    }
62
63    function track(event, extra) {
64        try {
65            var slug = window.LANDING_SLUG || window.location.pathname.replace(/^\//, '').replace(/\/$/, '');
66            var base = Object.assign({ event: event, slug: slug, language: currentLang() }, getUtm());
67            var payload = JSON.stringify(Object.assign(base, extra || {}));
68            var blob = new Blob([payload], { type: 'application/json' });
69            if (!navigator.sendBeacon || !navigator.sendBeacon('/api/track', blob)) {
70                fetch('/api/track', {
71                    method: 'POST',
72                    headers: { 'Content-Type': 'application/json' },
73                    body: payload,
74                    keepalive: true,
75                });
76            }
77        } catch (e) {}
78    }
79
80    // Map server-side event names -> GA4 standard e-commerce events.
81    var GA4_MAP = {
82        checkout_open: 'begin_checkout',
83        purchase_success: 'purchase',
84        purchase_fail: 'purchase_fail'
85    };
86
87    function gaEvent(eventName, params) {
88        if (typeof window.gtag !== 'function') return;
89        try { window.gtag('event', eventName, params || {}); } catch (e) {}
90    }
91
92    // Fan-out: server SQLite + GA4. GA4 payload is built from server `extra` plus
93    // optional `gaExtra` (used to add GA4-specific fields like `items`).
94    function trackAll(event, extra, gaExtra) {
95        track(event, extra);
96        var gaName = GA4_MAP[event] || event;
97        var params = Object.assign({}, extra || {}, gaExtra || {});
98        // GA4 expects `value` for begin_checkout / purchase, not `amount`.
99        if (params.amount != null && params.value == null) params.value = params.amount;
100        gaEvent(gaName, params);
101    }
102
103    function productInfo(productKey) {
104        var products = window.LANDING_PRODUCTS || {};
105        var p = products[productKey] || {};
106        var price = parseFloat(p.amount);
107        return {
108            item_id: productKey,
109            item_name: p.displayName || p.cpDescription || productKey,
110            price: isNaN(price) ? undefined : price,
111            currency: p.currency || 'RUB',
112            period: p.duration || ''
113        };
114    }
115
116    function ga4Items(productKey) {
117        var info = productInfo(productKey);
118        return [{
119            item_id: info.item_id,
120            item_name: info.item_name,
121            price: info.price,
122            currency: info.currency,
123            quantity: 1
124        }];
125    }
126
127    function sha256Hex(str) {
128        if (!window.crypto || !window.crypto.subtle) return Promise.resolve(null);
129        var buf = new TextEncoder().encode(String(str).trim().toLowerCase());
130        return window.crypto.subtle.digest('SHA-256', buf).then(function (hash
130) {
131            return Array.from(new Uint8Array(hash))
132                .map(function (b) { return b.toString(16).padStart(2, '0'); })
133                .join('');
134        }).catch(function () { return null; });
135    }
136
137    function durationToRecurrent(duration) {
138        var m = /^P(\d+)([YMWD])$/.exec(duration || '');
139        if (!m) return { interval: 'Month', period: 1 };
140        var n = parseInt(m[1], 10);
141        switch (m[2]) {
142            case 'Y': return { interval: 'Year', period: n };
143            case 'M': return { interval: 'Month', period: n };
144            case 'W': return { interval: 'Week', period: n };
145            case 'D': return { interval: 'Day', period: n };
146        }
147        return { interval: 'Month', period: 1 };
148    }
149
150    function buildReceipt(label, amount, email) {
151        return {
152            Items: [{
153                label: label,
154                price: amount,
155                quantity: 1.00,
156                amount: amount,
157                vat: null,
158                method: 4,
159                object: 4
160            }],
161            taxationSystem: 1,
162            email: email,
163            phone: '',
164            isBso: false,
165            amounts: {
166                electronic: amount,
167                advancePayment: 0.00,
168                credit: 0.00,
169                provision: 0.00
170            }
171        };
172    }
173
174    function isValidEmail(s) {
175        return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
176    }
177
178    function getEmailInput() { return document.getElementById('checkout-email'); }
179    function getEmailErrorBox() { return document.getElementById('checkout-email-error'); }
180    function getModal() { return document.getElementById('checkout-modal'); }
181
182    function showEmailError(msg) {
183        var input = getEmailInput();
184        var box = getEmailErrorBox();
185        if (box) {
186            box.textContent = msg || 'Введите корректный e-mail';
187            box.hidden = false;
188        }
189        if (input) {
190            input.classList.add('is-invalid');
191            input.focus();
192        }
193    }
194
195    function clearEmailError() {
196        var input = getEmailInput();
197        var box = getEmailErrorBox();
198        if (box) box.hidden = true;
199        if (input) input.classList.remove('is-invalid');
200    }
201
202    function openModal(productKey) {
203        pendingProductKey = productKey;
204        var modal = getModal();
205        if (!modal) return;
206        clearEmailError();
207        modal.hidden = false;
208        // Force reflow so the transition runs after `display: flex` is applied.
209        // eslint-disable-next-line no-unused-expressions
210        modal.offsetHeight;
211        modal.classList.add('is-open');
212        document.body.classList.add('no-scroll');
213        var input = getEmailInput();
214        if (input) {
215            setTimeout(function () {
216                try { input.focus(); } catch (e) {}
217                if (input.value) {
218                    try { input.setSelectionRange(input.value.length, input.value.length); } catch (e) {}
219                }
220            }, 60);
221        }
222    }
223
224    function closeModal() {
225        var modal = getModal();
226        if (!modal) return;
227        modal.classList.remove('is-open');
228        document.body.classList.remove('no-scroll');
229        setTimeout(function () {
230            if (!modal.classList.contains('is-open')) modal.hidden = true;
231        }, 200);
232    }
233
234    function startPayment(productKey, email) {
235        var publicId = window.CP_PUBLIC_ID;
236        if (!publicId) {
237            console.error('CP_PUBLIC_ID is not set — payment unavailable');
238            showEmailError('Платёж временно недоступен. Попробуйте позже.');
239            return;
240        }
241        var products = window.LANDING_PRODUCTS || {};
242        var p = products[productKey];
243        if (!p) {
244            console.error('Unknown productKey:', productKey);
245            showEmailError('Тариф не найден. Перезагрузите страницу.');
246            return;
247        }
248
249        var price = parseFloat(p.amount);
250        var description = p.cpDescription || p.displayName || productKey;
251        var currency = p.currency || 'RUB';
252        var isRecurrent = !!p.recurrent;
253        var recurrentAmount = p.recurrentAmount != null ? parseFloat(p.recurrentAmount) : price;
254
255        var firstReceipt = buildReceipt(description, price, email);
256        var recurrentReceipt = buildReceipt(description, recurrentAmount, email);
257
258        var _u = getUtm();
259        var payData = { slug: window.LANDING_SLUG || window.location.pathname.replace(/^\//, '').replace(/\/$/, ''), utm_source: _u.utmSource || '', utm_medium: _u.utmMedium || '', utm_campaign: _u.utmCampaign || '', utm_content: _u.utmContent || '', CloudPayments: { customerReceipt: firstReceipt } };
260        if (isRecurrent) {
261            var rec = durationToRecurrent(p.duration);
262            payData.CloudPayments.recurrent = {
263                interval: rec.interval,
264                period: rec.period,
265                amount: recurrentAmount,
266                customerReceipt: recurrentReceipt
267            };
268        }
269
270        try { localStorage.setItem('cp_finalize_email', email); } catch (e) {}
271
272        var pInfo = productInfo(productKey);
273        trackAll(
274            'checkout_open',
275            { productKey: productKey, amount: price, currency: currency },
276            {
277                value: price,
278                currency: currency,
279                items: ga4Items(productKey),
280                product_key: productKey,
281                tariff_name: pInfo.item_name,
282                period: pInfo.period
283            }
284        );
285
286        var productLink = window.location.origin + window.location.pathname
287            + '?email=' + encodeURIComponent(email)
288            + '&product=' + encodeURIComponent(productKey);
289        fetch('/api/abandoned-checkout', {
290            method: 'POST',
291            headers: { 'Content-Type': 'application/json' },
292            body: JSON.stringify({
293                email: email,
294                productTitle: description,
295                productLink: productLink,
296                price: String(price) + (currency === 'RUB' ? ' ₽' : ' ' + currency),
297                source: 'coinkeeper',
298                slug: window.LANDING_SLUG || ''
299            })
300        }).catch(function () {});
301
302        closeModal();
303
304        var widget = new cp.CloudPayments();
305        widget.pay('charge', {
306            publicId: publicId,
307            description: description,
308            amount: price,
309            currency: currency,
310            accountId: email,
311            email: email,
312            skin: 'mini',
313            data: payData
314        }, {
315            onSuccess: function (options) {
316                var txId = options && (options.transactionId || options.TransactionId);
317                var pInfoOk = productInfo(productKey);
318                trackAll(
319                    'purchase_success',
320                    {
321                        productKey: productKey,
322                        transactionId: txId,
323                        amount: price,
324                        currency: currency
325                    },
326                    {
327                        transaction_id: txId,
328                        value: price,
329                        currency: currency,
330                        items: ga4Items(productKey),
331                        product_key: productKey,
332                        tariff_name: pInfoOk.item_name,
333                        period: pInfoOk.period
334                    }
335                );
336                var stashedEmail = '';
337                try { stashedEmail = localStorage.getItem('cp_finalize_email') || ''; } catch (e) {}
338                var body = { productKey: productKey };
339                if (txId) body.transactionId = txId;
340                if (stashedEmail) body.email = stashedEmail;
341                if (window.LANDING_SLUG) body.slug = window.LANDING_SLUG;
342                fetch('/api/finalize-purchase', {
343                    method: 'POST',
344                    headers: { 'Content-Type': 'application/json' },
345                    body: JSON.stringify(body),
346                    keepalive: true
347                }).catch(function () {
348                }).finally(function () {
349                    try { localStorage.removeItem('cp_finalize_email'); } catch (e) {}
350                    window.location.href = resolveReturnUrl() || '/thank-you';
351                });
352            },
353            onFail: function (reason) {
354                if (reason !== 'User has cancelled') {
355                    trackAll(
356                        'purchase_fail',
357                        { productKey: productKey, reason: String(reason || '') },
358                        { reason: String(reason || ''), item_id: productKey }
359                    );
360                    // Re-open the modal so the user can retry / fix the email.
361                    openModal(productKey);
362                    showEmailError('Платёж не прошёл. Попробуйте другую карту или повторите позже.');
363                }
364            }
365        });
366    }
367
368    function onCheckoutClick(e) {
369        var btn = e.currentTarget;
370        var productKey = btn.getAttribute('data-product-key');
371        if (!productKey) return;
372        var period = btn.getAttribute('data-period') || '';
373        var info = productInfo(productKey);
374        gaEvent('select_item', {
375            item_id: productKey,
376            item_name: info.item_name,
377            price: info.price,
378            currency: info.currency,
379            period: period,
380            product_key: productKey,
381            tariff_name: info.item_name,
382            items: ga4Items(productKey)
383        });
384        openModal(productKey);
385    }
386
387    function onFormSubmit(e) {
388        e.preventDefault();
389        if (!pendingProductKey) return;
390        var emailInput = getEmailInput();
391        var email = emailInput ? emailInput.value.trim() : '';
392        if (!email || !isValidEmail(email)) {
393            showEmailError('Введите корректный e-mail');
394            return;
395        }
396        clearEmailError();
397        var info = productInfo(pendingProductKey);
398        gaEvent('add_payment_info', {
399            item_id: pendingProductKey,
400            price: info.price,
401            currency: info.currency,
402            value: info.price,
403            items: ga4Items(pendingProductKey),
404            product_key: pendingProductKey,
405            tariff_name: info.item_name
406        });
407        // Set user_id (sha256 of email) so GA4 can dedupe across devices.
408        var keyForPayment = pendingProductKey;
409        sha256Hex(email).then(function (hash) {
410            if (hash && typeof window.gtag === 'function') {
411                try { window.gtag('set', { user_id: hash }); } catch (e) {}
412            }
413            startPayment(keyForPayment, email);
414        });
415    }
416
417    document.addEventListener('DOMContentLoaded', function () {
418        var emailInput = getEmailInput();
419        if (emailInput) {
420            if (!emailInput.value) {
421                var params = new URLSearchParams(window.location.search);
422                var pref = params.get('email') || '';
423                if (pref) emailInput.value = pref;
424            }
425            emailInput.addEventListener('input', clearEmailError);
426        }
427
428        var form = document.getElementById('checkout-modal-form');
429        if (form) form.addEventListener('submit', onFormSubmit);
430
431        var closeBtn = document.getElementById('checkout-modal-close');
432        if (closeBtn) closeBtn.addEventListener('click', closeModal);
433
434        var modal = getModal();
435        if (modal) {
436            modal.addEventListener('click', function (e) {
437                if (e.target === modal) closeModal();
438            });
439        }
440        document.addEventListener('keydown', function (e) {
441            if (e.key === 'Escape' && modal && modal.classList.contains('is-open')) closeModal();
442        });
443
444        var buttons = document.querySelectorAll('.checkout-btn');
445        buttons.forEach(function (btn) {
446            btn.addEventListener('click', onCheckoutClick);
447        });
448
449        if (!buttons.length) {
450            console.warn('landing_new_payment: no .checkout-btn elements found');
451        }
452
453        // select_promo: any link that scrolls to #pricing (header CTA, footer nav).
454        document.querySelectorAll('a[href="#pricing"]').forEach(function (a) {
455            a.addEventListener('click', function () {
456                var loc = a.getAttribute('data-cta-location') || 'inline';
457                gaEvent('select_promo', { cta_location: loc });
458            });
459        });
460
461        // CTA-якоря на секцию #download (бейджи сторов выше fold).
462        // Трекаем как select_promo с target=download — отличается от click по самим бейджам (download_click).
463        document.querySelectorAll('a[href="#download"]').forEach(function (a) {
464            a.addEventListener('click', function () {
465                var loc = a.getAttribute('data-cta-location') || 'inline';
466                gaEvent('select_promo', { cta_location: loc, target: 'download' });
467            });
468        });
469
470        // view_pricing: fire once per session when #pricing crosses 50% viewport.
471        var pricing = document.getElementById('pricing');
472        if (pricing && 'IntersectionObserver' in window) {
473            var fired = false;
474            try { fired = sessionStorage.getItem('ga4_view_pricing') === '1'; } catch (e) {}
475            if (!fired) {
476                var io = new IntersectionObserver(function (entries) {
477                    entries.forEach(function (entry) {
478                        if (entry.isIntersecting && !fired) {
479                            fired = true;
480                            try { sessionStorage.setItem('ga4_view_pricing', '1'); } catch (e) {}
481                            gaEvent('view_pricing', { section_id: 'pricing' });
482                            io.disconnect();
483                        }
484                    });
485                }, { threshold: 0.1 });
486                io.observe(pricing);
487            }
488        }
489
490        // language_switch
491        document.querySelectorAll('[data-lang-btn]').forEach(function (btn) {
492            btn.addEventListener('click', function () {
493                var lang = btn.getAttribute('data-lang-btn') || '';
494                if (typeof window.gtag === 'function') {
495                    try { window.gtag('set', { user_properties: { language: lang } }); } catch (e) {}
496                }
497                gaEvent('language_switch', { language: lang });
498            });
499        });
500
501        // download_click: App Store / Google Play badges.
502        document.querySelectorAll('a[data-store]').forEach(function (a) {
503            a.addEventListener('click', function () {
504                gaEvent('download_click', {
505                    store: a.getAttribute('data-store') || '',
506                    link_url: a.getAttribute('href') || ''
507                });
508            });
509        });
510    });
511})();

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.