@namiml/expo-sdk 3.4.0-dev.202605060437

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.
Files changed (60) hide show
  1. package/dist/index.cjs +4000 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.ts +151 -0
  4. package/dist/index.mjs +3966 -0
  5. package/dist/index.mjs.map +1 -0
  6. package/nami-expo-nami-iap.tgz +0 -0
  7. package/package.json +92 -0
  8. package/src/adapters/expo-device.adapter.ts +106 -0
  9. package/src/adapters/expo-purchase.adapter.ts +79 -0
  10. package/src/adapters/expo-storage.adapter.ts +92 -0
  11. package/src/adapters/expo-ui.adapter.ts +57 -0
  12. package/src/adapters/index.ts +33 -0
  13. package/src/amazon-kepler.d.ts +7 -0
  14. package/src/components/NamiView.tsx +1006 -0
  15. package/src/components/PaywallScreen.tsx +245 -0
  16. package/src/components/TemplateRenderer.tsx +243 -0
  17. package/src/components/containers/NamiBackgroundContainer.tsx +103 -0
  18. package/src/components/containers/NamiCarousel.tsx +217 -0
  19. package/src/components/containers/NamiCollapseContainer.tsx +116 -0
  20. package/src/components/containers/NamiContainer.tsx +315 -0
  21. package/src/components/containers/NamiContentContainer.tsx +140 -0
  22. package/src/components/containers/NamiFooter.tsx +35 -0
  23. package/src/components/containers/NamiHeader.tsx +45 -0
  24. package/src/components/containers/NamiProductContainer.tsx +248 -0
  25. package/src/components/containers/NamiRepeatingGrid.tsx +81 -0
  26. package/src/components/containers/NamiResponsiveGrid.tsx +75 -0
  27. package/src/components/containers/NamiStack.tsx +69 -0
  28. package/src/components/elements/NamiButton.tsx +285 -0
  29. package/src/components/elements/NamiCountdownTimer.tsx +123 -0
  30. package/src/components/elements/NamiImage.tsx +177 -0
  31. package/src/components/elements/NamiPlayPauseButton.tsx +93 -0
  32. package/src/components/elements/NamiProgressBar.tsx +90 -0
  33. package/src/components/elements/NamiProgressIndicator.tsx +41 -0
  34. package/src/components/elements/NamiQRCode.tsx +51 -0
  35. package/src/components/elements/NamiRadioButton.tsx +62 -0
  36. package/src/components/elements/NamiSegmentPicker.tsx +67 -0
  37. package/src/components/elements/NamiSegmentPickerItem.tsx +184 -0
  38. package/src/components/elements/NamiSpacer.tsx +23 -0
  39. package/src/components/elements/NamiSymbol.tsx +104 -0
  40. package/src/components/elements/NamiText.tsx +311 -0
  41. package/src/components/elements/NamiToggleButton.tsx +102 -0
  42. package/src/components/elements/NamiToggleSwitch.tsx +64 -0
  43. package/src/components/elements/NamiVideo.kepler.tsx +638 -0
  44. package/src/components/elements/NamiVideo.tsx +133 -0
  45. package/src/components/elements/NamiVolumeButton.tsx +93 -0
  46. package/src/context/FocusContext.tsx +169 -0
  47. package/src/context/PaywallContext.tsx +343 -0
  48. package/src/global.d.ts +5 -0
  49. package/src/index.ts +62 -0
  50. package/src/nami.ts +24 -0
  51. package/src/react-native-qrcode-svg.d.ts +4 -0
  52. package/src/utils/actionHandler.ts +281 -0
  53. package/src/utils/fonts.ts +359 -0
  54. package/src/utils/iconMap.ts +67 -0
  55. package/src/utils/impression.ts +39 -0
  56. package/src/utils/rendering.ts +197 -0
  57. package/src/utils/smartText.ts +148 -0
  58. package/src/utils/styles.ts +668 -0
  59. package/src/utils/tvFocus.ts +31 -0
  60. package/src/utils/videoControls.ts +49 -0
