import React, { useCallback, useMemo, useRef } from 'react'; import { Pressable, ActivityIndicator, StyleSheet, View, Linking as RNLinking } from 'react-native'; import { useFirstFocusReadyContext, usePaywallContext } from '../../context/PaywallContext'; import { applyStyles, parseColor, childSpacingStyle, extractPrefixedStyles, focusedStyleOverrides, parseSize, buttonFlexDirection } from '../../utils/styles'; import { handleAction, shouldHoist, ACTION_DEEP_LINK, ACTION_BUY_SKU, ACTION_SELECT_SKU, } from '../../utils/actionHandler'; import { buildSmartTextReplacements, interpolateSmartText } from '../../utils/smartText'; import { TemplateRenderer } from '../TemplateRenderer'; import type { TComponent } from '@namiml/sdk-core'; import { NamiReservedActions, SHOULD_SHOW_LOADING_INDICATOR } from '@namiml/sdk-core'; import { FocusedStyleProvider, useFocusableState, useFocusEnabled, useRegisterPreferredFocus } from '../../context/FocusContext'; import { useAnnouncer } from '../../context/AnnouncementContext'; import { classifyButtonAnnouncement } from '../../utils/a11yAnnouncement'; import { expandRenderableComponents } from '../../utils/rendering'; import { useTVPreferredFocus } from '../../utils/tvFocus'; interface Props { component: any; scaleFactor: number; onClose?: () => void; parentDirection?: string; } function createFlowButtonHandle(component: any) { return { ...component, firstTextValue(): string | undefined { const { components = [] } = component ?? {}; if (!Array.isArray(components)) return undefined; for (const child of components) { if (child?.component === 'text' && typeof child?.text === 'string') { return child.text; } } return undefined; }, }; } function openComponentUrl(url?: string) { if (!url) return; void RNLinking.canOpenURL(url) .then((canOpen) => { if (canOpen) { return RNLinking.openURL(url); } return undefined; }) .catch(() => {}); } function withDisabledChildStyles(child: TComponent, disabled: boolean): TComponent { if (!disabled || !child || typeof child !== 'object') { return child; } const disabledStyles = extractPrefixedStyles(child as Record, 'disabled'); const safeDisabledStyles = Object.entries(disabledStyles).reduce((result, [key, value]) => { if (value !== null && value !== undefined) { result[key] = value; } return result; }, {} as Record); return { ...child, ...safeDisabledStyles } as TComponent; } export const NamiButton: React.FC = ({ component, scaleFactor, onClose, parentDirection }) => { const ctx = usePaywallContext(); const focusReadyCtx = useFirstFocusReadyContext(); const focusEnabled = useFocusEnabled(); const announcer = useAnnouncer(); const ownAccessibilityLabel = component.screenreaderText || component.title || ''; const pressableRef = useRef(null); const { isFocused, onFocus, onBlur } = useFocusableState( Boolean(component.focused), focusEnabled, ); useTVPreferredFocus( pressableRef, focusEnabled && Boolean(component.focused), ); useRegisterPreferredFocus( pressableRef.current, focusEnabled && Boolean(component.focused), ); const smartTextSku = component?.smartTextSku ?? component?.sku; const resolvedChildren = useMemo( () => expandRenderableComponents(ctx, component.components), [ctx, component.components], ); const disabled = useMemo(() => { if (component.disabled === true) return true; if (!component.disabled) return false; const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, smartTextSku); const resolved = interpolateSmartText(String(component.disabled), replacements); return resolved === true || resolved === 'true'; }, [component.disabled, smartTextSku, ctx.state, ctx.flow]); const sku = useMemo(() => { if (!component.sku) return undefined; const items = ctx.state.skuItems ?? []; return items.find((s: any) => s.sku_ref_id === component.sku?.sku_ref_id) ?? component.sku; }, [component.sku, ctx.state.skuItems]); const onPress = useCallback(() => { if (disabled || !ctx.state.userInteractionEnabled) return; const onTap = component.onTap; const url = component.url; const flowButton = createFlowButtonHandle(component); if (url && onTap?.function !== ACTION_DEEP_LINK) { openComponentUrl(url); return; } const currentGroupId = onTap?.parameters?.partialState?.currentGroupId; if (currentGroupId) { ctx.setCurrentGroupData(String(currentGroupId), ''); } if (ctx.flow && component.id) { let actionId = component.id; if (onTap?.function === ACTION_BUY_SKU || onTap?.function === ACTION_SELECT_SKU) { actionId = NamiReservedActions.BUY_SKU; } if (ctx.flow.currentStepHasHoistedPrimaryActions(actionId)) { ctx.flow.triggerActions(actionId, flowButton, { sku }); return; } if (shouldHoist(onTap)) { ctx.flow.triggerBeforeActions(component.id, flowButton, { sku }); handleAction({ onTap, sku, ctx, onClose, componentChange: { id: component.id, name: component.title ?? component.screenreaderText, }, }); ctx.flow.triggerAfterActions(component.id, flowButton, { sku }); return; } } if (onTap) { handleAction({ onTap, sku, ctx, onClose, componentChange: { id: component.id, name: component.title ?? component.screenreaderText, }, }); } }, [component, sku, ctx, disabled, onClose]); const handleFocus = useCallback(() => { onFocus(); const paywallId = ctx.state.selectedPaywall?.id ?? 'unknown'; const page = ctx.state.currentPage ?? 'unknown'; const formFactor = ctx.state.formFactor; focusReadyCtx.notifyFirstFocusReady(paywallId, page, formFactor); // NAM-403: report focus so the first primary-CTA focus announces the full page. announcer.announceOnFocus(classifyButtonAnnouncement(component), ownAccessibilityLabel); }, [onFocus, focusReadyCtx, ctx.state.selectedPaywall?.id, ctx.state.currentPage, ctx.state.formFactor, announcer, component, ownAccessibilityLabel]); const { backgroundColor, borderOverride } = useMemo(() => { const focused = isFocused; const fill = focused ? (component.focusedFillColor ?? component.focusedFillColorFallback) : component.fillColor; const fallback = focused ? component.focusedFillColorFallback : component.fillColorFallback; const textColor = focused ? parseColor(component.focusedFontColor) ?? parseColor(component.fontColor) : parseColor(component.fontColor); const bgSource = fill ?? fallback; const bgColor = parseColor(bgSource) ?? parseColor(fallback); return { color: textColor, backgroundColor: bgColor, borderOverride: focused ? focusedStyleOverrides(component, scaleFactor) : {}, }; }, [component, scaleFactor, isFocused]); const containerStyle = useMemo(() => { const disabledStyles = extractPrefixedStyles(component, 'disabled'); const base = applyStyles(disabled ? { ...component, ...disabledStyles } : component, scaleFactor, isFocused, parentDirection); if (disabled) { const disabledBg = parseColor(disabledStyles.fillColor ?? component.disabledFillColor); if (disabledBg) base.backgroundColor = disabledBg; base.opacity = 0.6; } if (base.borderRadius != null) { base.overflow = 'hidden'; } // NAM-2417: a directionless button defaults to horizontal (row). applyStyles uses the // shared flexDirectionFromConfig whose default is 'column'; override for buttons here. base.flexDirection = buttonFlexDirection(component.direction); return base; }, [component, scaleFactor, disabled, backgroundColor, borderOverride, parentDirection, isFocused ]); const showLoading = SHOULD_SHOW_LOADING_INDICATOR && ctx.state.purchaseInProgress && component.sku?.id === (ctx.state as any).productBeingPurchased?.id; const renderedChildren = useMemo(() => resolvedChildren.map((child: TComponent, i: number) => { const resolvedChild = withDisabledChildStyles(child, disabled); const buttonDirection = component.direction ?? "horizontal"; const spacingStyle = i === 0 ? {} : childSpacingStyle( i, { spacing: component.spacing, direction: buttonDirection }, scaleFactor, ); const hasSpacing = Object.keys(spacingStyle).length > 0; const childRawWidth = (resolvedChild as any).width ?? (resolvedChild as any).fixedWidth; const needsFlexShare = buttonDirection === "horizontal" && childRawWidth == null; const childNode = ( ); if (hasSpacing || needsFlexShare) { return ( {childNode} ); } return childNode; }), [resolvedChildren, component.direction, component.spacing, scaleFactor, onClose, disabled]); return ( [containerStyle, pressed && styles.pressed]} > {showLoading ? ( ) : ( {renderedChildren} )} ); }; const styles = StyleSheet.create({ pressed: { opacity: 0.7 }, flexShare: { flex: 1 }, });