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.
- package/android/build.gradle +2 -2
- package/android/src/main/java/com/namiml/reactnative/{NamiCampaignManagerBridge.kt → NamiCampaignManagerBridgeModule.kt} +28 -4
- package/android/src/main/java/com/namiml/reactnative/NamiFlowManagerBridgeModule.kt +261 -1
- package/dist/specs/NativeNamiFlowManager.d.ts +2 -0
- package/dist/src/NamiFlowManager.d.ts +2 -1
- package/dist/src/types.d.ts +112 -1
- package/dist/src/version.d.ts +1 -1
- package/ios/NamiFlowManagerBridge.m +1 -0
- package/ios/NamiFlowManagerBridge.swift +230 -2
- package/package.json +1 -1
- package/react-native-nami-sdk.podspec +1 -1
- package/specs/NativeNamiFlowManager.ts +7 -0
- package/src/NamiFlowManager.ts +33 -8
- package/src/__tests__/NamiFlowManager.test.ts +115 -1
- package/src/types.ts +118 -1
- package/src/version.ts +1 -1
package/android/build.gradle
CHANGED
|
@@ -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.
|
|
89
|
-
amazonImplementation "com.namiml:sdk-amazon:3.4.4-dev.
|
|
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 =
|
|
161
|
+
paywallLaunchContext =
|
|
162
|
+
PaywallLaunchContext(
|
|
163
|
+
productGroups = productGroups.toList(),
|
|
164
|
+
customAttributes = customAttributes,
|
|
165
|
+
customObject = customObject,
|
|
166
|
+
)
|
|
144
167
|
} else {
|
|
145
|
-
paywallLaunchContext =
|
|
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:
|
|
9
|
+
registerStepHandoff: (callback: NamiFlowHandoffStepHandler) => (() => void);
|
|
9
10
|
resume: () => void;
|
|
10
11
|
pause: () => void;
|
|
11
12
|
registerEventHandler: (callback: (payload: Record<string, unknown>) => void) => (() => void);
|
package/dist/src/types.d.ts
CHANGED
|
@@ -127,7 +127,15 @@ export type NamiEntitlement = {
|
|
|
127
127
|
referenceId: string;
|
|
128
128
|
relatedSkus: NamiSKU[];
|
|
129
129
|
};
|
|
130
|
-
|
|
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;
|
package/dist/src/version.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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;
|
package/src/NamiFlowManager.ts
CHANGED
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
NativeEventEmitter,
|
|
5
5
|
} from 'react-native';
|
|
6
6
|
import type { Spec } from '../specs/NativeNamiFlowManager';
|
|
7
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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