rampkit-expo-dev 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,6 +74,9 @@ dependencies {
74
74
  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
75
75
  implementation 'com.google.android.play:review:2.0.1'
76
76
  implementation 'com.google.android.play:review-ktx:2.0.1'
77
+ implementation 'com.android.billingclient:billing:6.1.0'
78
+ implementation 'com.android.billingclient:billing-ktx:6.1.0'
79
+ implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
77
80
  }
78
81
 
79
82
  def getKotlinVersion() {
@@ -1,7 +1,6 @@
1
1
  package expo.modules.rampkit
2
2
 
3
3
  import android.Manifest
4
- import android.app.Activity
5
4
  import android.app.ActivityManager
6
5
  import android.app.NotificationChannel
7
6
  import android.app.NotificationManager
@@ -16,26 +15,39 @@ import android.os.Vibrator
16
15
  import android.os.VibratorManager
17
16
  import android.provider.Settings
18
17
  import android.util.DisplayMetrics
18
+ import android.util.Log
19
19
  import android.view.WindowManager
20
20
  import androidx.core.app.ActivityCompat
21
21
  import androidx.core.content.ContextCompat
22
+ import com.android.billingclient.api.*
22
23
  import com.google.android.play.core.review.ReviewManagerFactory
23
24
  import expo.modules.kotlin.Promise
24
25
  import expo.modules.kotlin.modules.Module
25
26
  import expo.modules.kotlin.modules.ModuleDefinition
27
+ import kotlinx.coroutines.*
28
+ import org.json.JSONObject
29
+ import java.io.OutputStreamWriter
30
+ import java.net.HttpURLConnection
31
+ import java.net.URL
26
32
  import java.text.SimpleDateFormat
27
33
  import java.util.Date
28
34
  import java.util.Locale
29
35
  import java.util.TimeZone
30
36
  import java.util.UUID
31
37
 
32
- class RampKitModule : Module() {
38
+ class RampKitModule : Module(), PurchasesUpdatedListener {
39
+ private val TAG = "RampKit"
33
40
  private val PREFS_NAME = "rampkit_prefs"
34
41
  private val USER_ID_KEY = "rk_user_id"
35
42
  private val INSTALL_DATE_KEY = "rk_install_date"
36
43
  private val LAUNCH_COUNT_KEY = "rk_launch_count"
37
44
  private val LAST_LAUNCH_KEY = "rk_last_launch"
38
45
 
46
+ private var billingClient: BillingClient? = null
47
+ private var appId: String? = null
48
+ private var userId: String? = null
49
+ private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
50
+
39
51
  private val context: Context
40
52
  get() = requireNotNull(appContext.reactContext)
41
53
 
@@ -94,7 +106,7 @@ class RampKitModule : Module() {
94
106
  }
95
107
 
96
108
  AsyncFunction("isReviewAvailable") {
97
- true // Google Play In-App Review is generally available
109
+ true
98
110
  }
99
111
 
100
112
  AsyncFunction("getStoreUrl") {
@@ -112,6 +124,300 @@ class RampKitModule : Module() {
112
124
  AsyncFunction("getNotificationPermissions") {
113
125
  getNotificationPermissions()
114
126
  }
127
+
128
+ // ============================================================================
129
+ // Transaction Observer (Google Play Billing)
130
+ // ============================================================================
131
+
132
+ AsyncFunction("startTransactionObserver") { appId: String ->
133
+ startTransactionObserver(appId)
134
+ }
135
+
136
+ AsyncFunction("stopTransactionObserver") {
137
+ stopTransactionObserver()
138
+ }
139
+
140
+ OnDestroy {
141
+ stopTransactionObserver()
142
+ coroutineScope.cancel()
143
+ }
144
+ }
145
+
146
+ // ============================================================================
147
+ // PurchasesUpdatedListener Implementation
148
+ // ============================================================================
149
+
150
+ override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
151
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
152
+ for (purchase in purchases) {
153
+ handlePurchase(purchase)
154
+ }
155
+ } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
156
+ Log.d(TAG, "User cancelled the purchase")
157
+ } else {
158
+ Log.e(TAG, "Purchase failed: ${billingResult.debugMessage}")
159
+ }
160
+ }
161
+
162
+ // ============================================================================
163
+ // Transaction Observer
164
+ // ============================================================================
165
+
166
+ private fun startTransactionObserver(appId: String) {
167
+ this.appId = appId
168
+ this.userId = getOrCreateUserId()
169
+
170
+ billingClient = BillingClient.newBuilder(context)
171
+ .setListener(this)
172
+ .enablePendingPurchases()
173
+ .build()
174
+
175
+ billingClient?.startConnection(object : BillingClientStateListener {
176
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
177
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
178
+ Log.d(TAG, "Billing client connected")
179
+ // Query existing purchases
180
+ queryExistingPurchases()
181
+ } else {
182
+ Log.e(TAG, "Billing setup failed: ${billingResult.debugMessage}")
183
+ }
184
+ }
185
+
186
+ override fun onBillingServiceDisconnected() {
187
+ Log.d(TAG, "Billing service disconnected")
188
+ // Try to reconnect
189
+ coroutineScope.launch {
190
+ delay(5000)
191
+ startTransactionObserver(appId)
192
+ }
193
+ }
194
+ })
195
+
196
+ Log.d(TAG, "Transaction observer started")
197
+ }
198
+
199
+ private fun stopTransactionObserver() {
200
+ billingClient?.endConnection()
201
+ billingClient = null
202
+ Log.d(TAG, "Transaction observer stopped")
203
+ }
204
+
205
+ private fun queryExistingPurchases() {
206
+ val client = billingClient ?: return
207
+
208
+ // Query in-app purchases
209
+ client.queryPurchasesAsync(
210
+ QueryPurchasesParams.newBuilder()
211
+ .setProductType(BillingClient.ProductType.INAPP)
212
+ .build()
213
+ ) { billingResult, purchases ->
214
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
215
+ for (purchase in purchases) {
216
+ if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) {
217
+ handlePurchase(purchase)
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // Query subscriptions
224
+ client.queryPurchasesAsync(
225
+ QueryPurchasesParams.newBuilder()
226
+ .setProductType(BillingClient.ProductType.SUBS)
227
+ .build()
228
+ ) { billingResult, purchases ->
229
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
230
+ for (purchase in purchases) {
231
+ if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) {
232
+ handlePurchase(purchase)
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ private fun handlePurchase(purchase: Purchase) {
240
+ val appId = this.appId ?: return
241
+ val userId = this.userId ?: return
242
+
243
+ when (purchase.purchaseState) {
244
+ Purchase.PurchaseState.PURCHASED -> {
245
+ // Acknowledge the purchase if not already acknowledged
246
+ if (!purchase.isAcknowledged) {
247
+ val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
248
+ .setPurchaseToken(purchase.purchaseToken)
249
+ .build()
250
+
251
+ billingClient?.acknowledgePurchase(acknowledgePurchaseParams) { result ->
252
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
253
+ Log.d(TAG, "Purchase acknowledged")
254
+ }
255
+ }
256
+ }
257
+
258
+ // Get product details for price info
259
+ coroutineScope.launch {
260
+ val productDetails = getProductDetails(purchase.products.firstOrNull() ?: "")
261
+
262
+ val properties = mutableMapOf<String, Any?>(
263
+ "productId" to purchase.products.firstOrNull(),
264
+ "transactionId" to purchase.orderId,
265
+ "originalTransactionId" to purchase.orderId,
266
+ "purchaseToken" to purchase.purchaseToken,
267
+ "purchaseDate" to getIso8601Timestamp(purchase.purchaseTime),
268
+ "quantity" to purchase.quantity,
269
+ "isAutoRenewing" to purchase.isAutoRenewing
270
+ )
271
+
272
+ productDetails?.let { details ->
273
+ val pricingPhase = details.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()
274
+ ?: details.oneTimePurchaseOfferDetails?.let { oneTime ->
275
+ object {
276
+ val priceAmountMicros = oneTime.priceAmountMicros
277
+ val priceCurrencyCode = oneTime.priceCurrencyCode
278
+ val formattedPrice = oneTime.formattedPrice
279
+ }
280
+ }
281
+
282
+ if (pricingPhase != null) {
283
+ properties["price"] = when (pricingPhase) {
284
+ is ProductDetails.PricingPhase -> pricingPhase.priceAmountMicros / 1_000_000.0
285
+ else -> (pricingPhase as? Any)?.let {
286
+ (it.javaClass.getMethod("getPriceAmountMicros").invoke(it) as Long) / 1_000_000.0
287
+ }
288
+ }
289
+ properties["currency"] = when (pricingPhase) {
290
+ is ProductDetails.PricingPhase -> pricingPhase.priceCurrencyCode
291
+ else -> (pricingPhase as? Any)?.let {
292
+ it.javaClass.getMethod("getPriceCurrencyCode").invoke(it) as String
293
+ }
294
+ }
295
+ properties["localizedPrice"] = when (pricingPhase) {
296
+ is ProductDetails.PricingPhase -> pricingPhase.formattedPrice
297
+ else -> (pricingPhase as? Any)?.let {
298
+ it.javaClass.getMethod("getFormattedPrice").invoke(it) as String
299
+ }
300
+ }
301
+ }
302
+ properties["localizedName"] = details.name
303
+ properties["productType"] = details.productType
304
+ }
305
+
306
+ sendPurchaseEvent(appId, userId, "purchase_completed", properties)
307
+ }
308
+ }
309
+ Purchase.PurchaseState.PENDING -> {
310
+ Log.d(TAG, "Purchase pending: ${purchase.products}")
311
+ }
312
+ }
313
+ }
314
+
315
+ private suspend fun getProductDetails(productId: String): ProductDetails? {
316
+ if (productId.isEmpty()) return null
317
+ val client = billingClient ?: return null
318
+
319
+ // Try as subscription first
320
+ var result = queryProductDetails(client, productId, BillingClient.ProductType.SUBS)
321
+ if (result == null) {
322
+ // Try as in-app purchase
323
+ result = queryProductDetails(client, productId, BillingClient.ProductType.INAPP)
324
+ }
325
+ return result
326
+ }
327
+
328
+ private suspend fun queryProductDetails(
329
+ client: BillingClient,
330
+ productId: String,
331
+ productType: String
332
+ ): ProductDetails? = suspendCancellableCoroutine { continuation ->
333
+ val productList = listOf(
334
+ QueryProductDetailsParams.Product.newBuilder()
335
+ .setProductId(productId)
336
+ .setProductType(productType)
337
+ .build()
338
+ )
339
+
340
+ val params = QueryProductDetailsParams.newBuilder()
341
+ .setProductList(productList)
342
+ .build()
343
+
344
+ client.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
345
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
346
+ continuation.resume(productDetailsList.firstOrNull()) {}
347
+ } else {
348
+ continuation.resume(null) {}
349
+ }
350
+ }
351
+ }
352
+
353
+ private fun sendPurchaseEvent(appId: String, userId: String, eventName: String, properties: Map<String, Any?>) {
354
+ coroutineScope.launch {
355
+ try {
356
+ val event = JSONObject().apply {
357
+ put("appId", appId)
358
+ put("appUserId", userId)
359
+ put("eventId", UUID.randomUUID().toString().lowercase())
360
+ put("eventName", eventName)
361
+ put("sessionId", UUID.randomUUID().toString().lowercase())
362
+ put("occurredAt", getIso8601Timestamp())
363
+ put("device", JSONObject().apply {
364
+ put("platform", "Android")
365
+ put("platformVersion", Build.VERSION.RELEASE)
366
+ put("deviceModel", "${Build.MANUFACTURER} ${Build.MODEL}")
367
+ put("sdkVersion", "1.0.0")
368
+ put("appVersion", getAppVersion())
369
+ put("buildNumber", getBuildNumber())
370
+ })
371
+ put("context", JSONObject().apply {
372
+ put("locale", Locale.getDefault().toLanguageTag())
373
+ put("regionCode", Locale.getDefault().country)
374
+ })
375
+ put("properties", JSONObject(properties.filterValues { it != null }))
376
+ }
377
+
378
+ val url = URL("https://uustlzuvjmochxkxatfx.supabase.co/functions/v1/app-user-events")
379
+ val connection = url.openConnection() as HttpURLConnection
380
+ connection.apply {
381
+ requestMethod = "POST"
382
+ setRequestProperty("Content-Type", "application/json")
383
+ setRequestProperty("apikey", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk")
384
+ setRequestProperty("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk")
385
+ doOutput = true
386
+ }
387
+
388
+ OutputStreamWriter(connection.outputStream).use { writer ->
389
+ writer.write(event.toString())
390
+ }
391
+
392
+ val responseCode = connection.responseCode
393
+ Log.d(TAG, "Purchase event sent: $eventName - Status: $responseCode")
394
+ connection.disconnect()
395
+ } catch (e: Exception) {
396
+ Log.e(TAG, "Failed to send purchase event", e)
397
+ }
398
+ }
399
+ }
400
+
401
+ private fun getAppVersion(): String? {
402
+ return try {
403
+ context.packageManager.getPackageInfo(context.packageName, 0).versionName
404
+ } catch (e: Exception) {
405
+ null
406
+ }
407
+ }
408
+
409
+ private fun getBuildNumber(): String? {
410
+ return try {
411
+ val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
412
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
413
+ packageInfo.longVersionCode.toString()
414
+ } else {
415
+ @Suppress("DEPRECATION")
416
+ packageInfo.versionCode.toString()
417
+ }
418
+ } catch (e: Exception) {
419
+ null
420
+ }
115
421
  }
116
422
 
117
423
  // Device Info Collection
@@ -138,7 +444,7 @@ class RampKitModule : Module() {
138
444
  "bundleId" to context.packageName,
139
445
  "appName" to getAppName(),
140
446
  "appVersion" to packageInfo?.versionName,
141
- "buildNumber" to getBuildNumber(packageInfo),
447
+ "buildNumber" to getBuildNumberFromPackage(packageInfo),
142
448
  "platform" to "Android",
143
449
  "platformVersion" to Build.VERSION.RELEASE,
144
450
  "deviceModel" to "${Build.MANUFACTURER} ${Build.MODEL}",
@@ -295,7 +601,6 @@ class RampKitModule : Module() {
295
601
 
296
602
  // Notifications
297
603
  private fun requestNotificationPermissions(options: Map<String, Any>?): Map<String, Any> {
298
- // Create notification channel for Android 8+
299
604
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
300
605
  val androidOptions = options?.get("android") as? Map<*, *>
301
606
  val channelId = androidOptions?.get("channelId") as? String ?: "default"
@@ -314,7 +619,6 @@ class RampKitModule : Module() {
314
619
  notificationManager.createNotificationChannel(channel)
315
620
  }
316
621
 
317
- // For Android 13+, check POST_NOTIFICATIONS permission
318
622
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
319
623
  val granted = ContextCompat.checkSelfPermission(
320
624
  context,
@@ -322,7 +626,6 @@ class RampKitModule : Module() {
322
626
  ) == PackageManager.PERMISSION_GRANTED
323
627
 
324
628
  if (!granted) {
325
- // Request permission - this will be handled by the app
326
629
  appContext.currentActivity?.let { activity ->
327
630
  ActivityCompat.requestPermissions(
328
631
  activity,
@@ -339,7 +642,6 @@ class RampKitModule : Module() {
339
642
  )
340
643
  }
341
644
 
342
- // For older Android versions, notifications are allowed by default
343
645
  return mapOf(
344
646
  "granted" to true,
345
647
  "status" to "granted",
@@ -388,7 +690,7 @@ class RampKitModule : Module() {
388
690
  }
389
691
  }
390
692
 
391
- private fun getBuildNumber(packageInfo: android.content.pm.PackageInfo?): String? {
693
+ private fun getBuildNumberFromPackage(packageInfo: android.content.pm.PackageInfo?): String? {
392
694
  return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
393
695
  packageInfo?.longVersionCode?.toString()
394
696
  } else {
@@ -442,4 +744,10 @@ class RampKitModule : Module() {
442
744
  sdf.timeZone = TimeZone.getTimeZone("UTC")
443
745
  return sdf.format(Date())
444
746
  }
747
+
748
+ private fun getIso8601Timestamp(millis: Long): String {
749
+ val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
750
+ sdf.timeZone = TimeZone.getTimeZone("UTC")
751
+ return sdf.format(Date(millis))
752
+ }
445
753
  }
@@ -67,43 +67,8 @@ export declare class RampKitCore {
67
67
  * Track a CTA tap
68
68
  */
69
69
  trackCtaTap(buttonId: string, buttonText?: string): void;
70
- /**
71
- * Track paywall shown
72
- */
73
- trackPaywallShown(paywallId: string, placement?: string, products?: Array<{
74
- productId: string;
75
- price?: number;
76
- currency?: string;
77
- }>): void;
78
- /**
79
- * Track paywall primary action tap
80
- */
81
- trackPaywallPrimaryActionTap(paywallId: string, productId?: string): void;
82
- /**
83
- * Track paywall closed
84
- */
85
- trackPaywallClosed(paywallId: string, reason: "dismissed" | "purchased" | "backgrounded"): void;
86
- /**
87
- * Track purchase started
88
- */
89
- trackPurchaseStarted(productId: string, amount?: number, currency?: string): void;
90
- /**
91
- * Track purchase completed
92
- */
93
- trackPurchaseCompleted(properties: {
94
- productId: string;
95
- amount: number;
96
- currency: string;
97
- transactionId: string;
98
- originalTransactionId?: string;
99
- purchaseDate?: string;
100
- }): void;
101
- /**
102
- * Track purchase failed
103
- */
104
- trackPurchaseFailed(productId: string, errorCode: string, errorMessage: string): void;
105
70
  /**
106
71
  * Cleanup SDK resources
107
72
  */
108
- cleanup(): void;
73
+ cleanup(): Promise<void>;
109
74
  }
package/build/RampKit.js CHANGED
@@ -10,6 +10,7 @@ const RampkitOverlay_1 = require("./RampkitOverlay");
10
10
  const userId_1 = require("./userId");
11
11
  const DeviceInfoCollector_1 = require("./DeviceInfoCollector");
12
12
  const EventManager_1 = require("./EventManager");
13
+ const RampKitNative_1 = require("./RampKitNative");
13
14
  const constants_1 = require("./constants");
14
15
  class RampKitCore {
15
16
  constructor() {
@@ -51,6 +52,9 @@ class RampKitCore {
51
52
  EventManager_1.eventManager.trackAppSessionStarted(this.deviceInfo.isFirstLaunch, this.deviceInfo.launchCount);
52
53
  // Step 5: Setup app state listener for background/foreground tracking
53
54
  this.setupAppStateListener();
55
+ // Step 6: Start transaction observer for automatic purchase tracking
56
+ console.log("[RampKit] Init: Starting transaction observer...");
57
+ await RampKitNative_1.TransactionObserver.start(config.appId);
54
58
  this.initialized = true;
55
59
  }
56
60
  catch (e) {
@@ -276,46 +280,15 @@ class RampKitCore {
276
280
  trackCtaTap(buttonId, buttonText) {
277
281
  EventManager_1.eventManager.trackCtaTap(buttonId, buttonText);
278
282
  }
279
- /**
280
- * Track paywall shown
281
- */
282
- trackPaywallShown(paywallId, placement, products) {
283
- EventManager_1.eventManager.trackPaywallShown(paywallId, placement, products);
284
- }
285
- /**
286
- * Track paywall primary action tap
287
- */
288
- trackPaywallPrimaryActionTap(paywallId, productId) {
289
- EventManager_1.eventManager.trackPaywallPrimaryActionTap(paywallId, productId);
290
- }
291
- /**
292
- * Track paywall closed
293
- */
294
- trackPaywallClosed(paywallId, reason) {
295
- EventManager_1.eventManager.trackPaywallClosed(paywallId, reason);
296
- }
297
- /**
298
- * Track purchase started
299
- */
300
- trackPurchaseStarted(productId, amount, currency) {
301
- EventManager_1.eventManager.trackPurchaseStarted(productId, amount, currency);
302
- }
303
- /**
304
- * Track purchase completed
305
- */
306
- trackPurchaseCompleted(properties) {
307
- EventManager_1.eventManager.trackPurchaseCompleted(properties);
308
- }
309
- /**
310
- * Track purchase failed
311
- */
312
- trackPurchaseFailed(productId, errorCode, errorMessage) {
313
- EventManager_1.eventManager.trackPurchaseFailed(productId, errorCode, errorMessage);
314
- }
283
+ // Note: Purchase and paywall events are automatically tracked by the native
284
+ // StoreKit 2 (iOS) and Google Play Billing (Android) transaction observers.
285
+ // No manual tracking is needed.
315
286
  /**
316
287
  * Cleanup SDK resources
317
288
  */
318
- cleanup() {
289
+ async cleanup() {
290
+ // Stop transaction observer
291
+ await RampKitNative_1.TransactionObserver.stop();
319
292
  if (this.appStateSubscription) {
320
293
  this.appStateSubscription.remove();
321
294
  this.appStateSubscription = null;
@@ -16,6 +16,8 @@ interface RampKitNativeModule {
16
16
  getStoreUrl(): Promise<string | null>;
17
17
  requestNotificationPermissions(options?: NotificationOptions): Promise<NotificationPermissionResult>;
18
18
  getNotificationPermissions(): Promise<NotificationPermissionResult>;
19
+ startTransactionObserver(appId: string): Promise<void>;
20
+ stopTransactionObserver(): Promise<void>;
19
21
  }
20
22
  export interface NativeDeviceInfo {
21
23
  appUserId: string;
@@ -149,3 +151,15 @@ export declare const Notifications: {
149
151
  MIN: number;
150
152
  };
151
153
  };
154
+ export declare const TransactionObserver: {
155
+ /**
156
+ * Start listening for purchase transactions
157
+ * Automatically tracks purchases to the RampKit backend
158
+ * @param appId - The RampKit app ID
159
+ */
160
+ start(appId: string): Promise<void>;
161
+ /**
162
+ * Stop listening for purchase transactions
163
+ */
164
+ stop(): Promise<void>;
165
+ };
@@ -4,7 +4,7 @@
4
4
  * TypeScript interface to the native iOS/Android module
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.Notifications = exports.StoreReview = exports.Haptics = void 0;
7
+ exports.TransactionObserver = exports.Notifications = exports.StoreReview = exports.Haptics = void 0;
8
8
  exports.getDeviceInfo = getDeviceInfo;
9
9
  exports.getUserId = getUserId;
10
10
  exports.getStoredValue = getStoredValue;
@@ -60,6 +60,8 @@ function createFallbackModule() {
60
60
  async getNotificationPermissions() {
61
61
  return { granted: false, status: "denied", canAskAgain: false };
62
62
  },
63
+ async startTransactionObserver(_appId) { },
64
+ async stopTransactionObserver() { },
63
65
  };
64
66
  }
65
67
  function generateFallbackUserId() {
@@ -253,3 +255,34 @@ exports.Notifications = {
253
255
  MIN: 1,
254
256
  },
255
257
  };
258
+ // ============================================================================
259
+ // Transaction Observer API (StoreKit 2 / Google Play Billing)
260
+ // ============================================================================
261
+ exports.TransactionObserver = {
262
+ /**
263
+ * Start listening for purchase transactions
264
+ * Automatically tracks purchases to the RampKit backend
265
+ * @param appId - The RampKit app ID
266
+ */
267
+ async start(appId) {
268
+ try {
269
+ await RampKitNativeModule.startTransactionObserver(appId);
270
+ console.log("[RampKit] Transaction observer started");
271
+ }
272
+ catch (e) {
273
+ console.warn("[RampKit] Failed to start transaction observer:", e);
274
+ }
275
+ },
276
+ /**
277
+ * Stop listening for purchase transactions
278
+ */
279
+ async stop() {
280
+ try {
281
+ await RampKitNativeModule.stopTransactionObserver();
282
+ console.log("[RampKit] Transaction observer stopped");
283
+ }
284
+ catch (e) {
285
+ console.warn("[RampKit] Failed to stop transaction observer:", e);
286
+ }
287
+ },
288
+ };
@@ -366,6 +366,11 @@ function Overlay(props) {
366
366
  else if (typeof pager.setPageWithoutAnimation === "function") {
367
367
  pager.setPageWithoutAnimation(nextIndex);
368
368
  }
369
+ // Explicitly send vars to the new page after setting it
370
+ // This ensures the webview receives the latest state
371
+ requestAnimationFrame(() => {
372
+ sendVarsToWebView(nextIndex);
373
+ });
369
374
  return;
370
375
  }
371
376
  setIsTransitioning(true);
@@ -380,6 +385,10 @@ function Overlay(props) {
380
385
  // @ts-ignore: method exists on PagerView instance
381
386
  (_c = (_b = (_a = pagerRef.current) === null || _a === void 0 ? void 0 : _a.setPageWithoutAnimation) === null || _b === void 0 ? void 0 : _b.call(_a, nextIndex)) !== null && _c !== void 0 ? _c : (_d = pagerRef.current) === null || _d === void 0 ? void 0 : _d.setPage(nextIndex);
382
387
  requestAnimationFrame(() => {
388
+ // Explicitly send vars to the new page after the page switch completes
389
+ // This ensures the webview receives the latest state even if onPageSelected
390
+ // timing was off during the transition
391
+ sendVarsToWebView(nextIndex);
383
392
  react_native_1.Animated.timing(fadeOpacity, {
384
393
  toValue: 0,
385
394
  duration: 160,
@@ -395,14 +404,20 @@ function Overlay(props) {
395
404
  .replace(/`/g, "\\`");
396
405
  return `(function(){try{document.dispatchEvent(new MessageEvent('message',{data:${json}}));}catch(e){}})();`;
397
406
  }
398
- function sendVarsToWebView(i) {
407
+ function sendVarsToWebView(i, isInitialLoad = false) {
399
408
  const wv = webviewsRef.current[i];
400
409
  if (!wv)
401
410
  return;
402
411
  const payload = { type: "rampkit:variables", vars: varsRef.current };
403
412
  if (__DEV__)
404
- console.log("[Rampkit] sendVarsToWebView", i, varsRef.current);
405
- lastInitSendRef.current[i] = Date.now();
413
+ console.log("[Rampkit] sendVarsToWebView", i, varsRef.current, { isInitialLoad });
414
+ // Only update the stale filter timestamp during initial page load,
415
+ // not when syncing vars on page selection. This prevents the filter
416
+ // from incorrectly rejecting legitimate user interactions that happen
417
+ // immediately after navigating to a screen.
418
+ if (isInitialLoad) {
419
+ lastInitSendRef.current[i] = Date.now();
420
+ }
406
421
  // @ts-ignore: injectJavaScript exists on WebView instance
407
422
  wv.injectJavaScript(buildDispatchScript(payload));
408
423
  }
@@ -470,7 +485,12 @@ function Overlay(props) {
470
485
  // ensure current page is synced with latest vars when selected
471
486
  if (__DEV__)
472
487
  console.log("[Rampkit] onPageSelected", pos);
473
- sendVarsToWebView(pos);
488
+ // Use requestAnimationFrame to ensure the webview is fully active and ready
489
+ // to receive injected JS. Without this delay, the first navigation back
490
+ // to a screen may not properly receive the updated variables.
491
+ requestAnimationFrame(() => {
492
+ sendVarsToWebView(pos);
493
+ });
474
494
  // Track screen change event
475
495
  if (props.onScreenChange && props.screens[pos]) {
476
496
  props.onScreenChange(pos, props.screens[pos].id);
@@ -561,10 +581,10 @@ function Overlay(props) {
561
581
  props.onScreenChange(0, props.screens[0].id);
562
582
  }
563
583
  }
564
- // Initialize this page with current vars
584
+ // Initialize this page with current vars (isInitialLoad=true to enable stale filter)
565
585
  if (__DEV__)
566
586
  console.log("[Rampkit] onLoadEnd init send vars", i);
567
- sendVarsToWebView(i);
587
+ sendVarsToWebView(i, true);
568
588
  }, onMessage: (ev) => {
569
589
  var _a, _b, _c, _d;
570
590
  const raw = ev.nativeEvent.data;
package/build/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { eventManager } from "./EventManager";
9
9
  export { collectDeviceInfo, getSessionDurationSeconds, getSessionStartTime, } from "./DeviceInfoCollector";
10
10
  export { default as RampKitNative } from "./RampKitNative";
11
11
  export type { NativeDeviceInfo, NativeLaunchData } from "./RampKitNative";
12
- export { Haptics, StoreReview, Notifications } from "./RampKitNative";
12
+ export { Haptics, StoreReview, Notifications, TransactionObserver } from "./RampKitNative";
13
13
  export type { ImpactStyle, NotificationType, NotificationOptions, NotificationPermissionResult } from "./RampKitNative";
14
14
  export type { DeviceInfo, RampKitEvent, EventDevice, EventContext, RampKitConfig, RampKitEventName, AppSessionStartedProperties, AppSessionEndedProperties, AppBackgroundedProperties, AppForegroundedProperties, OnboardingStartedProperties, OnboardingScreenViewedProperties, OnboardingQuestionAnsweredProperties, OnboardingCompletedProperties, OnboardingAbandonedProperties, ScreenViewProperties, CtaTapProperties, NotificationsPromptShownProperties, NotificationsResponseProperties, PaywallShownProperties, PaywallPrimaryActionTapProperties, PaywallClosedProperties, PurchaseStartedProperties, PurchaseCompletedProperties, PurchaseFailedProperties, } from "./types";
15
15
  export { SDK_VERSION, CAPABILITIES } from "./constants";
package/build/index.js CHANGED
@@ -7,7 +7,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
7
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
8
8
  };
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.CAPABILITIES = exports.SDK_VERSION = exports.Notifications = exports.StoreReview = exports.Haptics = exports.RampKitNative = exports.getSessionStartTime = exports.getSessionDurationSeconds = exports.collectDeviceInfo = exports.eventManager = exports.getRampKitUserId = exports.RampKit = void 0;
10
+ exports.CAPABILITIES = exports.SDK_VERSION = exports.TransactionObserver = exports.Notifications = exports.StoreReview = exports.Haptics = exports.RampKitNative = exports.getSessionStartTime = exports.getSessionDurationSeconds = exports.collectDeviceInfo = exports.eventManager = exports.getRampKitUserId = exports.RampKit = void 0;
11
11
  const RampKit_1 = require("./RampKit");
12
12
  // Main SDK singleton instance
13
13
  exports.RampKit = RampKit_1.RampKitCore.instance;
@@ -30,6 +30,7 @@ var RampKitNative_2 = require("./RampKitNative");
30
30
  Object.defineProperty(exports, "Haptics", { enumerable: true, get: function () { return RampKitNative_2.Haptics; } });
31
31
  Object.defineProperty(exports, "StoreReview", { enumerable: true, get: function () { return RampKitNative_2.StoreReview; } });
32
32
  Object.defineProperty(exports, "Notifications", { enumerable: true, get: function () { return RampKitNative_2.Notifications; } });
33
+ Object.defineProperty(exports, "TransactionObserver", { enumerable: true, get: function () { return RampKitNative_2.TransactionObserver; } });
33
34
  // Export constants
34
35
  var constants_1 = require("./constants");
35
36
  Object.defineProperty(exports, "SDK_VERSION", { enumerable: true, get: function () { return constants_1.SDK_VERSION; } });
@@ -10,6 +10,11 @@ public class RampKitModule: Module {
10
10
  private let launchCountKey = "rk_launch_count"
11
11
  private let lastLaunchKey = "rk_last_launch"
12
12
 
13
+ // Transaction observer task
14
+ private var transactionObserverTask: Task<Void, Never>?
15
+ private var appId: String?
16
+ private var userId: String?
17
+
13
18
  public func definition() -> ModuleDefinition {
14
19
  Name("RampKit")
15
20
 
@@ -62,11 +67,10 @@ public class RampKitModule: Module {
62
67
  }
63
68
 
64
69
  AsyncFunction("isReviewAvailable") { () -> Bool in
65
- return true // Always available on iOS
70
+ return true
66
71
  }
67
72
 
68
73
  AsyncFunction("getStoreUrl") { () -> String? in
69
- // Return nil - app should provide its own store URL
70
74
  return nil
71
75
  }
72
76
 
@@ -81,6 +85,18 @@ public class RampKitModule: Module {
81
85
  AsyncFunction("getNotificationPermissions") { () -> [String: Any] in
82
86
  return await self.getNotificationPermissions()
83
87
  }
88
+
89
+ // ============================================================================
90
+ // Transaction Observer (StoreKit 2)
91
+ // ============================================================================
92
+
93
+ AsyncFunction("startTransactionObserver") { (appId: String) in
94
+ self.startTransactionObserver(appId: appId)
95
+ }
96
+
97
+ AsyncFunction("stopTransactionObserver") { () in
98
+ self.stopTransactionObserver()
99
+ }
84
100
  }
85
101
 
86
102
  // MARK: - Device Info Collection
@@ -369,6 +385,205 @@ public class RampKitModule: Module {
369
385
  }
370
386
  }
371
387
 
388
+ // MARK: - StoreKit 2 Transaction Observer
389
+
390
+ private func startTransactionObserver(appId: String) {
391
+ self.appId = appId
392
+ self.userId = getOrCreateUserId()
393
+
394
+ // Cancel existing observer if any
395
+ transactionObserverTask?.cancel()
396
+
397
+ // Start listening for transactions (iOS 15+)
398
+ if #available(iOS 15.0, *) {
399
+ transactionObserverTask = Task {
400
+ await self.listenForTransactions()
401
+ }
402
+
403
+ // Also check for any unfinished transactions
404
+ Task {
405
+ await self.handleUnfinishedTransactions()
406
+ }
407
+ }
408
+
409
+ print("[RampKit] Transaction observer started")
410
+ }
411
+
412
+ private func stopTransactionObserver() {
413
+ transactionObserverTask?.cancel()
414
+ transactionObserverTask = nil
415
+ print("[RampKit] Transaction observer stopped")
416
+ }
417
+
418
+ @available(iOS 15.0, *)
419
+ private func listenForTransactions() async {
420
+ // Listen for transaction updates
421
+ for await result in Transaction.updates {
422
+ do {
423
+ let transaction = try self.checkVerified(result)
424
+ await self.handleTransaction(transaction)
425
+ await transaction.finish()
426
+ } catch {
427
+ print("[RampKit] Transaction verification failed: \(error)")
428
+ }
429
+ }
430
+ }
431
+
432
+ @available(iOS 15.0, *)
433
+ private func handleUnfinishedTransactions() async {
434
+ // Handle any transactions that weren't finished
435
+ for await result in Transaction.unfinished {
436
+ do {
437
+ let transaction = try self.checkVerified(result)
438
+ await self.handleTransaction(transaction)
439
+ await transaction.finish()
440
+ } catch {
441
+ print("[RampKit] Unfinished transaction verification failed: \(error)")
442
+ }
443
+ }
444
+ }
445
+
446
+ @available(iOS 15.0, *)
447
+ private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
448
+ switch result {
449
+ case .unverified(_, let error):
450
+ throw error
451
+ case .verified(let safe):
452
+ return safe
453
+ }
454
+ }
455
+
456
+ @available(iOS 15.0, *)
457
+ private func handleTransaction(_ transaction: Transaction) async {
458
+ guard let appId = self.appId, let userId = self.userId else { return }
459
+
460
+ // Determine event type based on transaction
461
+ let eventName: String
462
+ var properties: [String: Any] = [:]
463
+
464
+ switch transaction.revocationReason {
465
+ case .some(let reason):
466
+ eventName = "purchase_refunded"
467
+ properties["revocationReason"] = reason == .developerIssue ? "developer_issue" : "other"
468
+ case .none:
469
+ if transaction.isUpgraded {
470
+ eventName = "subscription_upgraded"
471
+ } else {
472
+ switch transaction.productType {
473
+ case .autoRenewable:
474
+ if transaction.originalID == transaction.id {
475
+ eventName = "purchase_completed"
476
+ } else {
477
+ eventName = "subscription_renewed"
478
+ }
479
+ case .consumable, .nonConsumable:
480
+ eventName = "purchase_completed"
481
+ case .nonRenewable:
482
+ eventName = "purchase_completed"
483
+ default:
484
+ eventName = "purchase_completed"
485
+ }
486
+ }
487
+ }
488
+
489
+ // Build properties
490
+ properties["productId"] = transaction.productID
491
+ properties["transactionId"] = String(transaction.id)
492
+ properties["originalTransactionId"] = String(transaction.originalID)
493
+ properties["purchaseDate"] = ISO8601DateFormatter().string(from: transaction.purchaseDate)
494
+
495
+ if let expirationDate = transaction.expirationDate {
496
+ properties["expirationDate"] = ISO8601DateFormatter().string(from: expirationDate)
497
+ }
498
+
499
+ properties["quantity"] = transaction.purchasedQuantity
500
+ properties["productType"] = mapProductType(transaction.productType)
501
+ properties["environment"] = transaction.environment.rawValue
502
+
503
+ if let webOrderLineItemID = transaction.webOrderLineItemID {
504
+ properties["webOrderLineItemId"] = webOrderLineItemID
505
+ }
506
+
507
+ // Get price info from product if available
508
+ if let product = await getProduct(for: transaction.productID) {
509
+ properties["price"] = product.price
510
+ properties["currency"] = product.priceFormatStyle.currencyCode
511
+ properties["localizedPrice"] = product.displayPrice
512
+ properties["localizedName"] = product.displayName
513
+ }
514
+
515
+ // Send event to backend
516
+ await sendPurchaseEvent(
517
+ appId: appId,
518
+ userId: userId,
519
+ eventName: eventName,
520
+ properties: properties
521
+ )
522
+ }
523
+
524
+ @available(iOS 15.0, *)
525
+ private func getProduct(for productId: String) async -> Product? {
526
+ do {
527
+ let products = try await Product.products(for: [productId])
528
+ return products.first
529
+ } catch {
530
+ return nil
531
+ }
532
+ }
533
+
534
+ @available(iOS 15.0, *)
535
+ private func mapProductType(_ type: Product.ProductType) -> String {
536
+ switch type {
537
+ case .consumable: return "consumable"
538
+ case .nonConsumable: return "non_consumable"
539
+ case .autoRenewable: return "auto_renewable"
540
+ case .nonRenewable: return "non_renewable"
541
+ @unknown default: return "unknown"
542
+ }
543
+ }
544
+
545
+ private func sendPurchaseEvent(appId: String, userId: String, eventName: String, properties: [String: Any]) async {
546
+ let event: [String: Any] = [
547
+ "appId": appId,
548
+ "appUserId": userId,
549
+ "eventId": UUID().uuidString.lowercased(),
550
+ "eventName": eventName,
551
+ "sessionId": UUID().uuidString.lowercased(),
552
+ "occurredAt": ISO8601DateFormatter().string(from: Date()),
553
+ "device": [
554
+ "platform": isPad() ? "iPadOS" : "iOS",
555
+ "platformVersion": UIDevice.current.systemVersion,
556
+ "deviceModel": getDeviceModelIdentifier(),
557
+ "sdkVersion": "1.0.0",
558
+ "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
559
+ "buildNumber": Bundle.main.infoDictionary?["CFBundleVersion"] as? String
560
+ ],
561
+ "context": [
562
+ "locale": Locale.current.identifier,
563
+ "regionCode": Locale.current.regionCode
564
+ ],
565
+ "properties": properties
566
+ ]
567
+
568
+ guard let url = URL(string: "https://uustlzuvjmochxkxatfx.supabase.co/functions/v1/app-user-events") else { return }
569
+
570
+ var request = URLRequest(url: url)
571
+ request.httpMethod = "POST"
572
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
573
+ request.setValue("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk", forHTTPHeaderField: "apikey")
574
+ request.setValue("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk", forHTTPHeaderField: "Authorization")
575
+
576
+ do {
577
+ request.httpBody = try JSONSerialization.data(withJSONObject: event)
578
+ let (_, response) = try await URLSession.shared.data(for: request)
579
+ if let httpResponse = response as? HTTPURLResponse {
580
+ print("[RampKit] Purchase event sent: \(eventName) - Status: \(httpResponse.statusCode)")
581
+ }
582
+ } catch {
583
+ print("[RampKit] Failed to send purchase event: \(error)")
584
+ }
585
+ }
586
+
372
587
  // MARK: - Device Helpers
373
588
 
374
589
  private func getDeviceModelIdentifier() -> String {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rampkit-expo-dev",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "The Expo SDK for RampKit. Build, test, and personalize app onboardings with instant updates.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",