react-native-nami-sdk 3.4.4-dev.202606241952 → 3.4.4-dev.202606260324

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.
@@ -85,8 +85,8 @@ dependencies {
85
85
  implementation fileTree(dir: 'libs', include: ['*.jar'])
86
86
  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
87
87
 
88
- playImplementation "com.namiml:sdk-android:3.4.4-dev.202606241952"
89
- amazonImplementation "com.namiml:sdk-amazon:3.4.4-dev.202606241952"
88
+ playImplementation "com.namiml:sdk-android:3.4.4-dev.202606260324"
89
+ amazonImplementation "com.namiml:sdk-amazon:3.4.4-dev.202606260324"
90
90
 
91
91
  implementation "com.facebook.react:react-native:+" // From node_modules
92
92
  coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
@@ -4,7 +4,17 @@ import android.app.Activity
4
4
  import android.net.Uri
5
5
  import android.util.Log
6
6
  import androidx.core.net.toUri
7
- import com.facebook.react.bridge.*
7
+ import com.facebook.react.bridge.Arguments
8
+ import com.facebook.react.bridge.Callback
9
+ import com.facebook.react.bridge.Promise
10
+ import com.facebook.react.bridge.ReactApplicationContext
11
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
12
+ import com.facebook.react.bridge.ReactMethod
13
+ import com.facebook.react.bridge.ReadableMap
14
+ import com.facebook.react.bridge.ReadableType
15
+ import com.facebook.react.bridge.WritableArray
16
+ import com.facebook.react.bridge.WritableMap
17
+ import com.facebook.react.bridge.WritableNativeArray
8
18
  import com.facebook.react.module.annotations.ReactModule
9
19
  import com.facebook.react.modules.core.DeviceEventManagerModule
10
20
  import com.facebook.react.turbomodule.core.interfaces.TurboModule
@@ -106,7 +116,15 @@ class NamiCampaignManagerBridgeModule internal constructor(
106
116
  attr.getDouble(key)
107
117
  }
108
118
  }
109
- ReadableType.Null -> ""
119
+ ReadableType.Null -> {
120
+ // A JSON null from JS must stay "not set". Coercing it to
121
+ // "" makes the launch-context presence check (raw != null)
122
+ // report it as "set" and reroutes flow branches (NAM-2227).
123
+ // Omit the key so it resolves to null, matching sdk/core and
124
+ // the Apple NSNull->nil normalization (NAM-1368).
125
+ Log.d(NAME, "customAttribute '$key' is null; omitting (treated as not set)")
126
+ continue
127
+ }
110
128
  ReadableType.Array -> attr.getArray(key)?.toArrayList() ?: emptyList<Any>()
111
129
  ReadableType.Map -> attr.getMap(key)?.toHashMap() ?: emptyMap<String, Any>()
112
130
  }
@@ -140,9 +158,15 @@ class NamiCampaignManagerBridgeModule internal constructor(
140
158
  }
141
159
 
142
160
  if (context.hasKey("productGroups")) {
143
- paywallLaunchContext = PaywallLaunchContext(productGroups = productGroups.toList(), customAttributes = customAttributes, customObject = customObject)
161
+ paywallLaunchContext =
162
+ PaywallLaunchContext(
163
+ productGroups = productGroups.toList(),
164
+ customAttributes = customAttributes,
165
+ customObject = customObject,
166
+ )
144
167
  } else {
145
- paywallLaunchContext = PaywallLaunchContext(productGroups = null, customAttributes = customAttributes, customObject = customObject)
168
+ paywallLaunchContext =
169
+ PaywallLaunchContext(productGroups = null, customAttributes = customAttributes, customObject = customObject)
146
170
  }
147
171
  }
148
172
 
@@ -8,11 +8,22 @@ import com.facebook.react.bridge.Promise
8
8
  import com.facebook.react.bridge.ReactApplicationContext
9
9
  import com.facebook.react.bridge.ReactContextBaseJavaModule
10
10
  import com.facebook.react.bridge.ReactMethod
11
+ import com.facebook.react.bridge.ReadableMap
11
12
  import com.facebook.react.bridge.WritableMap
12
13
  import com.facebook.react.module.annotations.ReactModule
13
14
  import com.facebook.react.modules.core.DeviceEventManagerModule
14
15
  import com.facebook.react.turbomodule.core.interfaces.TurboModule
15
16
  import com.namiml.flow.NamiFlowManager
17
+ import com.namiml.flow.NamiHandoffComplete
18
+ import com.namiml.flow.NamiHandoffOutcome
19
+ import com.namiml.flow.NamiLoginOutcome
20
+ import com.namiml.flow.NamiLoginSuccess
21
+ import com.namiml.flow.NamiPermissionOutcome
22
+ import com.namiml.flow.NamiPurchaseOutcome
23
+ import com.namiml.paywall.NamiSKU
24
+ import com.namiml.paywall.model.NamiPurchaseSuccess
25
+ import java.util.UUID
26
+ import java.util.concurrent.ConcurrentHashMap
16
27
 
17
28
  @ReactModule(name = NamiFlowManagerBridgeModule.NAME)
