import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'; import { View, StyleSheet, Animated, Dimensions, StatusBar, BackHandler, ActivityIndicator } from 'react-native'; import { SafeAreaProvider, initialWindowMetrics } from 'react-native-safe-area-context'; import type { IPaywall, NamiPaywallLaunchContext, NamiCampaign, FlowNavigationOptions, NamiFlow, NamiPaywallEvent, } from '@namiml/sdk-core'; import { getPaywallDataFromLabel, getPaywall, isValidUrl, isNamiFlowCampaign, NamiReservedActions, hasAllPaywalls, NamiEventEmitter, NamiPaywallAction, PAYWALL_ACTION_EVENT, logger, } from '@namiml/sdk-core'; // `.instance.presentFlow()` and `.instance.flowOpen` are class statics not on // the narrow public NamiFlowManager proxy; renderer code goes through the // `_internal` namespace on root (NAM-1217). import { _internal } from '@namiml/sdk-core'; const { NamiFlowManager } = _internal; import { PaywallProvider } from '../context/PaywallContext'; import { PaywallScreen } from './PaywallScreen'; import { expoUIAdapter } from '../adapters'; import { FocusProvider } from '../context/FocusContext'; import { prewarmPaywallFonts, prepareAndLoadFontsWithTimeout } from '../utils/fonts'; import { parseColor } from '../utils/styles'; import { backfill as backfillProducts, collectSkuRefIds } from '../products/productStore'; export interface NamiViewProps { /** Campaign placement label */ placement?: string; /** Campaign URL for deep-link resolution */ url?: string; /** Called when the paywall should be dismissed */ onClose?: () => void; /** Called when the user taps the sign-in action */ onSignIn?: () => void; /** Called when a deep link action fires */ onDeepLink?: (url: string) => void; /** Called when a purchase is initiated */ onPurchase?: (sku: any) => void; /** Called when the user taps the restore purchases action */ onRestore?: () => void; /** Catch-all callback for every paywall action event */ onPaywallEvent?: (event: NamiPaywallEvent) => void; /** Called when a flow handoff step fires */ onHandoff?: (handoffTag: string, handoffData?: Record) => void; /** Called when a flow event fires */ onFlowEvent?: (event: Record) => void; } type LaunchRequest = { type?: string; value: string; context?: NamiPaywallLaunchContext; }; type FlowState = { paywalls: IPaywall[]; index: number; animation?: FlowNavigationOptions | null; }; type CachedLaunch = { paywallData: IPaywall | null; campaignData: NamiCampaign | null; launchPreviewPaywall: IPaywall | null; }; type RemoteBackFallbackAttempt = { flow: NamiFlow; stepId: string; paywallId?: string; }; const DEFAULT_CONTEXT: NamiPaywallLaunchContext = { productGroups: [], customAttributes: {}, customObject: {}, currentGroup: '', }; const REMOTE_BACK_FALLBACK_DELAY_MS = 600; export const NamiView: React.FC = ({ placement, url, onClose, onSignIn, onDeepLink, onPurchase, onRestore, onPaywallEvent, onHandoff, onFlowEvent, }) => { const [launchRequest, setLaunchRequest] = useState(null); const [reloadKey, setReloadKey] = useState(0); const [isClosing, setIsClosing] = useState(false); const [paywallData, setPaywallData] = useState(null); const [campaignData, setCampaignData] = useState(null); const [launchPreviewPaywall, setLaunchPreviewPaywall] = useState(null); const [pendingTransitionPaywall, setPendingTransitionPaywall] = useState(null); const [initialScreenCommitted, setInitialScreenCommitted] = useState(false); const [loading, setLoading] = useState(true); const [flowState, setFlowState] = useState({ paywalls: [], index: 0, animation: null }); const flowRef = useRef(); const lastLaunchKeyRef = useRef(''); const lastAppearStepIdRef = useRef(''); const launchCacheRef = useRef>(new Map()); const remoteBackFallbackTimeoutRef = useRef | null>(null); const remoteBackFallbackAttemptRef = useRef(null); const pendingNavigationFrameRef = useRef(null); // Stable refs for callbacks so event listeners always see latest values const onCloseRef = useRef(onClose); const onSignInRef = useRef(onSignIn); const onDeepLinkRef = useRef(onDeepLink); const onPurchaseRef = useRef(onPurchase); const onRestoreRef = useRef(onRestore); const onPaywallEventRef = useRef(onPaywallEvent); const onHandoffRef = useRef(onHandoff); const onFlowEventRef = useRef(onFlowEvent); useEffect(() => { onCloseRef.current = onClose; }, [onClose]); useEffect(() => { onSignInRef.current = onSignIn; }, [onSignIn]); useEffect(() => { onDeepLinkRef.current = onDeepLink; }, [onDeepLink]); useEffect(() => { onPurchaseRef.current = onPurchase; }, [onPurchase]); useEffect(() => { onRestoreRef.current = onRestore; }, [onRestore]); useEffect(() => { onPaywallEventRef.current = onPaywallEvent; }, [onPaywallEvent]); useEffect(() => { onHandoffRef.current = onHandoff; }, [onHandoff]); useEffect(() => { onFlowEventRef.current = onFlowEvent; }, [onFlowEvent]); const resolvedLaunch = useMemo(() => { if (launchRequest?.value) return launchRequest; if (url) return { type: 'url', value: url }; if (placement) { // A URL/deeplink campaign carries its URL in the value. If one is handed // in via the placement prop, classify it as a url launch so core takes // the URL-match branch — otherwise the label lookup misses, no paywall // resolves, and NamiView spins on the placeholder forever. return { type: isValidUrl(placement) ? 'url' : 'label', value: placement }; } return { type: undefined, value: '' }; }, [launchRequest, url, placement]); const ctx = useMemo( () => ({ ...DEFAULT_CONTEXT, ...(resolvedLaunch.context ?? launchRequest?.context ?? {}), customAttributes: { ...DEFAULT_CONTEXT.customAttributes, ...(resolvedLaunch.context?.customAttributes ?? launchRequest?.context?.customAttributes ?? {}), }, customObject: { ...DEFAULT_CONTEXT.customObject, ...(resolvedLaunch.context?.customObject ?? launchRequest?.context?.customObject ?? {}), }, productGroups: resolvedLaunch.context?.productGroups ?? launchRequest?.context?.productGroups ?? DEFAULT_CONTEXT.productGroups, }), [launchRequest?.context, resolvedLaunch.context], ); const isFlowCampaign = isNamiFlowCampaign(campaignData as any); const clearRemoteBackFallback = useCallback(() => { if (remoteBackFallbackTimeoutRef.current) { clearTimeout(remoteBackFallbackTimeoutRef.current); remoteBackFallbackTimeoutRef.current = null; } remoteBackFallbackAttemptRef.current = null; }, []); const finishActiveFlow = useCallback(() => { clearRemoteBackFallback(); const activeFlow = flowRef.current; if (!activeFlow) return; // Cast comparison sides to `unknown` first — `currentFlow` is typed via // the `_internal` namespace's NamiFlow class declaration (NAM-1217), // `activeFlow` (via flowRef) is typed via the public-barrel's NamiFlow. // Identical runtime class, but bundled .d.ts emits each separately and // TS treats them as nominally distinct. See the cast in the // presentFlow block below. if ((NamiFlowManager.instance.currentFlow as unknown) === activeFlow || NamiFlowManager.instance.flowOpen) { NamiFlowManager.finish(); } flowRef.current = undefined; lastAppearStepIdRef.current = ''; }, [clearRemoteBackFallback]); const requestClose = useCallback(() => { clearRemoteBackFallback(); if (onCloseRef.current) { setIsClosing(true); onCloseRef.current(); return; } }, [clearRemoteBackFallback]); const scheduleRemoteBackFallbackClose = useCallback((flow: NamiFlow, stepId: string, paywallId?: string) => { clearRemoteBackFallback(); remoteBackFallbackAttemptRef.current = { flow, stepId, paywallId, }; remoteBackFallbackTimeoutRef.current = setTimeout(() => { remoteBackFallbackTimeoutRef.current = null; const attempt = remoteBackFallbackAttemptRef.current; const activeFlow = flowRef.current; const activeStepId = activeFlow?.currentFlowStep?.id; const activeStepType = activeFlow?.currentFlowStep?.type; const activePaywallId = paywallData?.id; const transitionedToTerminalExit = !!attempt && attempt.flow === activeFlow && activePaywallId === attempt.paywallId && ( activeFlow?.currentFlowStep?.type === 'exit' || NamiFlowManager.instance.flowOpen === false ); const shouldFallbackClose = !isClosing && !!attempt && ( ( attempt.flow === activeFlow && activeFlow?.currentFlowStep?.id === attempt.stepId && activePaywallId === attempt.paywallId && activeFlow?.previousStepAvailable === false ) || transitionedToTerminalExit ); if (!shouldFallbackClose) { remoteBackFallbackAttemptRef.current = null; return; } remoteBackFallbackAttemptRef.current = null; finishActiveFlow(); requestClose(); }, REMOTE_BACK_FALLBACK_DELAY_MS); }, [clearRemoteBackFallback, finishActiveFlow, isClosing, paywallData?.id, requestClose]); const handleFlowNavigation = useCallback( (paywall: IPaywall, options: FlowNavigationOptions) => { const flow = flowRef.current; const currentPaywall = flowState.paywalls[flowState.index] ?? flowState.paywalls[flowState.paywalls.length - 1] ?? paywallData; const shouldUseAnimatedTransition = options.transition !== 'none' && !!currentPaywall && currentPaywall.id !== paywall.id; const applyNavigation = () => { if (flow?.previousFlowStep) { flow.executeLifecycle( flow.previousFlowStep, NamiReservedActions.DISAPPEAR, ); } setFlowState(() => { if (!shouldUseAnimatedTransition || !currentPaywall) { return { paywalls: [paywall], index: 0, animation: options }; } return { paywalls: [currentPaywall, paywall], index: 1, animation: options, }; }); setPaywallData(paywall); if (flow?.currentFlowStep) { flow.executeLifecycle( flow.currentFlowStep, NamiReservedActions.APPEAR, ); lastAppearStepIdRef.current = flow.currentFlowStep.id; } }; if (!shouldUseAnimatedTransition) { setPendingTransitionPaywall(null); applyNavigation(); return; } setPendingTransitionPaywall(paywall); if (pendingNavigationFrameRef.current != null) { cancelAnimationFrame(pendingNavigationFrameRef.current); } pendingNavigationFrameRef.current = requestAnimationFrame(() => { pendingNavigationFrameRef.current = null; applyNavigation(); }); }, [flowState.index, flowState.paywalls, paywallData], ); const handleFlowAnimationSettled = useCallback((paywall: IPaywall) => { setPendingTransitionPaywall(null); setFlowState((prev) => { const current = prev.paywalls[prev.index] ?? prev.paywalls[prev.paywalls.length - 1]; if (!prev.animation && current?.id === paywall.id) { return prev; } if (prev.paywalls.length > 1 && prev.index > 0) { return { paywalls: prev.paywalls, index: prev.index, animation: null, }; } return { paywalls: [paywall], index: 0, animation: null }; }); }, []); useEffect(() => { const subscription = BackHandler.addEventListener("hardwareBackPress", () => { const flow = flowRef.current; if (flow?.currentFlowStep) { const stepcrumbs = Array.isArray(flow.stepcrumbs) ? flow.stepcrumbs : []; const previousScreenStep = [...stepcrumbs] .slice(0, -1) .reverse() .find((step) => step?.type === 'screen'); const hasRemoteBackActions = !!flow.currentFlowStep?.actions?.[NamiReservedActions.REMOTE_BACK]?.length; const canNavigateBackInFlow = flow.previousStepAvailable || ( Boolean(previousScreenStep) && previousScreenStep?.allow_back_to !== false ); if (hasRemoteBackActions) { if (!canNavigateBackInFlow) { scheduleRemoteBackFallbackClose( flow, flow.currentFlowStep.id, paywallData?.id, ); } else { clearRemoteBackFallback(); } flow.triggerActions(NamiReservedActions.REMOTE_BACK); return true; } clearRemoteBackFallback(); if (!canNavigateBackInFlow) { finishActiveFlow(); requestClose(); return true; } flow.back(); return true; } if (paywallData) { requestClose(); return true; } return false; }); return () => subscription.remove(); }, [clearRemoteBackFallback, finishActiveFlow, paywallData, requestClose, scheduleRemoteBackFallbackClose]); // ─── Paywall action event listener ─────────────────────────────────── useEffect(() => { const emitter = NamiEventEmitter.getInstance(); const handler = (event: NamiPaywallEvent) => { // Forward to catch-all onPaywallEventRef.current?.(event); // Route to specific callbacks switch (event.action) { case NamiPaywallAction.CLOSE_PAYWALL: onCloseRef.current?.(); break; case NamiPaywallAction.SIGN_IN: onSignInRef.current?.(); break; case NamiPaywallAction.DEEPLINK: if (event.deeplinkUrl) { onDeepLinkRef.current?.(event.deeplinkUrl); } break; case NamiPaywallAction.BUY_SKU: onPurchaseRef.current?.(event.sku); break; case NamiPaywallAction.RESTORE_PURCHASES: onRestoreRef.current?.(); break; } }; emitter.addListener(PAYWALL_ACTION_EVENT, handler); return () => { emitter.removeListener(PAYWALL_ACTION_EVENT, handler); }; }, [finishActiveFlow]); // ─── Flow handlers ─────────────────────────────────────────────────── useEffect(() => { NamiFlowManager.registerStepHandoff( (handoffTag: string, handoffData?: Record) => { onHandoffRef.current?.(handoffTag, handoffData); }, ); NamiFlowManager.registerEventHandler((eventData: Record) => { onFlowEventRef.current?.(eventData); }); return () => { NamiFlowManager.registerStepHandoff(undefined); NamiFlowManager.registerEventHandler(undefined); }; }, []); // ─── ExpoUIAdapter listener subscriptions ──────────────────────────── useEffect(() => { const unsubPaywall = expoUIAdapter.onPaywallRequested((info: any, context) => { setLaunchRequest({ type: info?.type, value: info?.value ?? '', context }); }); const unsubReRender = expoUIAdapter.onReRenderRequested(() => { setReloadKey(k => k + 1); }); const unsubFlow = expoUIAdapter.onFlowNavigationRequested((paywall, options) => { handleFlowNavigation(paywall, options); }); return () => { unsubPaywall(); unsubReRender(); unsubFlow(); }; }, [handleFlowNavigation]); useEffect(() => { return () => { if (pendingNavigationFrameRef.current != null) { cancelAnimationFrame(pendingNavigationFrameRef.current); pendingNavigationFrameRef.current = null; } clearRemoteBackFallback(); finishActiveFlow(); }; }, [clearRemoteBackFallback, finishActiveFlow]); // ─── Reset state when launch key changes ───────────────────────────── useEffect(() => { const launchKey = `${resolvedLaunch.type ?? ''}:${resolvedLaunch.value ?? ''}`; if (launchKey !== lastLaunchKeyRef.current) { lastLaunchKeyRef.current = launchKey; setIsClosing(false); setFlowState({ paywalls: [], index: 0, animation: null }); finishActiveFlow(); setPaywallData(null); setCampaignData(null); setLaunchPreviewPaywall(null); setPendingTransitionPaywall(null); setInitialScreenCommitted(false); lastAppearStepIdRef.current = ''; } }, [finishActiveFlow, resolvedLaunch.type, resolvedLaunch.value]); // ─── Campaign / paywall resolution ─────────────────────────────────── useEffect(() => { let cancelled = false; const resolveLaunch = async () => { const value = resolvedLaunch.value; const type = resolvedLaunch.type ?? (url ? 'url' : 'label'); const cacheKey = `${type}:${value}`; if (!value) { setLoading(false); setPaywallData(null); setCampaignData(null); setLaunchPreviewPaywall(null); return; } const cachedLaunch = launchCacheRef.current.get(cacheKey); if (cachedLaunch) { setLaunchPreviewPaywall(cachedLaunch.launchPreviewPaywall); setPaywallData(cachedLaunch.paywallData); setCampaignData(cachedLaunch.campaignData); setLoading(false); } else { setLoading(true); } try { const data = getPaywallDataFromLabel(value, type); const paywall = data?.paywall as IPaywall | undefined; const flowPaywalls = isNamiFlowCampaign(data?.campaign as any) ? (data?.campaign?.flow?.object?.screens ?? []) .map((screenId: string) => getPaywall(screenId)) .filter((item): item is IPaywall => item != null) : []; const initialPaywall = paywall ?? flowPaywalls[0]; const nextCampaign = data?.campaign ? (data.campaign as NamiCampaign) : ({ value, type } as any); const nextLaunch = { launchPreviewPaywall: initialPaywall ?? null, paywallData: data?.paywall ?? null, campaignData: nextCampaign, }; if (!cancelled) { setLaunchPreviewPaywall(nextLaunch.launchPreviewPaywall); setInitialScreenCommitted(false); setPaywallData(nextLaunch.paywallData); setCampaignData(nextLaunch.campaignData); setLoading(false); launchCacheRef.current.set(cacheKey, nextLaunch); } prewarmPaywallFonts([paywall, ...flowPaywalls]); const launchSkuRefIds = collectSkuRefIds( [paywall, ...flowPaywalls].filter((item): item is IPaywall => item != null), ); if (launchSkuRefIds.length) void backfillProducts(launchSkuRefIds); if (initialPaywall?.fonts) { void prepareAndLoadFontsWithTimeout(initialPaywall.fonts); } } catch (e) { if (!cancelled) { setLoading(false); } logger.warn('[NamiExpo] Failed to resolve paywall launch.', e); } }; void resolveLaunch(); return () => { cancelled = true; }; }, [resolvedLaunch.value, resolvedLaunch.type, url, reloadKey]); // ─── Flow presentation ─────────────────────────────────────────────── useEffect(() => { if (!campaignData) return; if (!isNamiFlowCampaign(campaignData as any)) return; const screens = campaignData.flow?.object?.screens ?? []; if (screens.length && !hasAllPaywalls(screens)) return; if (flowRef.current) return; const flow = NamiFlowManager.instance.presentFlow(campaignData as any, { type: resolvedLaunch.type, value: resolvedLaunch.value, context: ctx, } as any, ctx); // `presentFlow` returns the class from the `_internal` namespace // (NAM-1217); flowRef.current is typed via the public-barrel re-export // of the same class. Bundled .d.ts files emit each declaration in // isolation, so TypeScript sees them as nominally distinct classes // even though they're the same runtime constructor. Cast to align. flowRef.current = flow as unknown as NamiFlow; }, [campaignData, resolvedLaunch.type, resolvedLaunch.value, ctx, reloadKey]); useEffect(() => { const flow = flowRef.current; const activePaywall = flowState.paywalls[flowState.index] ?? flowState.paywalls[flowState.paywalls.length - 1]; if (!flow || !activePaywall || !flow.currentFlowStep) { return; } const currentStep = flow.currentFlowStep; if (lastAppearStepIdRef.current === currentStep.id) { return; } flow.executeLifecycle(currentStep, NamiReservedActions.APPEAR); lastAppearStepIdRef.current = currentStep.id; }, [flowState.paywalls, flowState.index]); useEffect(() => { const attempt = remoteBackFallbackAttemptRef.current; if (!attempt) { return; } if (isClosing) { clearRemoteBackFallback(); return; } const activeFlow = flowRef.current; const activeStepId = activeFlow?.currentFlowStep?.id; const activePaywallId = paywallData?.id; const didNavigateAway = attempt.flow !== activeFlow || attempt.stepId !== activeStepId || attempt.paywallId !== activePaywallId; if (didNavigateAway) { clearRemoteBackFallback(); } }, [clearRemoteBackFallback, flowState.index, flowState.paywalls, isClosing, paywallData?.id]); const hasFlowScreen = flowState.paywalls.length > 0; const launchPlaceholderPaywall = launchPreviewPaywall ?? paywallData ?? flowState.paywalls[flowState.index] ?? flowState.paywalls[0] ?? null; const showLaunchPlaceholder = Boolean(launchPlaceholderPaywall) && (loading || !initialScreenCommitted); const showTransitionPlaceholder = Boolean(pendingTransitionPaywall) && !loading && !isClosing; const handleInitialCommitted = useCallback((_paywall: IPaywall, _pageName: string, _formFactor?: string) => { setInitialScreenCommitted(true); }, []); if (isClosing) { return ; } if (loading || !campaignData || (!isFlowCampaign && !paywallData) || (isFlowCampaign && !hasFlowScreen)) { return ; } return ( // Full-bleed root so the paywall background extends under the status bar and // home indicator (edge-to-edge). Safe-area insets are applied only to the // chrome (header/footer) via useSafeAreaInsets, not the whole tree — the // previous core SafeAreaView inset every edge and let the #000 root show // through as letterbox bands. SafeAreaProvider guarantees an inset context // even if the host app didn't mount one (nested providers are supported). {isFlowCampaign ? ( setPendingTransitionPaywall(null)} onInitialCommitted={handleInitialCommitted} onClose={onClose} campaign={campaignData} context={ctx} flow={flowRef.current} /> ) : ( )} {!isClosing && showLaunchPlaceholder && } {showTransitionPlaceholder && } ); }; const LaunchPlaceholder: React.FC<{ paywall: IPaywall | null; overlay?: boolean; }> = ({ paywall, overlay = false }) => { const initialPageName = paywall?.template?.initialState?.currentPage ?? 'page1'; const page = paywall?.template?.pages?.find((item: any) => item?.name === initialPageName) ?? null; const backgroundContainer = (page as any)?.backgroundContainer; const backgroundColor = parseColor(backgroundContainer?.fillColor) ?? parseColor(backgroundContainer?.fillColorFallback) ?? '#111118'; return ( ); }; const getPaywallBackgroundColor = (paywall: IPaywall | null | undefined): string => { const initialPageName = paywall?.template?.initialState?.currentPage ?? 'page1'; const page = paywall?.template?.pages?.find((item: any) => item?.name === initialPageName) ?? null; const backgroundContainer = (page as any)?.backgroundContainer; return ( parseColor(backgroundContainer?.fillColor) ?? parseColor(backgroundContainer?.fillColorFallback) ?? '#111118' ); }; /** Renders multi-step flow paywalls with transition animations */ const FlowRenderer: React.FC<{ paywalls: IPaywall[]; currentIndex: number; animation?: FlowNavigationOptions; onSettled?: (paywall: IPaywall) => void; onTransitionVisible?: () => void; onInitialCommitted?: (paywall: IPaywall, pageName: string, formFactor?: string) => void; onClose?: () => void; campaign: NamiCampaign; context: NamiPaywallLaunchContext; flow?: NamiFlow; }> = ({ paywalls, currentIndex, animation, onSettled, onTransitionVisible, onInitialCommitted, onClose, campaign, context, flow }) => { const { width, height } = Dimensions.get('window'); const progress = useRef(new Animated.Value(0)).current; const transitionSequenceRef = useRef(0); const lastTransitionSignatureRef = useRef(''); const startedTransitionKeyRef = useRef(null); const [completedTransitionKey, setCompletedTransitionKey] = useState(null); const transition = animation?.transition ?? 'slide'; const direction = animation?.direction === 'backward' ? -1 : 1; const isVertical = transition === 'verticalSlide'; const distance = isVertical ? height : width; const shouldAnimatePair = paywalls.length === 2 && currentIndex === 1 && animation != null; const shouldRenderPair = shouldAnimatePair; const sourcePaywall = shouldRenderPair ? paywalls[0] : undefined; const targetPaywall = shouldRenderPair ? paywalls[1] : (paywalls[currentIndex] ?? paywalls[0]); const targetBackgroundColor = useMemo( () => getPaywallBackgroundColor(targetPaywall), [targetPaywall], ); const transitionSignature = shouldAnimatePair ? `${sourcePaywall?.id ?? 'source'}->${targetPaywall?.id ?? 'target'}:${transition}:${direction}` : ''; if (shouldAnimatePair && lastTransitionSignatureRef.current !== transitionSignature) { lastTransitionSignatureRef.current = transitionSignature; transitionSequenceRef.current += 1; } else if (!shouldAnimatePair) { lastTransitionSignatureRef.current = ''; } const activeTransitionKey = shouldAnimatePair ? `${transitionSignature}:${transitionSequenceRef.current}` : ''; const [committedTarget, setCommittedTarget] = useState<{ transitionKey: string | null; paywallId: string | null; }>({ transitionKey: null, paywallId: null, }); const interactionReady = !shouldAnimatePair || ( committedTarget.transitionKey === activeTransitionKey && committedTarget.paywallId === (targetPaywall?.id ?? null) ); const transitionReady = shouldAnimatePair && interactionReady; const animationReady = !shouldAnimatePair || transitionReady; const handleTargetCommitted = useCallback((paywall: IPaywall, _pageName: string, formFactor?: string) => { if (paywall.id !== targetPaywall?.id) { return; } setCommittedTarget({ transitionKey: activeTransitionKey || null, paywallId: paywall.id ?? null, }); }, [activeTransitionKey, targetPaywall?.id]); const handleVisibleCommitted = useCallback((paywall: IPaywall, pageName: string, formFactor?: string) => { if (currentIndex === 0) { onInitialCommitted?.(paywall, pageName, formFactor); } handleTargetCommitted(paywall, pageName, formFactor); }, [currentIndex, handleTargetCommitted, onInitialCommitted]); useLayoutEffect(() => { const activePaywall = paywalls[currentIndex] ?? paywalls[paywalls.length - 1]; if (!shouldAnimatePair) { startedTransitionKeyRef.current = null; setCompletedTransitionKey(null); progress.stopAnimation(); progress.setValue(0); return; } if (transition === 'fade' || transition === 'none') { if (startedTransitionKeyRef.current === activeTransitionKey) { return; } startedTransitionKeyRef.current = activeTransitionKey; setCompletedTransitionKey(null); progress.stopAnimation(); progress.setValue(0); if (activePaywall) { requestAnimationFrame(() => { onSettled?.(activePaywall); }); } return; } if (!transitionReady) { if (startedTransitionKeyRef.current !== activeTransitionKey) { progress.stopAnimation(); progress.setValue(0); } return; } if (startedTransitionKeyRef.current === activeTransitionKey) { return; } startedTransitionKeyRef.current = activeTransitionKey; setCompletedTransitionKey(null); progress.stopAnimation(); progress.setValue(0); Animated.timing(progress, { toValue: 1, duration: 250, useNativeDriver: true, }).start(({ finished }) => { if (finished) { setCompletedTransitionKey(activeTransitionKey); } }); }, [ activeTransitionKey, animation?.direction, currentIndex, onSettled, paywalls, progress, sourcePaywall?.id, transitionReady, transition, ]); useEffect(() => { if ( !shouldAnimatePair || !targetPaywall || !interactionReady || completedTransitionKey !== activeTransitionKey ) { return; } onSettled?.(targetPaywall); }, [ activeTransitionKey, completedTransitionKey, interactionReady, onSettled, shouldAnimatePair, targetPaywall, ]); useEffect(() => { if (!shouldAnimatePair || !interactionReady) { return; } onTransitionVisible?.(); }, [interactionReady, onTransitionVisible, shouldAnimatePair]); if (transition === 'fade') { const current = paywalls[currentIndex] ?? paywalls[0]; if (!current) return null; return ( ); } const renderedPaywalls = shouldRenderPair ? [ { paywall: sourcePaywall, role: 'source' as const, key: 0 }, { paywall: targetPaywall, role: 'target' as const, key: 1 }, ] : [{ paywall: paywalls[currentIndex] ?? paywalls[0], role: 'target' as const, key: currentIndex }]; return ( {renderedPaywalls.map(({ paywall: pw, role, key }) => { if (!pw) { return null; } const animatedStyle = role === 'target' && shouldAnimatePair ? { opacity: 0 } : null; return ( ); })} {shouldAnimatePair && !interactionReady && ( )} {transitionReady && targetPaywall && ( )} ); }; const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#000' }, loading: { flex: 1, backgroundColor: '#111118', alignItems: 'center', justifyContent: 'center', }, loadingOverlay: { ...StyleSheet.absoluteFillObject, zIndex: 50, }, flowContainer: { flex: 1, overflow: 'hidden' }, flowScreen: { position: 'absolute', top: 0, left: 0 }, });