import { LIQUID_VARIABLE_REGEX, SMART_TEXT_PATTERN, resolveLoopSource, type TComponent, } from '@namiml/sdk-core'; import { buildSmartTextReplacements, interpolate, interpolateSmartText } from './smartText'; const DEEP_SMART_TEXT_PRESERVE_KEYS = new Set([ 'components', 'productBaseComponents', 'productFeaturedComponents', 'collapseHeader', ]); function componentSmartTextSku(component: any): any { return component?.smartTextSku ?? component?.sku; } export function conditionComponentMatches( ctx: any, condition: any, sku: any = componentSmartTextSku(condition), ): boolean { if (condition.assertions) { return condition.assertions.every((test: any) => testObjectMatches(ctx, test, undefined, sku)); } if (condition.orAssertions) { return !condition.orAssertions.length || condition.orAssertions.some((test: any) => testObjectMatches(ctx, test, undefined, sku)); } return false; } export function withOverrides>( ctx: any, { conditionAttributes, ...component }: T, sku: any = componentSmartTextSku(component), ): T { if (!conditionAttributes) return component as T; return conditionAttributes.reduce((nextComponent: T, condition: any): T => { if (!conditionComponentMatches(ctx, condition, sku)) return nextComponent; const resolvedAttributes = Object.fromEntries( Object.entries(condition.attributes ?? {}).map(([key, value]) => [ key, valueFromSmartText(ctx, value, sku), ]), ); return { ...nextComponent, ...resolvedAttributes }; }, component as T); } export function valueFromSmartText(ctx: any, value: any, sku?: any, block: any = null): any { const replacements = buildSmartTextReplacements( ctx.state, ctx.flow, sku ?? ctx.state?.sku, block, ); if (typeof value === 'string' && value.includes(SMART_TEXT_PATTERN)) { return interpolateSmartText(value, replacements); } return value; } export function resolveComponentSmartText(component: any, ctx: any): any { const smartTextSku = componentSmartTextSku(component); const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, smartTextSku); const preservedEntries: Array<[string, any]> = []; const shallowComponent = Object.entries(component ?? {}).reduce((output, [key, value]) => { if (DEEP_SMART_TEXT_PRESERVE_KEYS.has(key)) { // Child component trees need to keep their original template strings so they can // be resolved later with their own component/SKU context. preservedEntries.push([key, value]); return output; } output[key] = valueFromSmartText(ctx, value, smartTextSku); return output; }, {} as Record); const resolvedComponent = interpolate(shallowComponent, replacements); preservedEntries.forEach(([key, value]) => { resolvedComponent[key] = value; }); return resolvedComponent; } export function expandRenderableComponents( ctx: any, components: TComponent[] | undefined, ): TComponent[] { return (components ?? []).flatMap((component) => expandRenderableComponent(ctx, component as TComponent), ); } function expandRenderableComponent(ctx: any, component: TComponent | undefined): TComponent[] { if (!component || (component as any).hidden) { return []; } const smartTextSku = componentSmartTextSku(component); if ((component as any).component === 'condition') { if (!conditionComponentMatches(ctx, component, smartTextSku)) { return []; } return expandRenderableComponents(ctx, (component as any).components); } let resolvedComponent: any = component; if ((component as any).conditionAttributes?.length) { resolvedComponent = withOverrides(ctx, resolvedComponent, smartTextSku); } resolvedComponent = resolveComponentSmartText(resolvedComponent, ctx); return resolvedComponent?.hidden ? [] : [resolvedComponent as TComponent]; } export function getRepeatingListBlocks(ctx: any, component: any): TComponent[][] { if (!component.loopSource) { return []; } const source = resolveLoopSource(component.loopSource); const filteredBlocks: any[] = (source ?? []).filter((block: any) => !component.loopSourceConditions?.length || component.loopSourceConditions.every((value: any) => testObjectMatches(ctx, value, block), ), ); const renderItem = (block: any): TComponent[] => interpolate(component.components, interpolate({ block }, { block })); const groupBy: string | undefined = component.groupBy; const headerTemplate: TComponent | undefined = component.groupHeaderTemplate; // Slots injected after the data pipeline (filter → group → cap). A slot is visible when its // (non-block) conditions all pass; it renders as a plain item block (no __namiGroupHeader // marker → no timeline dot). Unknown positions/ordinals are skipped (tolerant). const visibleSlots: any[] = (component.slots ?? []).filter( (slot: any) => !slot.conditions?.length || slot.conditions.every((condition: any) => testObjectMatches(ctx, condition, undefined)), ); const slotBlock = (slot: any): TComponent[] => [slot.template as TComponent]; const listStart = visibleSlots.filter((s) => s.position === 'listStart').map(slotBlock); const listEnd = visibleSlots.filter((s) => s.position === 'listEnd').map(slotBlock); if (!groupBy || !headerTemplate) { const cappedBlocks = component.max != null ? filteredBlocks.slice(0, component.max) : filteredBlocks; return [...listStart, ...cappedBlocks.map(renderItem), ...listEnd]; } const sectionOrder: string[] = []; const sections = new Map(); for (const block of filteredBlocks) { const resolved = interpolate(groupBy, interpolate({ block }, { block })); const key = resolved == null ? '' : String(resolved); if (!sections.has(key)) { sectionOrder.push(key); sections.set(key, []); } sections.get(key)!.push(block); } // Resolve groupStart/groupEnd target keys using the pre-cap filtered blocks: a group qualifies // if any item passes all itemConditions (block context); ordinal picks first/last qualifier. const groupQualifies = (slot: any, blocks: any[]): boolean => { const conditions = slot.group?.itemConditions ?? []; if (!conditions.length) return blocks.length > 0; return blocks.some((block) => conditions.every((c: any) => testObjectMatches(ctx, c, block))); }; const targetGroupKey = (slot: any): string | undefined => { const qualifying = sectionOrder.filter((key) => groupQualifies(slot, sections.get(key)!)); return slot.group?.ordinal === 'last' ? qualifying[qualifying.length - 1] : qualifying[0]; }; const groupStartByKey = new Map(); const groupEndByKey = new Map(); for (const slot of visibleSlots) { const byKey = slot.position === 'groupStart' ? groupStartByKey : slot.position === 'groupEnd' ? groupEndByKey : null; if (!byKey) continue; const key = targetGroupKey(slot); if (key == null) continue; if (!byKey.has(key)) byKey.set(key, []); byKey.get(key)!.push(slotBlock(slot)); } const result: TComponent[][] = [...listStart]; for (const key of sectionOrder) { const group = { key }; const header = interpolate(headerTemplate, interpolate({ group }, { group })) as any; header.__namiGroupHeader = true; result.push([header]); result.push(...(groupStartByKey.get(key) ?? [])); const groupItems = component.groupMax != null ? sections.get(key)!.slice(0, component.groupMax) : sections.get(key)!; for (const block of groupItems) { result.push(renderItem(block)); } result.push(...(groupEndByKey.get(key) ?? [])); } result.push(...listEnd); return result; } /** * Accessibility props for a collapse container's trigger (NAM-2528): a button * whose expanded/collapsed state is announced by VoiceOver/TalkBack. */ export function collapseHeaderA11yProps(isOpen: boolean) { return { accessibilityRole: 'button' as const, accessibilityState: { expanded: isOpen }, }; } /** * Accessibility props for a collapse container's body, which stays mounted at * maxHeight 0 while collapsed and must be hidden from assistive tech then. */ export function collapseBodyA11yProps(isOpen: boolean) { return { accessibilityElementsHidden: !isOpen, importantForAccessibility: (isOpen ? 'auto' : 'no-hide-descendants') as 'auto' | 'no-hide-descendants', }; } function testObjectMatches( ctx: any, { value: rawValue, expected, operator }: { value: any; expected: any; operator: string }, block?: any, sku?: any, ): boolean { let value = valueFromSmartText(ctx, rawValue, sku, block); expected = valueFromSmartText(ctx, expected, sku, block); if (operator === 'equals') return value === expected; if (operator === 'notEquals') return value !== expected; if (operator === 'contains') { if (typeof rawValue === 'string' && rawValue.includes('launch.productGroups')) { const launchGroups = ctx?.state?.launch?.productGroups; const fallbackGroups = Array.isArray(ctx?.state?.groups) ? ctx.state.groups.map((group: any) => group?.ref).filter(Boolean) : []; const unresolvedValue = value == null || value === '' || value === rawValue || (Array.isArray(value) && value.length === 0); if (unresolvedValue) { value = Array.isArray(launchGroups) && launchGroups.length ? launchGroups : fallbackGroups; } } if (!Array.isArray(value) && typeof value !== 'string' && !(value instanceof String)) { value = value?.toString?.(); } if (expected === 'collapse') { expected += `_${ctx.getCurrentCollapsibleSku?.()?.skuId ?? ''}`; } return value?.includes?.(expected); } if (operator === 'notContains') { if (!Array.isArray(value) && typeof value !== 'string' && !(value instanceof String)) { value = value?.toString?.(); } return !value?.includes?.(expected); } if (operator === 'set') { const present = value != null && value !== '' && !value.toString().match(LIQUID_VARIABLE_REGEX); if (typeof expected === 'boolean') { return present === expected; } return present; } if (operator === 'notSet') { return value == null || value === '' || !!value.toString().match(LIQUID_VARIABLE_REGEX); } if (operator === 'gt') return value > expected; if (operator === 'gte') return value >= expected; if (operator === 'lt') return value < expected; if (operator === 'lte') return value <= expected; return false; } /** * WCAG 1.3.1 (NAM-1451): title-type text is a structural heading. * Boolean heading only — RN cannot express heading levels. */ export function textAccessibilityRole( component: { textType?: string }, ): 'header' | undefined { return component.textType === 'title' ? 'header' : undefined; }