PageSourceSearch

https://hjarni.com/assets/controllers/bridge/in_app_billing-3c854352.js

js hjarni.com collected 2026-09-24 11:51:59 UTC 8,665 bytes, 205 lines download raw bytes

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.