import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Dimensions } from 'react-native'; import type { IPaywall, ISkuMenu, TPaywallContext, NamiPaywallLaunchContext, NamiCampaign, NamiProductDetails, NamiSKU, NamiPaywallEvent, TDevice, TimerState, TPaywallMedia, NamiAppSuppliedVideoDetails, NamiFlow, FormFieldValidator, } from '@namiml/sdk-core'; import { PaywallState, initialState } from '@namiml/sdk-core'; type PaywallStateWithPageHistory = PaywallState & { canGoBackPage(): boolean; goBackPage(): boolean; }; function asMethod( candidate: unknown, ): ((...args: TArgs) => TResult) | undefined { return typeof candidate === 'function' ? (candidate as (...args: TArgs) => TResult) : undefined; } export interface PaywallContextValue { state: TPaywallContext; productDetails: NamiProductDetails[]; flow?: NamiFlow; filteredSkuMenus: ISkuMenu[]; setPaywall(paywall: IPaywall, context: NamiPaywallLaunchContext, campaign: NamiCampaign): void; notifyFirstFocusReady(paywallId: string, page: string, formFactor?: string): void; setCurrentPage(page: string): void; canGoBackPage(): boolean; goBackPage(): boolean; setCurrentGroupData(groupId: string, groupName: string): void; setSelectedProducts(products: Record): void; setCurrentFormId(formId: string, value?: string): void; setFormState(formId: string, value: boolean | string): void; registerFormFieldValidator(formId: string, validator: FormFieldValidator): void; setFormFieldError(formId: string, message: string): void; clearFormFieldError(formId: string): void; setTimerState(timerId: string, remainingSeconds: number, savedAt: number, hasEmittedCompletion: boolean): void; getTimerState(timerId: string): TimerState | undefined; setProductDetails(details: NamiProductDetails[]): void; setPurchaseInProgress(inProgress: boolean): void; setPurchase(inProgress: boolean, product?: NamiSKU): void; setCustomerAttribute(attributes: Record): void; removeCustomerAttribute(key: string): void; setIsLoggedIn(isLoggedIn: boolean): void; setAppSuppliedVideoDetails(details: NamiAppSuppliedVideoDetails): void; resetAppSuppliedVideoDetails(): void; setMediaList(media: TPaywallMedia[]): void; setSafeAreaTop(top: number): void; setFullScreenPresentation(full: boolean): void; setFormFactor(factor: TDevice): void; setUserInteractionEnabled(enabled: boolean): void; setUserTags(tags: Record): void; setLaunchDetails(value: string, type?: string): void; setOpenHeaderIds(id: string, sku?: NamiSKU): void; setFlow(flow: NamiFlow): void; setCurrentSlideIndex(index: number): void; getPaywallActionEventData(): Partial; getSelectedPaywall(): IPaywall | undefined; getSelectedCampaign(): NamiCampaign | undefined; } const PaywallCtx = createContext(null); const FirstFocusReadyCtx = createContext<{ firstFocusReadyKey: string | null; notifyFirstFocusReady(paywallId: string, page: string, formFactor?: string): void; } | null>(null); export function usePaywallContext(): PaywallContextValue { const ctx = useContext(PaywallCtx); if (!ctx) throw new Error('usePaywallContext must be used within a PaywallProvider'); return ctx; } export function useFirstFocusReadyContext(): { firstFocusReadyKey: string | null; notifyFirstFocusReady(paywallId: string, page: string, formFactor?: string): void; } { const ctx = useContext(FirstFocusReadyCtx); if (!ctx) throw new Error('useFirstFocusReadyContext must be used within a PaywallProvider'); return ctx; } interface PaywallProviderProps { paywall: IPaywall; context: NamiPaywallLaunchContext; campaign: NamiCampaign; flow?: NamiFlow; onFirstFocusReady?: (paywallId: string, page: string, formFactor?: string) => void; children: React.ReactNode; } export const PaywallProvider: React.FC = ({ paywall, context, campaign, flow, onFirstFocusReady, children, }) => { const providerRef = useRef(null); const pageHistoryRef = useRef([]); const [firstFocusReadyKey, setFirstFocusReadyKey] = useState(null); const [state, setState] = useState(() => { const provider = PaywallState.create(paywall, context, campaign) as PaywallStateWithPageHistory; if (flow) provider.setFlow(flow); providerRef.current = provider; pageHistoryRef.current = [provider.state.currentPage ?? paywall.template?.initialState?.currentPage ?? 'page1']; return cloneStateWithDynamicProps(provider.state, provider); }); useEffect(() => { const provider = providerRef.current; if (!provider) return; const unsubscribe = provider.subscribe(() => { setState(cloneStateWithDynamicProps(provider.state, provider)); }); return () => { unsubscribe(); PaywallState.remove(provider); }; }, []); useEffect(() => { const provider = providerRef.current; if (!provider) return; setFirstFocusReadyKey(null); provider.setPaywall(paywall, context, campaign); if (flow) provider.setFlow(flow); pageHistoryRef.current = [provider.state.currentPage ?? paywall.template?.initialState?.currentPage ?? 'page1']; setState(cloneStateWithDynamicProps(provider.state, provider)); }, [paywall, context, campaign, flow]); const methods = useMemo(() => ({ setPaywall: (nextPaywall: IPaywall, nextContext: NamiPaywallLaunchContext, nextCampaign: NamiCampaign) => { providerRef.current?.setPaywall(nextPaywall, nextContext, nextCampaign); const provider = providerRef.current; pageHistoryRef.current = [provider?.state.currentPage ?? nextPaywall.template?.initialState?.currentPage ?? 'page1']; }, setCurrentPage: (page: string) => { const provider = providerRef.current; const currentPage = provider?.state.currentPage ?? pageHistoryRef.current[pageHistoryRef.current.length - 1] ?? 'page1'; if (!page || page === currentPage) { return; } const currentHistory = pageHistoryRef.current.length ? pageHistoryRef.current : [currentPage]; pageHistoryRef.current = [...currentHistory, page]; provider?.setCurrentPage(page); }, canGoBackPage: () => { if (pageHistoryRef.current.length > 1) { return true; } return asMethod<[], boolean>(providerRef.current?.canGoBackPage)?.call(providerRef.current) ?? false; }, goBackPage: () => { const provider = providerRef.current; if (pageHistoryRef.current.length > 1) { const nextHistory = [...pageHistoryRef.current]; nextHistory.pop(); const previousPage = nextHistory[nextHistory.length - 1]; if (!previousPage) { return false; } pageHistoryRef.current = nextHistory; const providerHandled = asMethod<[], boolean>(provider?.goBackPage)?.call(provider) ?? false; if (!providerHandled) { provider?.setCurrentPage(previousPage); } return true; } return asMethod<[], boolean>(provider?.goBackPage)?.call(provider) ?? false; }, setCurrentGroupData: (groupId: string, groupName: string) => { providerRef.current?.setCurrentGroupData(groupId, groupName); }, setSelectedProducts: (products: Record) => { providerRef.current?.setSelectedProducts(products); }, setCurrentFormId: (formId: string, value?: string) => { providerRef.current?.setCurrentFormId(formId, value); }, setFormState: (formId: string, value: boolean | string) => { providerRef.current?.setFormState(formId, value); }, registerFormFieldValidator: (formId: string, validator: FormFieldValidator) => { providerRef.current?.registerFormFieldValidator(formId, validator); }, setFormFieldError: (formId: string, message: string) => { providerRef.current?.setFormFieldError(formId, message); }, clearFormFieldError: (formId: string) => { providerRef.current?.clearFormFieldError(formId); }, setTimerState: (timerId: string, remainingSeconds: number, savedAt: number, hasEmittedCompletion: boolean) => { providerRef.current?.setTimerState(timerId, remainingSeconds, savedAt, hasEmittedCompletion); }, getTimerState: (timerId: string) => { return providerRef.current?.getTimerState(timerId); }, setProductDetails: (details: NamiProductDetails[]) => { providerRef.current?.setProductDetails(details); }, setPurchaseInProgress: (inProgress: boolean) => { providerRef.current?.setPurchaseInProgress(inProgress); }, setPurchase: (inProgress: boolean, product?: NamiSKU) => { providerRef.current?.setPurchase(inProgress, product); }, setCustomerAttribute: (attributes: Record) => { providerRef.current?.setCustomerAttribute(attributes); }, removeCustomerAttribute: (key: string) => { providerRef.current?.removeCustomerAttribute(key); }, setIsLoggedIn: (isLoggedIn: boolean) => { providerRef.current?.setIsLoggedIn(isLoggedIn); }, setAppSuppliedVideoDetails: (details: NamiAppSuppliedVideoDetails) => { providerRef.current?.setAppSuppliedVideoDetails(details); }, resetAppSuppliedVideoDetails: () => { providerRef.current?.resetAppSuppliedVideoDetails(); }, setMediaList: (media: TPaywallMedia[]) => { providerRef.current?.setMediaList(media); }, setSafeAreaTop: (top: number) => { providerRef.current?.setSafeAreaTop(top); }, setFullScreenPresentation: (full: boolean) => { providerRef.current?.setFullScreenPresentation(full); }, setFormFactor: (factor: TDevice) => { providerRef.current?.setFormFactor(factor); }, setUserInteractionEnabled: (enabled: boolean) => { providerRef.current?.setUserInteractionEnabled(enabled); }, setUserTags: (tags: Record) => { providerRef.current?.setUserTags(tags); }, setLaunchDetails: (value: string, type?: string) => { providerRef.current?.setLaunchDetails(value, type); }, setOpenHeaderIds: (id: string, sku?: NamiSKU) => { providerRef.current?.setOpenHeaderIds(id, sku); }, setFlow: (nextFlow: NamiFlow) => { providerRef.current?.setFlow(nextFlow); }, notifyFirstFocusReady: (paywallId: string, page: string, formFactor?: string) => { setFirstFocusReadyKey(`${paywallId}:${page}:${formFactor ?? ''}`); onFirstFocusReady?.(paywallId, page, formFactor); }, setCurrentSlideIndex: (index: number) => { providerRef.current?.setCurrentSlideIndex(index); }, getPaywallActionEventData: () => { return providerRef.current?.getPaywallActionEventData() ?? {}; }, getSelectedPaywall: () => { return providerRef.current?.getSelectedPaywall(); }, getSelectedCampaign: () => { return providerRef.current?.getSelectedCampaign(); }, }), [onFirstFocusReady]); const focusReadyValue = useMemo( () => ({ firstFocusReadyKey, notifyFirstFocusReady: methods.notifyFirstFocusReady, }), [firstFocusReadyKey, methods], ); const value = useMemo(() => { const provider = providerRef.current; return { state, productDetails: provider?.getProductDetails() ?? provider?.productDetails ?? [], flow: provider?.flow, filteredSkuMenus: provider?.filteredSkuMenus ?? [], ...methods, }; }, [methods, state]); return ( {children} ); }; export default PaywallCtx; function cloneStateWithDynamicProps( state: TPaywallContext | undefined, provider: PaywallState, ): TPaywallContext { const nextState = { ...(state ?? initialState) } as TPaywallContext; defineDynamicStateProps(nextState, provider); return nextState; } function defineDynamicStateProps(state: TPaywallContext, provider: PaywallState): void { Object.defineProperty(state, 'tvQuality', { get: () => getTVQuality(provider), enumerable: false, configurable: true, }); Object.defineProperty(state, 'viewportWidth', { get: getViewportWidth, enumerable: false, configurable: true, }); Object.defineProperty(state, 'viewportHeight', { get: getViewportHeight, enumerable: false, configurable: true, }); } function getTVQuality(provider: PaywallState): string { if (provider.getFormFactor() !== 'television') { return ''; } const { width, height, scale } = Dimensions.get('window'); const maxDimension = Math.max(width, height) * (scale || 1); const minDimension = Math.min(width, height) * (scale || 1); if (!maxDimension || !minDimension) { return '720p'; } if (maxDimension >= 3840 || minDimension >= 2160) { return '4K'; } if (maxDimension >= 1920 || minDimension >= 1080) { return '1080p'; } return '720p'; } function getViewportWidth(): number { return Dimensions.get('window').width; } function getViewportHeight(): number { return Dimensions.get('window').height; }