rampkit-expo-dev 0.0.52 → 0.0.54

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.
@@ -42,6 +42,7 @@ class RampKitModule : Module(), PurchasesUpdatedListener {
42
42
  private val INSTALL_DATE_KEY = "rk_install_date"
43
43
  private val LAUNCH_COUNT_KEY = "rk_launch_count"
44
44
  private val LAST_LAUNCH_KEY = "rk_last_launch"
45
+ private val ORIGINAL_TXN_PREFIX = "rk_orig_txn_"
45
46
 
46
47
  private var billingClient: BillingClient? = null
47
48
  private var appId: String? = null
@@ -247,7 +248,7 @@ class RampKitModule : Module(), PurchasesUpdatedListener {
247
248
  val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
248
249
  .setPurchaseToken(purchase.purchaseToken)
249
250
  .build()
250
-
251
+
251
252
  billingClient?.acknowledgePurchase(acknowledgePurchaseParams) { result ->
252
253
  if (result.responseCode == BillingClient.BillingResponseCode.OK) {
253
254
  Log.d(TAG, "Purchase acknowledged")
@@ -257,53 +258,93 @@ class RampKitModule : Module(), PurchasesUpdatedListener {
257
258
 
258
259
  // Get product details for price info
259
260
  coroutineScope.launch {
260
- val productDetails = getProductDetails(purchase.products.firstOrNull() ?: "")
261
-
261
+ val productId = purchase.products.firstOrNull() ?: ""
262
+ val productDetails = getProductDetails(productId)
263
+
264
+ // Determine originalTransactionId for subscription tracking
265
+ // For Android, we store the first orderId for each product as the "original"
266
+ val originalTransactionId = getOrStoreOriginalTransactionId(productId, purchase.orderId)
267
+ val isRenewal = originalTransactionId != purchase.orderId
268
+
269
+ // Determine event type (matching iOS SDK logic)
270
+ val eventName: String
271
+ val isTrial: Boolean
272
+ val isIntroOffer: Boolean
273
+
274
+ // Check for free trial or intro offer
275
+ val subscriptionOffer = productDetails?.subscriptionOfferDetails?.firstOrNull()
276
+ val firstPricingPhase = subscriptionOffer?.pricingPhases?.pricingPhaseList?.firstOrNull()
277
+ val hasFreeTrial = firstPricingPhase?.priceAmountMicros == 0L
278
+
279
+ when {
280
+ // Subscription renewal
281
+ isRenewal && purchase.isAutoRenewing -> {
282
+ eventName = "subscription_renewed"
283
+ isTrial = false
284
+ isIntroOffer = false
285
+ }
286
+ // Trial started (free trial)
287
+ hasFreeTrial && !isRenewal -> {
288
+ eventName = "trial_started"
289
+ isTrial = true
290
+ isIntroOffer = true
291
+ }
292
+ // Regular purchase
293
+ else -> {
294
+ eventName = "purchase_completed"
295
+ isTrial = false
296
+ isIntroOffer = false
297
+ }
298
+ }
299
+
262
300
  val properties = mutableMapOf<String, Any?>(
263
- "productId" to purchase.products.firstOrNull(),
301
+ "productId" to productId,
264
302
  "transactionId" to purchase.orderId,
265
- "originalTransactionId" to purchase.orderId,
303
+ "originalTransactionId" to originalTransactionId,
266
304
  "purchaseToken" to purchase.purchaseToken,
267
305
  "purchaseDate" to getIso8601Timestamp(purchase.purchaseTime),
268
306
  "quantity" to purchase.quantity,
269
- "isAutoRenewing" to purchase.isAutoRenewing
307
+ "isAutoRenewing" to purchase.isAutoRenewing,
308
+ "isTrial" to isTrial,
309
+ "isIntroOffer" to isIntroOffer
270
310
  )
271
311
 
272
312
  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
- }
313
+ properties["productType"] = if (details.productType == BillingClient.ProductType.SUBS) "auto_renewable" else "non_consumable"
314
+
315
+ // Get pricing info
316
+ val oneTimePurchase = details.oneTimePurchaseOfferDetails
317
+ val subscriptionOfferDetails = details.subscriptionOfferDetails?.firstOrNull()
318
+
319
+ if (oneTimePurchase != null) {
320
+ properties["amount"] = oneTimePurchase.priceAmountMicros / 1_000_000.0
321
+ properties["currency"] = oneTimePurchase.priceCurrencyCode
322
+ properties["priceFormatted"] = oneTimePurchase.formattedPrice
323
+ } else if (subscriptionOfferDetails != null) {
324
+ // For subscriptions, get the base pricing phase (not the free trial phase)
325
+ val basePricingPhase = subscriptionOfferDetails.pricingPhases.pricingPhaseList
326
+ .firstOrNull { it.priceAmountMicros > 0 }
327
+ ?: subscriptionOfferDetails.pricingPhases.pricingPhaseList.lastOrNull()
328
+
329
+ basePricingPhase?.let { phase ->
330
+ properties["amount"] = phase.priceAmountMicros / 1_000_000.0
331
+ properties["currency"] = phase.priceCurrencyCode
332
+ properties["priceFormatted"] = phase.formattedPrice
333
+ properties["subscriptionPeriod"] = formatBillingPeriod(phase.billingPeriod)
280
334
  }
281
335
 
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
- }
336
+ // Offer type detection
337
+ val freeTrialPhase = subscriptionOfferDetails.pricingPhases.pricingPhaseList
338
+ .firstOrNull { it.priceAmountMicros == 0L }
339
+ if (freeTrialPhase != null) {
340
+ properties["offerType"] = "introductory"
300
341
  }
301
342
  }
343
+
302
344
  properties["localizedName"] = details.name
303
- properties["productType"] = details.productType
304
345
  }
305
346
 
306
- sendPurchaseEvent(appId, userId, "purchase_completed", properties)
347
+ sendPurchaseEvent(appId, userId, eventName, properties)
307
348
  }
308
349
  }
309
350
  Purchase.PurchaseState.PENDING -> {
@@ -312,6 +353,36 @@ class RampKitModule : Module(), PurchasesUpdatedListener {
312
353
  }
313
354
  }
314
355
 
356
+ /**
357
+ * Get or store the original transaction ID for a product.
358
+ * This enables subscription renewal tracking by comparing subsequent orderIds
359
+ * to the original orderId for the same product.
360
+ */
361
+ private fun getOrStoreOriginalTransactionId(productId: String, currentOrderId: String?): String? {
362
+ if (currentOrderId == null) return null
363
+
364
+ val key = ORIGINAL_TXN_PREFIX + productId
365
+ val storedOriginal = prefs.getString(key, null)
366
+
367
+ return if (storedOriginal != null) {
368
+ // Return the stored original
369
+ storedOriginal
370
+ } else {
371
+ // This is the first purchase for this product, store it as original
372
+ prefs.edit().putString(key, currentOrderId).apply()
373
+ currentOrderId
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Format Google Play billing period to ISO 8601 duration format
379
+ * Input format: P1W, P1M, P3M, P6M, P1Y
380
+ */
381
+ private fun formatBillingPeriod(billingPeriod: String): String {
382
+ // Google Play already uses ISO 8601 format, just return it
383
+ return billingPeriod
384
+ }
385
+
315
386
  private suspend fun getProductDetails(productId: String): ProductDetails? {
316
387
  if (productId.isEmpty()) return null
317
388
  val client = billingClient ?: return null
@@ -68,7 +68,8 @@ export declare class RampKitCore {
68
68
  */
69
69
  trackCtaTap(buttonId: string, buttonText?: string): void;
70
70
  /**
71
- * Cleanup SDK resources
71
+ * Reset the SDK state and re-initialize
72
+ * Call this when a user logs out or when you need to clear all cached state
72
73
  */
73
- cleanup(): Promise<void>;
74
+ reset(): Promise<void>;
74
75
  }
package/build/RampKit.js CHANGED
@@ -209,6 +209,13 @@ class RampKitCore {
209
209
  const rampkitContext = this.deviceInfo
210
210
  ? (0, DeviceInfoCollector_1.buildRampKitContext)(this.deviceInfo)
211
211
  : undefined;
212
+ // Extract navigation data for spatial layout-based navigation
213
+ const navigation = data.navigation
214
+ ? {
215
+ mainFlow: data.navigation.mainFlow || [],
216
+ screenPositions: data.navigation.screenPositions,
217
+ }
218
+ : undefined;
212
219
  // Track onboarding started event
213
220
  const onboardingId = data.onboardingId || data.id || "unknown";
214
221
  EventManager_1.eventManager.trackOnboardingStarted(onboardingId, screens.length);
@@ -229,6 +236,7 @@ class RampKitCore {
229
236
  variables,
230
237
  requiredScripts,
231
238
  rampkitContext,
239
+ navigation,
232
240
  onOnboardingFinished: (payload) => {
233
241
  var _a;
234
242
  // Track onboarding completed
@@ -290,18 +298,34 @@ class RampKitCore {
290
298
  // StoreKit 2 (iOS) and Google Play Billing (Android) transaction observers.
291
299
  // No manual tracking is needed.
292
300
  /**
293
- * Cleanup SDK resources
301
+ * Reset the SDK state and re-initialize
302
+ * Call this when a user logs out or when you need to clear all cached state
294
303
  */
295
- async cleanup() {
304
+ async reset() {
305
+ if (!this.config) {
306
+ console.warn("[RampKit] Reset: No config found, cannot re-initialize");
307
+ return;
308
+ }
309
+ console.log("[RampKit] Reset: Clearing SDK state...");
296
310
  // Stop transaction observer
297
311
  await RampKitNative_1.TransactionObserver.stop();
312
+ // Remove app state listener
298
313
  if (this.appStateSubscription) {
299
314
  this.appStateSubscription.remove();
300
315
  this.appStateSubscription = null;
301
316
  }
317
+ // Reset event manager state
302
318
  EventManager_1.eventManager.reset();
319
+ // Reset session
303
320
  (0, DeviceInfoCollector_1.resetSession)();
321
+ // Clear local state
322
+ this.userId = null;
323
+ this.deviceInfo = null;
324
+ this.onboardingData = null;
304
325
  this.initialized = false;
326
+ console.log("[RampKit] Reset: Re-initializing SDK...");
327
+ // Re-initialize with stored config
328
+ await this.init(this.config);
305
329
  }
306
330
  }
307
331
  exports.RampKitCore = RampKitCore;
@@ -1,4 +1,4 @@
1
- import { RampKitContext } from "./types";
1
+ import { RampKitContext, NavigationData } from "./types";
2
2
  export declare const injectedHardening = "\n(function(){\n try {\n var meta = document.querySelector('meta[name=\"viewport\"]');\n if (!meta) { meta = document.createElement('meta'); meta.name = 'viewport'; document.head.appendChild(meta); }\n meta.setAttribute('content','width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover');\n var style = document.createElement('style');\n style.textContent='html,body{overflow-x:hidden!important;} html,body,*{-webkit-user-select:none!important;user-select:none!important;-webkit-touch-callout:none!important;-ms-user-select:none!important;touch-action: pan-y;} *{-webkit-tap-highlight-color: rgba(0,0,0,0)!important;} ::selection{background: transparent!important;} ::-moz-selection{background: transparent!important;} a,img{-webkit-user-drag:none!important;user-drag:none!important;-webkit-touch-callout:none!important} input,textarea{caret-color:transparent!important;-webkit-user-select:none!important;user-select:none!important}';\n document.head.appendChild(style);\n var prevent=function(e){e.preventDefault&&e.preventDefault();};\n document.addEventListener('gesturestart',prevent,{passive:false});\n document.addEventListener('gesturechange',prevent,{passive:false});\n document.addEventListener('gestureend',prevent,{passive:false});\n document.addEventListener('dblclick',prevent,{passive:false});\n document.addEventListener('wheel',function(e){ if(e.ctrlKey) e.preventDefault(); },{passive:false});\n document.addEventListener('touchmove',function(e){ if(e.scale && e.scale !== 1) e.preventDefault(); },{passive:false});\n document.addEventListener('selectstart',prevent,{passive:false,capture:true});\n document.addEventListener('contextmenu',prevent,{passive:false,capture:true});\n document.addEventListener('copy',prevent,{passive:false,capture:true});\n document.addEventListener('cut',prevent,{passive:false,capture:true});\n document.addEventListener('paste',prevent,{passive:false,capture:true});\n document.addEventListener('dragstart',prevent,{passive:false,capture:true});\n // Belt-and-suspenders: aggressively clear any attempted selection\n var clearSel=function(){\n try{var sel=window.getSelection&&window.getSelection(); if(sel&&sel.removeAllRanges) sel.removeAllRanges();}catch(_){} }\n document.addEventListener('selectionchange',clearSel,{passive:true,capture:true});\n document.onselectstart=function(){ clearSel(); return false; };\n try{ document.documentElement.style.webkitUserSelect='none'; document.documentElement.style.userSelect='none'; }catch(_){ }\n try{ document.body.style.webkitUserSelect='none'; document.body.style.userSelect='none'; }catch(_){ }\n var __selTimer = setInterval(clearSel, 160);\n window.addEventListener('pagehide',function(){ try{ clearInterval(__selTimer); }catch(_){} });\n // Continuously enforce no-select on all elements and new nodes\n var enforceNoSelect = function(el){\n try{\n el.style && (el.style.webkitUserSelect='none', el.style.userSelect='none', el.style.webkitTouchCallout='none');\n el.setAttribute && (el.setAttribute('unselectable','on'), el.setAttribute('contenteditable','false'));\n }catch(_){}\n }\n try{\n var all=document.getElementsByTagName('*');\n for(var i=0;i<all.length;i++){ enforceNoSelect(all[i]); }\n var obs = new MutationObserver(function(muts){\n for(var j=0;j<muts.length;j++){\n var m=muts[j];\n if(m.type==='childList'){\n m.addedNodes && m.addedNodes.forEach && m.addedNodes.forEach(function(n){ if(n && n.nodeType===1){ enforceNoSelect(n); var q=n.getElementsByTagName? n.getElementsByTagName('*'): []; for(var k=0;k<q.length;k++){ enforceNoSelect(q[k]); }}});\n } else if(m.type==='attributes'){\n enforceNoSelect(m.target);\n }\n }\n });\n obs.observe(document.documentElement,{ childList:true, subtree:true, attributes:true, attributeFilter:['contenteditable','style'] });\n }catch(_){ }\n } catch(_) {}\n})(); true;\n";
3
3
  export declare const injectedNoSelect = "\n(function(){\n try {\n if (window.__rkNoSelectApplied) return true;\n window.__rkNoSelectApplied = true;\n var style = document.getElementById('rk-no-select-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'rk-no-select-style';\n style.innerHTML = \"\n * {\n user-select: none !important;\n -webkit-user-select: none !important;\n -webkit-touch-callout: none !important;\n }\n ::selection {\n background: transparent !important;\n }\n \";\n document.head.appendChild(style);\n }\n var prevent = function(e){ if(e && e.preventDefault) e.preventDefault(); return false; };\n document.addEventListener('contextmenu', prevent, { passive: false, capture: true });\n document.addEventListener('selectstart', prevent, { passive: false, capture: true });\n } catch (_) {}\n true;\n})();\n";
4
4
  export declare const injectedVarsHandler = "\n(function(){\n try {\n if (window.__rkVarsHandlerApplied) return true;\n window.__rkVarsHandlerApplied = true;\n \n // Handler function that updates variables and notifies the page\n window.__rkHandleVarsUpdate = function(vars) {\n if (!vars || typeof vars !== 'object') return;\n // Update the global variables object\n window.__rampkitVariables = vars;\n // Dispatch a custom event that the page's JS can listen to for re-rendering\n try {\n document.dispatchEvent(new CustomEvent('rampkit:vars-updated', { detail: vars }));\n } catch(e) {}\n // Also try calling a global handler if the page defined one\n try {\n if (typeof window.onRampkitVarsUpdate === 'function') {\n window.onRampkitVarsUpdate(vars);\n }\n } catch(e) {}\n };\n \n // Listen for message events from React Native\n document.addEventListener('message', function(event) {\n try {\n var data = event.data;\n if (data && data.type === 'rampkit:variables' && data.vars) {\n window.__rkHandleVarsUpdate(data.vars);\n }\n } catch(e) {}\n }, false);\n \n // Also listen on window for compatibility\n window.addEventListener('message', function(event) {\n try {\n var data = event.data;\n if (data && data.type === 'rampkit:variables' && data.vars) {\n window.__rkHandleVarsUpdate(data.vars);\n }\n } catch(e) {}\n }, false);\n } catch (_) {}\n true;\n})();\n";
@@ -16,6 +16,7 @@ export declare function showRampkitOverlay(opts: {
16
16
  variables?: Record<string, any>;
17
17
  requiredScripts?: string[];
18
18
  rampkitContext?: RampKitContext;
19
+ navigation?: NavigationData;
19
20
  onClose?: () => void;
20
21
  onOnboardingFinished?: (payload?: any) => void;
21
22
  onShowPaywall?: (payload?: any) => void;
@@ -39,6 +40,7 @@ declare function Overlay(props: {
39
40
  variables?: Record<string, any>;
40
41
  requiredScripts?: string[];
41
42
  rampkitContext?: RampKitContext;
43
+ navigation?: NavigationData;
42
44
  prebuiltDocs?: string[];
43
45
  onRequestClose: () => void;
44
46
  onOnboardingFinished?: (payload?: any) => void;
@@ -572,7 +572,7 @@ function showRampkitOverlay(opts) {
572
572
  return; // already visible
573
573
  // Always build fresh docs to ensure templates are resolved with current context
574
574
  const prebuiltDocs = undefined;
575
- sibling = new react_native_root_siblings_1.default(((0, jsx_runtime_1.jsx)(Overlay, { onboardingId: opts.onboardingId, screens: opts.screens, variables: opts.variables, requiredScripts: opts.requiredScripts, rampkitContext: opts.rampkitContext, prebuiltDocs: prebuiltDocs, onRequestClose: () => {
575
+ sibling = new react_native_root_siblings_1.default(((0, jsx_runtime_1.jsx)(Overlay, { onboardingId: opts.onboardingId, screens: opts.screens, variables: opts.variables, requiredScripts: opts.requiredScripts, rampkitContext: opts.rampkitContext, navigation: opts.navigation, prebuiltDocs: prebuiltDocs, onRequestClose: () => {
576
576
  var _a;
577
577
  activeCloseHandler = null;
578
578
  hideRampkitOverlay();
@@ -1025,6 +1025,133 @@ function Overlay(props) {
1025
1025
  const lastVarsSendTimeRef = (0, react_1.useRef)([]);
1026
1026
  // Stale value window in milliseconds - matches iOS SDK (600ms)
1027
1027
  const STALE_VALUE_WINDOW_MS = 600;
1028
+ // ============================================================================
1029
+ // Navigation Resolution Helpers (matches iOS SDK behavior)
1030
+ // ============================================================================
1031
+ /**
1032
+ * Resolve `__continue__` to the actual target screen ID using navigation data
1033
+ * @param fromScreenId - The current screen ID
1034
+ * @returns The target screen ID, or null if at the end of the flow or should use fallback
1035
+ */
1036
+ const resolveContinue = (fromScreenId) => {
1037
+ const navigation = props.navigation;
1038
+ // If no navigation data, fall back to array order
1039
+ if (!(navigation === null || navigation === void 0 ? void 0 : navigation.mainFlow) || navigation.mainFlow.length === 0) {
1040
+ console.log("[RampKit] 🧭 No navigation.mainFlow, using array order for __continue__");
1041
+ return null; // Will use fallback
1042
+ }
1043
+ const { mainFlow, screenPositions } = navigation;
1044
+ // Check if current screen is in the main flow
1045
+ const currentFlowIndex = mainFlow.indexOf(fromScreenId);
1046
+ if (currentFlowIndex !== -1) {
1047
+ // Navigate to next screen in main flow
1048
+ const nextFlowIndex = currentFlowIndex + 1;
1049
+ if (nextFlowIndex < mainFlow.length) {
1050
+ const nextScreenId = mainFlow[nextFlowIndex];
1051
+ console.log(`[RampKit] 🧭 __continue__ resolved via mainFlow: ${fromScreenId} → ${nextScreenId} (flow index ${currentFlowIndex} → ${nextFlowIndex})`);
1052
+ return nextScreenId;
1053
+ }
1054
+ else {
1055
+ console.log(`[RampKit] 🧭 __continue__: at end of mainFlow (index ${currentFlowIndex})`);
1056
+ return null;
1057
+ }
1058
+ }
1059
+ // Current screen is NOT in mainFlow (it's a variant screen)
1060
+ // Find the appropriate next main screen based on X position
1061
+ if (screenPositions) {
1062
+ const currentPosition = screenPositions[fromScreenId];
1063
+ if (currentPosition) {
1064
+ console.log(`[RampKit] 🧭 Current screen '${fromScreenId}' is a variant (row: ${currentPosition.row}, x: ${currentPosition.x})`);
1065
+ // Find the main flow screen that comes AFTER this X position
1066
+ // (the screen with the smallest X that is > currentPosition.x)
1067
+ let bestCandidate = null;
1068
+ for (const mainScreenId of mainFlow) {
1069
+ const mainPos = screenPositions[mainScreenId];
1070
+ if (mainPos && mainPos.x > currentPosition.x) {
1071
+ if (!bestCandidate || mainPos.x < bestCandidate.x) {
1072
+ bestCandidate = { screenId: mainScreenId, x: mainPos.x };
1073
+ }
1074
+ }
1075
+ }
1076
+ if (bestCandidate) {
1077
+ console.log(`[RampKit] 🧭 __continue__ from variant: ${fromScreenId} → ${bestCandidate.screenId} (next main screen at x:${bestCandidate.x})`);
1078
+ return bestCandidate.screenId;
1079
+ }
1080
+ else {
1081
+ console.log("[RampKit] 🧭 __continue__ from variant: no main screen to the right, end of flow");
1082
+ return null;
1083
+ }
1084
+ }
1085
+ }
1086
+ // Position data not available for current screen, fall back to array
1087
+ console.log(`[RampKit] 🧭 Screen '${fromScreenId}' not found in positions, using array fallback`);
1088
+ return null;
1089
+ };
1090
+ /**
1091
+ * Resolve `__goBack__` to the actual target screen ID using navigation data
1092
+ * @param fromScreenId - The current screen ID
1093
+ * @returns The target screen ID, or null if at the start of the flow or should use fallback
1094
+ */
1095
+ const resolveGoBack = (fromScreenId) => {
1096
+ const navigation = props.navigation;
1097
+ // If no navigation data, fall back to array order
1098
+ if (!(navigation === null || navigation === void 0 ? void 0 : navigation.mainFlow) || navigation.mainFlow.length === 0) {
1099
+ console.log("[RampKit] 🧭 No navigation.mainFlow, using array order for __goBack__");
1100
+ return null; // Will use fallback
1101
+ }
1102
+ const { mainFlow, screenPositions } = navigation;
1103
+ // Check if current screen is in the main flow
1104
+ const currentFlowIndex = mainFlow.indexOf(fromScreenId);
1105
+ if (currentFlowIndex !== -1) {
1106
+ // Navigate to previous screen in main flow
1107
+ const prevFlowIndex = currentFlowIndex - 1;
1108
+ if (prevFlowIndex >= 0) {
1109
+ const prevScreenId = mainFlow[prevFlowIndex];
1110
+ console.log(`[RampKit] 🧭 __goBack__ resolved via mainFlow: ${fromScreenId} → ${prevScreenId} (flow index ${currentFlowIndex} → ${prevFlowIndex})`);
1111
+ return prevScreenId;
1112
+ }
1113
+ else {
1114
+ console.log(`[RampKit] 🧭 __goBack__: at start of mainFlow (index ${currentFlowIndex})`);
1115
+ return null;
1116
+ }
1117
+ }
1118
+ // Current screen is NOT in mainFlow (it's a variant screen)
1119
+ // Go back to the main flow screen at or before this X position
1120
+ if (screenPositions) {
1121
+ const currentPosition = screenPositions[fromScreenId];
1122
+ if (currentPosition) {
1123
+ console.log(`[RampKit] 🧭 Current screen '${fromScreenId}' is a variant (row: ${currentPosition.row}, x: ${currentPosition.x})`);
1124
+ // Find the main flow screen that is at or before this X position
1125
+ // (the screen with the largest X that is <= currentPosition.x)
1126
+ let bestCandidate = null;
1127
+ for (const mainScreenId of mainFlow) {
1128
+ const mainPos = screenPositions[mainScreenId];
1129
+ if (mainPos && mainPos.x <= currentPosition.x) {
1130
+ if (!bestCandidate || mainPos.x > bestCandidate.x) {
1131
+ bestCandidate = { screenId: mainScreenId, x: mainPos.x };
1132
+ }
1133
+ }
1134
+ }
1135
+ if (bestCandidate) {
1136
+ console.log(`[RampKit] 🧭 __goBack__ from variant: ${fromScreenId} → ${bestCandidate.screenId} (main screen at x:${bestCandidate.x})`);
1137
+ return bestCandidate.screenId;
1138
+ }
1139
+ else {
1140
+ console.log("[RampKit] 🧭 __goBack__ from variant: no main screen to the left, start of flow");
1141
+ return null;
1142
+ }
1143
+ }
1144
+ }
1145
+ // Position data not available for current screen, fall back to array
1146
+ console.log(`[RampKit] 🧭 Screen '${fromScreenId}' not found in positions, using array fallback`);
1147
+ return null;
1148
+ };
1149
+ /**
1150
+ * Get the screen index for a given screen ID
1151
+ */
1152
+ const getScreenIndex = (screenId) => {
1153
+ return props.screens.findIndex((s) => s.id === screenId);
1154
+ };
1028
1155
  // Fade-in when overlay becomes visible
1029
1156
  react_1.default.useEffect(() => {
1030
1157
  if (visible && !isClosing) {
@@ -1092,6 +1219,7 @@ function Overlay(props) {
1092
1219
  // This ensures the webview receives the latest state
1093
1220
  requestAnimationFrame(() => {
1094
1221
  sendVarsToWebView(nextIndex);
1222
+ sendOnboardingStateToWebView(nextIndex);
1095
1223
  });
1096
1224
  return;
1097
1225
  }
@@ -1140,8 +1268,9 @@ function Overlay(props) {
1140
1268
  useNativeDriver: true,
1141
1269
  }),
1142
1270
  ]).start(() => {
1143
- // Send vars to the new page
1271
+ // Send vars and onboarding state to the new page
1144
1272
  sendVarsToWebView(nextIndex);
1273
+ sendOnboardingStateToWebView(nextIndex);
1145
1274
  setIsTransitioning(false);
1146
1275
  });
1147
1276
  });
@@ -1160,10 +1289,11 @@ function Overlay(props) {
1160
1289
  // @ts-ignore: method exists on PagerView instance
1161
1290
  (_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);
1162
1291
  requestAnimationFrame(() => {
1163
- // Explicitly send vars to the new page after the page switch completes
1292
+ // Explicitly send vars and onboarding state to the new page after the page switch completes
1164
1293
  // This ensures the webview receives the latest state even if onPageSelected
1165
1294
  // timing was off during the transition
1166
1295
  sendVarsToWebView(nextIndex);
1296
+ sendOnboardingStateToWebView(nextIndex);
1167
1297
  react_native_1.Animated.timing(fadeOpacity, {
1168
1298
  toValue: 0,
1169
1299
  duration: 160,
@@ -1217,6 +1347,57 @@ function Overlay(props) {
1217
1347
  }
1218
1348
  })();`;
1219
1349
  }
1350
+ // Build a script that updates the onboarding state
1351
+ // This calls window.__rampkitUpdateOnboarding(index, screenId) to update
1352
+ // onboarding.currentIndex, onboarding.progress, etc.
1353
+ function buildOnboardingStateScript(screenIndex, screenId, totalScreens) {
1354
+ return `(function() {
1355
+ try {
1356
+ // Set total screens global
1357
+ window.RK_TOTAL_SCREENS = ${totalScreens};
1358
+
1359
+ // Call the update function if it exists
1360
+ if (typeof window.__rampkitUpdateOnboarding === 'function') {
1361
+ window.__rampkitUpdateOnboarding(${screenIndex}, '${screenId}');
1362
+ console.log('[RampKit] Called __rampkitUpdateOnboarding(${screenIndex}, ${screenId})');
1363
+ }
1364
+
1365
+ // Also dispatch a message event for any listeners
1366
+ var payload = {
1367
+ type: 'rampkit:onboarding-state',
1368
+ currentIndex: ${screenIndex},
1369
+ screenId: '${screenId}',
1370
+ totalScreens: ${totalScreens}
1371
+ };
1372
+
1373
+ try {
1374
+ document.dispatchEvent(new MessageEvent('message', { data: payload }));
1375
+ } catch(e) {}
1376
+
1377
+ // Also dispatch custom event
1378
+ try {
1379
+ document.dispatchEvent(new CustomEvent('rampkit:onboarding-state', { detail: payload }));
1380
+ } catch(e) {}
1381
+
1382
+ } catch(e) {
1383
+ console.log('[RampKit] sendOnboardingState error:', e);
1384
+ }
1385
+ })();`;
1386
+ }
1387
+ // Send onboarding state to a WebView
1388
+ function sendOnboardingStateToWebView(i) {
1389
+ var _a;
1390
+ const wv = webviewsRef.current[i];
1391
+ if (!wv)
1392
+ return;
1393
+ const screenId = ((_a = props.screens[i]) === null || _a === void 0 ? void 0 : _a.id) || '';
1394
+ const totalScreens = props.screens.length;
1395
+ if (__DEV__) {
1396
+ console.log("[Rampkit] sendOnboardingStateToWebView", i, { screenId, totalScreens });
1397
+ }
1398
+ // @ts-ignore: injectJavaScript exists on WebView instance
1399
+ wv.injectJavaScript(buildOnboardingStateScript(i, screenId, totalScreens));
1400
+ }
1220
1401
  function sendVarsToWebView(i, isInitialLoad = false) {
1221
1402
  const wv = webviewsRef.current[i];
1222
1403
  if (!wv)
@@ -1230,6 +1411,9 @@ function Overlay(props) {
1230
1411
  // This is more reliable as it doesn't depend on event listeners being set up
1231
1412
  // @ts-ignore: injectJavaScript exists on WebView instance
1232
1413
  wv.injectJavaScript(buildDirectVarsScript(varsRef.current));
1414
+ // NOTE: Do NOT call sendOnboardingStateToWebView here - it would cause infinite loops
1415
+ // because the WebView echoes back variables which triggers another sendVarsToWebView.
1416
+ // Onboarding state is sent separately in onLoadEnd and onPageSelected.
1233
1417
  }
1234
1418
  /**
1235
1419
  * Broadcast variables to all WebViews, optionally excluding one.
@@ -1311,6 +1495,8 @@ function Overlay(props) {
1311
1495
  // so we retry a few times.
1312
1496
  requestAnimationFrame(() => {
1313
1497
  sendVarsToWebView(pos);
1498
+ // Send onboarding state once after vars
1499
+ sendOnboardingStateToWebView(pos);
1314
1500
  });
1315
1501
  // Retry after a short delay in case the first send didn't work
1316
1502
  setTimeout(() => {
@@ -1326,8 +1512,24 @@ function Overlay(props) {
1326
1512
  }
1327
1513
  };
1328
1514
  const handleAdvance = (i, animation = "fade") => {
1515
+ var _a;
1516
+ const currentScreenId = (_a = props.screens[i]) === null || _a === void 0 ? void 0 : _a.id;
1517
+ // Try to resolve using navigation data
1518
+ if (currentScreenId) {
1519
+ const resolvedId = resolveContinue(currentScreenId);
1520
+ if (resolvedId) {
1521
+ const targetIndex = getScreenIndex(resolvedId);
1522
+ if (targetIndex >= 0 && targetIndex < props.screens.length) {
1523
+ navigateToIndex(targetIndex, animation);
1524
+ RampKitNative_1.Haptics.impactAsync("light").catch(() => { });
1525
+ return;
1526
+ }
1527
+ }
1528
+ }
1529
+ // Fallback to array order
1329
1530
  const last = props.screens.length - 1;
1330
1531
  if (i < last) {
1532
+ console.log(`[RampKit] 🧭 __continue__ fallback to array index ${i + 1}`);
1331
1533
  navigateToIndex(i + 1, animation);
1332
1534
  RampKitNative_1.Haptics.impactAsync("light").catch(() => { });
1333
1535
  }
@@ -1338,6 +1540,29 @@ function Overlay(props) {
1338
1540
  handleRequestClose({ completed: true });
1339
1541
  }
1340
1542
  };
1543
+ const handleGoBack = (i, animation = "fade") => {
1544
+ var _a;
1545
+ const currentScreenId = (_a = props.screens[i]) === null || _a === void 0 ? void 0 : _a.id;
1546
+ // Try to resolve using navigation data
1547
+ if (currentScreenId) {
1548
+ const resolvedId = resolveGoBack(currentScreenId);
1549
+ if (resolvedId) {
1550
+ const targetIndex = getScreenIndex(resolvedId);
1551
+ if (targetIndex >= 0 && targetIndex < props.screens.length) {
1552
+ navigateToIndex(targetIndex, animation);
1553
+ return;
1554
+ }
1555
+ }
1556
+ }
1557
+ // Fallback to array order
1558
+ if (i > 0) {
1559
+ console.log(`[RampKit] 🧭 __goBack__ fallback to array index ${i - 1}`);
1560
+ navigateToIndex(i - 1, animation);
1561
+ }
1562
+ else {
1563
+ handleRequestClose();
1564
+ }
1565
+ };
1341
1566
  async function handleNotificationPermissionRequest(payload) {
1342
1567
  var _a, _b;
1343
1568
  // Track that notification permission was requested
@@ -1420,6 +1645,8 @@ function Overlay(props) {
1420
1645
  if (__DEV__)
1421
1646
  console.log("[Rampkit] onLoadEnd init send vars", i);
1422
1647
  sendVarsToWebView(i, true);
1648
+ // Send onboarding state on initial load (separate from vars to avoid loops)
1649
+ sendOnboardingStateToWebView(i);
1423
1650
  }, onMessage: (ev) => {
1424
1651
  var _a, _b, _c, _d;
1425
1652
  const raw = ev.nativeEvent.data;
@@ -1541,12 +1768,7 @@ function Overlay(props) {
1541
1768
  if ((data === null || data === void 0 ? void 0 : data.type) === "rampkit:navigate") {
1542
1769
  const target = data === null || data === void 0 ? void 0 : data.targetScreenId;
1543
1770
  if (target === "__goBack__") {
1544
- if (i > 0) {
1545
- navigateToIndex(i - 1, (data === null || data === void 0 ? void 0 : data.animation) || "fade");
1546
- }
1547
- else {
1548
- handleRequestClose();
1549
- }
1771
+ handleGoBack(i, (data === null || data === void 0 ? void 0 : data.animation) || "fade");
1550
1772
  return;
1551
1773
  }
1552
1774
  if (!target || target === "__continue__") {
@@ -1563,12 +1785,7 @@ function Overlay(props) {
1563
1785
  return;
1564
1786
  }
1565
1787
  if ((data === null || data === void 0 ? void 0 : data.type) === "rampkit:goBack") {
1566
- if (i > 0) {
1567
- navigateToIndex(i - 1, (data === null || data === void 0 ? void 0 : data.animation) || "fade");
1568
- }
1569
- else {
1570
- handleRequestClose();
1571
- }
1788
+ handleGoBack(i, (data === null || data === void 0 ? void 0 : data.animation) || "fade");
1572
1789
  return;
1573
1790
  }
1574
1791
  if ((data === null || data === void 0 ? void 0 : data.type) === "rampkit:close") {
@@ -1621,23 +1838,13 @@ function Overlay(props) {
1621
1838
  return;
1622
1839
  }
1623
1840
  if (raw === "rampkit:goBack") {
1624
- if (i > 0) {
1625
- navigateToIndex(i - 1);
1626
- }
1627
- else {
1628
- handleRequestClose();
1629
- }
1841
+ handleGoBack(i);
1630
1842
  return;
1631
1843
  }
1632
1844
  if (raw.startsWith("rampkit:navigate:")) {
1633
1845
  const target = raw.slice("rampkit:navigate:".length);
1634
1846
  if (target === "__goBack__") {
1635
- if (i > 0) {
1636
- navigateToIndex(i - 1);
1637
- }
1638
- else {
1639
- handleRequestClose();
1640
- }
1847
+ handleGoBack(i);
1641
1848
  return;
1642
1849
  }
1643
1850
  if (!target || target === "__continue__") {
package/build/index.d.ts CHANGED
@@ -11,5 +11,5 @@ export { default as RampKitNative } from "./RampKitNative";
11
11
  export type { NativeDeviceInfo, NativeLaunchData } from "./RampKitNative";
12
12
  export { Haptics, StoreReview, Notifications, TransactionObserver } from "./RampKitNative";
13
13
  export type { ImpactStyle, NotificationType, NotificationOptions, NotificationPermissionResult } from "./RampKitNative";
14
- export type { DeviceInfo, RampKitEvent, EventDevice, EventContext, RampKitConfig, RampKitEventName, RampKitContext, RampKitDeviceContext, RampKitUserContext, AppSessionStartedProperties, AppSessionEndedProperties, AppBackgroundedProperties, AppForegroundedProperties, OnboardingStartedProperties, OnboardingScreenViewedProperties, OnboardingQuestionAnsweredProperties, OnboardingCompletedProperties, OnboardingAbandonedProperties, ScreenViewProperties, CtaTapProperties, NotificationsPromptShownProperties, NotificationsResponseProperties, PaywallShownProperties, PaywallPrimaryActionTapProperties, PaywallClosedProperties, PurchaseStartedProperties, PurchaseCompletedProperties, PurchaseFailedProperties, } from "./types";
14
+ export type { DeviceInfo, RampKitEvent, EventDevice, EventContext, RampKitConfig, RampKitEventName, RampKitContext, RampKitDeviceContext, RampKitUserContext, NavigationData, ScreenPosition, 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/types.d.ts CHANGED
@@ -229,3 +229,24 @@ export interface RampKitContext {
229
229
  device: RampKitDeviceContext;
230
230
  user: RampKitUserContext;
231
231
  }
232
+ /**
233
+ * Navigation data structure from the editor's spatial layout
234
+ * This is exported alongside screens to enable proper navigation resolution
235
+ */
236
+ export interface NavigationData {
237
+ /** Ordered array of screen IDs in the main flow (sorted by X position, main row only) */
238
+ mainFlow: string[];
239
+ /** Map of screen ID to position information */
240
+ screenPositions?: Record<string, ScreenPosition>;
241
+ }
242
+ /**
243
+ * Position information for a screen in the editor canvas
244
+ */
245
+ export interface ScreenPosition {
246
+ /** X coordinate in the editor canvas */
247
+ x: number;
248
+ /** Y coordinate in the editor canvas */
249
+ y: number;
250
+ /** Row classification: "main" for main row screens, "variant" for screens below */
251
+ row: "main" | "variant";
252
+ }
@@ -458,63 +458,86 @@ public class RampKitModule: Module {
458
458
  @available(iOS 15.0, *)
459
459
  private func handleTransaction(_ transaction: Transaction) async {
460
460
  guard let appId = self.appId, let userId = self.userId else { return }
461
-
462
- // Determine event type based on transaction
461
+
462
+ let formatter = ISO8601DateFormatter()
463
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
464
+
465
+ // Determine event type based on transaction (matching iOS SDK logic)
463
466
  let eventName: String
464
467
  var properties: [String: Any] = [:]
465
-
466
- switch transaction.revocationReason {
467
- case .some(let reason):
468
- eventName = "purchase_refunded"
469
- properties["revocationReason"] = reason == .developerIssue ? "developer_issue" : "other"
470
- case .none:
471
- if transaction.isUpgraded {
472
- eventName = "subscription_upgraded"
473
- } else {
474
- // Determine event based on product type
475
- let productType = transaction.productType
476
- if productType == .autoRenewable {
477
- if transaction.originalID == transaction.id {
478
- eventName = "purchase_completed"
479
- } else {
480
- eventName = "subscription_renewed"
481
- }
482
- } else {
483
- eventName = "purchase_completed"
484
- }
468
+
469
+ // Check revocation first (subscription_canceled)
470
+ if transaction.revocationDate != nil {
471
+ eventName = "subscription_canceled"
472
+ if let reason = transaction.revocationReason {
473
+ properties["revocationReason"] = reason == .developerIssue ? "developerIssue" : "other"
485
474
  }
475
+ properties["revocationDate"] = transaction.revocationDate.map { formatter.string(from: $0) }
486
476
  }
487
-
488
- // Build properties
477
+ // Check if this is a renewal (originalID != id)
478
+ else if transaction.originalID != transaction.id {
479
+ eventName = "subscription_renewed"
480
+ }
481
+ // Check if it's a trial or intro offer
482
+ else if let offerType = transaction.offerType, offerType == .introductory {
483
+ eventName = "trial_started"
484
+ }
485
+ // Default to purchase_completed
486
+ else {
487
+ eventName = "purchase_completed"
488
+ }
489
+
490
+ // Build properties (matching iOS SDK PurchaseEventDetails)
489
491
  properties["productId"] = transaction.productID
490
492
  properties["transactionId"] = String(transaction.id)
491
493
  properties["originalTransactionId"] = String(transaction.originalID)
492
- properties["purchaseDate"] = ISO8601DateFormatter().string(from: transaction.purchaseDate)
493
-
494
- if let expirationDate = transaction.expirationDate {
495
- properties["expirationDate"] = ISO8601DateFormatter().string(from: expirationDate)
496
- }
497
-
494
+ properties["purchaseDate"] = formatter.string(from: transaction.purchaseDate)
498
495
  properties["quantity"] = transaction.purchasedQuantity
499
496
  properties["productType"] = mapProductType(transaction.productType)
500
-
501
- // environment property is only available in iOS 16.0+
502
- if #available(iOS 16.0, *) {
503
- properties["environment"] = transaction.environment.rawValue
497
+
498
+ // Expiration date
499
+ if let expirationDate = transaction.expirationDate {
500
+ properties["expirationDate"] = formatter.string(from: expirationDate)
504
501
  }
505
-
502
+
503
+ // Trial/intro offer detection
504
+ if let offerType = transaction.offerType {
505
+ properties["isTrial"] = offerType == .introductory
506
+ properties["isIntroOffer"] = offerType == .introductory
507
+ properties["offerType"] = formatOfferType(offerType)
508
+ }
509
+
510
+ // Offer ID
511
+ if let offerId = transaction.offerID {
512
+ properties["offerId"] = offerId
513
+ }
514
+
515
+ // Storefront country
516
+ properties["storefront"] = transaction.storefrontCountryCode
517
+
518
+ // Web order line item ID
506
519
  if let webOrderLineItemID = transaction.webOrderLineItemID {
507
520
  properties["webOrderLineItemId"] = webOrderLineItemID
508
521
  }
509
-
522
+
523
+ // Environment (iOS 16+)
524
+ if #available(iOS 16.0, *) {
525
+ properties["environment"] = transaction.environment.rawValue
526
+ }
527
+
510
528
  // Get price info from product if available
511
529
  if let product = await getProduct(for: transaction.productID) {
512
- properties["price"] = product.price
530
+ properties["amount"] = product.price
513
531
  properties["currency"] = product.priceFormatStyle.currencyCode
514
- properties["localizedPrice"] = product.displayPrice
515
- properties["localizedName"] = product.displayName
532
+ properties["priceFormatted"] = product.displayPrice
533
+
534
+ // Subscription-specific info
535
+ if let subscription = product.subscription {
536
+ properties["subscriptionPeriod"] = formatSubscriptionPeriod(subscription.subscriptionPeriod)
537
+ properties["subscriptionGroupId"] = subscription.subscriptionGroupID
538
+ }
516
539
  }
517
-
540
+
518
541
  // Send event to backend
519
542
  await sendPurchaseEvent(
520
543
  appId: appId,
@@ -523,6 +546,36 @@ public class RampKitModule: Module {
523
546
  properties: properties
524
547
  )
525
548
  }
549
+
550
+ @available(iOS 15.0, *)
551
+ private func formatOfferType(_ offerType: Transaction.OfferType) -> String {
552
+ if offerType == .introductory {
553
+ return "introductory"
554
+ } else if offerType == .promotional {
555
+ return "promotional"
556
+ } else if offerType == .code {
557
+ return "code"
558
+ } else {
559
+ return "unknown"
560
+ }
561
+ }
562
+
563
+ @available(iOS 15.0, *)
564
+ private func formatSubscriptionPeriod(_ period: Product.SubscriptionPeriod) -> String {
565
+ // ISO 8601 duration format
566
+ let unit = period.unit
567
+ if unit == .day {
568
+ return "P\(period.value)D"
569
+ } else if unit == .week {
570
+ return "P\(period.value)W"
571
+ } else if unit == .month {
572
+ return "P\(period.value)M"
573
+ } else if unit == .year {
574
+ return "P\(period.value)Y"
575
+ } else {
576
+ return "P\(period.value)D"
577
+ }
578
+ }
526
579
 
527
580
  @available(iOS 15.0, *)
528
581
  private func getProduct(for productId: String) async -> Product? {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rampkit-expo-dev",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
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",