import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, BackHandler, Keyboard, Pressable, View, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { IPaywall, TPages } from '@namiml/sdk-core'; import { useFirstFocusReadyContext, usePaywallContext } from '../context/PaywallContext'; import { NamiContentContainer } from './containers/NamiContentContainer'; import { NamiBackgroundContainer } from './containers/NamiBackgroundContainer'; import { NamiHeader } from './containers/NamiHeader'; import { NamiFooter } from './containers/NamiFooter'; import { AnnouncementProvider, useAnnouncer } from '../context/AnnouncementContext'; import { A11yLiveRegion } from './elements/A11yLiveRegion'; import { parseColor } from '../utils/styles'; import { getDeviceScaleFactor, NamiEventEmitter, NamiPaywallAction, PAYWALL_ACTION_EVENT, NamiReservedActions } from '@namiml/sdk-core'; import { prepareAndLoadFontsWithTimeout } from '../utils/fonts'; import { postImpression } from '../utils/impression'; interface Props { paywall: IPaywall; onClose?: () => void; onCommitted?: (paywall: IPaywall, pageName: string, formFactor?: string) => void; holdInteractionUntilFocus?: boolean; isActive?: boolean; } let KeplerTVFocusGuideView: React.ComponentType | null = null; try { KeplerTVFocusGuideView = require('@amazon-devices/react-native-kepler').TVFocusGuideView; } catch { KeplerTVFocusGuideView = null; } const fontGateCache = new Map(); export const PaywallScreen: React.FC = ({ paywall, onClose, onCommitted, holdInteractionUntilFocus = false, isActive = true, }) => { const ctx = usePaywallContext(); const insets = useSafeAreaInsets(); const focusReadyCtx = useFirstFocusReadyContext(); // Push the measured top safe-area inset into paywall state so templates can // position chrome via ${state.safeAreaTop} (e.g. header topPadding, // background topMargin). Mirrors the native Android renderer, which measures // the display cutout and calls setSafeAreaTop. The full-bleed root lets the // background extend under the status bar; this offsets the content back into // the safe area. No safeAreaBottom equivalent exists in core/Android state. // Depend only on insets.top (stable per device/orientation) — depending on // ctx would re-run every render and loop through setState. useEffect(() => { ctx.setSafeAreaTop(insets.top); }, [insets.top]); const scaleFactor = getDeviceScaleFactor(ctx.state.formFactor); const userInteractionEnabled = ctx.state.userInteractionEnabled !== false; const currentPageName = ctx.state.selectedPaywall === paywall ? ctx.state.currentPage : paywall.template?.initialState?.currentPage ?? 'page1'; const page = useMemo(() => { let currentPage: string; if (ctx.state.selectedPaywall === paywall) { currentPage = ctx.state.currentPage; } else { currentPage = paywall.template?.initialState?.currentPage ?? 'page1'; } return paywall.template?.pages?.find((p: TPages) => p.name === currentPage) ?? null; }, [paywall, ctx.state.selectedPaywall, ctx.state.currentPage]); const firstFocusReadyKey = `${paywall.id ?? 'unknown'}:${page?.name ?? currentPageName}:${ctx.state.formFactor ?? ''}`; const shouldHoldForFocus = holdInteractionUntilFocus && ctx.state.formFactor === 'television'; const focusReady = !shouldHoldForFocus || focusReadyCtx.firstFocusReadyKey === firstFocusReadyKey; const interactionEnabled = userInteractionEnabled && focusReady; const fontNames = useMemo(() => Object.keys(paywall.fonts ?? {}).sort(), [paywall.fonts]); const fontGateCacheKey = `${paywall.id ?? 'unknown'}:${page?.name ?? currentPageName}:${fontNames.join(',')}`; const hasFonts = fontNames.length > 0; const [fontGateState, setFontGateState] = useState<'pending' | 'ready' | 'fallback'>(() => { if (!hasFonts) { return 'ready'; } return fontGateCache.get(fontGateCacheKey) ?? 'pending'; }); useEffect(() => { void postImpression({ segment: ctx.state.selectedCampaign?.segment, call_to_action: paywall.id, }); NamiEventEmitter.getInstance().emit(PAYWALL_ACTION_EVENT, { ...ctx.getPaywallActionEventData(), action: NamiPaywallAction.SHOW_PAYWALL, }); }, [paywall.id, ctx.state.selectedCampaign?.segment]); useEffect(() => { let cancelled = false; if (!hasFonts) { setFontGateState('ready'); return; } const cachedGateState = fontGateCache.get(fontGateCacheKey); if (cachedGateState) { setFontGateState(cachedGateState); return; } setFontGateState('pending'); void prepareAndLoadFontsWithTimeout(paywall.fonts, 1500).then((result) => { if (!cancelled) { const resolvedState = result === 'ready' ? 'ready' : 'fallback'; fontGateCache.set(fontGateCacheKey, resolvedState); setFontGateState(resolvedState); } }); return () => { cancelled = true; }; }, [currentPageName, fontGateCacheKey, fontNames.length, hasFonts, page?.name, paywall.fonts, paywall.id]); useEffect(() => { if (fontGateState === 'pending') { return; } const paywallId = paywall.id ?? 'unknown'; const pageName = page?.name ?? currentPageName; onCommitted?.(paywall, pageName, ctx.state.formFactor); }, [fontGateState, paywall, paywall.id, page?.name, currentPageName, interactionEnabled, onCommitted, ctx.state.formFactor]); useEffect(() => { if (!isActive) { return; } const subscription = BackHandler.addEventListener('hardwareBackPress', () => { const hasRemoteBackActions = !!ctx.flow?.currentFlowStep?.actions?.[NamiReservedActions.REMOTE_BACK]?.length; if (hasRemoteBackActions) { return false; } const canGoBackPage = ctx.canGoBackPage(); if (!canGoBackPage) { return false; } return ctx.goBackPage(); }); return () => subscription.remove(); }, [ctx, isActive]); const { backgroundContainer, header = [], contentContainer, footer = [], } = (page ?? {}) as any; const hasHeader = Array.isArray(header) ? header.length > 0 : Array.isArray((header as any)?.components) ? (header as any).components.length > 0 : Boolean(header); const hasFooter = Array.isArray(footer) ? footer.length > 0 : Array.isArray((footer as any)?.components) ? (footer as any).components.length > 0 : Boolean(footer); const bgColor = parseColor(backgroundContainer?.fillColor) ?? parseColor(backgroundContainer?.fillColorFallback) ?? 'transparent'; const waitingOverlayColor = bgColor === 'transparent' ? '#111118' : bgColor; const PageFocusWrapper = ctx.state.formFactor === 'television' && KeplerTVFocusGuideView ? KeplerTVFocusGuideView : View; const pageFocusWrapperProps = ctx.state.formFactor === 'television' && KeplerTVFocusGuideView ? { autoFocus: true, style: styles.pageRoot, } : { style: styles.pageRoot }; if (!page) { throw new Error(`Page with name ${ctx.state.currentPage} not found in paywall template.`); } if (fontGateState === 'pending') { return ( ); } // NAM-1933: on phone/tablet, tapping the paywall background (empty area above // the keyboard) dismisses the keyboard. A Pressable wrapping the page content // yields the responder to interactive children (buttons, the text field), so // its onPress fires only for taps on non-interactive/empty areas. TV has no // keyboard, so it renders the content without the Pressable. const isTelevision = ctx.state.formFactor === 'television'; const pageContent = ( {backgroundContainer && ( )} {hasHeader && ( )} {contentContainer && ( )} {hasFooter && ( )} {shouldHoldForFocus && !focusReady && ( )} ); return ( {isTelevision ? ( pageContent ) : ( Keyboard.dismiss()} accessible={false}> {pageContent} )} ); }; /** * NAM-403: renders the assertive live region using text from the announcer. * Must live under AnnouncementProvider so it can read useAnnouncer(). */ const A11yLiveRegionMount: React.FC = () => { const { liveRegionText } = useAnnouncer(); return ; }; const styles = StyleSheet.create({ screen: { flex: 1 }, pendingScreen: { alignItems: 'center', justifyContent: 'center', }, pageRoot: { flex: 1 }, waitingOverlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center', zIndex: 20, }, });