import React, { useEffect, useMemo, useRef, useState } from 'react'; import { View, ImageBackground, Pressable, StyleSheet } from 'react-native'; import { usePaywallContext } from '../../context/PaywallContext'; import { applyStyles, childSpacingStyle, focusedStyleOverrides, parseSizeOrPercent, resolveFillImageUrl, } from '../../utils/styles'; import { handleAction } from '../../utils/actionHandler'; import { buildSmartTextReplacements, interpolateSmartText } from '../../utils/smartText'; import { TemplateRenderer } from '../TemplateRenderer'; import type { TComponent } from '@namiml/sdk-core'; import { parseToSemver } from '@namiml/sdk-core'; import { FocusScope, useFocusableState, useRegisterPreferredFocus } from '../../context/FocusContext'; import { expandRenderableComponents } from '../../utils/rendering'; import { useTVPreferredFocus } from '../../utils/tvFocus'; interface Props { component: any; scaleFactor: number; onClose?: () => void; parentDirection?: string; parentCrossAxisFitContent?: boolean; } export const NamiContainer: React.FC = ({ component, scaleFactor, onClose, parentDirection, parentCrossAxisFitContent = false, }) => { const ctx = usePaywallContext(); const { isFocused: selfFocused, onFocus, onBlur } = useFocusableState(Boolean(component.focused)); const wrapperRef = useRef(null); const resolvedChildren = useMemo( () => expandRenderableComponents(ctx, component.components), [ctx, component.components], ); useTVPreferredFocus( wrapperRef, Boolean(component.onTap && component.focused), ); useRegisterPreferredFocus( wrapperRef.current, Boolean(component.onTap && component.focused), ); const initialFocusWithin = useMemo( () => hasInitiallyFocusedDescendant(resolvedChildren), [resolvedChildren], ); const [focusWithin, setFocusWithin] = useState(initialFocusWithin); const isFocused = selfFocused || focusWithin || initialFocusWithin; useEffect(() => { if (initialFocusWithin) { setFocusWithin(true); } }, [initialFocusWithin]); const containerStyle = useMemo( () => { const base = applyStyles(component, scaleFactor, false, parentDirection); const rawHeight = component.height ?? component.fixedHeight; const componentId = String(component.id ?? ''); const componentType = String(component.namiComponentType ?? ''); const isDividerLike = componentType === 'divider' || /^divider/i.test(componentId); if ( parentDirection === 'horizontal' && parentCrossAxisFitContent && rawHeight === '100%' && !isDividerLike ) { delete (base as any).height; } return base; }, [component, scaleFactor, parentDirection, parentCrossAxisFitContent] ); const direction = component.direction ?? 'vertical'; const parentRawHeight = component.height ?? component.fixedHeight; const minSdk = parseToSemver(ctx.state.selectedPaywall?.template?.min_sdk_version ?? '0.0.0'); const shouldSizeChildren = minSdk.minor < 2; const sizeKey = direction === 'vertical' ? 'width' : 'height'; const shouldApplyLegacyChildSizing = shouldSizeChildren && direction !== 'horizontal'; const children = useMemo(() => resolvedChildren.map((child: TComponent, i: number) => { const spacingStyle = i === 0 ? {} : childSpacingStyle(i, { spacing: component.spacing, direction }, scaleFactor); const hasSpacing = Object.keys(spacingStyle).length > 0; const childRawWidth = (child as any).width ?? (child as any).fixedWidth; const childRawHeight = (child as any).height ?? (child as any).fixedHeight; const childType = (child as any).component; const childId = String((child as any).id ?? ''); const childNamiType = String((child as any).namiComponentType ?? ''); const isDividerLike = childNamiType === 'divider' || /^divider/i.test(childId); const conditionFirstChild = childType === 'condition' ? (child as any).components?.[0] : undefined; const resolvedChildWidth = childRawWidth ?? conditionFirstChild?.width ?? conditionFirstChild?.fixedWidth; const resolvedChildHeight = childRawHeight ?? conditionFirstChild?.height ?? conditionFirstChild?.fixedHeight; const needsFlexShare = direction === 'horizontal' && resolvedChildWidth == null; const shouldShrinkWrapper = direction === 'horizontal' && ( resolvedChildWidth == null || (typeof resolvedChildWidth === 'string' && resolvedChildWidth.trim().endsWith('%')) ); const hasExplicitPercentWidthSibling = direction === 'horizontal' && resolvedChildren.some((candidate, candidateIndex) => { if (candidateIndex === i) return false; const candidateRawWidth = (candidate as any).width ?? (candidate as any).fixedWidth ?? ((candidate as any).component === 'condition' ? (candidate as any).components?.[0]?.width ?? (candidate as any).components?.[0]?.fixedWidth : undefined); return typeof candidateRawWidth === 'string' && candidateRawWidth.trim().endsWith('%'); }); const shouldShareFullWidthColumn = direction === 'horizontal' && typeof resolvedChildWidth === 'string' && resolvedChildWidth.trim() === '100%' && !hasExplicitPercentWidthSibling; const wrapWidth = parseSizeOrPercent(resolvedChildWidth, scaleFactor); const wrapHeight = parseSizeOrPercent(resolvedChildHeight, scaleFactor); const shouldStretchDivider = direction === 'horizontal' && isDividerLike && resolvedChildHeight === '100%'; const shouldIgnoreCrossAxisFullHeight = direction === 'horizontal' && parentRawHeight === 'fitContent' && resolvedChildHeight === '100%' && !isDividerLike; const wrapperSizeStyle = { ...(!shouldShareFullWidthColumn && wrapWidth != null ? { width: wrapWidth as any } : {}), ...(!shouldStretchDivider && !shouldIgnoreCrossAxisFullHeight && wrapHeight != null ? { height: wrapHeight as any } : {}), ...(shouldStretchDivider ? { alignSelf: 'stretch' as const, minHeight: 1 } : {}), }; const childNode = ( ); const shouldWrap = shouldSizeChildren || needsFlexShare || hasSpacing || direction === 'horizontal'; if (shouldWrap) { return ( {childNode} ); } return childNode; }), [resolvedChildren, component.spacing, direction, scaleFactor, sizeKey, shouldApplyLegacyChildSizing, onClose, parentRawHeight]); const fillImageUrl = resolveFillImageUrl(component.fillImage) ? (() => { const replacements = buildSmartTextReplacements( ctx.state, ctx.flow, component?.smartTextSku ?? component?.sku ); const raw = resolveFillImageUrl(component.fillImage) as string; const resolved = interpolateSmartText(raw, replacements); return resolved == null ? undefined : String(resolved); })() : undefined; const borderOverride = useMemo(() => { if (!isFocused) return {}; return focusedStyleOverrides(component, scaleFactor); }, [component, isFocused, scaleFactor]); const Wrapper = component.onTap ? Pressable : View; const radiusStyles = pickBorderRadius(containerStyle); // Only clip a filled "tile" with specific (non-all-corners) rounding so a moved // image is cut off at the tile edge (NAM-1798). All-corners / unrounded filled // containers stay unclipped so their overflowing content is not cut off and the // NC-3948 / NAM-1201 over-rounding is not reintroduced. Matches the Apple/Web gate. const roundBorders = component.roundBorders; const hasSpecificCorners = Array.isArray(roundBorders) && roundBorders.length > 0 && roundBorders.length < 4; const shouldClip = Boolean(component.fillColor || component.fillImage) && hasSpecificCorners; const wrapperStyle = [ styles.relative, containerStyle, borderOverride, shouldClip ? styles.clip : null, ]; const wrapperPropsFinal = component.onTap ? { onPress: () => handleAction({ onTap: component.onTap, ctx, onClose, componentChange: { id: component.id, name: component.title, }, }), onFocus, onBlur, ref: wrapperRef, hasTVPreferredFocus: Boolean(component.focused), style: ({ pressed }: any) => [wrapperStyle, pressed && styles.pressed], } : { style: wrapperStyle }; if (fillImageUrl) { return ( 0 ? radiusStyles : undefined} resizeMode={ component.fillImage?.imageCropping === 'fit' ? 'contain' : 'cover' } /> {children} ); } return ( {children} ); }; const styles = StyleSheet.create({ pressed: { opacity: 0.7 }, relative: { position: 'relative' }, clip: { overflow: 'hidden' }, flexShare: { flex: 1 }, horizontalShrink: { minWidth: 0, flexShrink: 1 }, rowPercentColumn: { flexGrow: 1, flexBasis: 0, minWidth: 0 }, }); function pickBorderRadius(style: any): Record { if (!style) return {}; const keys = [ 'borderRadius', 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius', ]; return keys.reduce((acc, key) => { const value = style[key]; if (typeof value === 'number') (acc as any)[key] = value; return acc; }, {} as Record); } function hasInitiallyFocusedDescendant(components: TComponent[] = []): boolean { for (const component of components) { if ((component as any)?.focused) { return true; } const childGroups = [ (component as any)?.components, (component as any)?.collapseHeader, (component as any)?.productBaseComponents, (component as any)?.productFeaturedComponents, ]; for (const group of childGroups) { if (Array.isArray(group) && hasInitiallyFocusedDescendant(group as TComponent[])) { return true; } } } return false; }