1import { BridgeComponent } from "@hotwired/hotwire-native-bridge" 2 3// The web half of in-app billing, shared by both stores. The shell owns the 4// money; this owns the page around it. 5// 6// The split matters: a store will only sell through its own client, which only 7// exists natively, but every price it quotes is local, tax-inclusive and 8// rounded by the store â so the page must never render a price of its own. It 9// renders slots, asks the shell what the store says, and fills them in. Until 10// that reply arrives the buy button stays disabled, because a purchase button 11// whose price is unknown is exactly the one that must not be tappable. 12// 13// The purchase token comes back the same way and is posted through a real form: 14// the shell has no HTTP client and no credentials, so the page's own session is 15// what authenticates the claim (see GooglePlay::PurchasesController and 16// AppStore::PurchasesController). 17// 18// NOT a file named *_controller.js on purpose. eagerLoadControllersFrom 19// registers every controller path matching that suffix, and a BridgeComponent 20// with no `static component` registered as a Stimulus controller is a component 21// with no name â so the base class lives here and only the two subclasses, 22// which each name their store, are loadable. 23// 24// The message contract is identical on both platforms, which is what lets one 25// page and one controller serve both: 26// 27// connect { accountId } -> { products: [{ basePlanId, formattedPrice, offerToken }] } 28// purchase { basePlanId } -> { purchaseState, purchaseToken, errorMessage } 29// restore {} -> the same reply shape as purchase 30// 31// On iOS `basePlanId` is the StoreKit product id and `purchaseToken` is the 32// transaction's JWS; on Android they are the Play base plan id and the purchase 33// token. Neither is interpreted here â the server decides what a purchase 34// means. 35export default class InAppBillingController extends BridgeComponent { 36 static targets = ["monthlyPill", "annualPill", "savingBadge", "price", "cadence", "cta", "status", "form", "token"] 37 static values = { accountId: String, monthlyPlan: String, annualPlan: String, storeName: String } 38 39 connect() { 40 super.connect() 41 // Annual first, matching the web checkout's default. 42 this.interval = "annual" 43 this.products = {} 44 this.purchasing = false 45 46 this.send("connect", { accountId: this.accountIdValue }, message => { 47 this.products = {} 48 for (const product of message.data.products || []) { 49 this.products[product.basePlanId] = product 50 } 51 this.render() 52 }) 53 } 54 55 select({ params: { interval } }) { 56 if (this.purchasing) return 57 this.interval = interval 58 this.render() 59 } 60 61 purchase() { 62 if (this.purchasing) return 63 64 const product = this.selectedProduct 65 if (!product) return 66 67 // Tracked here, not inferred from the button's disabled state, because 68 // render() owns that attribute and will happily turn it back on. Switching 69 // interval mid-purchase used to re-enable the button and overwrite 70 // "Openingâ¦", which let a second tap start a second flow while the store's 71 // sheet was still open â and the shell drops the first purchase's result 72 // when that happens. 73 this.beginPurchase(`Opening ${this.storeName}â¦`) 74 75 this.send("purchase", { basePlanId: product.basePlanId }, message => this.handlePurchaseReply(message)) 76 } 77 78 // Guideline 3.1.1 requires a Restore Purchases control on any screen that 79 // sells. It is a purchase by another name â the shell answers with the same 80 // reply shape, carrying the existing transaction rather than a new one â so 81 // it runs through exactly the same flag handling and the same server round 82 // trip. A restore that granted anything client-side would be a restore anyone 83 // could fake. 84 restore() { 85 if (this.purchasing) return 86 87 this.beginPurchase("Checking with the storeâ¦") 88 89 this.send("restore", {}, message => this.handlePurchaseReply(message, { restoring: true })) 90 } 91 92 beginPurchase(label) { 93 this.purchasing = true 94 this.ctaTarget.disabled = true 95 this.ctaTarget.textContent = label 96 this.clearStatus() 97 } 98 99 handlePurchaseReply(message, { restoring = false } = {}) { 100 const { purchaseState, purchaseToken, errorMessage } = message.data 101 102 if (purchaseState === "purchased" && purchaseToken) { 103 // Let the server decide what the purchase means. It re-reads the purchase 104 // from the store and refuses anything that doesn't line up, so nothing 105 // here is trusted beyond "there is a token to check". 106 // 107 // `purchasing` stays set: the POST below is a full page load, and until it 108 // lands the page is still interactive. Clearing the flag here let a pill 109 // tap re-enable the button while "Confirmingâ¦" was showing and start a 110 // second flow on top of the submit. 111 this.ctaTarget.textContent = "Confirmingâ¦" 112 this.tokenTarget.value = purchaseToken 113 this.formTarget.requestSubmit() 114 return 115 } 116 117 this.purchasing = false 118 119 // A pending purchase is a slow payment method (or Ask to Buy) that has not 120 // settled. The store finishes it on its own and the webhook grants Pro when 121 // it clears, so the button stays DISABLED â re-enabling it here invites a 122 // second charge for a purchase that is already going through. 123 if (purchaseState === "pending") { 124 this.ctaTarget.disabled = true 125 this.ctaTarget.textContent = `Waiting for ${this.storeName}â¦` 126 this.showStatus( 127 `${this.storeName} is still processing that payment. Pro switches on as soon as it clears, so there is no need to buy again.`, 128 { tone: "neutral" } 129 ) 130 return 131 } 132 133 this.render() 134 // A cancelled purchase is a decision, not a failure â saying anything about
135 // it would be nagging. A cancelled RESTORE is the same gesture. 136 if (purchaseState === "cancelled") return 137 138 if (restoring) { 139 this.showStatus(errorMessage || "We couldn't find a purchase to restore on this account.", { tone: "neutral" }) 140 return 141 } 142 143 this.showStatus(errorMessage || `${this.storeName} couldn't complete that purchase. Please try again.`) 144 } 145 146 get storeName() { 147 return this.storeNameValue || "the store" 148 } 149 150 get selectedProduct() { 151 return this.products[this.interval === "annual" ? this.annualPlanValue : this.monthlyPlanValue] 152 } 153 154 render() { 155 if (this.purchasing) return 156 157 const annual = this.interval === "annual" 158 this.setPill(this.annualPillTarget, annual) 159 this.setPill(this.monthlyPillTarget, !annual) 160 // The badge sits inside the annual pill, so it inverts with it. 161 this.savingBadgeTarget.className = annual 162 ? "px-1.5 py-0.5 text-[10px] font-semibold rounded-full bg-white/20 text-white whitespace-nowrap" 163 : "px-1.5 py-0.5 text-[10px] font-semibold rounded-full bg-emerald-100 text-emerald-700 whitespace-nowrap" 164 165 const product = this.selectedProduct 166 if (!product) { 167 // Either the reply hasn't arrived yet or the store has no price for this 168 // plan in this country. Both mean the same thing to the user: not buyable. 169 this.priceTarget.textContent = "â" 170 this.priceTarget.className = "text-stone-300" 171 this.cadenceTarget.textContent = `Loading prices from ${this.storeName}â¦` 172 this.ctaTarget.disabled = true 173 this.ctaTarget.textContent = "Upgrade to Pro" 174 return 175 } 176 177 this.priceTarget.textContent = product.formattedPrice 178 this.priceTarget.className = "" 179 this.cadenceTarget.textContent = annual ? "Billed yearly" : "Billed monthly" 180 this.ctaTarget.disabled = false 181 this.ctaTarget.textContent = `Upgrade to Pro for ${product.formattedPrice}` 182 } 183 184 setPill(element, active) { 185 element.className = `inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full text-xs font-medium transition-colors ${ 186 active ? "bg-stone-900 text-white" : "text-stone-400" 187 }` 188 } 189 190 showStatus(message, { tone = "error" } = {}) { 191 this.statusTarget.textContent = message 192 this.statusTarget.classList.remove("hidden") 193 this.statusTarget.classList.toggle("text-red-600", tone === "error") 194 this.statusTarget.classList.toggle("text-stone-500", tone !== "error") 195 } 196 197 clearStatus() { 198 this.statusTarget.textContent = "" 199 this.statusTarget.classList.add("hidden") 200 // Reset the tone: a neutral notice recolours this element, and leaving it 201 // grey would render the next real error as ordinary text. 202 this.statusTarget.classList.remove("text-stone-500") 203 this.statusTarget.classList.add("text-red-600") 204 } 205}
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.