PageSourceSearch

https://mp3.now/static/js/upsell.js?v=1791030787

js mp3.now collected 2026-10-09 21:51:07 UTC 14,538 bytes, 336 lines download raw bytes

1/**
2 * mp3.now upsell renderer.
3 *
4 * Endpoints that throw 429/402 with {upsell: {variant, trigger, plan}}
5 * are routed through window.Mp3NowUpsell.show(payload). The renderer
6 * paints the variant the server picked, sets the attribution cookies
7 * (used by /billing/api/guest-checkout to thread the variant into
8 * Stripe metadata so the webhook can credit the conversion), and
9 * launches the embedded-checkout modal when the user taps Upgrade.
10 *
11 * Variant slugs: 'modal' | 'drawer' | 'inline' | 'banner'.
12 * Trigger slugs are free-form ('converter', etc) and used for
13 * per-feature segmentation in the admin dashboard.
14 */
15(function () {
16  'use strict';
17
18  var COOKIE_TTL_MIN = 30;
19  var TRIGGER_COPY = {
20    converter:          { feature: 'conversions',             plan: 'premium' },
21    default:            { feature: 'premium features',        plan: 'premium' },
22  };
23
24  function setCookie(name, value, minutes) {
25    var d = new Date();
26    d.setTime(d.getTime() + minutes * 60 * 1000);
27    document.cookie = name + '=' + encodeURIComponent(value) +
28                      ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
29  }
30
31  function logEvent(variant, trigger, kind) {
32    try {
33      fetch('/api/upsell-event', {
34        method: 'POST',
35        credentials: 'same-origin',
36        headers: { 'Content-Type': 'application/json' },
37        body: JSON.stringify({ variant: variant, trigger: trigger, kind: kind }),
38        keepalive: true,
39      });
40    } catch (e) {}
41    // Mirror to Matomo + Clicky so the same funnel shows up in both dashboards
42    if (window.Mp3NowAnalytics) {
43      window.Mp3NowAnalytics.track('Upsell', kind, variant + (trigger ? ':' + trigger : ''));
44    }
45  }
46
47  function buildBullets() {
48    return [
49      'Unlimited usage — no daily caps',
50      'Faster, priority queue',
51      'Bigger uploads — up to 200 MB per file',
52      'Cancel anytime',
53    ];
54  }
55
56  function renderFeatureLine(trigger) {
57    var t = TRIGGER_COPY[trigger] || TRIGGER_COPY.default;
58    return 'You\'ve used your free ' + t.feature + ' for today.';
59  }
60
61  /* ── Common factory: returns a card body (sans wrapper) ───────────────── */
62  function cardInner(payload, opts) {
63    opts = opts || {};
64    var t = TRIGGER_COPY[payload.trigger] || TRIGGER_COPY.default;
65    var bullets = buildBullets().map(function (b) { return '<li>' + b + '</li>'; }).join('');
66    var headline = opts.headline || 'Keep going with Premium';
67    return [
68      '<span class="up-pill">Daily limit reached</span>',
69      '<div class="up-title">', headline, '</div>',
70      '<p class="up-sub">', renderFeatureLine(payload.trigger),
71        ' Upgrade for unlimited access and faster processing.</p>',
72      '<div class="up-price">$7.99<span class="up-price-mo">/mo, billed annually</span></div>',
73      '<ul class="up-bullets">', bullets, '</ul>',
74      '<button type="button" class="up-cta" data-upsell-cta>Upgrade to Premium</button>',
75      '<button type="button" class="up-cta-secondary" data-upsell-dismiss>Maybe later</button>',
76    ].join('');
77  }
78
79  function attribute(payload) {
80    setCookie('upsell_v', payload.variant, COOKIE_TTL_MIN);
81    setCookie('upsell_t', payload.trigger || '', COOKIE_TTL_MIN);
82  }
83
84  function handleCardClicks(root, payload, cleanup) {
85    var cta     = root.querySelector('[data-upsell-cta]');
86    var dismiss = root.querySelector('[data-upsell-dismiss]');
87    var close   = root.querySelector('[data-upsell-close]');
88
89    if (cta) cta.addEventListener('click', function () {
90      logEvent(payload.variant, payload.trigger, 'click');
91      attribute(payload);
92      cleanup();
93      openCheckout(payload);
94    });
95    if (dismiss) dismiss.addEventListener('click', cleanup);
96    if (close)   close.addEventListener('click', cleanup);
97  }
98
99  /* ── Variant renderers ────────────────────────────────────────────────── */
100  function renderModal(payload) {
101    var wrap = document.createElement('div');
102    wrap.className = 'up-modal-overlay';
103    wrap.innerHTML =
104      '<div class="up-card up-modal-card">' +
105        '<button type="button" class="up-close" data-upsell-close aria-label="Close">&times;</button>' +
106        cardInner(payload) +
107      '</div>';
108    document.body.appendChild(wrap);
109    wrap.addEventListener('click', function (e) {
110      if (e.target === wrap) wrap.remove();
111    });
112    handleCardClicks(wrap, payload, function () { wrap.remove(); });
113    return wrap;
114  }
115
116  function renderDrawer(payload) {
117    var wrap = document.createElement('div');
118    wrap.className = 'up-drawer-overlay';
119    wrap.innerHTML =
120      '<div class="up-card up-drawer-card">' +
121        '<div class="up-drawer-handle"></div>' +
122        '<button type="button" class="up-close" data-upsell-close aria-label="Close">&times;</button>' +
123        cardInner(payload) +
124      '</div>';
125    document.body.appendChild(wrap);
126    wrap.addEventListener('click', function (e) {
127      if (e.target === wrap) wrap.remove();
128    });
129    handleCardClicks(wrap, payload, function () { wrap.remove(); });
130    return wrap;
131  }
132
133  function renderInline(payload) {
134    // Try to find the API result area; fall back to before the body.
135    var host =
136      document.querySelector('[data-upsell-host]') ||
137      document.querySelector('.upsell-host') ||
138      document.querySelector('#result, .result-area') ||
139      document.querySelector('main') ||
140      document.body;
141    // Remove any prior inline card so we don't stack
142    var prior = host.querySelector('.up-inline-card');
143    if (prior) prior.remove();
144    var card = document.createElement('div');
145    card.className = 'up-card up-inline-card';
146    card.innerHTML = cardInner(payload, { headline: 'You\'ve hit today\'s free limit' });
147    host.insertBefore(card, host.firstChild);
148    card.scrollIntoView({ behavior: 'smooth', block: 'center' });
149    handleCardClicks(card, payload, function () { card.remove(); });
150    return card;
151  }
152
153  function renderBanner(payload) {
154    var existing = document.querySelector('.up-banner');
155    if (existing) existing.remove();
156    var bar = document.createElement('div');
157    bar.className = 'up-banner';
158    bar.innerHTML =
159      '<div class="up-banner-text">' +
160        '<strong>Daily limit reached.</strong> Upgrade to Premium ($7.99/mo) for unlimited access.' +
161      '</div>' +
162      '<button type="button" class="up-banner-cta" data-upsell-cta>Upgrade</button>' +
163      '<button type="button" class="up-banner-close" data-upsell-close aria-label="Dismiss">&times;</button>';
164    document.body.appendChild(bar);
165    handleCardClicks(bar, payload, function () { bar.remove(); });
166    return bar;
167  }
168
169  /* ── Embedded Stripe checkout modal (lazy-loaded) ─────────────────────── */
170  var stripe = null, checkout = null;
171
172  async function openCheckout(payload) {
173    var pk = window.__STRIPE_PK__ || '';
174    if (!pk) {
175      // No Stripe key on page — fall back to pricing.
176      window.location.href = '/pricing';
177      return;
178    }
179    // Build an overlay if pricing.html's modal isn't on the current page
180    var overlay = document.getElementById('stripeCheckoutModal');
181    var fresh = false;
182    if (!overlay) {
183      fresh = true;
184      overlay = document.createElement('div');
185      overlay.id = 'stripeCheckoutModal';
186      overlay.className = 'scm-overlay';
187      overlay.innerHTML =
188        '<div class="scm-dialog">' +
189          '<div class="scm-header">' +
190            '<div class="scm-header-info">' +
191              '<span class="scm-plan-label">Premium</span>' +
192              // "billed annually" is not decoration: this checkout always sends
193              // billing:'annual', so the card is charged a year up front. Saying
194              // only "$7.99/mo" here sets up a surprise charge when a trial ends.
195              '<span class="scm-plan-price">' + (payload.trial ? '3 days free, then $7.99/mo, billed annually' : '$7.99/mo, billed annually') + '</span>' +
196            '</div>' +
197            '<button type="button" class="scm-close" id="scmCloseG" aria-label="Close">&times;</button>' +
198          '</div>' +
199          '<div class="scm-body">' +
200            '<div id="stripeCheckoutMountG" style="min-height:520px;"></div>' +
201            '<div class="scm-loading" id="scmLoadingG">Loading secure checkout&hellip;</div>' +
202            '<div class="scm-error" id="scmErrorG" hidden></div>' +
203          '</div>' +
204        '</div>';
205      document.body.appendChild(overlay);
206      injectCheckoutCss();
207    }
208    overlay.hidden = false;
209    document.body.classList.add('scm-open');
210
211    var mount = document.getElementById(fresh ? 'stripeCheckoutMountG' : 'stripeCheckoutMount');
212    var loading = document.getElementById(fresh ? 'scmLoadingG' : 'scmLoading');
213    var errBox = document.getElementById(fresh ? 'scmErrorG' : 'scmError');
214    var closeBtn = document.getElementById(fresh ? 'scmCloseG' : 'scmClose');
215
216    function closeIt() {
217      overlay.hidden = true;
218      document.body.classList.remove('scm-open');
219      if (checkout) { try { checkout.unmount(); } catch(e){} try { checkout.destroy(); } catch(e){} checkout = null; }
220    }
221    if (closeBtn) closeBtn.onclick = closeIt;
222    overlay.onclick = function (e) { if (e.target === overlay) closeIt(); };
223
224    try {
225      await ensureStripeJs();
226      if (!stripe) stripe = Stripe(pk);
227      var resp = await fetch('/billing/api/guest-checkout', {
228        method: 'POST',
229        credentials: 'same-origin',
230        headers: { 'Content-Type': 'application/json' },
231        body: JSON.stringify({
232          plan: payload.plan || 'premium',
233          billing: 'annual',
234          // Eligibility is still decided server-side; this only asks.
235          trial: !!payload.trial,
236        }),
237      });
238      if (!resp.ok) {
239        var err = await resp.json().catch(function () { return { error: 'Checkout failed.' }; });
240        throw new Error(err.error || 'Checkout failed.');
241      }
242      var data = await resp.json();
243      // Embedded Checkout loads m.stripe.network, which adblockers / Brave
244      // Shields / Safari ITP frequently block — initEmbeddedCheckout then throws
245      // and the modal shows a dead-end error (the funnel killer: opened
246      // checkouts that never complete). We KEEP the user in the modal — no auto
247      // redirect — but when the embed is blocked we surface a one-click link to
248      // Stripe's secure hosted page so they can still pay.
249      try {
250        checkout = await stripe.initEmbeddedCheckout({ clientSecret: data.client_secret });
251        if (loading) loading.hidden = true;
252        checkout.mount('#' + mount.id);
253      } catch (embErr) {
254        if (loading) loading.hidden = true;
255        if (errBox && data.fallback_url) {
256          errBox.innerHTML =
257            'A browser extension is blocking the in-page checkout. ' +
258            '<a href="' + data.fallback_url + '" style="font-weight:700;text-decoration:underline;">' +
259            'Continue to secure checkout →</a>';
260          errBox.hidden = false;
261          return;
262        }
263        throw embErr;
264      }
265    } catch (e) {
266      if (loading) loading.hidden = true;
267      if (errBox) { errBox.textContent = e.message || 'Could not load checkout.'; errBox.hidden = false; }
268    }
269  }
270
271  function ensureStripeJs() {
272    return new Promise(function (resolve, reject) {
273      if (typeof Stripe !== 'undefined') return resolve();
274      var s = document.createElement('script');
275      s.src = 'https://js.stripe.com/v3/';
276      s.onload = resolve;
277      s.onerror = function () { reject(new Error('Stripe.js failed to load.')); };
278      document.head.appendChild(s);
279    });
280  }
281
282  function injectCheckoutCss() {
283    if (document.getElementById('scmInjectedCss')) return;
284    var css = document.createElement('style');
285    css.id = 'scmInjectedCss';
286    css.textContent =
287      '.scm-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);
287display:flex;align-items:center;justify-content:center;z-index:9000;padding:16px;overflow-y:auto}' +
288      '.scm-overlay[hidden]{display:none}' +
289      '.scm-dialog{background:#fff;border-radius:14px;width:100%;max-width:480px;box-shadow:0 20px 60px rgba(0,0,0,.3);overflow:hidden;max-height:92vh;display:flex;flex-direction:column}' +
290      '.scm-header{display:flex;align-items:center;justify-content:space-between;padding:16px 22px;border-bottom:1px solid #e6e8eb;background:#f8fafc}' +
291      '.scm-header-info{display:flex;flex-direction:column;gap:2px}' +
292      '.scm-plan-label{font-size:14px;font-weight:700;color:#1a1a1a}' +
293      '.scm-plan-price{font-size:12px;color:#4a5160}' +
294      '.scm-close{background:transparent;border:none;cursor:pointer;width:32px;height:32px;border-radius:50%;color:#4a5160;font-size:22px;line-height:1}' +
295      '.scm-close:hover{background:#e6e8eb}' +
296      '.scm-body{flex:1;overflow-y:auto}' +
297      '.scm-loading{padding:80px 20px;text-align:center;color:#4a5160;font-size:14px}' +
298      '.scm-error{margin:18px;padding:12px 14px;background:#fee2e2;color:#991b1b;border-radius:8px;font-size:13px;line-height:1.5}' +
299      'body.scm-open{overflow:hidden}';
300    document.head.appendChild(css);
301  }
302
303  /* ── Public entry point ───────────────────────────────────────────────── */
304  function show(payload) {
305    if (!payload || !payload.variant) return null;
306    if (window.Mp3NowAnalytics) {
307      window.Mp3NowAnalytics.track('Upsell', 'impression',
308        payload.variant + (payload.trigger ? ':' + payload.trigger : ''));
309    }
310    switch (payload.variant) {
311      case 'modal':   return renderModal(payload);
312      case 'drawer':  return renderDrawer(payload);
313      case 'inline':  return renderInline(payload);
314      case 'banner':  return renderBanner(payload);
315    }
316    return null;
317  }
318
319  /* Intercept fetch responses with an upsell payload — convenience helper.
320     Use like: Mp3NowUpsell.tryShowFromResponse(jsonData) after fetch failure. */
321  function tryShowFromResponse(data) {
322    if (data && data.upsell) {
323      show(data.upsell);
324      return true;
325    }
326    return false;
327  }
328
329  window.Mp3NowUpsell = {
330    show: show,
331    tryShowFromResponse: tryShowFromResponse,
332    // Opens the embedded checkout straight away — used by the sticky
333    // "Start free trial" bar and the funnel CTAs.
334    openCheckout: openCheckout,
335  };
336})();

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.