@@ -0,0 +1,1006 @@
1
+ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react';
2
+ import { View, StyleSheet, Animated, Dimensions, SafeAreaView, StatusBar, BackHandler, ActivityIndicator } from 'react-native';
3
+ import type {
4
+ IPaywall,
5
+ NamiPaywallLaunchContext,
6
+ NamiCampaign,
7
+ FlowNavigationOptions,
8
+ NamiFlow,
9
+ NamiPaywallEvent,
10
+ } from '@namiml/sdk-core';
11
+ import {
12
+ getPaywallDataFromLabel,
13
+ getPaywall,
14
+ isNamiFlowCampaign,
15
+ NamiFlowManager,
16
+ NamiReservedActions,
17
+ hasAllPaywalls,
18
+ NamiEventEmitter,
19
+ NamiPaywallAction,
20
+ PAYWALL_ACTION_EVENT,
21
+ logger,
22
+ } from '@namiml/sdk-core';
23
+ import { PaywallProvider } from '../context/PaywallContext';
24
+ import { PaywallScreen } from './PaywallScreen';
25
+ import { expoUIAdapter } from '../adapters';
26
+ import { FocusProvider } from '../context/FocusContext';
27
+ import { prewarmPaywallFonts, prepareAndLoadFontsWithTimeout } from '../utils/fonts';
28
+ import { parseColor } from '../utils/styles';
29
+
30
+ export interface NamiViewProps {
31
+ /** Campaign placement label */
32
+ placement?: string;
33
+ /** Campaign URL for deep-link resolution */
34
+ url?: string;
35
+ /** Called when the paywall should be dismissed */
36
+ onClose?: () => void;
37
+ /** Called when the user taps the sign-in action */
38
+ onSignIn?: () => void;
39
+ /** Called when a deep link action fires */
40
+ onDeepLink?: (url: string) => void;
41
+ /** Called when a purchase is initiated */
42
+ onPurchase?: (sku: any) => void;
43
+ /** Called when the user taps the restore purchases action */
44
+ onRestore?: () => void;
45
+ /** Catch-all callback for every paywall action event */
46
+ onPaywallEvent?: (event: NamiPaywallEvent) => void;
47
+ /** Called when a flow handoff step fires */
48
+ onHandoff?: (handoffTag: string, handoffData?: Record<string, any>) => void;
49
+ /** Called when a flow event fires */
50
+ onFlowEvent?: (event: Record<string, any>) => void;
51
+ }
52
+
53
+ type LaunchRequest = {
54
+ type?: string;
55
+ value: string;
56
+ context?: NamiPaywallLaunchContext;
57
+ };
58
+
59
+ type FlowState = {
60
+ paywalls: IPaywall[];
61
+ index: number;
62
+ animation?: FlowNavigationOptions | null;
63
+ };
64
+
65
+ type CachedLaunch = {
66
+ paywallData: IPaywall | null;
67
+ campaignData: NamiCampaign | null;
68
+ launchPreviewPaywall: IPaywall | null;
69
+ };
70
+
71
+ type RemoteBackFallbackAttempt = {
72
+ flow: NamiFlow;
73
+ stepId: string;
74
+ paywallId?: string;
75
+ };
76
+
77
+ const DEFAULT_CONTEXT: NamiPaywallLaunchContext = {
78
+ productGroups: [],
79
+ customAttributes: {},
80
+ customObject: {},
81
+ currentGroup: '',
82
+ };
83
+
84
+ const REMOTE_BACK_FALLBACK_DELAY_MS = 600;
85
+
86
+ export const NamiView: React.FC<NamiViewProps> = ({
87
+ placement,
88
+ url,
89
+ onClose,
90
+ onSignIn,
91
+ onDeepLink,
92
+ onPurchase,
93
+ onRestore,
94
+ onPaywallEvent,
95
+ onHandoff,
96
+ onFlowEvent,
97
+ }) => {
98
+ const [launchRequest, setLaunchRequest] = useState<LaunchRequest | null>(null);
99
+ const [reloadKey, setReloadKey] = useState(0);
100
+ const [isClosing, setIsClosing] = useState(false);
101
+
102
+ const [paywallData, setPaywallData] = useState<IPaywall | null>(null);
103
+ const [campaignData, setCampaignData] = useState<NamiCampaign | null>(null);
104
+ const [launchPreviewPaywall, setLaunchPreviewPaywall] = useState<IPaywall | null>(null);
105
+ const [pendingTransitionPaywall, setPendingTransitionPaywall] = useState<IPaywall | null>(null);
106
+ const [initialScreenCommitted, setInitialScreenCommitted] = useState(false);
107
+ const [loading, setLoading] = useState(true);
108
+
109
+ const [flowState, setFlowState] = useState<FlowState>({ paywalls: [], index: 0, animation: null });
110
+ const flowRef = useRef<NamiFlow | undefined>();
111
+ const lastLaunchKeyRef = useRef<string>('');
112
+ const lastAppearStepIdRef = useRef<string>('');
113
+ const launchCacheRef = useRef<Map<string, CachedLaunch>>(new Map());
114
+ const remoteBackFallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
115
+ const remoteBackFallbackAttemptRef = useRef<RemoteBackFallbackAttempt | null>(null);
116
+ const pendingNavigationFrameRef = useRef<number | null>(null);
117
+
118
+ // Stable refs for callbacks so event listeners always see latest values
119
+ const onCloseRef = useRef(onClose);
120
+ const onSignInRef = useRef(onSignIn);
121
+ const onDeepLinkRef = useRef(onDeepLink);
122
+ const onPurchaseRef = useRef(onPurchase);
123
+ const onRestoreRef = useRef(onRestore);
124
+ const onPaywallEventRef = useRef(onPaywallEvent);
125
+ const onHandoffRef = useRef(onHandoff);
126
+ const onFlowEventRef = useRef(onFlowEvent);
127
+
128
+ useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
129
+ useEffect(() => { onSignInRef.current = onSignIn; }, [onSignIn]);
130
+ useEffect(() => { onDeepLinkRef.current = onDeepLink; }, [onDeepLink]);
131
+ useEffect(() => { onPurchaseRef.current = onPurchase; }, [onPurchase]);
132
+ useEffect(() => { onRestoreRef.current = onRestore; }, [onRestore]);
133
+ useEffect(() => { onPaywallEventRef.current = onPaywallEvent; }, [onPaywallEvent]);
134
+ useEffect(() => { onHandoffRef.current = onHandoff; }, [onHandoff]);
135
+ useEffect(() => { onFlowEventRef.current = onFlowEvent; }, [onFlowEvent]);
136
+
137
+ const resolvedLaunch = useMemo<LaunchRequest>(() => {
138
+ if (launchRequest?.value) return launchRequest;
139
+ if (url) return { type: 'url', value: url };
140
+ if (placement) return { type: 'label', value: placement };
141
+ return { type: undefined, value: '' };
142
+ }, [launchRequest, url, placement]);
143
+
144
+ const ctx = useMemo<NamiPaywallLaunchContext>(
145
+ () => ({
146
+ ...DEFAULT_CONTEXT,
147
+ ...(resolvedLaunch.context ?? launchRequest?.context ?? {}),
148
+ customAttributes: {
149
+ ...DEFAULT_CONTEXT.customAttributes,
150
+ ...(resolvedLaunch.context?.customAttributes ??
151
+ launchRequest?.context?.customAttributes ??
152
+ {}),
153
+ },
154
+ customObject: {
155
+ ...DEFAULT_CONTEXT.customObject,
156
+ ...(resolvedLaunch.context?.customObject ??
157
+ launchRequest?.context?.customObject ??
158
+ {}),
159
+ },
160
+ productGroups:
161
+ resolvedLaunch.context?.productGroups ??
162
+ launchRequest?.context?.productGroups ??
163
+ DEFAULT_CONTEXT.productGroups,
164
+ }),
165
+ [launchRequest?.context, resolvedLaunch.context],
166
+ );
167
+ const isFlowCampaign = isNamiFlowCampaign(campaignData as any);
168
+
169
+ const clearRemoteBackFallback = useCallback(() => {
170
+ if (remoteBackFallbackTimeoutRef.current) {
171
+ clearTimeout(remoteBackFallbackTimeoutRef.current);
172
+ remoteBackFallbackTimeoutRef.current = null;
173
+ }
174
+ remoteBackFallbackAttemptRef.current = null;
175
+ }, []);
176
+
177
+ const finishActiveFlow = useCallback(() => {
178
+ clearRemoteBackFallback();
179
+ const activeFlow = flowRef.current;
180
+ if (!activeFlow) return;
181
+ if (NamiFlowManager.instance.currentFlow === activeFlow || NamiFlowManager.instance.flowOpen) {
182
+ NamiFlowManager.finish();
183
+ }
184
+ flowRef.current = undefined;
185
+ lastAppearStepIdRef.current = '';
186
+ }, [clearRemoteBackFallback]);
187
+
188
+ const requestClose = useCallback(() => {
189
+ clearRemoteBackFallback();
190
+ if (onCloseRef.current) {
191
+ setIsClosing(true);
192
+ onCloseRef.current();
193
+ return;
194
+ }
195
+ }, [clearRemoteBackFallback]);
196
+
197
+ const scheduleRemoteBackFallbackClose = useCallback((flow: NamiFlow, stepId: string, paywallId?: string) => {
198
+ clearRemoteBackFallback();
199
+ remoteBackFallbackAttemptRef.current = {
200
+ flow,
201
+ stepId,
202
+ paywallId,
203
+ };
204
+ remoteBackFallbackTimeoutRef.current = setTimeout(() => {
205
+ remoteBackFallbackTimeoutRef.current = null;
206
+ const attempt = remoteBackFallbackAttemptRef.current;
207
+ const activeFlow = flowRef.current;
208
+ const activeStepId = activeFlow?.currentFlowStep?.id;
209
+ const activeStepType = activeFlow?.currentFlowStep?.type;
210
+ const activePaywallId = paywallData?.id;
211
+ const transitionedToTerminalExit =
212
+ !!attempt
213
+ && attempt.flow === activeFlow
214
+ && activePaywallId === attempt.paywallId
215
+ && (
216
+ activeFlow?.currentFlowStep?.type === 'exit'
217
+ || NamiFlowManager.instance.flowOpen === false
218
+ );
219
+ const shouldFallbackClose =
220
+ !isClosing
221
+ && !!attempt
222
+ && (
223
+ (
224
+ attempt.flow === activeFlow
225
+ && activeFlow?.currentFlowStep?.id === attempt.stepId
226
+ && activePaywallId === attempt.paywallId
227
+ && activeFlow?.previousStepAvailable === false
228
+ )
229
+ || transitionedToTerminalExit
230
+ );
231
+
232
+ if (!shouldFallbackClose) {
233
+ remoteBackFallbackAttemptRef.current = null;
234
+ return;
235
+ }
236
+
237
+ remoteBackFallbackAttemptRef.current = null;
238
+ finishActiveFlow();
239
+ requestClose();
240
+ }, REMOTE_BACK_FALLBACK_DELAY_MS);
241
+ }, [clearRemoteBackFallback, finishActiveFlow, isClosing, paywallData?.id, requestClose]);
242
+
243
+ const handleFlowNavigation = useCallback(
244
+ (paywall: IPaywall, options: FlowNavigationOptions) => {
245
+ const flow = flowRef.current;
246
+ const currentPaywall =
247
+ flowState.paywalls[flowState.index] ??
248
+ flowState.paywalls[flowState.paywalls.length - 1] ??
249
+ paywallData;
250
+ const shouldUseAnimatedTransition =
251
+ options.transition !== 'none'
252
+ && !!currentPaywall
253
+ && currentPaywall.id !== paywall.id;
254
+
255
+ const applyNavigation = () => {
256
+ if (flow?.previousFlowStep) {
257
+ flow.executeLifecycle(
258
+ flow.previousFlowStep,
259
+ NamiReservedActions.DISAPPEAR,
260
+ );
261
+ }
262
+
263
+ setFlowState(() => {
264
+ if (!shouldUseAnimatedTransition || !currentPaywall) {
265
+ return { paywalls: [paywall], index: 0, animation: options };
266
+ }
267
+
268
+ return {
269
+ paywalls: [currentPaywall, paywall],
270
+ index: 1,
271
+ animation: options,
272
+ };
273
+ });
274
+ setPaywallData(paywall);
275
+
276
+ if (flow?.currentFlowStep) {
277
+ flow.executeLifecycle(
278
+ flow.currentFlowStep,
279
+ NamiReservedActions.APPEAR,
280
+ );
281
+ lastAppearStepIdRef.current = flow.currentFlowStep.id;
282
+ }
283
+ };
284
+
285
+ if (!shouldUseAnimatedTransition) {
286
+ setPendingTransitionPaywall(null);
287
+ applyNavigation();
288
+ return;
289
+ }
290
+
291
+ setPendingTransitionPaywall(paywall);
292
+ if (pendingNavigationFrameRef.current != null) {
293
+ cancelAnimationFrame(pendingNavigationFrameRef.current);
294
+ }
295
+ pendingNavigationFrameRef.current = requestAnimationFrame(() => {
296
+ pendingNavigationFrameRef.current = null;
297
+ applyNavigation();
298
+ });
299
+ },
300
+ [flowState.index, flowState.paywalls, paywallData],
301
+ );
302
+
303
+ const handleFlowAnimationSettled = useCallback((paywall: IPaywall) => {
304
+ setPendingTransitionPaywall(null);
305
+ setFlowState((prev) => {
306
+ const current = prev.paywalls[prev.index] ?? prev.paywalls[prev.paywalls.length - 1];
307
+ if (!prev.animation && current?.id === paywall.id) {
308
+ return prev;
309
+ }
310
+
311
+ if (prev.paywalls.length > 1 && prev.index > 0) {
312
+ return {
313
+ paywalls: prev.paywalls,
314
+ index: prev.index,
315
+ animation: null,
316
+ };
317
+ }
318
+
319
+ return { paywalls: [paywall], index: 0, animation: null };
320
+ });
321
+ }, []);
322
+
323
+ useEffect(() => {
324
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
325
+ const flow = flowRef.current;
326
+ if (flow?.currentFlowStep) {
327
+ const stepcrumbs = Array.isArray(flow.stepcrumbs) ? flow.stepcrumbs : [];
328
+ const previousScreenStep = [...stepcrumbs]
329
+ .slice(0, -1)
330
+ .reverse()
331
+ .find((step) => step?.type === 'screen');
332
+ const hasRemoteBackActions = !!flow.currentFlowStep?.actions?.[NamiReservedActions.REMOTE_BACK]?.length;
333
+ const canNavigateBackInFlow =
334
+ flow.previousStepAvailable
335
+ || (
336
+ Boolean(previousScreenStep)
337
+ && previousScreenStep?.allow_back_to !== false
338
+ );
339
+ if (hasRemoteBackActions) {
340
+ if (!canNavigateBackInFlow) {
341
+ scheduleRemoteBackFallbackClose(
342
+ flow,
343
+ flow.currentFlowStep.id,
344
+ paywallData?.id,
345
+ );
346
+ } else {
347
+ clearRemoteBackFallback();
348
+ }
349
+ flow.triggerActions(NamiReservedActions.REMOTE_BACK);
350
+ return true;
351
+ }
352
+ clearRemoteBackFallback();
353
+ if (!canNavigateBackInFlow) {
354
+ finishActiveFlow();
355
+ requestClose();
356
+ return true;
357
+ }
358
+ flow.back();
359
+ return true;
360
+ }
361
+
362
+ if (paywallData) {
363
+ requestClose();
364
+ return true;
365
+ }
366
+ return false;
367
+ });
368
+
369
+ return () => subscription.remove();
370
+ }, [clearRemoteBackFallback, finishActiveFlow, paywallData, requestClose, scheduleRemoteBackFallbackClose]);
371
+
372
+ // ─── Paywall action event listener ───────────────────────────────────
373
+ useEffect(() => {
374
+ const emitter = NamiEventEmitter.getInstance();
375
+
376
+ const handler = (event: NamiPaywallEvent) => {
377
+ // Forward to catch-all
378
+ onPaywallEventRef.current?.(event);
379
+
380
+ // Route to specific callbacks
381
+ switch (event.action) {
382
+ case NamiPaywallAction.CLOSE_PAYWALL:
383
+ onCloseRef.current?.();
384
+ break;
385
+ case NamiPaywallAction.SIGN_IN:
386
+ onSignInRef.current?.();
387
+ break;
388
+ case NamiPaywallAction.DEEPLINK:
389
+ if (event.deeplinkUrl) {
390
+ onDeepLinkRef.current?.(event.deeplinkUrl);
391
+ }
392
+ break;
393
+ case NamiPaywallAction.BUY_SKU:
394
+ onPurchaseRef.current?.(event.sku);
395
+ break;
396
+ case NamiPaywallAction.RESTORE_PURCHASES:
397
+ onRestoreRef.current?.();
398
+ break;
399
+ }
400
+ };
401
+
402
+ emitter.addListener(PAYWALL_ACTION_EVENT, handler);
403
+ return () => {
404
+ emitter.removeListener(PAYWALL_ACTION_EVENT, handler);
405
+ };
406
+ }, [finishActiveFlow]);
407
+
408
+ // ─── Flow handlers ───────────────────────────────────────────────────
409
+ useEffect(() => {
410
+ NamiFlowManager.registerStepHandoff(
411
+ (handoffTag: string, handoffData?: Record<string, any>) => {
412
+ onHandoffRef.current?.(handoffTag, handoffData);
413
+ },
414
+ );
415
+
416
+ NamiFlowManager.registerEventHandler((eventData: Record<string, any>) => {
417
+ onFlowEventRef.current?.(eventData);
418
+ });
419
+
420
+ return () => {
421
+ NamiFlowManager.registerStepHandoff(undefined);
422
+ NamiFlowManager.registerEventHandler(() => {});
423
+ };
424
+ }, []);
425
+
426
+ // ─── ExpoUIAdapter listener subscriptions ────────────────────────────
427
+ useEffect(() => {
428
+ const unsubPaywall = expoUIAdapter.onPaywallRequested((info: any, context) => {
429
+ setLaunchRequest({ type: info?.type, value: info?.value ?? '', context });
430
+ });
431
+ const unsubReRender = expoUIAdapter.onReRenderRequested(() => {
432
+ setReloadKey(k => k + 1);
433
+ });
434
+ const unsubFlow = expoUIAdapter.onFlowNavigationRequested((paywall, options) => {
435
+ handleFlowNavigation(paywall, options);
436
+ });
437
+
438
+ return () => {
439
+ unsubPaywall();
440
+ unsubReRender();
441
+ unsubFlow();
442
+ };
443
+ }, [handleFlowNavigation]);
444
+
445
+ useEffect(() => {
446
+ return () => {
447
+ if (pendingNavigationFrameRef.current != null) {
448
+ cancelAnimationFrame(pendingNavigationFrameRef.current);
449
+ pendingNavigationFrameRef.current = null;
450
+ }
451
+ clearRemoteBackFallback();
452
+ finishActiveFlow();
453
+ };
454
+ }, [clearRemoteBackFallback, finishActiveFlow]);
455
+
456
+ // ─── Reset state when launch key changes ─────────────────────────────
457
+ useEffect(() => {
458
+ const launchKey = `${resolvedLaunch.type ?? ''}:${resolvedLaunch.value ?? ''}`;
459
+ if (launchKey !== lastLaunchKeyRef.current) {
460
+ lastLaunchKeyRef.current = launchKey;
461
+ setIsClosing(false);
462
+ setFlowState({ paywalls: [], index: 0, animation: null });
463
+ finishActiveFlow();
464
+ setPaywallData(null);
465
+ setCampaignData(null);
466
+ setLaunchPreviewPaywall(null);
467
+ setPendingTransitionPaywall(null);
468
+ setInitialScreenCommitted(false);
469
+ lastAppearStepIdRef.current = '';
470
+ }
471
+ }, [finishActiveFlow, resolvedLaunch.type, resolvedLaunch.value]);
472
+
473
+ // ─── Campaign / paywall resolution ───────────────────────────────────
474
+ useEffect(() => {
475
+ let cancelled = false;
476
+
477
+ const resolveLaunch = async () => {
478
+ const value = resolvedLaunch.value;
479
+ const type = resolvedLaunch.type ?? (url ? 'url' : 'label');
480
+ const cacheKey = `${type}:${value}`;
481
+ if (!value) {
482
+ setLoading(false);
483
+ setPaywallData(null);
484
+ setCampaignData(null);
485
+ setLaunchPreviewPaywall(null);
486
+ return;
487
+ }
488
+
489
+ const cachedLaunch = launchCacheRef.current.get(cacheKey);
490
+ if (cachedLaunch) {
491
+ setLaunchPreviewPaywall(cachedLaunch.launchPreviewPaywall);
492
+ setPaywallData(cachedLaunch.paywallData);
493
+ setCampaignData(cachedLaunch.campaignData);
494
+ setLoading(false);
495
+ } else {
496
+ setLoading(true);
497
+ }
498
+
499
+ try {
500
+ const data = getPaywallDataFromLabel(value, type);
501
+ const paywall = data?.paywall as IPaywall | undefined;
502
+ const flowPaywalls = isNamiFlowCampaign(data?.campaign as any)
503
+ ? (data?.campaign?.flow?.object?.screens ?? [])
504
+ .map((screenId: string) => getPaywall(screenId))
505
+ .filter((item): item is IPaywall => item != null)
506
+ : [];
507
+ const initialPaywall = paywall ?? flowPaywalls[0];
508
+ const nextCampaign = data?.campaign ? (data.campaign as NamiCampaign) : ({ value, type } as any);
509
+ const nextLaunch = {
510
+ launchPreviewPaywall: initialPaywall ?? null,
511
+ paywallData: data?.paywall ?? null,
512
+ campaignData: nextCampaign,
513
+ };
514
+
515
+ if (!cancelled) {
516
+ setLaunchPreviewPaywall(nextLaunch.launchPreviewPaywall);
517
+ setInitialScreenCommitted(false);
518
+ setPaywallData(nextLaunch.paywallData);
519
+ setCampaignData(nextLaunch.campaignData);
520
+ setLoading(false);
521
+ launchCacheRef.current.set(cacheKey, nextLaunch);
522
+ }
523
+
524
+ prewarmPaywallFonts([paywall, ...flowPaywalls]);
525
+
526
+ if (initialPaywall?.fonts) {
527
+ void prepareAndLoadFontsWithTimeout(initialPaywall.fonts);
528
+ }
529
+ } catch (e) {
530
+ if (!cancelled) {
531
+ setLoading(false);
532
+ }
533
+ logger.warn('[NamiExpo] Failed to resolve paywall launch.', e);
534
+ }
535
+ };
536
+
537
+ void resolveLaunch();
538
+
539
+ return () => {
540
+ cancelled = true;
541
+ };
542
+ }, [resolvedLaunch.value, resolvedLaunch.type, url, reloadKey]);
543
+
544
+ // ─── Flow presentation ───────────────────────────────────────────────
545
+ useEffect(() => {
546
+ if (!campaignData) return;
547
+ if (!isNamiFlowCampaign(campaignData as any)) return;
548
+
549
+ const screens = campaignData.flow?.object?.screens ?? [];
550
+ if (screens.length && !hasAllPaywalls(screens)) return;
551
+
552
+ if (flowRef.current) return;
553
+
554
+ const flow = NamiFlowManager.instance.presentFlow(campaignData as any, {
555
+ type: resolvedLaunch.type,
556
+ value: resolvedLaunch.value,
557
+ context: ctx,
558
+ } as any, ctx);
559
+
560
+ flowRef.current = flow;
561
+ }, [campaignData, resolvedLaunch.type, resolvedLaunch.value, ctx, reloadKey]);
562
+
563
+ useEffect(() => {
564
+ const flow = flowRef.current;
565
+ const activePaywall =
566
+ flowState.paywalls[flowState.index] ??
567
+ flowState.paywalls[flowState.paywalls.length - 1];
568
+
569
+ if (!flow || !activePaywall || !flow.currentFlowStep) {
570
+ return;
571
+ }
572
+
573
+ const currentStep = flow.currentFlowStep;
574
+ if (lastAppearStepIdRef.current === currentStep.id) {
575
+ return;
576
+ }
577
+
578
+ flow.executeLifecycle(currentStep, NamiReservedActions.APPEAR);
579
+ lastAppearStepIdRef.current = currentStep.id;
580
+ }, [flowState.paywalls, flowState.index]);
581
+
582
+ useEffect(() => {
583
+ const attempt = remoteBackFallbackAttemptRef.current;
584
+ if (!attempt) {
585
+ return;
586
+ }
587
+
588
+ if (isClosing) {
589
+ clearRemoteBackFallback();
590
+ return;
591
+ }
592
+
593
+ const activeFlow = flowRef.current;
594
+ const activeStepId = activeFlow?.currentFlowStep?.id;
595
+ const activePaywallId = paywallData?.id;
596
+ const didNavigateAway =
597
+ attempt.flow !== activeFlow
598
+ || attempt.stepId !== activeStepId
599
+ || attempt.paywallId !== activePaywallId;
600
+
601
+ if (didNavigateAway) {
602
+ clearRemoteBackFallback();
603
+ }
604
+ }, [clearRemoteBackFallback, flowState.index, flowState.paywalls, isClosing, paywallData?.id]);
605
+
606
+ const hasFlowScreen = flowState.paywalls.length > 0;
607
+ const launchPlaceholderPaywall =
608
+ launchPreviewPaywall
609
+ ?? paywallData
610
+ ?? flowState.paywalls[flowState.index]
611
+ ?? flowState.paywalls[0]
612
+ ?? null;
613
+ const showLaunchPlaceholder =
614
+ Boolean(launchPlaceholderPaywall)
615
+ && (loading || !initialScreenCommitted);
616
+ const showTransitionPlaceholder =
617
+ Boolean(pendingTransitionPaywall)
618
+ && !loading
619
+ && !isClosing;
620
+
621
+ const handleInitialCommitted = useCallback((_paywall: IPaywall, _pageName: string, _formFactor?: string) => {
622
+ setInitialScreenCommitted(true);
623
+ }, []);
624
+
625
+ if (isClosing) {
626
+ return <SafeAreaView style={styles.root} />;
627
+ }
628
+
629
+ if (loading || !campaignData || (!isFlowCampaign && !paywallData) || (isFlowCampaign && !hasFlowScreen)) {
630
+ return <LaunchPlaceholder paywall={launchPlaceholderPaywall} />;
631
+ }
632
+
633
+ return (
634
+ <SafeAreaView style={styles.root}>
635
+ <StatusBar barStyle="light-content" />
636
+ {isFlowCampaign ? (
637
+ <FlowRenderer
638
+ paywalls={flowState.paywalls}
639
+ currentIndex={flowState.index}
640
+ animation={flowState.animation ?? undefined}
641
+ onSettled={handleFlowAnimationSettled}
642
+ onTransitionVisible={() => setPendingTransitionPaywall(null)}
643
+ onInitialCommitted={handleInitialCommitted}
644
+ onClose={onClose}
645
+ campaign={campaignData}
646
+ context={ctx}
647
+ flow={flowRef.current}
648
+ />
649
+ ) : (
650
+ <PaywallProvider paywall={paywallData as IPaywall} context={ctx} campaign={campaignData}>
651
+ <PaywallScreen
652
+ paywall={paywallData as IPaywall}
653
+ onClose={requestClose}
654
+ onCommitted={handleInitialCommitted}
655
+ isActive
656
+ />
657
+ </PaywallProvider>
658
+ )}
659
+ {!isClosing && showLaunchPlaceholder && <LaunchPlaceholder paywall={launchPlaceholderPaywall} overlay />}
660
+ {showTransitionPlaceholder && <LaunchPlaceholder paywall={pendingTransitionPaywall} overlay />}
661
+ </SafeAreaView>
662
+ );
663
+ };
664
+
665
+ const LaunchPlaceholder: React.FC<{
666
+ paywall: IPaywall | null;
667
+ overlay?: boolean;
668
+ }> = ({ paywall, overlay = false }) => {
669
+ const initialPageName = paywall?.template?.initialState?.currentPage ?? 'page1';
670
+ const page = paywall?.template?.pages?.find((item: any) => item?.name === initialPageName) ?? null;
671
+ const backgroundContainer = (page as any)?.backgroundContainer;
672
+ const backgroundColor =
673
+ parseColor(backgroundContainer?.fillColor)
674
+ ?? parseColor(backgroundContainer?.fillColorFallback)
675
+ ?? '#111118';
676
+
677
+ return (
678
+ <View
679
+ pointerEvents="none"
680
+ style={[
681
+ styles.loading,
682
+ overlay ? styles.loadingOverlay : null,
683
+ { backgroundColor },
684
+ ]}
685
+ >
686
+ <ActivityIndicator size="large" color="#FFFFFF" />
687
+ </View>
688
+ );
689
+ };
690
+
691
+ const getPaywallBackgroundColor = (paywall: IPaywall | null | undefined): string => {
692
+ const initialPageName = paywall?.template?.initialState?.currentPage ?? 'page1';
693
+ const page = paywall?.template?.pages?.find((item: any) => item?.name === initialPageName) ?? null;
694
+ const backgroundContainer = (page as any)?.backgroundContainer;
695
+
696
+ return (
697
+ parseColor(backgroundContainer?.fillColor)
698
+ ?? parseColor(backgroundContainer?.fillColorFallback)
699
+ ?? '#111118'
700
+ );
701
+ };
702
+
703
+ /** Renders multi-step flow paywalls with transition animations */
704
+ const FlowRenderer: React.FC<{
705
+ paywalls: IPaywall[];
706
+ currentIndex: number;
707
+ animation?: FlowNavigationOptions;
708
+ onSettled?: (paywall: IPaywall) => void;
709
+ onTransitionVisible?: () => void;
710
+ onInitialCommitted?: (paywall: IPaywall, pageName: string, formFactor?: string) => void;
711
+ onClose?: () => void;
712
+ campaign: NamiCampaign;
713
+ context: NamiPaywallLaunchContext;
714
+ flow?: NamiFlow;
715
+ }> = ({ paywalls, currentIndex, animation, onSettled, onTransitionVisible, onInitialCommitted, onClose, campaign, context, flow }) => {
716
+ const { width, height } = Dimensions.get('window');
717
+ const progress = useRef(new Animated.Value(0)).current;
718
+ const transitionSequenceRef = useRef(0);
719
+ const lastTransitionSignatureRef = useRef('');
720
+ const startedTransitionKeyRef = useRef<string | null>(null);
721
+ const [completedTransitionKey, setCompletedTransitionKey] = useState<string | null>(null);
722
+ const transition = animation?.transition ?? 'slide';
723
+ const direction = animation?.direction === 'backward' ? -1 : 1;
724
+ const isVertical = transition === 'verticalSlide';
725
+ const distance = isVertical ? height : width;
726
+ const shouldAnimatePair =
727
+ paywalls.length === 2
728
+ && currentIndex === 1
729
+ && animation != null;
730
+ const shouldRenderPair = shouldAnimatePair;
731
+ const sourcePaywall = shouldRenderPair ? paywalls[0] : undefined;
732
+ const targetPaywall = shouldRenderPair
733
+ ? paywalls[1]
734
+ : (paywalls[currentIndex] ?? paywalls[0]);
735
+ const targetBackgroundColor = useMemo(
736
+ () => getPaywallBackgroundColor(targetPaywall),
737
+ [targetPaywall],
738
+ );
739
+ const transitionSignature = shouldAnimatePair
740
+ ? `${sourcePaywall?.id ?? 'source'}->${targetPaywall?.id ?? 'target'}:${transition}:${direction}`
741
+ : '';
742
+
743
+ if (shouldAnimatePair && lastTransitionSignatureRef.current !== transitionSignature) {
744
+ lastTransitionSignatureRef.current = transitionSignature;
745
+ transitionSequenceRef.current += 1;
746
+ } else if (!shouldAnimatePair) {
747
+ lastTransitionSignatureRef.current = '';
748
+ }
749
+
750
+ const activeTransitionKey = shouldAnimatePair
751
+ ? `${transitionSignature}:${transitionSequenceRef.current}`
752
+ : '';
753
+ const [committedTarget, setCommittedTarget] = useState<{
754
+ transitionKey: string | null;
755
+ paywallId: string | null;
756
+ }>({
757
+ transitionKey: null,
758
+ paywallId: null,
759
+ });
760
+ const interactionReady =
761
+ !shouldAnimatePair
762
+ || (
763
+ committedTarget.transitionKey === activeTransitionKey
764
+ && committedTarget.paywallId === (targetPaywall?.id ?? null)
765
+ );
766
+ const transitionReady = shouldAnimatePair && interactionReady;
767
+ const animationReady =
768
+ !shouldAnimatePair
769
+ || transitionReady;
770
+
771
+ const handleTargetCommitted = useCallback((paywall: IPaywall, _pageName: string, formFactor?: string) => {
772
+ if (paywall.id !== targetPaywall?.id) {
773
+ return;
774
+ }
775
+ setCommittedTarget({
776
+ transitionKey: activeTransitionKey || null,
777
+ paywallId: paywall.id ?? null,
778
+ });
779
+ }, [activeTransitionKey, targetPaywall?.id]);
780
+
781
+ const handleVisibleCommitted = useCallback((paywall: IPaywall, pageName: string, formFactor?: string) => {
782
+ if (currentIndex === 0) {
783
+ onInitialCommitted?.(paywall, pageName, formFactor);
784
+ }
785
+ handleTargetCommitted(paywall, pageName, formFactor);
786
+ }, [currentIndex, handleTargetCommitted, onInitialCommitted]);
787
+
788
+ useLayoutEffect(() => {
789
+ const activePaywall = paywalls[currentIndex] ?? paywalls[paywalls.length - 1];
790
+
791
+ if (!shouldAnimatePair) {
792
+ startedTransitionKeyRef.current = null;
793
+ setCompletedTransitionKey(null);
794
+ progress.stopAnimation();
795
+ progress.setValue(0);
796
+ return;
797
+ }
798
+
799
+ if (transition === 'fade' || transition === 'none') {
800
+ if (startedTransitionKeyRef.current === activeTransitionKey) {
801
+ return;
802
+ }
803
+
804
+ startedTransitionKeyRef.current = activeTransitionKey;
805
+ setCompletedTransitionKey(null);
806
+ progress.stopAnimation();
807
+ progress.setValue(0);
808
+ if (activePaywall) {
809
+ requestAnimationFrame(() => {
810
+ onSettled?.(activePaywall);
811
+ });
812
+ }
813
+ return;
814
+ }
815
+
816
+ if (!transitionReady) {
817
+ if (startedTransitionKeyRef.current !== activeTransitionKey) {
818
+ progress.stopAnimation();
819
+ progress.setValue(0);
820
+ }
821
+ return;
822
+ }
823
+
824
+ if (startedTransitionKeyRef.current === activeTransitionKey) {
825
+ return;
826
+ }
827
+
828
+ startedTransitionKeyRef.current = activeTransitionKey;
829
+ setCompletedTransitionKey(null);
830
+ progress.stopAnimation();
831
+ progress.setValue(0);
832
+ Animated.timing(progress, {
833
+ toValue: 1,
834
+ duration: 250,
835
+ useNativeDriver: true,
836
+ }).start(({ finished }) => {
837
+ if (finished) {
838
+ setCompletedTransitionKey(activeTransitionKey);
839
+ }
840
+ });
841
+ }, [
842
+ activeTransitionKey,
843
+ animation?.direction,
844
+ currentIndex,
845
+ onSettled,
846
+ paywalls,
847
+ progress,
848
+ sourcePaywall?.id,
849
+ transitionReady,
850
+ transition,
851
+ ]);
852
+
853
+ useEffect(() => {
854
+ if (
855
+ !shouldAnimatePair
856
+ || !targetPaywall
857
+ || !interactionReady
858
+ || completedTransitionKey !== activeTransitionKey
859
+ ) {
860
+ return;
861
+ }
862
+
863
+ onSettled?.(targetPaywall);
864
+ }, [
865
+ activeTransitionKey,
866
+ completedTransitionKey,
867
+ interactionReady,
868
+ onSettled,
869
+ shouldAnimatePair,
870
+ targetPaywall,
871
+ ]);
872
+
873
+ useEffect(() => {
874
+ if (!shouldAnimatePair || !interactionReady) {
875
+ return;
876
+ }
877
+
878
+ onTransitionVisible?.();
879
+ }, [interactionReady, onTransitionVisible, shouldAnimatePair]);
880
+
881
+ if (transition === 'fade') {
882
+ const current = paywalls[currentIndex] ?? paywalls[0];
883
+ if (!current) return null;
884
+ return (
885
+ <View style={styles.flowContainer}>
886
+ <View style={{ width, height }}>
887
+ <FocusProvider enabled>
888
+ <PaywallProvider paywall={current} context={context} campaign={campaign} flow={flow}>
889
+ <PaywallScreen
890
+ paywall={current}
891
+ onClose={onClose}
892
+ onCommitted={handleVisibleCommitted}
893
+ isActive
894
+ />
895
+ </PaywallProvider>
896
+ </FocusProvider>
897
+ </View>
898
+ </View>
899
+ );
900
+ }
901
+
902
+ const renderedPaywalls = shouldRenderPair
903
+ ? [
904
+ { paywall: sourcePaywall, role: 'source' as const, key: 0 },
905
+ { paywall: targetPaywall, role: 'target' as const, key: 1 },
906
+ ]
907
+ : [{ paywall: paywalls[currentIndex] ?? paywalls[0], role: 'target' as const, key: currentIndex }];
908
+
909
+ return (
910
+ <View style={styles.flowContainer}>
911
+ {renderedPaywalls.map(({ paywall: pw, role, key }) => {
912
+ if (!pw) {
913
+ return null;
914
+ }
915
+
916
+ const animatedStyle = role === 'target' && shouldAnimatePair
917
+ ? { opacity: 0 }
918
+ : null;
919
+
920
+ return (
921
+ <Animated.View
922
+ key={pw.id ?? key}
923
+ pointerEvents={role === 'target' && !shouldAnimatePair && interactionReady ? 'auto' : 'none'}
924
+ style={[
925
+ styles.flowScreen,
926
+ {
927
+ width,
928
+ height,
929
+ zIndex: role === 'target' ? 2 : 1,
930
+ elevation: role === 'target' ? 2 : 1,
931
+ },
932
+ animatedStyle ?? null,
933
+ ]}
934
+ >
935
+ <FocusProvider enabled={role === 'target' && !shouldAnimatePair && interactionReady}>
936
+ <PaywallProvider
937
+ paywall={pw}
938
+ context={context}
939
+ campaign={campaign}
940
+ flow={flow}
941
+ >
942
+ <PaywallScreen
943
+ paywall={pw}
944
+ onClose={onClose}
945
+ onCommitted={role === 'target' ? handleVisibleCommitted : undefined}
946
+ isActive={role === 'target' && !shouldAnimatePair && interactionReady}
947
+ />
948
+ </PaywallProvider>
949
+ </FocusProvider>
950
+ </Animated.View>
951
+ );
952
+ })}
953
+ {shouldAnimatePair && !interactionReady && (
954
+ <LaunchPlaceholder paywall={targetPaywall ?? null} overlay />
955
+ )}
956
+ {transitionReady && targetPaywall && (
957
+ <Animated.View
958
+ pointerEvents="none"
959
+ style={[
960
+ styles.flowScreen,
961
+ {
962
+ width,
963
+ height,
964
+ zIndex: 3,
965
+ elevation: 3,
966
+ backgroundColor: targetBackgroundColor,
967
+ },
968
+ isVertical
969
+ ? {
970
+ transform: [{
971
+ translateY: progress.interpolate({
972
+ inputRange: [0, 1],
973
+ outputRange: [height * direction, 0],
974
+ }),
975
+ }],
976
+ }
977
+ : {
978
+ transform: [{
979
+ translateX: progress.interpolate({
980
+ inputRange: [0, 1],
981
+ outputRange: [width * direction, 0],
982
+ }),
983
+ }],
984
+ },
985
+ ]}
986
+ />
987
+ )}
988
+ </View>
989
+ );
990
+ };
991
+
992
+ const styles = StyleSheet.create({
993
+ root: { flex: 1, backgroundColor: '#000' },
994
+ loading: {
995
+ flex: 1,
996
+ backgroundColor: '#111118',
997
+ alignItems: 'center',
998
+ justifyContent: 'center',
999
+ },
1000
+ loadingOverlay: {
1001
+ ...StyleSheet.absoluteFillObject,
1002
+ zIndex: 50,
1003
+ },
1004
+ flowContainer: { flex: 1, overflow: 'hidden' },
1005
+ flowScreen: { position: 'absolute', top: 0, left: 0 },
1006
+ });