diff --git a/src/embedded/components/IterableEmbeddedBanner.tsx b/src/embedded/components/IterableEmbeddedBanner.tsx
deleted file mode 100644
index 56b4ca32b..000000000
--- a/src/embedded/components/IterableEmbeddedBanner.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { View, Text } from 'react-native';
-
-import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps';
-
-export const IterableEmbeddedBanner = ({
- config,
- message,
- onButtonClick = () => {},
-}: IterableEmbeddedComponentProps) => {
- console.log(`🚀 > IterableEmbeddedBanner > config:`, config);
- console.log(`🚀 > IterableEmbeddedBanner > message:`, message);
- console.log(`🚀 > IterableEmbeddedBanner > onButtonClick:`, onButtonClick);
-
- return (
-
- IterableEmbeddedBanner
-
- );
-};
diff --git a/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.ts b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.ts
new file mode 100644
index 000000000..89c159f76
--- /dev/null
+++ b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.ts
@@ -0,0 +1,82 @@
+import { StyleSheet, Platform } from 'react-native';
+
+// See https://support.iterable.com/hc/en-us/articles/23230946708244-Out-of-the-Box-Views-for-Embedded-Messages#banners
+export const IMAGE_HEIGHT = Platform.OS === 'android' ? 80 : 100;
+export const IMAGE_WIDTH = Platform.OS === 'android' ? 80 : 100;
+
+export const styles = StyleSheet.create({
+ body: {
+ alignSelf: 'stretch',
+ fontSize: 14,
+ fontWeight: '400',
+ lineHeight: 20,
+ },
+ bodyContainer: {
+ alignItems: 'center',
+ alignSelf: 'stretch',
+ display: 'flex',
+ flexDirection: 'row',
+ paddingTop: 4,
+ },
+ button: {
+ borderRadius: 32,
+ gap: 8,
+ },
+ buttonContainer: {
+ alignItems: 'flex-start',
+ alignSelf: 'stretch',
+ display: 'flex',
+ flexDirection: 'row',
+ gap: 12,
+ width: '100%',
+ },
+ buttonText: {
+ fontSize: 14,
+ fontWeight: '400',
+ lineHeight: 20,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ },
+ container: {
+ alignItems: 'flex-start',
+ borderStyle: 'solid',
+ boxShadow:
+ '0 1px 1px 0 rgba(0, 0, 0, 0.06), 0 0 2px 0 rgba(0, 0, 0, 0.06), 0 0 1px 0 rgba(0, 0, 0, 0.08)',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 16,
+ justifyContent: 'center',
+ padding: 16,
+ width: '100%',
+ },
+ mediaContainer: {
+ alignItems: 'flex-start',
+ alignSelf: 'stretch',
+ display: 'flex',
+ flexDirection: 'row',
+ },
+ mediaImage: {
+ borderRadius: 6,
+ borderStyle: 'solid',
+ borderWidth: 1,
+ height: IMAGE_HEIGHT,
+ paddingHorizontal: 0,
+ paddingVertical: 0,
+ width: IMAGE_WIDTH,
+ },
+ textContainer: {
+ alignSelf: 'center',
+ display: 'flex',
+ flexDirection: 'column',
+ flexGrow: 1,
+ flexShrink: 1,
+ gap: 4,
+ width: '100%',
+ },
+ title: {
+ fontSize: 16,
+ fontWeight: '700',
+ lineHeight: 16,
+ paddingBottom: 4,
+ },
+});
diff --git a/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.test.tsx b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.test.tsx
new file mode 100644
index 000000000..61657be68
--- /dev/null
+++ b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.test.tsx
@@ -0,0 +1,372 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { fireEvent, render } from '@testing-library/react-native';
+
+import { IterableEmbeddedViewType } from '../../enums/IterableEmbeddedViewType';
+import { useEmbeddedView } from '../../hooks/useEmbeddedView';
+import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage';
+import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton';
+import { IterableEmbeddedBanner } from './IterableEmbeddedBanner';
+
+const mockHandleButtonClick = jest.fn();
+const mockHandleMessageClick = jest.fn();
+
+jest.mock('../../hooks/useEmbeddedView', () => ({
+ useEmbeddedView: jest.fn(),
+}));
+
+const mockUseEmbeddedView = useEmbeddedView as jest.MockedFunction<
+ typeof useEmbeddedView
+>;
+
+const defaultParsedStyles = {
+ backgroundColor: '#ffffff',
+ borderColor: '#E0DEDF',
+ borderCornerRadius: 8,
+ borderWidth: 1,
+ primaryBtnBackgroundColor: '#6A266D',
+ primaryBtnTextColor: '#ffffff',
+ secondaryBtnBackgroundColor: 'transparent',
+ secondaryBtnTextColor: '#79347F',
+ titleTextColor: '#3D3A3B',
+ bodyTextColor: '#787174',
+};
+
+function mockUseEmbeddedViewReturn(
+ overrides: Partial> = {}
+) {
+ mockUseEmbeddedView.mockReturnValue({
+ parsedStyles: defaultParsedStyles,
+ handleButtonClick: mockHandleButtonClick,
+ handleMessageClick: mockHandleMessageClick,
+ media: { url: null, caption: null, shouldShow: false },
+ ...overrides,
+ });
+}
+
+describe('IterableEmbeddedBanner', () => {
+ const baseMessage: IterableEmbeddedMessage = {
+ metadata: {
+ messageId: 'msg-1',
+ campaignId: 1,
+ placementId: 1,
+ },
+ elements: {
+ title: 'Banner Title',
+ body: 'Banner body text.',
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseEmbeddedViewReturn();
+ });
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { getByText } = render(
+
+ );
+ expect(getByText('Banner Title')).toBeTruthy();
+ expect(getByText('Banner body text.')).toBeTruthy();
+ });
+
+ it('should render title and body from message.elements', () => {
+ const message: IterableEmbeddedMessage = {
+ ...baseMessage,
+ elements: {
+ title: 'Custom Banner Title',
+ body: 'Custom banner body.',
+ },
+ };
+ const { getByText } = render(
+
+ );
+ expect(getByText('Custom Banner Title')).toBeTruthy();
+ expect(getByText('Custom banner body.')).toBeTruthy();
+ });
+
+ it('should apply parsedStyles to container and text', () => {
+ const customStyles = {
+ ...defaultParsedStyles,
+ backgroundColor: '#000000',
+ titleTextColor: '#ff0000',
+ bodyTextColor: '#00ff00',
+ };
+ mockUseEmbeddedViewReturn({ parsedStyles: customStyles });
+
+ const { getByText, UNSAFE_getAllByType } = render(
+
+ );
+
+ const views = UNSAFE_getAllByType('View' as any);
+ const styleArray = (s: any) => (Array.isArray(s) ? s : [s]);
+ const container = views.find(
+ (v: any) =>
+ v.props.style &&
+ styleArray(v.props.style).some(
+ (sty: any) => sty && sty.backgroundColor === '#000000'
+ )
+ );
+ expect(container).toBeTruthy();
+ expect(styleArray(container!.props.style)).toEqual(
+ expect.arrayContaining([
+ expect.any(Object),
+ expect.objectContaining({
+ backgroundColor: '#000000',
+ borderColor: customStyles.borderColor,
+ borderRadius: customStyles.borderCornerRadius,
+ borderWidth: customStyles.borderWidth,
+ }),
+ ])
+ );
+
+ const title = getByText('Banner Title');
+ const body = getByText('Banner body text.');
+ expect(title.props.style).toEqual(
+ expect.arrayContaining([
+ expect.any(Object),
+ expect.objectContaining({ color: '#ff0000' }),
+ ])
+ );
+ expect(body.props.style).toEqual(
+ expect.arrayContaining([
+ expect.any(Object),
+ expect.objectContaining({ color: '#00ff00' }),
+ ])
+ );
+ });
+
+ it('should not render button container when message has no buttons', () => {
+ const message: IterableEmbeddedMessage = {
+ ...baseMessage,
+ elements: { ...baseMessage.elements, buttons: undefined },
+ };
+ const { queryByText } = render(
+
+ );
+ expect(queryByText('Primary')).toBeNull();
+ });
+
+ it('should not render button container when buttons array is empty', () => {
+ const message: IterableEmbeddedMessage = {
+ ...baseMessage,
+ elements: { ...baseMessage.elements, buttons: [] },
+ };
+ const { queryByText } = render(
+
+ );
+ expect(queryByText('Primary')).toBeNull();
+ });
+ });
+
+ describe('Buttons', () => {
+ const primaryButton: IterableEmbeddedMessageElementsButton = {
+ id: 'btn-primary',
+ title: 'Primary',
+ action: { type: 'openUrl', data: 'https://example.com' },
+ };
+ const secondaryButton: IterableEmbeddedMessageElementsButton = {
+ id: 'btn-secondary',
+ title: 'Secondary',
+ };
+
+ it('should render buttons when message has buttons', () => {
+ const message: IterableEmbeddedMessage = {
+ ...baseMessage,
+ elements: {
+ ...baseMessage.elements,
+ buttons: [primaryButton, secondaryButton],
+ },
+ };
+ const { getByText } = render(
+
+ );
+ expect(getByText('Primary')).toBeTruthy();
+ expect(getByText('Secondary')).toBeTruthy();
+ });
+
+ it('should apply primary and secondary button text colors from parsedStyles', () => {
+ const message: IterableEmbeddedMessage = {
+ ...baseMessage,
+ elements: {
+ ...baseMessage.elements,
+ buttons: [primaryButton, secondaryButton],
+ },
+ };
+ const { getByText } = render(
+
+ );
+
+ const primaryText = getByText('Primary');
+ const secondaryText = getByText('Secondary');
+ expect(primaryText.props.style).toEqual(
+ expect.arrayContaining([
+ expect.any(Object),
+ expect.objectContaining({
+ color: defaultParsedStyles.primaryBtnTextColor,
+ }),
+ ])
+ );
+ expect(secondaryText.props.style).toEqual(
+ expect.arrayContaining([
+ expect.any(Object),
+ expect.objectContaining({
+ color: defaultParsedStyles.secondaryBtnTextColor,
+ }),
+ ])
+ );
+ });
+
+ it('should call handleButtonClick with correct button when button is pressed', () => {
+ const message: IterableEmbeddedMessage = {
+ ...baseMessage,
+ elements: {
+ ...baseMessage.elements,
+ buttons: [primaryButton, secondaryButton],
+ },
+ };
+ const { getByText } = render(
+
+ );
+
+ fireEvent.press(getByText('Primary'));
+ expect(mockHandleButtonClick).toHaveBeenCalledTimes(1);
+ expect(mockHandleButtonClick).toHaveBeenCalledWith(primaryButton);
+
+ fireEvent.press(getByText('Secondary'));
+ expect(mockHandleButtonClick).toHaveBeenCalledTimes(2);
+ expect(mockHandleButtonClick).toHaveBeenLastCalledWith(secondaryButton);
+ });
+ });
+
+ describe('Media', () => {
+ it('should not render media when media.shouldShow is false', () => {
+ const { UNSAFE_queryAllByType } = render(
+
+ );
+ const images = UNSAFE_queryAllByType('Image' as any);
+ expect(images.length).toBe(0);
+ });
+
+ it('should render media image when media.shouldShow is true', () => {
+ const media = {
+ url: 'https://example.com/image.png',
+ caption: 'Banner image',
+ shouldShow: true,
+ };
+ mockUseEmbeddedViewReturn({ media });
+
+ const { UNSAFE_queryAllByType } = render(
+
+ );
+
+ const images = UNSAFE_queryAllByType('Image' as any);
+ expect(images.length).toBeGreaterThan(0);
+ expect((images[0] as any).props.source.uri).toBe(media.url);
+ });
+ });
+
+ describe('Message click', () => {
+ it('should call handleMessageClick when banner is pressed', () => {
+ const { getByText } = render(
+
+ );
+
+ fireEvent.press(getByText('Banner Title'));
+ expect(mockHandleMessageClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('useEmbeddedView integration', () => {
+ it('should call useEmbeddedView with Banner viewType and props', () => {
+ const config = { backgroundColor: '#abc' } as any;
+ const onButtonClick = jest.fn();
+ const onMessageClick = jest.fn();
+
+ render(
+
+ );
+
+ expect(mockUseEmbeddedView).toHaveBeenCalledTimes(1);
+ expect(mockUseEmbeddedView).toHaveBeenCalledWith(
+ IterableEmbeddedViewType.Banner,
+ {
+ message: baseMessage,
+ config,
+ onButtonClick,
+ onMessageClick,
+ }
+ );
+ });
+
+ it('should call useEmbeddedView with default callbacks when not provided', () => {
+ render();
+
+ expect(mockUseEmbeddedView).toHaveBeenCalledWith(
+ IterableEmbeddedViewType.Banner,
+ expect.objectContaining({
+ message: baseMessage,
+ onButtonClick: expect.any(Function),
+ onMessageClick: expect.any(Function),
+ })
+ );
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle message with missing elements', () => {
+ const message: IterableEmbeddedMessage = {
+ metadata: baseMessage.metadata,
+ elements: undefined,
+ };
+ const { queryByText } = render(
+
+ );
+ expect(queryByText('Banner Title')).toBeNull();
+ expect(queryByText('Banner body text.')).toBeNull();
+ });
+
+ it('should handle message with empty title and body without throwing', () => {
+ const message: IterableEmbeddedMessage = {
+ ...baseMessage,
+ elements: { title: '', body: '' },
+ };
+ const { getAllByText } = render(
+
+ );
+ const emptyTextNodes = getAllByText('');
+ expect(emptyTextNodes.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('should render multiple buttons and call handleButtonClick with correct button for each', () => {
+ const message: IterableEmbeddedMessage = {
+ ...baseMessage,
+ elements: {
+ ...baseMessage.elements,
+ buttons: [
+ { id: 'unique-id-1', title: 'First' },
+ { id: 'unique-id-2', title: 'Second' },
+ ],
+ },
+ };
+ const { getByText } = render(
+
+ );
+ expect(getByText('First')).toBeTruthy();
+ expect(getByText('Second')).toBeTruthy();
+ fireEvent.press(getByText('First'));
+ expect(mockHandleButtonClick).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'unique-id-1', title: 'First' })
+ );
+ fireEvent.press(getByText('Second'));
+ expect(mockHandleButtonClick).toHaveBeenLastCalledWith(
+ expect.objectContaining({ id: 'unique-id-2', title: 'Second' })
+ );
+ });
+ });
+});
diff --git a/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx
new file mode 100644
index 000000000..c5892e04c
--- /dev/null
+++ b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx
@@ -0,0 +1,130 @@
+import {
+ Image,
+ Text,
+ TouchableOpacity,
+ View,
+ type TextStyle,
+ type ViewStyle,
+ PixelRatio,
+ Pressable,
+} from 'react-native';
+
+import { IterableEmbeddedViewType } from '../../enums';
+import { useEmbeddedView } from '../../hooks/useEmbeddedView';
+import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps';
+import {
+ styles,
+ IMAGE_HEIGHT,
+ IMAGE_WIDTH,
+} from './IterableEmbeddedBanner.styles';
+
+/**
+ * TODO: figure out how default action works.
+ */
+
+export const IterableEmbeddedBanner = ({
+ config,
+ message,
+ onButtonClick = () => {},
+ onMessageClick = () => {},
+}: IterableEmbeddedComponentProps) => {
+ const { parsedStyles, media, handleButtonClick, handleMessageClick } =
+ useEmbeddedView(IterableEmbeddedViewType.Banner, {
+ message,
+ config,
+ onButtonClick,
+ onMessageClick,
+ });
+
+ const buttons = message.elements?.buttons ?? [];
+
+ return (
+ handleMessageClick()}>
+
+ {}
+
+
+
+ {message.elements?.title}
+
+
+ {message.elements?.body}
+
+
+ {media.shouldShow && (
+
+
+
+ )}
+
+ {buttons.length > 0 && (
+
+ {buttons.map((button, index) => {
+ const backgroundColor =
+ index === 0
+ ? parsedStyles.primaryBtnBackgroundColor
+ : parsedStyles.secondaryBtnBackgroundColor;
+ const textColor =
+ index === 0
+ ? parsedStyles.primaryBtnTextColor
+ : parsedStyles.secondaryBtnTextColor;
+ return (
+ handleButtonClick(button)}
+ key={button.id}
+ >
+
+ {button.title}
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+};
diff --git a/src/embedded/components/IterableEmbeddedBanner/index.ts b/src/embedded/components/IterableEmbeddedBanner/index.ts
new file mode 100644
index 000000000..39ed47189
--- /dev/null
+++ b/src/embedded/components/IterableEmbeddedBanner/index.ts
@@ -0,0 +1,2 @@
+export * from './IterableEmbeddedBanner';
+export { IterableEmbeddedBanner as default } from './IterableEmbeddedBanner';