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">×</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">×</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">×</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">×</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…</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.