PageSourceSearch

https://coinkeeper.me/calc/cta.js

js coinkeeper.me collected 2026-10-02 03:44:00 UTC 24,998 bytes, 452 lines download raw bytes

1/* ============================================================
2   Общий CTA-модуль виральных калькуляторов CoinKeeper
3   Подключается ПОСЛЕ определения window.CALC_CONFIG и наличия <div id="cta-root">.
4
5   Единый «простой» паттерн (как regret-calculator):
6     [ Поделиться результатом ]  ← рендерит картинку 9:16, показывает ПРЕВЬЮ,
7                                    затем шлёт через Web Share / сохранение
8       строка-причина (мост «цифра → CoinKeeper»)
9     [ Установить CoinKeeper ]
10
11   CALC_CONFIG = {
12     id:               'earnings-counter',
13     getInstallReason: () => 'HTML-строка',
14     getShareCard:     () => ({ eyebrow, currency, big, unit, sub, hook }),
15     getShareText:     () => 'текст-подпись к картинке',
16     related:          [ { id, href, icon, title, hook } ]
17   }
18
19   Публичное API: window.CTA.refresh() — перерисовать текст-причину.
20   ============================================================ */
21(function () {
22  'use strict';
23
24  var cfg = window.CALC_CONFIG;
25  var root = document.getElementById('cta-root');
26  if (!cfg || !root) { return; }
27
28  function detectPlatform() {
29    var ua = navigator.userAgent || '';
30    if (/Android/i.test(ua)) { return 'android'; }
31    if (/iPhone|iPod/i.test(ua)) { return 'ios'; }
32    // iPadOS 13+ маскируется под Mac — отличаем по touch-точкам
33    if (/iPad/i.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1)) { return 'ios'; }
34    return 'desktop';
35  }
36  function installUrl(platform) {
37    var utm = '?utm_source=viral&utm_medium=calculator&utm_campaign=' + cfg.id;
38    if (platform === 'ios')     { return 'https://apps.apple.com/app/id1335547405' + utm; }
39    if (platform === 'android') { return 'https://play.google.com/store/apps/details?id=com.disrapp.coinkeeper3' + utm; }
40    return 'https://coinkeeper.me/3/' + utm;
41  }
42  var INSTALL_PLATFORM = detectPlatform();
43  var INSTALL = installUrl(INSTALL_PLATFORM);
44
45  /* --- Предзагрузка официального логотипа для карточки (same-origin → canvas не tainted) --- */
46  var logoImg = new Image();
47  var logoReady = new Promise(function (resolve) {
48    logoImg.onload = resolve; logoImg.onerror = resolve;
49  });
50  logoImg.src = 'logoname.png';
51
52  /* --- GA4 / dataLayer (активно: analytics.js грузит G-W3NJLXBFND на всех страницах) --- */
53  function track(event, params) {
54    params = params || {};
55    params.calc_id = cfg.id;
56    try {
57      if (typeof window.gtag === 'function') { window.gtag('event', event, params); }
58      else { (window.dataLayer = window.dataLayer || []).push(Object.assign({ event: event }, params)); }
59    } catch (e) {}
60    if (window.console && console.debug) { console.debug('[cta]', event, params); }
61  }
62
63  /* ============================================================
64     Рендер карточки результата на canvas (Stories 9:16)
65     ============================================================ */
66  var COLORS = { ink: '#1E2857', ink2: '#273572', yellow: '#FFB433', orange: '#FF791F', white: '#FFFFFF' };
67
68  function roundRect(ctx, x, y, w, h, r) {
69    ctx.beginPath();
70    ctx.moveTo(x + r, y);
71    ctx.arcTo(x + w, y, x + w, y + h, r);
72    ctx.arcTo(x + w, y + h, x, y + h, r);
73    ctx.arcTo(x, y + h, x, y, r);
74    ctx.arcTo(x, y, x + w, y, r);
75    ctx.closePath();
76  }
77
78  function drawCard(W, H, card) {
79    var c = document.createElement('canvas');
80    c.width = W; c.height = H;
81    var ctx = c.getContext('2d');
82
83    // фон: тёмный градиент + тёплый блик
84    var g = ctx.createLinearGradient(0, 0, W, H);
85    g.addColorStop(0, COLORS.ink); g.addColorStop(1, COLORS.ink2);
86    ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
87    var rg = ctx.createRadialGradient(W, 0, 0, W, 0, W * 0.9);
88    rg.addColorStop(0, 'rgba(255,121,31,0.30)'); rg.addColorStop(1, 'rgba(255,121,31,0)');
89    ctx.fillStyle = rg; ctx.fillRect(0, 0, W, H);
90
91    var cx = W / 2;
92    var S = W / 1080; // масштаб по ширине
93    ctx.textAlign = 'center';
94
95    // бренд сверху — официальный логотип на белой плашке (на тёмном фоне сам wordmark тёмный)
96    if (logoImg.complete && logoImg.naturalWidth) {
97      var lw = 360 * S;
98      var lh = lw * (logoImg.naturalHeight / logoImg.naturalWidth);
99      var padX = 32 * S, padY = 24 * S;
100      var chipW = lw + padX * 2, chipH = lh + padY * 2;
101      var chipX = cx - chipW / 2, chipY = 96 * S;
102      roundRect(ctx, chipX, chipY, chipW, chipH, 30 * S);
103      ctx.fillStyle = '#FFFFFF'; ctx.fill();
104      ctx.drawImage(logoImg, chipX + padX, chipY + padY, lw, lh);
105    } else {
106      // фоллбэк, если картинка не загрузилась
107      ctx.fillStyle = 'rgba(255,255,255,0.92)';
108      ctx.font = '800 ' + (44 * S) + 'px Nunito, sans-serif';
109      ctx.fillText('CoinKeeper', cx, 170 * S);
110    }
111
112    // центральный стек (если есть drawExtra — сдвигаем выше, чтобы освободить место для графика)
113    var hasExtra = typeof card.drawExtra === 'function';
114    var cy = H / 2;
115    var yOff = hasExtra ? -180 * S : 0;
116    // eyebrow
117    ctx.fillStyle = 'rgba(255,255,255,0.62)';
118    ctx.font = '800 ' + (34 * S) + 'px Nunito, sans-serif';
119    wrapText(ctx, (card.eyebrow || '').toUpperCase(), cx, cy - 200 * S + yOff, W - 180 * S, 46 * S, 2);
120
121    // большое число (₽ жёлтым + значение белым)
122    var bigFontSize = hasExtra ? 140 : 170;
123    var numFont = '900 ' + (bigFontSize * S) + 'px Nunito, sans-serif';
124    ctx.font = numFont;
125    var cur = card.currency ? (card.currency + ' ') : '';
126    var num = String(card.big || '');
127    var curW = ctx.measureText(cur).width;
128    var numW = ctx.measureText(num).width;
129    // если не влезает — уменьшаем
130    var total = curW + numW;
131    var maxW = W - 120 * S;
132    if (total > maxW) {
133      var k = maxW / total;
134      numFont = '900 ' + Math.floor(bigFontSize * S * k) + 'px Nunito, sans-serif';
135      ctx.font = numFont; curW = ctx.measureText(cur).width; numW = ctx.measureText(num).width; total = curW + numW;
136    }
137    var startX = cx - total / 2;
138    ctx.textAlign = 'left';
139    ctx.fillStyle = COLORS.yellow; ctx.fillText(cur, startX, cy + 30 * S + yOff);
140    ctx.fillStyle = COLORS.white; ctx.fillText(num, startX + curW, cy + 30 * S + yOff);
141    ctx.textAlign = 'center';
142
143    // unit
144    ctx.fillStyle = 'rgba(255,255,255,0.78)';
145    ctx.font = '600 ' + (44 * S) + 'px Nunito, sans-serif';
146    ctx.fillText(card.unit || '', cx, cy + 130 * S + yOff);
147
148    // sub-строка
149    if (card.sub) {
150      ctx.fillStyle = 'rgba(255,255,255,0.55)';
151      ctx.font = '700 ' + (38 * S) + 'px Nunito, sans-serif';
152      ctx.fillText(card.sub, cx, cy + 200 * S + yOff);
153    }
154
155    // опциональный кастомный блок (например, мини-график)
156    if (hasExtra) {
157      try { card.drawExtra(ctx, W, H, S); } catch (e) {}
158    }
159
160    // низ: hook + url
161    ctx.fillStyle = COLORS.yellow;
162    ctx.font = '800 ' + (40 * S) + 'px Nunito, sans-serif';
163    ctx.fillText((card.hook || 'Посчитай свой результат') + ' →', cx, H - 200 * S);
164    ctx.fillStyle = 'rgba(255,255,255,0.5)';
165    ctx.font = '700 ' + (34 * S) + 'px Nunito, sans-serif';
166    ctx.fillText('calc.coinkeeper.me', cx, H - 130 * S);
167
168    return c;
169  }
170
171  function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
172    var words = String(text).split(' '), line = '', lines = [];
173    for (var i = 0; i < words.length; i++) {
174      var test = line ? line + ' ' + words[i] : words[i];
175      if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = words[i]; }
176      else { line = test; }
177    }
178    if (line) { lines.push(line); }
179    if (maxLines) { lines = lines.slice(0, maxLines); }
180    var startY = y - (lines.length - 1) * lineHeight;
181    for (var j = 0; j < lines.length; j++) { ctx.fillText(lines[j], x, startY + j * lineHeight); }
182  }
183
184  function canvasToBlob(canvas) {
185    return new Promise(function (resolve) {
186      if (canvas.toBlob) { canvas.toBlob(resolve, 'image/png'); }
187      else { resolve(dataURItoBlob(canvas.toDataURL('image/png'))); }
188    });
189  }
190  function dataURItoBlob(uri) {
191    var b = atob(uri.split(',')[1]), arr = new Uint8Array(b.length);
192    for (var i = 0; i < b.length; i++) { arr[i] = b.charCodeAt(i); }
193    return new Blob([arr], { type: 'image/png' });
194  }
195
196  // отрендерить story-картинку 9:16 → { blob, url, file }
197  async function buildCard() {
198    try { if (document.fonts && document.fonts.ready) { await document.fonts.ready; } } catch (e) {}
199    await logoReady;
200    var card = cfg.getShareCard ? cfg.getShareCard() : {};
201    var canvas = drawCard(1080, 1920, card);
202    var blob = await canvasToBlob(canvas);
203    var file = new File([blob], cfg.id + '-story.png', { type: 'image/png' });
204    return { blob: blob, url: URL.createObjectURL(blob), file: file };
205  }
206
207  function downloadBlob(blob, name) {
208    var url = URL.createObjectURL(blob);
209    var a = document.createElement('a');
210    a.href = url;
210 a.download = name; document.body.appendChild(a); a.click();
211    document.body.removeChild(a); setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
212  }
213
214  /* --- Toast --- */
215  var toastEl;
216  function toast(msg) {
217    if (!toastEl) { toastEl = document.createElement('div'); toastEl.className = 'cta-toast'; document.body.appendChild(toastEl); }
218    toastEl.textContent = msg; toastEl.classList.add('show');
219    clearTimeout(toast._t); toast._t = setTimeout(function () { toastEl.classList.remove('show'); }, 2400);
220  }
221
222  /* ============================================================
223     Превью-шит: показываем сгенерированную картинку, затем шарим/сохраняем
224     ============================================================ */
225  var overlay, current; // current = { blob, url, file }
226
227  function buildSheet() {
228    overlay = document.createElement('div');
229    overlay.className = 'cta-preview-overlay';
230    overlay.innerHTML =
231      '<div class="cta-preview" role="dialog" aria-modal="true" aria-label="Поделиться результатом">' +
232        '<div class="cta-preview-grip"></div>' +
233        '<div class="cta-preview-title">Вот что вы отправите</div>' +
234        '<div class="cta-preview-stage">' +
235          '<div class="cta-preview-loader" id="cta-prev-loader"><span class="cta-spinner"></span>Готовим картинку…</div>' +
236          '<img class="cta-preview-img" id="cta-prev-img" alt="Превью результата" hidden />' +
237        '</div>' +
238        '<div class="cta-preview-actions" id="cta-prev-actions" hidden>' +
239          '<button class="btn btn-primary" data-act="share" id="cta-prev-share">Поделиться</button>' +
240          '<button class="btn btn-secondary" data-act="save">Сохранить картинку</button>' +
241        '</div>' +
242        '<button class="cta-preview-cancel" data-act="cancel">Отмена</button>' +
243      '</div>';
244    document.body.appendChild(overlay);
245
246    overlay.addEventListener('click', function (e) {
247      if (e.target === overlay) { closeSheet(); return; }
248      var btn = e.target.closest('[data-act]');
249      if (!btn) { return; }
250      var act = btn.dataset.act;
251      if (act === 'cancel') { closeSheet(); return; }
252      if (act === 'save' && current) {
253        downloadBlob(current.blob, cfg.id + '-story.png');
254        track('share_image', { method: 'download' });
255        toast('Картинка сохранена'); closeSheet(); return;
256      }
257      if (act === 'share' && current) {
258        // навигатор.share вызывается прямо из тапа (user gesture) — файл уже готов
259        if (navigator.canShare && navigator.canShare({ files: [current.file] })) {
260          navigator.share({ files: [current.file], text: cfg.getShareText ? cfg.getShareText() : '' })
261            .then(function () { track('share_image', { method: 'native' }); closeSheet(); })
262            .catch(function (err) { if (!err || err.name !== 'AbortError') { /* остаёмся в превью */ } });
263        } else {
264          downloadBlob(current.blob, cfg.id + '-story.png');
265          track('share_image', { method: 'download' });
266          toast('Картинка сохранена — выложите в сторис'); closeSheet();
267        }
268      }
269    });
270    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeSheet(); } });
271  }
272
273  function openPreview() {
274    track('share_open', {});
275    if (!overlay) { buildSheet(); }
276    var img = overlay.querySelector('#cta-prev-img');
277    var loader = overlay.querySelector('#cta-prev-loader');
278    var actions = overlay.querySelector('#cta-prev-actions');
279    var shareBtn = overlay.querySelector('#cta-prev-share');
280    // сброс состояния
281    img.hidden = true; loader.hidden = false; actions.hidden = true;
282    if (current && current.url) { URL.revokeObjectURL(current.url); current = null; }
283
284    requestAnimationFrame(function () { overlay.classList.add('open'); });
285
286    buildCard().then(function (res) {
287      current = res;
288      img.src = res.url; img.hidden = false; loader.hidden = true;
289      // показать «Поделиться» только если поддерживается шеринг файла
290      var canShareFile = !!(navigator.canShare && navigator.canShare({ files: [res.file] }));
291      shareBtn.hidden = !canShareFile;
292      actions.hidden = false;
293    }).catch(function () {
294      loader.textContent = 'Не удалось подготовить картинку';
295    });
296  }
297
298  function closeSheet() {
299    if (overlay) { overlay.classList.remove('open'); }
300    if (current && current.url) { URL.revokeObjectURL(current.url); current = null; }
301  }
302
303  /* ============================================================
304     Промо подписки Platinum Plus
305     Единый апселл-блок на всех калькуляторах. Можно отключить
306     на конкретной странице через window.CALC_CONFIG.platinum = false.
307     ============================================================ */
308  var PLATINUM_URL = 'https://coinkeeper.me/3?utm_source=viral&utm_medium=calc_platinum&utm_campaign=' + cfg.id + '#pricing';
309  var PLATINUM_FEATURES = [
310    { icon: '🎙', title: 'Голосовой ввод', hook: 'Продиктуйте трату обычными словами — ИИ заполнит сумму, категорию и счёт' },
311    { icon: '📷', title: 'Добавление по фото', hook: 'Снимок чека или экрана банка превращается в готовые операции' },
312    { icon: '💬', title: 'ИИ-ассистент по деньгам', hook: 'Спросите, куда уходят деньги и где сократить — ответит простым языком' },
313    { icon: '📊', title: 'Виджеты на главный экран', hook: 'Лимиты и расходы под контролем, не открывая приложение' }
314  ];
315
316  function platinumBlock() {
317    if (cfg.platinum === false) { return ''; }
318    var rows = PLATINUM_FEATURES.map(function (f) {
319      return '<div class="cta-plat-feat">' +
320        '<span class="cpf-icon">' + f.icon + '</span>' +
321        '<span class="cpf-body"><span class="cpf-title">' + f.title + '</span>' +
322        '<span class="cpf-hook">' + f.hook + '</span></span></div>';
323    }).join('');
324    return '<div class="cta-platinum">' +
325      '<div class="cta-plat-eyebrow">★ Platinum Plus</div>' +
326      '<div class="cta-plat-title">CoinKeeper умнее с&nbsp;Platinum&nbsp;Plus</div>' +
327      '<div class="cta-plat-sub">Считать деньги вручную — прошлый век. Подписка добавляет ИИ, который вносит операции и подсказывает, где вы переплачиваете.</div>' +
328      '<div class="cta-plat-feats">' + rows + '</div>' +
329      '<a class="btn cta-plat-cta" id="cta-plat-btn" href="' + PLATINUM_URL + '" target="_blank" rel="noopener">Посмотреть тарифы →</a>' +
330      '</div>';
331  }
332
333  /* ============================================================
334     Промо курсов CoinKeeper (edu.coinkeeper.me)
335     Показывается ПЕРЕД блоком Platinum Plus на калькуляторах,
336     чья тема совпадает с курсом: window.CALC_CONFIG.course =
337     'svoboda' | 'finplan' | 'pravilainvest'. Без поля — блока нет.
338     ============================================================ */
339  var COURSES = {
340    svoboda: {
341      title: 'Курс «Свобода&nbsp;от&nbsp;Долгов»',
342      sub: 'Калькулятор показал цифры — курс показывает, что с ними делать. 10 практических уроков: как выбраться из долгов, перестать брать новые и вернуть контроль над деньгами.',
343      features: [
344        { icon: '📉', title: 'Оценить долговую нагрузку', hook: 'Разберётесь, сколько на самом деле съедают платежи и что делать в первую очередь' },
345        { icon: '🔄', title: 'Рефинансирование', hook: 'Как законно снизить ставку и переплату по действующим кредитам' },
346        { icon: '⚡', title: 'Досрочное погашение', hook: 'Стратегия, которая приближает дату свободы и экономит на процентах' },
347        { icon: '🧠', title: 'Психология долгов', hook: 'Почему мы берём в долг — и как перестать залезать в новые' }
348      ]
349    },
350    finplan: {
351      title: 'Курс «Личный&nbsp;
351финансовый&nbsp;план»',
352      sub: 'Калькулятор показал цифру — курс доводит её до плана. 12 уроков-марафон: как накопить на крупную цель и перестать гадать, куда расходятся деньги.',
353      features: [
354        { icon: '🎯', title: 'Крупные цели', hook: 'Квартира, машина, образование детям — как посчитать срок и сумму, а не мечтать' },
355        { icon: '🛟', title: 'Подушка безопасности', hook: 'Сколько держать про запас именно вам и где эти деньги хранить' },
356        { icon: '📋', title: 'Личный финансовый план', hook: 'Рабочая тетрадь: доходы, расходы, цели и сроки — в одной таблице' },
357        { icon: '🔁', title: 'Финансовые привычки', hook: 'Как откладывать регулярно, не срываясь на второй месяц' }
358      ]
359    },
360    pravilainvest: {
361      title: 'Курс «Правила&nbsp;Инвестиций»',
362      sub: 'Калькулятор показал, сколько стоит бездействие. Курс из 15 уроков — как собрать портфель под свою цель и не паниковать на просадках.',
363      features: [
364        { icon: '🧭', title: 'Инвестиционный план', hook: 'Под вашу цель и срок, а не «купить то, что советуют в чате»' },
365        { icon: '📈', title: 'Акции и активы', hook: 'Какие классы активов бывают и как выбирать между ними' },
366        { icon: '🏦', title: 'Облигации и вклады', hook: 'Где реальная доходность после налогов и инфляции' },
367        { icon: '🧱', title: 'Сборка портфеля', hook: 'Пропорции, ребалансировка и поведение в шторм на рынке' }
368      ]
369    }
370  };
371
372  function courseUrl(slug) {
373    return 'https://edu.coinkeeper.me/' + slug +
374      '?utm_source=viral&utm_medium=calc_course&utm_campaign=' + cfg.id;
375  }
376
377  function courseBlock() {
378    var c = COURSES[cfg.course];
379    if (!c) { return ''; }
380    var rows = c.features.map(function (f) {
381      return '<div class="cta-plat-feat">' +
382        '<span class="cpf-icon">' + f.icon + '</span>' +
383        '<span class="cpf-body"><span class="cpf-title">' + f.title + '</span>' +
384        '<span class="cpf-hook">' + f.hook + '</span></span></div>';
385    }).join('');
386    return '<div class="cta-platinum cta-course">' +
387      '<div class="cta-plat-eyebrow">★ Курс CoinKeeper</div>' +
388      '<div class="cta-plat-title">' + c.title + '</div>' +
389      '<div class="cta-plat-sub">' + c.sub + '</div>' +
390      '<div class="cta-plat-feats">' + rows + '</div>' +
391      '<a class="btn cta-plat-cta" id="cta-course-btn" href="' + courseUrl(cfg.course) + '" target="_blank" rel="noopener">Узнать о курсе →</a>' +
392      '</div>';
393  }
394
395  // апселлы идут стопкой: сначала тематический курс, потом Platinum Plus
396  function upsellBlock() {
397    return courseBlock() + platinumBlock();
398  }
399
400  /* ============================================================
401     Мини-сетка других калькуляторов
402     ============================================================ */
403  function crossGrid() {
404    var rel = cfg.related || [];
405    if (!rel.length) { return ''; }
406    var cards = rel.map(function (r) {
407      var sep = r.href.indexOf('?') > -1 ? '&' : '?';
408      var href = r.href + sep + 'utm_source=viral&utm_medium=cross&from=' + cfg.id;
409      return '<a class="cta-cross-card" data-to="' + (r.id || r.href) + '" href="' + href + '">' +
410        '<span class="cc-icon">' + (r.icon || '🧮') + '</span>' +
411        '<span class="cc-body"><span class="cc-title">' + r.title + '</span>' +
412        (r.hook ? '<span class="cc-hook">' + r.hook + '</span>' : '') + '</span>' +
413        '<span class="cc-arrow">→</span></a>';
414    }).join('');
415    return '<div class="cta-cross"><div class="cta-cross-title">Ещё калькуляторы</div><div class="cta-cross-grid">' + cards + '</div></div>';
416  }
417
418  /* ============================================================
419     Рендер CTA-блока в #cta-root
420     ============================================================ */
421  function render() {
422    var reason = cfg.getInstallReason ? cfg.getInstallReason() : '';
423    root.innerHTML =
424      '<div class="cta-actions">' +
425        '<button class="btn btn-primary" id="cta-share-btn">Поделиться результатом</button>' +
426        '<div class="cta-reason" id="cta-reason">' + reason + '</div>' +
427        '<a class="btn btn-secondary" id="cta-install-btn" href="' + INSTALL + '" target="_blank" rel="noopener">
427Установить CoinKeeper</a>' +
428      '</div>' +
429      upsellBlock() +
430      crossGrid();
431
432    document.getElementById('cta-share-btn').addEventListener('click', openPreview);
433    document.getElementById('cta-install-btn').addEventListener('click', function () { track('install_click', { platform: INSTALL_PLATFORM }); });
434    var platBtn = document.getElementById('cta-plat-btn');
435    if (platBtn) { platBtn.addEventListener('click', function () { track('platinum_click', {}); }); }
436    var courseBtn = document.getElementById('cta-course-btn');
437    if (courseBtn) { courseBtn.addEventListener('click', function () { track('course_click', { course: cfg.course }); }); }
438    root.querySelectorAll('.cta-cross-card').forEach(function (a) {
439      a.addEventListener('click', function () { track('cross_calc_click', { to: a.dataset.to }); });
440    });
441  }
442
443  // перерисовать только динамический текст-причину
444  function refresh() {
445    var el = document.getElementById('cta-reason');
446    if (el && cfg.getInstallReason) { el.innerHTML = cfg.getInstallReason(); }
447  }
448
449  window.CTA = { refresh: refresh, open: openPreview };
450  render();
451  /* page_view шлёт сам gtag (send_page_view:true в analytics.js) — здесь не дублируем */
452})();

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.