18
29
  class NamiFlowManagerBridgeModule internal constructor(
@@ -31,13 +42,20 @@ class NamiFlowManagerBridgeModule internal constructor(
31
42
  @Volatile
32
43
  private var stepHandoffActive: Boolean = false
33
44
 
45
+ /** Pending handoff tokens keyed by handoffId. Thread-safe for concurrent complete() calls. */
46
+ private val pendingHandoffs = ConcurrentHashMap<String, NamiHandoffComplete>()
47
+
34
48
  @ReactMethod
35
49
  fun registerStepHandoff() {
36
50
  stepHandoffActive = true
37
- NamiFlowManager.registerStepHandoff { handoffTag, handoffData ->
51
+ NamiFlowManager.registerStepHandoff { handoffTag, handoffData, complete ->
38
52
  if (!stepHandoffActive) return@registerStepHandoff
53
+ val handoffId = UUID.randomUUID().toString()
54
+ pendingHandoffs[handoffId] = complete
55
+
39
56
  val payload =
40
57
  Arguments.createMap().apply {
58
+ putString("handoffId", handoffId)
41
59
  putString("handoffTag", handoffTag)
42
60
  if (handoffData != null) {
43
61
  try {
@@ -54,6 +72,30 @@ class NamiFlowManagerBridgeModule internal constructor(
54
72
  }
55
73
  }
56
74
 
75
+ /**
76
+ * Called from JS to complete a pending handoff with a typed outcome.
77
+ *
78
+ * The [outcome] map must include a "kind" key with one of: "done", "permission", "purchase",
79
+ * "login". Unknown kinds or malformed payloads emit a loud dev error and leave the token
80
+ * unconsumed (caller should call resume() to fall back to the legacy degrade path).
81
+ *
82
+ * A missing [handoffId] (e.g. after a Metro reload) logs a warning and returns — no crash.
83
+ */
84
+ @ReactMethod
85
+ fun completeHandoff(
86
+ handoffId: String,
87
+ outcome: ReadableMap,
88
+ ) {
89
+ val complete = pendingHandoffs.remove(handoffId)
90
+ if (complete == null) {
91
+ Log.w(NAME, "completeHandoff: no pending token for handoffId=$handoffId — ignoring (Metro reload?)")
92
+ return
93
+ }
94
+
95
+ val decoded = decodeOutcome(outcome) ?: return
96
+ complete(decoded)
97
+ }
98
+
57
99
  @ReactMethod
58
100
  fun unregisterStepHandoff() {
59
101
  stepHandoffActive = false
@@ -104,6 +146,224 @@ class NamiFlowManagerBridgeModule internal constructor(
104
146
  promise.resolve(NamiFlowManager.isFlowOpen())
105
147
  }
106
148
 
149
+ /**
150
+ * Decodes a JS outcome dict into the typed [NamiHandoffOutcome] sealed class.
151
+ *
152
+ * Returns null and emits a [Log.e] dev error on any validation failure; the caller must
153
+ * NOT invoke the completion token in that case.
154
+ */
155
+ private fun decodeOutcome(outcome: ReadableMap): NamiHandoffOutcome? {
156
+ val kind = outcome.getString("kind")
157
+ return when (kind) {
158
+ "done" -> NamiHandoffOutcome.Done
159
+
160
+ "permission" -> {
161
+ val permissionStr = outcome.getString("permission")
162
+ if (permissionStr == null) {
163
+ Log.e(NAME, "completeHandoff: 'permission' field is required for kind='permission'")
164
+ return null
165
+ }
166
+ val permissionType = resolvePermissionOutcome(permissionStr) ?: return null
167
+ val granted =
168
+ if (outcome.hasKey("granted")) {
169
+ outcome.getBoolean("granted")
170
+ } else {
171
+ Log.e(NAME, "completeHandoff: 'granted' field is required for kind='permission'")
172
+ return null
173
+ }
174
+ val detail = if (outcome.hasKey("detail")) outcome.getString("detail") else null
175
+ NamiHandoffOutcome.Permission(permissionType, granted, detail)
176
+ }
177
+
178
+ "purchase" -> {
179
+ val result = outcome.getString("result")
180
+ when (result) {
181
+ "success" -> {
182
+ val detailsMap = if (outcome.hasKey("details")) outcome.getMap("details") else null
183
+ if (detailsMap == null) {
184
+ Log.e(NAME, "completeHandoff: 'details' map is required for kind='purchase', result='success'")
185
+ return null
186
+ }
187
+ val purchaseSuccess = decodePurchaseSuccess(detailsMap) ?: return null
188
+ NamiHandoffOutcome.Purchase(NamiPurchaseOutcome.Success(purchaseSuccess))
189
+ }
190
+ "cancelled" -> NamiHandoffOutcome.Purchase(NamiPurchaseOutcome.Cancelled)
191
+ "failed" -> NamiHandoffOutcome.Purchase(NamiPurchaseOutcome.Failed())
192
+ else -> {
193
+ Log.e(NAME, "completeHandoff: unknown purchase result='$result'; expected 'success', 'cancelled', or 'failed'")
194
+ null
195
+ }
196
+ }
197
+ }
198
+
199
+ "login" -> {
200
+ val result = outcome.getString("result")
201
+ when (result) {
202
+ "success" -> {
203
+ val detailsMap = if (outcome.hasKey("details")) outcome.getMap("details") else null
204
+ if (detailsMap == null) {
205
+ Log.e(NAME, "completeHandoff: 'details' map is required for kind='login', result='success'")
206
+ return null
207
+ }
208
+ val loginSuccess = decodeLoginSuccess(detailsMap) ?: return null
209
+ NamiHandoffOutcome.Login(NamiLoginOutcome.Success(loginSuccess))
210
+ }
211
+ "cancelled" -> NamiHandoffOutcome.Login(NamiLoginOutcome.Cancelled)
212
+ "failed" -> NamiHandoffOutcome.Login(NamiLoginOutcome.Failed())
213
+ else -> {
214
+ Log.e(NAME, "completeHandoff: unknown login result='$result'; expected 'success', 'cancelled', or 'failed'")
215
+ null
216
+ }
217
+ }
218
+ }
219
+
220
+ else -> {
221
+ Log.e(NAME, "completeHandoff: unknown kind='$kind'; expected 'done', 'permission', 'purchase', or 'login'")
222
+ null
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Maps a wire permission string (case-insensitive) to [NamiPermissionOutcome].
229
+ * Unknown values emit a [Log.e] and return null.
230
+ */
231
+ private fun resolvePermissionOutcome(wire: String): NamiPermissionOutcome? {
232
+ // Case-insensitive match against enum entry names (Push, Location, Tracking, …)
233
+ return NamiPermissionOutcome.entries.firstOrNull {
234
+ it.name.equals(wire, ignoreCase = true)
235
+ } ?: run {
236
+ val valid = NamiPermissionOutcome.entries.joinToString { it.name.lowercase() }
237
+ Log.e(NAME, "completeHandoff: unknown permission='$wire'; valid values: $valid")
238
+ null
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Decodes the `details` sub-map for a purchase/success outcome into [NamiPurchaseSuccess].
244
+ *
245
+ * Mirrors the field layout already used by [NamiPaywallManagerBridgeModule.buySkuComplete]:
246
+ * - `product.id` → skuId (the Nami reference ID)
247
+ * - `product.skuId` → skuRefId (the store product ID)
248
+ * - `storeType` → "GooglePlay" or "Amazon"
249
+ * - GooglePlay: `orderId`, `purchaseToken`
250
+ * - Amazon: `receiptId`, `localizedPrice`, `userId`, `marketplace`
251
+ */
252
+ private fun decodePurchaseSuccess(details: ReadableMap): NamiPurchaseSuccess? {
253
+ val productMap = if (details.hasKey("product")) details.getMap("product") else null
254
+ val productId = productMap?.getString("id")
255
+ val skuRefId = productMap?.getString("skuId")
256
+
257
+ if (productId == null || skuRefId == null) {
258
+ // TODO(NAM-1560): construct NamiPurchaseSuccess from map — product.id and product.skuId
259
+ // are required but missing or null. The JS caller must supply a NamiSKU-shaped product
260
+ // map with at least {id, skuId} fields (matching the buySkuComplete wire format).
261
+ Log.e(NAME, "completeHandoff: purchase/success 'details.product' must include 'id' and 'skuId'")
262
+ return null
263
+ }
264
+
265
+ val namiSku =
266
+ NamiSKU.create(
267
+ skuId = productId,
268
+ skuRefId = skuRefId,
269
+ )
270
+
271
+ val storeType = details.getString("storeType") ?: "GooglePlay"
272
+
273
+ return when (storeType) {
274
+ "GooglePlay" -> {
275
+ val orderId = details.getString("orderId")
276
+ val purchaseToken = details.getString("purchaseToken")
277
+ if (orderId == null || purchaseToken == null) {
278
+ Log.e(NAME, "completeHandoff: purchase/success GooglePlay requires 'orderId' and 'purchaseToken'")
279
+ return null
280
+ }
281
+ NamiPurchaseSuccess.GooglePlay(
282
+ product = namiSku,
283
+ orderId = orderId,
284
+ purchaseToken = purchaseToken,
285
+ )
286
+ }
287
+ "Amazon" -> {
288
+ val receiptId = details.getString("receiptId")
289
+ val localizedPrice = details.getString("localizedPrice")
290
+ val userId = details.getString("userId")
291
+ val marketplace = details.getString("marketplace")
292
+ if (receiptId == null || localizedPrice == null || userId == null || marketplace == null) {
293
+ Log.e(
294
+ NAME,
295
+ "completeHandoff: purchase/success Amazon requires 'receiptId', 'localizedPrice', 'userId', and 'marketplace'",
296
+ )
297
+ return null
298
+ }
299
+ NamiPurchaseSuccess.Amazon(
300
+ product = namiSku,
301
+ receiptId = receiptId,
302
+ localizedPrice = localizedPrice,
303
+ userId = userId,
304
+ marketplace = marketplace,
305
+ )
306
+ }
307
+ else -> {
308
+ Log.e(NAME, "completeHandoff: unknown storeType='$storeType'; expected 'GooglePlay' or 'Amazon'")
309
+ null
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Decodes the `details` sub-map for a login/success outcome into [NamiLoginSuccess].
316
+ *
317
+ * The `attributes` map must contain only primitive values (String, Number, Boolean).
318
+ * Non-primitive values emit a [Log.e] and abort the decode — [NamiLoginSuccess.init]
319
+ * would throw on them anyway; we surface a clean dev error instead.
320
+ */
321
+ private fun decodeLoginSuccess(details: ReadableMap): NamiLoginSuccess? {
322
+ val loginId = details.getString("loginId")
323
+ if (loginId.isNullOrEmpty()) {
324
+ Log.e(NAME, "completeHandoff: login/success 'details.loginId' is required and must be non-empty")
325
+ return null
326
+ }
327
+
328
+ val cdpId = if (details.hasKey("cdpId")) details.getString("cdpId") else null
329
+
330
+ val attributes: Map<String, Any>? =
331
+ if (details.hasKey("attributes")) {
332
+ val attrsMap = details.getMap("attributes")
333
+ if (attrsMap != null) {
334
+ val raw = attrsMap.toHashMap()
335
+ // Validate that all values are primitives before handing to NamiLoginSuccess.
336
+ val invalid = raw.entries.firstOrNull { (_, v) -> v !is String && v !is Number && v !is Boolean }
337
+ if (invalid != null) {
338
+ Log.e(
339
+ NAME,
340
+ "completeHandoff: login/success attributes['${invalid.key}'] must be a primitive " +
341
+ "(String/Number/Boolean); got ${invalid.value?.let { it::class.simpleName } ?: "null"}",
342
+ )
343
+ return null
344
+ }
345
+ @Suppress("UNCHECKED_CAST")
346
+ raw as Map<String, Any>
347
+ } else {
348
+ null
349
+ }
350
+ } else {
351
+ null
352
+ }
353
+
354
+ return try {
355
+ NamiLoginSuccess(
356
+ loginId = loginId,
357
+ cdpId = cdpId,
358
+ attributes = attributes,
359
+ )
360
+ } catch (e: IllegalArgumentException) {
361
+ // NamiLoginSuccess.init validates attribute primitives — surface the message clearly.
362
+ Log.e(NAME, "completeHandoff: NamiLoginSuccess rejected attributes — ${e.message}")
363
+ null
364
+ }
365
+ }
366
+
107
367
  private fun sendEvent(
108
368
  eventName: String,
109
369
  params: WritableMap?,
@@ -1,9 +1,11 @@
1
1
  import type { TurboModule } from 'react-native';
2
+ import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes';
2
3
  export interface Spec extends TurboModule {
3
4
  finish(): void;
4
5
  isFlowOpen(): Promise<boolean>;
5
6
  registerStepHandoff(): void;
6
7
  unregisterStepHandoff(): void;
8
+ completeHandoff(handoffId: string, outcome: UnsafeObject): void;
7
9
  resume(): void;
8
10
  pause(): void;
9
11
  registerEventHandler(): void;
@@ -1,11 +1,12 @@
1
1
  import { NativeEventEmitter } from 'react-native';
2
+ import type { NamiFlowHandoffStepHandler } from './types';
2
3
  export declare enum NamiFlowManagerEvents {
3
4
  Handoff = "Handoff",
4
5
  FlowEvent = "FlowEvent"
5
6
  }
6
7
  export declare const NamiFlowManager: {
7
8
  emitter: NativeEventEmitter;
8
- registerStepHandoff: (callback: (handoffTag: string, handoffData?: Record<string, unknown>) => void) => (() => void);
9
+ registerStepHandoff: (callback: NamiFlowHandoffStepHandler) => (() => void);
9
10
  resume: () => void;
10
11
  pause: () => void;
11
12
  registerEventHandler: (callback: (payload: Record<string, unknown>) => void) => (() => void);
@@ -127,7 +127,15 @@ export type NamiEntitlement = {
127
127
  referenceId: string;
128
128
  relatedSkus: NamiSKU[];
129
129
  };
130
- export type NamiPurchaseDetails = {
130
+ /**
131
+ * Host-managed purchase payload handed back to the SDK on a successful
132
+ * `buysku` handoff (`complete({ kind: 'purchase', result: 'success', details })`).
133
+ * Fields are a superset across stores; the native bridge maps them onto the
134
+ * platform's own `NamiPurchaseSuccess` (Apple flat class / Android sealed
135
+ * GooglePlay|Amazon) at the boundary. Renamed from `NamiPurchaseDetails` for
136
+ * cross-SDK naming consistency (NAM-1560 §2.3).
137
+ */
138
+ export type NamiPurchaseSuccess = {
131
139
  product: NamiSKU;
132
140
  transactionID?: string;
133
141
  originalTransactionID?: string;
@@ -141,6 +149,12 @@ export type NamiPurchaseDetails = {
141
149
  marketplace?: string;
142
150
  storeType?: string;
143
151
  };
152
+ /**
153
+ * @deprecated Renamed to {@link NamiPurchaseSuccess} for cross-SDK naming
154
+ * consistency. Structurally identical; kept so existing host code keeps
155
+ * compiling. Prefer `NamiPurchaseSuccess` in new code.
156
+ */
157
+ export type NamiPurchaseDetails = NamiPurchaseSuccess;
144
158
  export type NamiPurchaseSuccessApple = {
145
159
  product: NamiSKU;
146
160
  transactionID: string;
@@ -244,10 +258,107 @@ export type NamiPaywallEventVideoMetadata = {
244
258
  contentTimecode?: number;
245
259
  contentDuration?: number;
246
260
  };
261
+ /**
262
+ * Native → JS handoff delivery payload. `handoffId` correlates the eventual
263
+ * `complete(outcome)` back to the native pending handoff (NAM-1560 §2.8).
264
+ */
247
265
  export type NamiFlowHandoffPayload = {
266
+ handoffId: string;
248
267
  handoffTag: string;
249
268
  handoffData?: Record<string, unknown>;
250
269
  };
270
+ /** Open-ended host-asserted attribute values — primitives only (no nesting). */
271
+ export type NamiHandoffAttributeValue = string | number | boolean;
272
+ /**
273
+ * Host-asserted login result. Only `loginId` is required. `attributes` is
274
+ * open-ended experience data (e.g. `{ subscriberStatus, subscribedTier }`) read
275
+ * by branch conditions and smart text via `login.<hostKey>`; never merged into
276
+ * Nami subscriber state and never touches entitlements.
277
+ */
278
+ export type NamiLoginSuccess = {
279
+ loginId: string;
280
+ cdpId?: string;
281
+ attributes?: Record<string, NamiHandoffAttributeValue>;
282
+ };
283
+ /** Permission types reportable from a handoff. Tier-1 (push/location/tracking)
284
+ * carry completion wire values natively; the rest are state/lifecycle only. */
285
+ export declare const NamiPermissionOutcome: {
286
+ readonly Push: "push";
287
+ readonly Location: "location";
288
+ readonly Tracking: "tracking";
289
+ readonly Camera: "camera";
290
+ readonly Microphone: "microphone";
291
+ readonly Photos: "photos";
292
+ readonly Contacts: "contacts";
293
+ readonly Bluetooth: "bluetooth";
294
+ readonly Calendar: "calendar";
295
+ readonly Biometrics: "biometrics";
296
+ readonly Health: "health";
297
+ readonly Motion: "motion";
298
+ readonly Speech: "speech";
299
+ readonly LocalNetwork: "localNetwork";
300
+ };
301
+ export type NamiPermissionOutcome = (typeof NamiPermissionOutcome)[keyof typeof NamiPermissionOutcome];
302
+ /** Typed outcome reported by the host's `complete(...)`. Discriminated on `kind`
303
+ * (purchase/login further discriminated on `result`). */
304
+ export type NamiHandoffOutcome = {
305
+ kind: 'done';
306
+ } | {
307
+ kind: 'permission';
308
+ permission: NamiPermissionOutcome;
309
+ granted: boolean;
310
+ detail?: string;
311
+ } | {
312
+ kind: 'purchase';
313
+ result: 'success';
314
+ details: NamiPurchaseSuccess;
315
+ } | {
316
+ kind: 'purchase';
317
+ result: 'cancelled';
318
+ } | {
319
+ kind: 'purchase';
320
+ result: 'failed';
321
+ error?: unknown;
322
+ } | {
323
+ kind: 'login';
324
+ result: 'success';
325
+ details: NamiLoginSuccess;
326
+ } | {
327
+ kind: 'login';
328
+ result: 'cancelled';
329
+ } | {
330
+ kind: 'login';
331
+ result: 'failed';
332
+ error?: unknown;
333
+ };
334
+ /** Per-handoff completion callback. Bare `complete()` ≙ `complete({kind:'done'})`. */
335
+ export type NamiHandoffComplete = (outcome?: NamiHandoffOutcome) => void;
336
+ /** The single handoff handler shape (NAM-1560 §2.1 — no dual registration). */
337
+ export type NamiFlowHandoffStepHandler = (handoffTag: string, handoffData: Record<string, unknown> | undefined, complete: NamiHandoffComplete) => void;
338
+ /** Closed-but-extensible handoff tag vocabulary (mirrors the builder's
339
+ * `FlowHandoffTag`, minus `__*__` internals). Unknown future tags still pass. */
340
+ export declare const NamiHandoffTag: {
341
+ readonly Push: "push";
342
+ readonly Location: "location";
343
+ readonly Tracking: "att";
344
+ readonly Deeplink: "deeplink";
345
+ readonly Complete: "complete";
346
+ readonly SignIn: "signin";
347
+ readonly Restore: "restore";
348
+ readonly SignInMvpd: "signin_mvpd";
349
+ readonly BuySku: "buysku";
350
+ readonly UserData: "userdata";
351
+ };
352
+ export type NamiKnownHandoffTag = (typeof NamiHandoffTag)[keyof typeof NamiHandoffTag];
353
+ export type NamiHandoffTagValue = NamiKnownHandoffTag | (string & {});
354
+ /** Outbound `userdata` envelope (Nami → customer) delivered with the `userdata` tag. */
355
+ export type NamiUserDataEnvelope = {
356
+ form_id: string;
357
+ step_id: string;
358
+ /** ISO-8601 UTC timestamp of delivery. */
359
+ collected_at: string;
360
+ fields: Record<string, boolean | string>;
361
+ };
251
362
  export type NamiAccountStateEvent = {
252
363
  action: AccountStateAction;
253
364
  success: boolean;
@@ -2,4 +2,4 @@
2
2
  * Auto-generated file. Do not edit manually.
3
3
  * React Native Nami SDK version.
4
4
  */
5
- export declare const NAMI_REACT_NATIVE_VERSION = "3.4.4-dev.202606241952";
5
+ export declare const NAMI_REACT_NATIVE_VERSION = "3.4.4-dev.202606260324";
@@ -17,6 +17,7 @@ RCT_EXTERN_METHOD(resume)
17
17
  RCT_EXTERN_METHOD(pause)
18
18
  RCT_EXTERN_METHOD(finish)
19
19
  RCT_EXTERN_METHOD(isFlowOpen:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
20
+ RCT_EXTERN_METHOD(completeHandoff:(NSString *)handoffId outcome:(NSDictionary *)outcome)
20
21
 
21
22
  + (BOOL)requiresMainQueueSetup {
22
23
  return YES;
@@ -11,7 +11,7 @@ import React
11
11
 
12
12
  @objc(RNNamiFlowManager)
13
13
  class RNNamiFlowManager: RCTEventEmitter {
14
- public static var shared: RNNamiFlowManager?
14
+ static var shared: RNNamiFlowManager?
15
15
 
16
16
  override init() {
17
17
  super.init()
@@ -44,11 +44,28 @@ class RNNamiFlowManager: RCTEventEmitter {
44
44
  return ["Handoff", "FlowEvent"]
45
45
  }
46
46
 
47
+ // MARK: - Pending handoff completion token storage
48
+
49
+ /// Serial queue used to synchronise all reads/writes of `pendingHandoffs`.
50
+ private let handoffQueue = DispatchQueue(label: "com.namiml.rn.flow-manager.handoffs")
51
+ /// Maps handoff UUID strings to their in-flight completion tokens.
52
+ private var pendingHandoffs: [String: NamiHandoffCompletion] = [:]
53
+
54
+ // MARK: - Step-handoff registration
55
+
47
56
  @objc func registerStepHandoff() {
48
57
  stepHandoffActive = true
49
- NamiFlowManager.registerStepHandoff { [weak self] tag, data in
58
+ NamiFlowManager.registerStepHandoff { [weak self] tag, data, complete in
50
59
  guard let self = self, self.stepHandoffActive else { return }
60
+
61
+ // Generate a UUID and store the completion token thread-safely.
62
+ let handoffId = UUID().uuidString
63
+ self.handoffQueue.sync {
64
+ self.pendingHandoffs[handoffId] = complete
65
+ }
66
+
51
67
  var payload: [String: Any] = [
68
+ "handoffId": handoffId,
52
69
  "handoffTag": tag,
53
70
  ]
54
71
  if let data = data {
@@ -63,6 +80,217 @@ class RNNamiFlowManager: RCTEventEmitter {
63
80
  stepHandoffActive = false
64
81
  }
65
82
 
83
+ // MARK: - Complete handoff (called from JS)
84
+
85
+ /**
86
+ Resolves a pending handoff by decoding the outcome dict sent from JavaScript
87
+ and calling the stored `NamiHandoffCompletion` token.
88
+
89
+ - Parameter handoffId: The UUID string previously emitted in the `Handoff` event.
90
+ - Parameter outcome: Discriminated-union dict produced by the TS bridge, e.g.
91
+ `{ "kind": "done" }` or `{ "kind": "login", "result": "success", … }`.
92
+ */
93
+ @objc func completeHandoff(_ handoffId: String, outcome: NSDictionary) {
94
+ // Look up and remove the token atomically so it can only fire once.
95
+ var token: NamiHandoffCompletion?
96
+ handoffQueue.sync {
97
+ token = pendingHandoffs.removeValue(forKey: handoffId)
98
+ }
99
+
100
+ guard let complete = token else {
101
+ // Stale call — harmless after a Metro reload or duplicate invocation.
102
+ print("[RNNamiFlowManager] Warning: completeHandoff called with unknown or already-consumed handoffId '\(handoffId)'. Ignoring.")
103
+ return
104
+ }
105
+
106
+ guard let decoded = decodeOutcome(outcome, handoffId: handoffId) else {
107
+ // decodeOutcome already logged the error; do NOT call complete.
108
+ return
109
+ }
110
+
111
+ complete(decoded)
112
+ }
113
+
114
+ // MARK: - Outcome decoder
115
+
116
+ /**
117
+ Decodes a JS outcome dict into a typed `NamiHandoffOutcome`.
118
+
119
+ Returns `nil` and logs a loud dev-mode error for any invalid payload so that
120
+ `completeHandoff` never delivers a malformed outcome to the flow engine.
121
+ */
122
+ private func decodeOutcome(_ outcome: NSDictionary, handoffId: String) -> NamiHandoffOutcome? {
123
+ guard let kind = outcome["kind"] as? String else {
124
+ logDecodeError(handoffId: handoffId, message: "outcome dict is missing required 'kind' field. Received: \(outcome)")
125
+ return nil
126
+ }
127
+
128
+ switch kind {
129
+ case "done":
130
+ return .done
131
+
132
+ case "permission":
133
+ guard let permissionString = outcome["permission"] as? String else {
134
+ logDecodeError(handoffId: handoffId, message: "'permission' kind requires a 'permission' String field. Received: \(outcome)")
135
+ return nil
136
+ }
137
+ guard let perm = NamiPermissionOutcome(rawValue: permissionString) else {
138
+ logDecodeError(handoffId: handoffId, message: "Unknown NamiPermissionOutcome rawValue '\(permissionString)'. Received: \(outcome)")
139
+ return nil
140
+ }
141
+ guard let granted = outcome["granted"] as? Bool else {
142
+ logDecodeError(handoffId: handoffId, message: "'permission' kind requires a 'granted' Bool field. Received: \(outcome)")
143
+ return nil
144
+ }
145
+ let detail = outcome["detail"] as? String
146
+ return .permission(perm, granted: granted, detail: detail)
147
+
148
+ case "purchase":
149
+ guard let result = outcome["result"] as? String else {
150
+ logDecodeError(handoffId: handoffId, message: "'purchase' kind requires a 'result' String field. Received: \(outcome)")
151
+ return nil
152
+ }
153
+ switch result {
154
+ case "success":
155
+ guard let details = outcome["details"] as? [String: Any] else {
156
+ logDecodeError(handoffId: handoffId, message: "'purchase/success' requires a 'details' dict. Received: \(outcome)")
157
+ return nil
158
+ }
159
+ guard let purchaseSuccess = buildPurchaseSuccess(from: details, handoffId: handoffId) else {
160
+ // buildPurchaseSuccess already logged the specific error.
161
+ return nil
162
+ }
163
+ return .purchase(.success(purchaseSuccess))
164
+ case "cancelled":
165
+ return .purchase(.cancelled)
166
+ case "failed":
167
+ return .purchase(.failed(nil))
168
+ default:
169
+ logDecodeError(handoffId: handoffId, message: "Unknown 'purchase' result '\(result)'. Expected 'success', 'cancelled', or 'failed'. Received: \(outcome)")
170
+ return nil
171
+ }
172
+
173
+ case "login":
174
+ guard let result = outcome["result"] as? String else {
175
+ logDecodeError(handoffId: handoffId, message: "'login' kind requires a 'result' String field. Received: \(outcome)")
176
+ return nil
177
+ }
178
+ switch result {
179
+ case "success":
180
+ guard let details = outcome["details"] as? [String: Any] else {
181
+ logDecodeError(handoffId: handoffId, message: "'login/success' requires a 'details' dict. Received: \(outcome)")
182
+ return nil
183
+ }
184
+ guard let loginId = details["loginId"] as? String else {
185
+ logDecodeError(handoffId: handoffId, message: "'login/success' details missing required 'loginId' String. Received: \(details)")
186
+ return nil
187
+ }
188
+ let cdpId = details["cdpId"] as? String
189
+ let rawAttrs = details["attributes"] as? [String: Any] ?? [:]
190
+
191
+ // Validate that every attribute value is a supported primitive.
192
+ for (key, value) in rawAttrs {
193
+ guard NamiHandoffAttributeValue(value) != nil else {
194
+ logDecodeError(handoffId: handoffId, message: "Login attribute '\(key)' has unsupported value type \(type(of: value)). Only String, Bool, Int, and Double are allowed. Received: \(value)")
195
+ return nil
196
+ }
197
+ }
198
+
199
+ let loginSuccess = NamiLoginSuccess(loginId: loginId, cdpId: cdpId, attributes: rawAttrs)
200
+ return .login(.success(loginSuccess))
201
+ case "cancelled":
202
+ return .login(.cancelled)
203
+ case "failed":
204
+ return .login(.failed(nil))
205
+ default:
206
+ logDecodeError(handoffId: handoffId, message: "Unknown 'login' result '\(result)'. Expected 'success', 'cancelled', or 'failed'. Received: \(outcome)")
207
+ return nil
208
+ }
209
+
210
+ default:
211
+ logDecodeError(handoffId: handoffId, message: "Unknown outcome 'kind' '\(kind)'. Expected 'done', 'permission', 'purchase', or 'login'. Received: \(outcome)")
212
+ return nil
213
+ }
214
+ }
215
+
216
+ // MARK: - NamiPurchaseSuccess builder
217
+
218
+ /**
219
+ Builds a `NamiPurchaseSuccess` from the 'details' sub-dict of a purchase/success outcome.
220
+
221
+ Mirrors the construction logic already used by `RNNamiPaywallManager.buySkuComplete`.
222
+ Returns `nil` and logs a dev error on any missing or invalid field.
223
+ */
224
+ private func buildPurchaseSuccess(from details: [String: Any], handoffId: String) -> NamiPurchaseSuccess? {
225
+ guard let product = details["product"] as? [String: Any] else {
226
+ logDecodeError(handoffId: handoffId, message: "'purchase/success' details missing 'product' dict. Received: \(details)")
227
+ return nil
228
+ }
229
+ guard let storeId = product["skuId"] as? String else {
230
+ logDecodeError(handoffId: handoffId, message: "'purchase/success' product missing 'skuId' String. Received: \(product)")
231
+ return nil
232
+ }
233
+ guard let namiId = product["id"] as? String else {
234
+ logDecodeError(handoffId: handoffId, message: "'purchase/success' product missing 'id' String. Received: \(product)")
235
+ return nil
236
+ }
237
+ let skuTypeString = product["type"] as? String ?? "unknown"
238
+ let namiSkuType: NamiSKUType
239
+ switch skuTypeString {
240
+ case "one_time_purchase":
241
+ namiSkuType = .one_time_purchase
242
+ case "subscription":
243
+ namiSkuType = .subscription
244
+ default:
245
+ namiSkuType = .unknown
246
+ }
247
+ let namiSku = NamiSKU(namiId: namiId, storeId: storeId, skuType: namiSkuType)
248
+
249
+ guard let transactionID = details["transactionID"] as? String else {
250
+ logDecodeError(handoffId: handoffId, message: "'purchase/success' details missing 'transactionID' String. Received: \(details)")
251
+ return nil
252
+ }
253
+ guard let originalTransactionID = details["originalTransactionID"] as? String else {
254
+ logDecodeError(handoffId: handoffId, message: "'purchase/success' details missing 'originalTransactionID' String. Received: \(details)")
255
+ return nil
256
+ }
257
+ guard let currencyCode = details["currencyCode"] as? String else {
258
+ logDecodeError(handoffId: handoffId, message: "'purchase/success' details missing 'currencyCode' String. Received: \(details)")
259
+ return nil
260
+ }
261
+
262
+ // Accept price as either a Decimal-bridged NSDecimalNumber, a Double, or a String.
263
+ let price: Decimal
264
+ if let decimalNumber = details["price"] as? NSDecimalNumber {
265
+ price = decimalNumber as Decimal
266
+ } else if let doubleValue = details["price"] as? Double {
267
+ price = Decimal(doubleValue)
268
+ } else if let priceString = details["price"] as? String, let parsed = Decimal(string: priceString) {
269
+ price = parsed
270
+ } else {
271
+ logDecodeError(handoffId: handoffId, message: "'purchase/success' details missing or unparseable 'price'. Received: \(details)")
272
+ return nil
273
+ }
274
+
275
+ return NamiPurchaseSuccess(
276
+ product: namiSku,
277
+ transactionID: transactionID,
278
+ originalTransactionID: originalTransactionID,
279
+ price: price,
280
+ currencyCode: currencyCode
281
+ )
282
+ }
283
+
284
+ // MARK: - Error logging helper
285
+
286
+ private func logDecodeError(handoffId: String, message: String) {
287
+ let fullMessage = "[RNNamiFlowManager] completeHandoff decode error (handoffId='\(handoffId)'): \(message)"
288
+ print(fullMessage)
289
+ #if DEBUG
290
+ assertionFailure(fullMessage)
291
+ #endif
292
+ }
293
+
66
294
  @objc func registerEventHandler() {
67
295
  eventHandlerActive = true
68
296
  NamiFlowManager.registerEventHandler { [weak self] payload in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nami-sdk",
3
- "version": "3.4.4-dev.202606241952",
3
+ "version": "3.4.4-dev.202606260324",
4
4
  "description": "React Native SDK for Nami - No-code paywall and onboarding flows with A/B testing.",
5
5
  "main": "index.ts",
6
6
  "types": "dist/index.d.ts",
@@ -21,7 +21,7 @@ Pod::Spec.new do |s|
21
21
  s.requires_arc = true
22
22
  s.swift_version = '5.0' # or your supported version
23
23
 
24
- s.dependency 'Nami', '3.4.4-dev.202606241952'
24
+ s.dependency 'Nami', '3.4.4-dev.202606260324'
25
25
 
26
26
  pod_target_xcconfig = {
27
27
  'DEFINES_MODULE' => 'YES',
@@ -1,5 +1,6 @@
1
1
  import type { TurboModule } from 'react-native';
2
2
  import { TurboModuleRegistry } from 'react-native';
3
+ import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes';
3
4
 
4
5
  export interface Spec extends TurboModule {
5
6
  finish(): void;
@@ -7,6 +8,12 @@ export interface Spec extends TurboModule {
7
8
 
8
9
  registerStepHandoff(): void;
9
10
  unregisterStepHandoff(): void;
11
+ // Reports a typed handoff outcome for a specific delivered handoff and resumes
12
+ // the flow. `handoffId` correlates the call back to the native pending handoff
13
+ // (TurboModule callbacks can't hold per-call closures). `outcome` is always
14
+ // required on the wire — the bare `complete()` case is normalized to
15
+ // `{ kind: 'done' }` in the JS wrapper before crossing the bridge (NAM-1560 §2.8).
16
+ completeHandoff(handoffId: string, outcome: UnsafeObject): void;
10
17
  resume(): void;
11
18
  pause(): void;
12
19
  registerEventHandler(): void;
@@ -4,7 +4,12 @@ import {
4
4
  NativeEventEmitter,
5
5
  } from 'react-native';
6
6
  import type { Spec } from '../specs/NativeNamiFlowManager';
7
- import type { NamiFlowHandoffPayload } from '../src/types';
7
+ import type {
8
+ NamiFlowHandoffPayload,
9
+ NamiFlowHandoffStepHandler,
10
+ NamiHandoffComplete,
11
+ NamiHandoffOutcome,
12
+ } from './types';
8
13
 
9
14
  const RNNamiFlowManager: Spec =
10
15
  TurboModuleRegistry.getEnforcing?.<Spec>('RNNamiFlowManager') ??
@@ -20,19 +25,39 @@ export enum NamiFlowManagerEvents {
20
25
  export const NamiFlowManager = {
21
26
  emitter,
22
27
 
23
- registerStepHandoff: (
24
- callback: (
25
- handoffTag: string,
26
- handoffData?: Record<string, unknown>,
27
- ) => void,
28
- ): (() => void) => {
28
+ registerStepHandoff: (callback: NamiFlowHandoffStepHandler): (() => void) => {
29
29
  console.info('[NamiFlowManager] Registering step handoff listener...');
30
30
 
31
31
  const sub = emitter.addListener(
32
32
  NamiFlowManagerEvents.Handoff,
33
33
  (event: NamiFlowHandoffPayload) => {
34
34
  console.info('[NamiFlowManager] Received handoff event:', event);
35
- callback(event.handoffTag, event.handoffData);
35
+
36
+ // Manufacture the per-handoff completion callback. The native event
37
+ // carries a `handoffId` that correlates this call back to the native
38
+ // pending handoff (TurboModule callbacks can't hold per-call closures).
39
+ let completed = false;
40
+ const complete: NamiHandoffComplete = (
41
+ outcome?: NamiHandoffOutcome,
42
+ ) => {
43
+ if (completed) {
44
+ console.warn(
45
+ `[NamiFlowManager] complete() called more than once for handoff '${event.handoffTag}' — ignored`,
46
+ );
47
+ return;
48
+ }
49
+ completed = true;
50
+ // The ONE bridge-side defaulting point: bare complete() ≙ done.
51
+ // `outcome` stays required on the wire (native never sees a null).
52
+ const normalized: NamiHandoffOutcome = outcome ?? { kind: 'done' };
53
+ RNNamiFlowManager.completeHandoff?.(event.handoffId, normalized);
54
+ };
55
+
56
+ // Single handler shape `(tag, data, complete)`. Legacy `(tag, data)`
57
+ // handlers still run — the extra arg is ignored — and their `resume()`
58
+ // degrades to done-with-warning natively (the native pending token owns
59
+ // that, surviving Metro fast-refresh wiping this closure).
60
+ callback(event.handoffTag, event.handoffData, complete);
36
61
  },
37
62
  );
38
63
 
@@ -6,13 +6,21 @@ const registerStepHandoff = jest.fn();
6
6
  const unregisterStepHandoff = jest.fn();
7
7
  const registerEventHandler = jest.fn();
8
8
  const unregisterEventHandler = jest.fn();
9
+ const completeHandoff = jest.fn();
9
10
  const finishMock = jest.fn();
10
11
  const isFlowOpenMock = jest.fn().mockResolvedValue(false);
11
12
  const resumeMock = jest.fn();
12
13
  const pauseMock = jest.fn();
13
14
 
14
15
  const subRemove = jest.fn();
15
- const addListener = jest.fn(() => ({ remove: subRemove }));
16
+ // Capture the registered listener per event name so tests can fire native events.
17
+ const listeners: Record<string, (event: unknown) => void> = {};
18
+ const addListener = jest.fn(
19
+ (eventName: string, handler: (e: unknown) => void) => {
20
+ listeners[eventName] = handler;
21
+ return { remove: subRemove };
22
+ },
23
+ );
16
24
 
17
25
  jest.mock('react-native', () => ({
18
26
  TurboModuleRegistry: {
@@ -21,6 +29,7 @@ jest.mock('react-native', () => ({
21
29
  unregisterStepHandoff,
22
30
  registerEventHandler,
23
31
  unregisterEventHandler,
32
+ completeHandoff,
24
33
  finish: finishMock,
25
34
  isFlowOpen: isFlowOpenMock,
26
35
  resume: resumeMock,
@@ -33,6 +42,7 @@ jest.mock('react-native', () => ({
33
42
  unregisterStepHandoff,
34
43
  registerEventHandler,
35
44
  unregisterEventHandler,
45
+ completeHandoff,
36
46
  },
37
47
  },
38
48
  NativeEventEmitter: jest.fn().mockImplementation(() => ({
@@ -41,6 +51,16 @@ jest.mock('react-native', () => ({
41
51
  }));
42
52
 
43
53
  import { NamiFlowManager } from '../NamiFlowManager';
54
+ import type { NamiHandoffOutcome } from '../types';
55
+
56
+ /** Fire a native `Handoff` event into the captured emitter listener. */
57
+ function fireHandoff(event: {
58
+ handoffId: string;
59
+ handoffTag: string;
60
+ handoffData?: Record<string, unknown>;
61
+ }) {
62
+ listeners.Handoff?.(event);
63
+ }
44
64
 
45
65
  describe('NamiFlowManager (React Native bridge)', () => {
46
66
  beforeEach(() => {
@@ -48,6 +68,8 @@ describe('NamiFlowManager (React Native bridge)', () => {
48
68
  unregisterStepHandoff.mockClear();
49
69
  registerEventHandler.mockClear();
50
70
  unregisterEventHandler.mockClear();
71
+ completeHandoff.mockClear();
72
+ resumeMock.mockClear();
51
73
  subRemove.mockClear();
52
74
  addListener.mockClear();
53
75
  });
@@ -69,6 +91,98 @@ describe('NamiFlowManager (React Native bridge)', () => {
69
91
  });
70
92
  });
71
93
 
94
+ describe('handoff outcome token bridge', () => {
95
+ it('delivers (tag, data, complete) and correlates complete() back to the handoffId', () => {
96
+ const handler = jest.fn(
97
+ (_tag: string, _data: unknown, complete: () => void) => complete(),
98
+ );
99
+ NamiFlowManager.registerStepHandoff(handler);
100
+
101
+ fireHandoff({
102
+ handoffId: 'h1',
103
+ handoffTag: 'signin',
104
+ handoffData: { a: 1 },
105
+ });
106
+
107
+ expect(handler).toHaveBeenCalledWith(
108
+ 'signin',
109
+ { a: 1 },
110
+ expect.any(Function),
111
+ );
112
+ // Bare complete() is normalized to {kind:'done'} before crossing the bridge.
113
+ expect(completeHandoff).toHaveBeenCalledTimes(1);
114
+ expect(completeHandoff).toHaveBeenCalledWith('h1', { kind: 'done' });
115
+ });
116
+
117
+ it('forwards an explicit outcome verbatim (incl. primitive login attributes)', () => {
118
+ const outcome: NamiHandoffOutcome = {
119
+ kind: 'login',
120
+ result: 'success',
121
+ details: {
122
+ loginId: 'user-123',
123
+ attributes: { subscriberStatus: 'active', seats: 3, trial: false },
124
+ },
125
+ };
126
+ NamiFlowManager.registerStepHandoff((_t, _d, complete) =>
127
+ complete(outcome),
128
+ );
129
+
130
+ fireHandoff({ handoffId: 'h2', handoffTag: 'signin' });
131
+
132
+ expect(completeHandoff).toHaveBeenCalledWith('h2', outcome);
133
+ });
134
+
135
+ it('is once-only per handoff — a second complete() warns and does not re-cross the bridge', () => {
136
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
137
+ NamiFlowManager.registerStepHandoff((_t, _d, complete) => {
138
+ complete({ kind: 'purchase', result: 'cancelled' });
139
+ complete(); // duplicate — must be ignored
140
+ });
141
+
142
+ fireHandoff({ handoffId: 'h3', handoffTag: 'buysku' });
143
+
144
+ expect(completeHandoff).toHaveBeenCalledTimes(1);
145
+ expect(completeHandoff).toHaveBeenCalledWith('h3', {
146
+ kind: 'purchase',
147
+ result: 'cancelled',
148
+ });
149
+ expect(warn).toHaveBeenCalledWith(
150
+ expect.stringContaining('called more than once'),
151
+ );
152
+ warn.mockRestore();
153
+ });
154
+
155
+ it('correlates concurrent handoffs to their own ids independently', () => {
156
+ const completes: Record<string, () => void> = {};
157
+ NamiFlowManager.registerStepHandoff((tag, _d, complete) => {
158
+ completes[tag] = complete;
159
+ });
160
+
161
+ fireHandoff({ handoffId: 'hA', handoffTag: 'push' });
162
+ fireHandoff({ handoffId: 'hB', handoffTag: 'location' });
163
+
164
+ completes.location();
165
+ completes.push();
166
+
167
+ expect(completeHandoff).toHaveBeenCalledWith('hB', { kind: 'done' });
168
+ expect(completeHandoff).toHaveBeenCalledWith('hA', { kind: 'done' });
169
+ });
170
+
171
+ it('legacy (tag, data) handler that calls resume() still works (degrades natively, no completeHandoff)', () => {
172
+ // Old-shape handler ignores the third arg and calls resume() directly.
173
+ const legacy = (_tag: string, _data?: Record<string, unknown>) => {
174
+ NamiFlowManager.resume();
175
+ };
176
+ // Cast at the call site only — the public signature is the 3-arg shape.
177
+ NamiFlowManager.registerStepHandoff(legacy as never);
178
+
179
+ fireHandoff({ handoffId: 'h4', handoffTag: 'complete' });
180
+
181
+ expect(resumeMock).toHaveBeenCalledTimes(1);
182
+ expect(completeHandoff).not.toHaveBeenCalled();
183
+ });
184
+ });
185
+
72
186
  describe('registerEventHandler', () => {
73
187
  it('returns a function and triggers native register', () => {
74
188
  const unsubscribe = NamiFlowManager.registerEventHandler(() => {});
package/src/types.ts CHANGED
@@ -259,7 +259,15 @@ export type NamiEntitlement = {
259
259
  relatedSkus: NamiSKU[];
260
260
  };
261
261
 
262
- export type NamiPurchaseDetails = {
262
+ /**
263
+ * Host-managed purchase payload handed back to the SDK on a successful
264
+ * `buysku` handoff (`complete({ kind: 'purchase', result: 'success', details })`).
265
+ * Fields are a superset across stores; the native bridge maps them onto the
266
+ * platform's own `NamiPurchaseSuccess` (Apple flat class / Android sealed
267
+ * GooglePlay|Amazon) at the boundary. Renamed from `NamiPurchaseDetails` for
268
+ * cross-SDK naming consistency (NAM-1560 §2.3).
269
+ */
270
+ export type NamiPurchaseSuccess = {
263
271
  product: NamiSKU;
264
272
  transactionID?: string;
265
273
  originalTransactionID?: string;
@@ -274,6 +282,13 @@ export type NamiPurchaseDetails = {
274
282
  storeType?: string; // 'Apple', 'GooglePlay', 'Amazon'
275
283
  };
276
284
 
285
+ /**
286
+ * @deprecated Renamed to {@link NamiPurchaseSuccess} for cross-SDK naming
287
+ * consistency. Structurally identical; kept so existing host code keeps
288
+ * compiling. Prefer `NamiPurchaseSuccess` in new code.
289
+ */
290
+ export type NamiPurchaseDetails = NamiPurchaseSuccess;
291
+
277
292
  export type NamiPurchaseSuccessApple = {
278
293
  product: NamiSKU;
279
294
  transactionID: string;
@@ -398,11 +413,113 @@ export type NamiPaywallEventVideoMetadata = {
398
413
  contentDuration?: number;
399
414
  };
400
415
 
416
+ /**
417
+ * Native → JS handoff delivery payload. `handoffId` correlates the eventual
418
+ * `complete(outcome)` back to the native pending handoff (NAM-1560 §2.8).
419
+ */
401
420
  export type NamiFlowHandoffPayload = {
421
+ handoffId: string;
402
422
  handoffTag: string;
403
423
  handoffData?: Record<string, unknown>;
404
424
  };
405
425
 
426
+ // ---------------------------------------------------------------------------
427
+ // Variant D — structured handoff outcomes (NAM-1560). Mirrors the core TS
428
+ // reference (sdk/core, NAM-1557 / PR #392). Keep the shapes in sync; the native
429
+ // bridges decode these dicts into the platform `NamiHandoffOutcome`.
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /** Open-ended host-asserted attribute values — primitives only (no nesting). */
433
+ export type NamiHandoffAttributeValue = string | number | boolean;
434
+
435
+ /**
436
+ * Host-asserted login result. Only `loginId` is required. `attributes` is
437
+ * open-ended experience data (e.g. `{ subscriberStatus, subscribedTier }`) read
438
+ * by branch conditions and smart text via `login.<hostKey>`; never merged into
439
+ * Nami subscriber state and never touches entitlements.
440
+ */
441
+ export type NamiLoginSuccess = {
442
+ loginId: string;
443
+ cdpId?: string;
444
+ attributes?: Record<string, NamiHandoffAttributeValue>;
445
+ };
446
+
447
+ /** Permission types reportable from a handoff. Tier-1 (push/location/tracking)
448
+ * carry completion wire values natively; the rest are state/lifecycle only. */
449
+ export const NamiPermissionOutcome = {
450
+ Push: 'push',
451
+ Location: 'location',
452
+ Tracking: 'tracking',
453
+ Camera: 'camera',
454
+ Microphone: 'microphone',
455
+ Photos: 'photos',
456
+ Contacts: 'contacts',
457
+ Bluetooth: 'bluetooth',
458
+ Calendar: 'calendar',
459
+ Biometrics: 'biometrics',
460
+ Health: 'health',
461
+ Motion: 'motion',
462
+ Speech: 'speech',
463
+ LocalNetwork: 'localNetwork',
464
+ } as const;
465
+ export type NamiPermissionOutcome =
466
+ (typeof NamiPermissionOutcome)[keyof typeof NamiPermissionOutcome];
467
+
468
+ /** Typed outcome reported by the host's `complete(...)`. Discriminated on `kind`
469
+ * (purchase/login further discriminated on `result`). */
470
+ export type NamiHandoffOutcome =
471
+ | { kind: 'done' }
472
+ | {
473
+ kind: 'permission';
474
+ permission: NamiPermissionOutcome;
475
+ granted: boolean;
476
+ detail?: string;
477
+ }
478
+ | { kind: 'purchase'; result: 'success'; details: NamiPurchaseSuccess }
479
+ | { kind: 'purchase'; result: 'cancelled' }
480
+ | { kind: 'purchase'; result: 'failed'; error?: unknown }
481
+ | { kind: 'login'; result: 'success'; details: NamiLoginSuccess }
482
+ | { kind: 'login'; result: 'cancelled' }
483
+ | { kind: 'login'; result: 'failed'; error?: unknown };
484
+
485
+ /** Per-handoff completion callback. Bare `complete()` ≙ `complete({kind:'done'})`. */
486
+ export type NamiHandoffComplete = (outcome?: NamiHandoffOutcome) => void;
487
+
488
+ /** The single handoff handler shape (NAM-1560 §2.1 — no dual registration). */
489
+ export type NamiFlowHandoffStepHandler = (
490
+ handoffTag: string,
491
+ handoffData: Record<string, unknown> | undefined,
492
+ complete: NamiHandoffComplete,
493
+ ) => void;
494
+
495
+ /** Closed-but-extensible handoff tag vocabulary (mirrors the builder's
496
+ * `FlowHandoffTag`, minus `__*__` internals). Unknown future tags still pass. */
497
+ export const NamiHandoffTag = {
498
+ Push: 'push',
499
+ Location: 'location',
500
+ Tracking: 'att',
501
+ Deeplink: 'deeplink',
502
+ Complete: 'complete',
503
+ SignIn: 'signin',
504
+ Restore: 'restore',
505
+ SignInMvpd: 'signin_mvpd',
506
+ BuySku: 'buysku',
507
+ UserData: 'userdata',
508
+ } as const;
509
+ export type NamiKnownHandoffTag =
510
+ (typeof NamiHandoffTag)[keyof typeof NamiHandoffTag];
511
+ // eslint-disable-next-line @typescript-eslint/ban-types
512
+ export type NamiHandoffTagValue = NamiKnownHandoffTag | (string & {});
513
+
514
+ /** Outbound `userdata` envelope (Nami → customer) delivered with the `userdata` tag. */
515
+ export type NamiUserDataEnvelope = {
516
+ form_id: string;
517
+ step_id: string;
518
+ /** ISO-8601 UTC timestamp of delivery. */
519
+ collected_at: string;
520
+ fields: Record<string, boolean | string>;
521
+ };
522
+
406
523
  export type NamiAccountStateEvent = {
407
524
  action: AccountStateAction;
408
525
  success: boolean;
package/src/version.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Auto-generated file. Do not edit manually.
3
3
  * React Native Nami SDK version.
4
4
  */
5
- export const NAMI_REACT_NATIVE_VERSION = '3.4.4-dev.202606241952';
5
+ export const NAMI_REACT_NATIVE_VERSION = '3.4.4-dev.202606260324';