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.