1/* Native in-app purchases for the Capacitor mobile apps. 2 * 3 * Loaded on /pro and /account. Does NOTHING in a normal browser - it only 4 * activates when the page runs inside the Capacitor WebView (window.Capacitor 5 * is injected by the native shell, along with the cordova-plugin-purchase 6 * JS, because assetmarketcap.com is the app's server.url). 7 * 8 * What it does in the app: 9 * - adds html.is-native-app (CSS hides Stripe/web-only billing UI - Apple 10 * guideline 3.1.1 forbids links out to external payment) 11 * - registers the 4 subscription products and shows localized store prices 12 * - intercepts the Stripe checkout <form> submits and runs the native 13 * App Store / Play Store purchase sheet instead 14 * - after purchase/restore, POSTs the receipt to /iap/validate which 15 * verifies it directly with Apple/Google and activates Pro on the account 16 * 17 * Product ids (must match the store consoles + IAP_PRODUCTS in iap.py). 18 * The stores use DIFFERENT ids: Apple's were created as Pro1month/Pro1year/ 19 * ProPlus1month/ProPlus1year (immutable once created); Google Play requires 20 * ids to start with a lowercase letter/digit, so Play uses amc_*. 21 */ 22(function () { 23 'use strict'; 24 25 var isNative = !!(window.Capacitor && window.Capacitor.isNativePlatform && 26 window.Capacitor.isNativePlatform()); 27 if (!isNative) return; 28 29 document.documentElement.classList.add('is-native-app'); 30 31 // Hide web-only billing UI inside the app (external purchase links are an 32 // App Store rejection; Play has the same rule for subscriptions). 33 var css = [ 34 'html.is-native-app form[action="/create-customer-portal-session"],', 35 'html.is-native-app form[action="/create-pro-plus-customer-portal-session"],', 36 'html.is-native-app form[action="/create-api-customer-portal-session"],', 37 'html.is-native-app form[action="/create-api-checkout-session"],', 38 'html.is-native-app form[action="/cancel-subscription"],', 39 'html.is-native-app form[action="/cancel-pro-plus-subscription"],', 40 'html.is-native-app form[action="/cancel-api-subscription"],', 41 'html.is-native-app .x-final-trust,', 42 'html.is-native-app [data-web-billing-only] { display: none !important; }', 43 'html.is-native-app [data-native-billing-only] { display: block !important; }', 44 'html.is-native-app .iap-restore { display: inline-block !important; }' 45 ].join('\n'); 46 var styleEl = document.createElement('style'); 47 styleEl.textContent = css; 48 document.head.appendChild(styleEl); 49 50 var IDS_APPLE = { 51 'pro:monthly': 'Pro1month', 52 'pro:annual': 'Pro1year', 53 'pro_plus:monthly': 'ProPlus1month', 54 'pro_plus:annual': 'ProPlus1year' 55 }; 56 var IDS_GOOGLE = { 57 'pro:monthly': 'amc_pro_monthly', 58 'pro:annual': 'amc_pro_annual', 59 'pro_plus:monthly': 'amc_proplus_monthly', 60 'pro_plus:annual': 'amc_proplus_annual' 61 }; 62 var PRODUCT_IDS = null;
62 // set in init() once the platform is known 63 // Stripe checkout endpoints -> tier, so the same interception works on 64 // /pro plan cards AND the /account subscribe/upgrade forms. 65 var FORM_TIER = { 66 '/create-checkout-session': 'pro', 67 '/create-pro-plus-checkout-session': 'pro_plus' 68 }; 69 70 var store = null; 71 var iapPlatform = null; 72 var busy = false; 73 74 // Block the Stripe checkout forms FIRST, before the store is ready. If the 75 // purchase plugin is missing or slow, the native app must fail closed (no 76 // purchase) rather than fall through to external payment, which is an App 77 // Store 3.1.1 rejection. These bind on DOM ready, not on deviceready. 78 if (document.readyState === 'loading') { 79 document.addEventListener('DOMContentLoaded', bindUi, false); 80 } else { 81 bindUi(); 82 } 83 function bindUi() { 84 interceptCheckoutForms(); 85 bindRestoreLinks(); 86 } 87 88 document.addEventListener('deviceready', init, false); 89 // Safety net: Capacitor fires deviceready, but if this script loads after 90 // the event already fired, init directly. 91 setTimeout(function () { 92 if (!store && window.CdvPurchase) init(); 93 }, 4000); 94 95 function init() { 96 if (store || !window.CdvPurchase) { 97 if (!window.CdvPurchase) console.warn('[iap] CdvPurchase not available'); 98 return; 99 } 100 store = CdvPurchase.store; 101 iapPlatform = (Capacitor.getPlatform() === 'ios') 102 ? CdvPurchase.Platform.APPLE_APPSTORE 103 : CdvPurchase.Platform.GOOGLE_PLAY; 104 PRODUCT_IDS = (Capacitor.getPlatform() === 'ios') ? IDS_APPLE : IDS_GOOGLE; 105 106 var products = Object.keys(PRODUCT_IDS).map(function (k) { 107 return { 108 id: PRODUCT_IDS[k], 109 type: CdvPurchase.ProductType.PAID_SUBSCRIPTION, 110 platform: iapPlatform 111 }; 112 }); 113 store.register(products); 114 115 store.when() 116 .approved(onApproved) 117 .productUpdated(refreshDisplayedPrices); 118 119 store.error(function (err) { 120 console.warn('[iap] store error', err && err.code, err && err.message); 121 busy = false; 122 resetButtons(); 123 }); 124 125 store.initialize([iapPlatform]).then(function () { 126 refreshDisplayedPrices(); 127 }); 128 129 // Checkout forms are already intercepted by bindUi(); just un-hide the 130 // restore link now that the store exists. 131 bindRestoreLinks(); 132 } 133 134 // ââ Purchase flow âââââââââââââââââââââââââââââââââââââââââââââ 135 136 function interceptCheckoutForms() { 137 document.addEventListener('submit', function (ev) { 138 var form = ev.target; 139 var action = (form.getAttribute && form.getAttribute('action')) || ''; 140 var tier = FORM_TIER[action]; 141 if (!tier) return; // not a subscription form 142 ev.preventDefault(); 143 ev.stopImmediatePropagation(); // block the web "Loading..." handler 144 if (busy) return; 145 if (!store) { 146 alert('The App Store is still connecting. Please try again in a moment.'); 147 return; 148 } 149 150 var intervalInput = form.querySelector('input[name=interval]'); 151 var interval = (intervalInput && intervalInput.value) === 'annual' ? 'annual' : 'monthly'; 152 order(tier, interval, form.querySelector('button[type=submit]')); 153 }, true); // capture phase: beat pro.html's own submit listener 154 } 155 156 function order(tier, interval, btn) { 157 var productId = PRODUCT_IDS[tier + ':' + interval]; 158 var product = store.get(productId, iapPlatform); 159 var offer = product && product.getOffer(); 160 if (!offer) { 161 alert('Store products are still loading. Please try again in a moment.'); 162 return; 163 } 164 busy = true; 165 if (btn) { btn.disabled = true; btn.dataset.prevText = btn.textContent; btn.textContent = 'Opening store...'; } 166 offer.order().then(function (err) { 167 // resolves when the store sheet closes; err is set if it failed/cancelled 168 busy = false; 169 resetButtons(); 170 if (err) console.warn('[iap] order result', err.code, err.message); 171 }); 172 } 173 174 function resetButtons() { 175 document.querySelectorAll('button[data-prev-text]').forEach(function (b) { 176 b.disabled = false;
177 b.textContent = b.dataset.prevText; 178 delete b.dataset.prevText; 179 }); 180 } 181 182 // ââ Server-side verification ââââââââââââââââââââââââââââââââââ 183 184 function onApproved(tx) { 185 var payload = null; 186 if (Capacitor.getPlatform() === 'ios') { 187 var receipt = null; 188 try { 189 receipt = tx.parentReceipt && tx.parentReceipt.nativeData && 190 tx.parentReceipt.nativeData.appStoreReceipt; 191 if (!receipt && store.localReceipts) { 192 store.localReceipts.forEach(function (r) { 193 if (!receipt && r.nativeData && r.nativeData.appStoreReceipt) { 194 receipt = r.nativeData.appStoreReceipt; 195 } 196 }); 197 } 198 } catch (e) { /* fall through */ } 199 if (!receipt) { console.warn('[iap] no app receipt on approved tx'); return; } 200 payload = { platform: 'ios', receipt: receipt }; 201 } else { 202 var np = tx.nativePurchase || {}; 203 if (!np.purchaseToken) { console.warn('[iap] no purchaseToken on approved tx'); return; } 204 payload = { 205 platform: 'android', 206 purchaseToken: np.purchaseToken, 207 productId: (tx.products && tx.products[0] && tx.products[0].id) || np.productId 208 }; 209 } 210 211 fetch('/iap/validate', { 212 method: 'POST', 213 credentials: 'same-origin', 214 headers: { 'Content-Type': 'application/json' }, 215 body: JSON.stringify(payload) 216 }) 217 .then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j }; }); }) 218 .then(function (res) { 219 var j = res.body || {}; 220 if (j.ok) { 221 tx.finish(); // acknowledges on Android too 222 if (j.active) window.location.href = '/account?iap=activated'; 223 } else if (j.error === 'purchase_linked_elsewhere') { 224 tx.finish(); // don't retry forever 225 alert(j.message || 'This subscription is linked to another account.'); 226 } else { 227 // Server/verification hiccup: leave the tx unfinished so the plugin 228 // re-fires approved on next app launch and we retry. 229 console.warn('[iap] validate failed', res.status, j.error, j.message); 230 } 231 }) 232 .catch(function (e) { console.warn('[iap] validate network error', e); }); 233 } 234 235 // ââ Restore + localized prices ââââââââââââââââââââââââââââââââ 236 237 function bindRestoreLinks() { 238 document.querySelectorAll('.iap-restore').forEach(function (el) { 239 el.hidden = false; 240 if (el.dataset.iapBound) return; // bindUi() and init() both call this 241 el.dataset.iapBound = '1'; 242 el.addEventListener('click', function (ev) { 243 ev.preventDefault(); 244 if (!store) return; 245 el.textContent = 'Restoring...'; 246 store.restorePurchases().then(function () { 247 el.textContent = 'Restore purchases'; 248 }); 249 }); 250 }); 251 } 252 253 function refreshDisplayedPrices() { 254 // Rewrite the /pro plan cards with the store's localized prices so the 255 // label always matches what the store sheet will charge. 256 var cards = document.querySelectorAll('.x-plan[data-plan]'); 257 if (!cards.length) return; 258 cards.forEach(function (card) { 259 var tier = card.dataset.plan === 'pro-plus' ? 'pro_plus' : 'pro'; 260 var mo = priceOf(PRODUCT_IDS[tier + ':monthly']); 261 var yr = priceOf(PRODUCT_IDS[tier + ':annual']); 262 if (mo) { 263 card.dataset.monthlyAmount = mo; 264 card.dataset.ctaMonthly = 'Get ' + (tier === 'pro' ? 'Pro' : 'Pro Plus') + ' - ' + mo + '/mo'; 265 } 266 if (yr) { 267 card.dataset.annualAmount = yr; 268 card.dataset.ctaAnnual = 'Get ' + (tier === 'pro' ? 'Pro' : 'Pro Plus') + ' - ' + yr + '/yr'; 269 } 270 if (mo || yr) { 271 // Localized price string already contains the currency symbol. 272 var cur = card.querySelector('.x-plan-cur'); 273 if (cur) cur.textContent = ''; 274 var on = document.querySelector('#xBilling button.is-on'); 275 var interval = (on && on.dataset.interval) === 'annual' ? 'annual' : 'monthly'; 276 var amt = card.querySelector('[data-price-amount]'); 277 var ctaTxt = card.querySelector('[data-cta-text]'); 278 if (amt) amt.textContent = card.dataset[interval + 'Amount']; 279 if (ctaTxt) ctaTxt.textContent = card.dataset['cta' + (interval === 'annual' ? 'Annual' : 'Monthly')]; 280 } 281 }); 282 } 283 284 function priceOf(productId) { 285 var p = store && store.get(productId, iapPlatform); 286 var offer = p && p.getOffer(); 287 var phases = offer && offer.pricingPhases; 288 return (phases && phases.length && phases[phases.length - 1].price) || null; 289 } 290})();
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.