diff --git a/example/src/components/Embedded/Embedded.styles.ts b/example/src/components/Embedded/Embedded.styles.ts index bd712bacf..a1e4f6257 100644 --- a/example/src/components/Embedded/Embedded.styles.ts +++ b/example/src/components/Embedded/Embedded.styles.ts @@ -1,6 +1,16 @@ import { StyleSheet } from 'react-native'; -import { button, buttonText, container, hr, link, input, title, subtitle } from '../../constants'; -import { utilityColors, backgroundColors } from '../../constants/styles/colors'; +import { + backgroundColors, + button, + buttonText, + colors, + container, + hr, + input, + subtitle, + title, + utilityColors, +} from '../../constants'; const styles = StyleSheet.create({ button, @@ -12,20 +22,10 @@ const styles = StyleSheet.create({ gap: 16, paddingHorizontal: 16, }, - embeddedTitle: { - fontSize: 16, - fontWeight: 'bold', - lineHeight: 20, - }, - embeddedTitleContainer: { - display: 'flex', - flexDirection: 'row', - }, hr, inputContainer: { marginVertical: 10, }, - link, subtitle: { ...subtitle, textAlign: 'center' }, text: { textAlign: 'center' }, textInput: input, @@ -33,6 +33,35 @@ const styles = StyleSheet.create({ utilitySection: { paddingHorizontal: 16, }, + viewTypeButton: { + alignItems: 'center', + borderColor: colors.brandCyan, + borderRadius: 8, + borderWidth: 2, + flex: 1, + paddingHorizontal: 12, + paddingVertical: 8, + }, + viewTypeButtonSelected: { + backgroundColor: colors.brandCyan, + }, + viewTypeButtonText: { + color: colors.brandCyan, + fontSize: 14, + fontWeight: '600', + }, + viewTypeButtonTextSelected: { + color: colors.backgroundPrimary, + }, + viewTypeButtons: { + flexDirection: 'row', + gap: 8, + justifyContent: 'space-around', + marginTop: 8, + }, + viewTypeSelector: { + marginVertical: 12, + }, warningContainer: { backgroundColor: backgroundColors.backgroundWarningSubtle, borderLeftColor: utilityColors.warning100, diff --git a/example/src/components/Embedded/Embedded.tsx b/example/src/components/Embedded/Embedded.tsx index bbbab436f..4ac18a6f4 100644 --- a/example/src/components/Embedded/Embedded.tsx +++ b/example/src/components/Embedded/Embedded.tsx @@ -8,8 +8,9 @@ import { import { useCallback, useState } from 'react'; import { Iterable, - type IterableAction, type IterableEmbeddedMessage, + IterableEmbeddedView, + IterableEmbeddedViewType, } from '@iterable/react-native-sdk'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -20,6 +21,8 @@ export const Embedded = () => { const [embeddedMessages, setEmbeddedMessages] = useState< IterableEmbeddedMessage[] >([]); + const [selectedViewType, setSelectedViewType] = + useState(IterableEmbeddedViewType.Banner); // Parse placement IDs from input const parsedPlacementIds = placementIdsInput @@ -52,35 +55,6 @@ export const Embedded = () => { }); }, [idsToFetch]); - const startEmbeddedImpression = useCallback( - (message: IterableEmbeddedMessage) => { - Iterable.embeddedManager.startImpression( - message.metadata.messageId, - // TODO: check if this should be changed to a number, as per the type - Number(message.metadata.placementId) - ); - }, - [] - ); - - const pauseEmbeddedImpression = useCallback( - (message: IterableEmbeddedMessage) => { - Iterable.embeddedManager.pauseImpression(message.metadata.messageId); - }, - [] - ); - - const handleClick = useCallback( - ( - message: IterableEmbeddedMessage, - buttonId: string | null, - action?: IterableAction | null - ) => { - Iterable.embeddedManager.handleClick(message, buttonId, action); - }, - [] - ); - return ( Embedded @@ -96,6 +70,69 @@ export const Embedded = () => { Enter placement IDs to fetch embedded messages + + Select View Type: + + + setSelectedViewType(IterableEmbeddedViewType.Banner) + } + > + + Banner + + + setSelectedViewType(IterableEmbeddedViewType.Card)} + > + + Card + + + + setSelectedViewType(IterableEmbeddedViewType.Notification) + } + > + + Notification + + + + Sync messages @@ -126,66 +163,11 @@ export const Embedded = () => { {embeddedMessages.map((message) => ( - - - Embedded message - - - startEmbeddedImpression(message)} - > - Start impression - - | - pauseEmbeddedImpression(message)} - > - Pause impression - - | - - handleClick(message, null, message.elements?.defaultAction) - } - > - Handle click - - - - metadata.messageId: {message.metadata.messageId} - metadata.placementId: {message.metadata.placementId} - elements.title: {message.elements?.title} - elements.body: {message.elements?.body} - - elements.defaultAction.data:{' '} - {message.elements?.defaultAction?.data} - - - elements.defaultAction.type:{' '} - {message.elements?.defaultAction?.type} - - {(message.elements?.buttons ?? []).map((button, buttonIndex) => ( - - - Button {buttonIndex + 1} - | - - handleClick(message, button.id, button.action) - } - > - Handle click - - - - button.id: {button.id} - button.title: {button.title} - button.action?.data: {button.action?.data} - button.action?.type: {button.action?.type} - - ))} - payload: {JSON.stringify(message.payload)} - + ))} diff --git a/src/embedded/components/IterableEmbeddedBanner.tsx b/src/embedded/components/IterableEmbeddedBanner.tsx new file mode 100644 index 000000000..56b4ca32b --- /dev/null +++ b/src/embedded/components/IterableEmbeddedBanner.tsx @@ -0,0 +1,19 @@ +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/IterableEmbeddedCard.tsx b/src/embedded/components/IterableEmbeddedCard.tsx new file mode 100644 index 000000000..87b2d1940 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedCard.tsx @@ -0,0 +1,18 @@ +import { View, Text } from 'react-native'; +import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; + +export const IterableEmbeddedCard = ({ + config, + message, + onButtonClick = () => {}, +}: IterableEmbeddedComponentProps) => { + console.log(`🚀 > IterableEmbeddedCard > config:`, config); + console.log(`🚀 > IterableEmbeddedCard > message:`, message); + console.log(`🚀 > IterableEmbeddedCard > onButtonClick:`, onButtonClick); + + return ( + + IterableEmbeddedCard + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedNotification.tsx b/src/embedded/components/IterableEmbeddedNotification.tsx new file mode 100644 index 000000000..686ea01e6 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification.tsx @@ -0,0 +1,22 @@ +import { View, Text } from 'react-native'; + +import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; + +export const IterableEmbeddedNotification = ({ + config, + message, + onButtonClick = () => {}, +}: IterableEmbeddedComponentProps) => { + console.log(`🚀 > IterableEmbeddedNotification > config:`, config); + console.log(`🚀 > IterableEmbeddedNotification > message:`, message); + console.log( + `🚀 > IterableEmbeddedNotification > onButtonClick:`, + onButtonClick + ); + + return ( + + IterableEmbeddedNotification + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedView.test.tsx b/src/embedded/components/IterableEmbeddedView.test.tsx new file mode 100644 index 000000000..4bcc47dcf --- /dev/null +++ b/src/embedded/components/IterableEmbeddedView.test.tsx @@ -0,0 +1,373 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render } from '@testing-library/react-native'; + +import { IterableEmbeddedViewType } from '../enums/IterableEmbeddedViewType'; +import { IterableEmbeddedView } from './IterableEmbeddedView'; +import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; +import { IterableEmbeddedCard } from './IterableEmbeddedCard'; +import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; + +// Mock the child components +jest.mock('./IterableEmbeddedBanner', () => ({ + IterableEmbeddedBanner: jest.fn(() => null), +})); + +jest.mock('./IterableEmbeddedCard', () => ({ + IterableEmbeddedCard: jest.fn(() => null), +})); + +jest.mock('./IterableEmbeddedNotification', () => ({ + IterableEmbeddedNotification: jest.fn(() => null), +})); + +describe('IterableEmbeddedView', () => { + const mockMessage = { + metadata: { + messageId: 'test-message-123', + campaignId: 123456, + placementId: 'test-placement', + }, + elements: { + title: 'Test Title', + body: 'Test Body', + }, + } as any; + + const mockConfig = { + backgroundColor: '#FFFFFF', + borderRadius: 8, + } as any; + + const mockOnButtonClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('View Type Rendering', () => { + it('should render IterableEmbeddedCard when viewType is Card', () => { + render( + + ); + + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + expect(IterableEmbeddedNotification).not.toHaveBeenCalled(); + }); + + it('should render IterableEmbeddedNotification when viewType is Notification', () => { + render( + + ); + + expect(IterableEmbeddedNotification).toHaveBeenCalledTimes(1); + expect(IterableEmbeddedCard).not.toHaveBeenCalled(); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + }); + + it('should render IterableEmbeddedBanner when viewType is Banner', () => { + render( + + ); + + expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); + expect(IterableEmbeddedCard).not.toHaveBeenCalled(); + expect(IterableEmbeddedNotification).not.toHaveBeenCalled(); + }); + + it('should render null for invalid viewType', () => { + render( + + ); + + expect(IterableEmbeddedCard).not.toHaveBeenCalled(); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + expect(IterableEmbeddedNotification).not.toHaveBeenCalled(); + }); + + it('should render null for undefined viewType', () => { + render( + + ); + + expect(IterableEmbeddedCard).not.toHaveBeenCalled(); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + expect(IterableEmbeddedNotification).not.toHaveBeenCalled(); + }); + }); + + describe('Props Passing', () => { + it('should pass message prop to Card component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + }); + }); + + it('should pass message prop to Banner component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedBanner as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + }); + }); + + it('should pass message prop to Notification component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedNotification as jest.Mock).mock + .calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + }); + }); + + it('should pass config prop to child component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + config: mockConfig, + }); + }); + + it('should pass onButtonClick prop to child component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + onButtonClick: mockOnButtonClick, + }); + }); + + it('should pass all props to child component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + config: mockConfig, + onButtonClick: mockOnButtonClick, + }); + }); + }); + + describe('Component Memoization', () => { + it('should memoize component selection based on viewType', () => { + const { rerender } = render( + + ); + + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + + // Re-render with same viewType but different message + const newMessage = { + ...mockMessage, + metadata: { + ...mockMessage.metadata, + messageId: 'different-id', + }, + }; + + rerender( + + ); + + // Should still render Card component (memoization means same component reference) + // Card should be called again with new props + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(2); + const lastCallArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[1][0]; + expect(lastCallArgs).toMatchObject({ + message: newMessage, + }); + }); + + it('should update component when viewType changes', () => { + const { rerender } = render( + + ); + + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + + // Re-render with different viewType + rerender( + + ); + + expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); + // Card was called only once (from initial render) + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + }); + }); + + describe('Edge Cases', () => { + it('should handle null config gracefully', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + config: null, + }); + }); + + it('should handle undefined config gracefully', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + config: undefined, + }); + }); + + it('should handle missing onButtonClick gracefully', () => { + render( + + ); + + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + }); + }); + + it('should handle numeric viewType values correctly', () => { + // Test with numeric value 0 (Banner) + render(); + expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); + + // Test with numeric value 1 (Card) + render(); + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); + + // Test with numeric value 2 (Notification) + render(); + expect(IterableEmbeddedNotification).toHaveBeenCalledTimes(1); + }); + }); + + describe('Component Type Verification', () => { + it('should render correct component type for each enum value', () => { + // Verify Banner enum value + const bannerResult = render( + + ); + expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); + bannerResult.unmount(); + + jest.clearAllMocks(); + + // Verify Card enum value + const cardResult = render( + + ); + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + cardResult.unmount(); + + jest.clearAllMocks(); + + // Verify Notification enum value + render( + + ); + expect(IterableEmbeddedNotification).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/embedded/components/IterableEmbeddedView.tsx b/src/embedded/components/IterableEmbeddedView.tsx new file mode 100644 index 000000000..c123c94b4 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedView.tsx @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; + +import { IterableEmbeddedViewType } from '../enums/IterableEmbeddedViewType'; + +import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; +import { IterableEmbeddedCard } from './IterableEmbeddedCard'; +import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; +import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; + +/** + * The props for the IterableEmbeddedView component. + */ +interface IterableEmbeddedViewProps extends IterableEmbeddedComponentProps { + /** The type of view to render. */ + viewType: IterableEmbeddedViewType; +} + +/** + * + * @param viewType - The type of view to render. + * @param message - The message to render. + * @param config - The config for the IterableEmbeddedView component, most likely used to style the view. + * @param onButtonClick - The function to call when a button is clicked. + * @returns The IterableEmbeddedView component. + * + * This component is used to render pre-created, customizable message displays + * included with Iterables RN SDK: cards, banners, and notifications. + */ +export const IterableEmbeddedView = ({ + viewType, + ...props +}: IterableEmbeddedViewProps) => { + const Cmp = useMemo(() => { + switch (viewType) { + case IterableEmbeddedViewType.Card: + return IterableEmbeddedCard; + case IterableEmbeddedViewType.Notification: + return IterableEmbeddedNotification; + case IterableEmbeddedViewType.Banner: + return IterableEmbeddedBanner; + default: + return null; + } + }, [viewType]); + + return Cmp ? : null; +}; diff --git a/src/embedded/components/index.ts b/src/embedded/components/index.ts new file mode 100644 index 000000000..15af78aba --- /dev/null +++ b/src/embedded/components/index.ts @@ -0,0 +1,4 @@ +export * from './IterableEmbeddedBanner'; +export * from './IterableEmbeddedCard'; +export * from './IterableEmbeddedNotification'; +export * from './IterableEmbeddedView'; diff --git a/src/embedded/enums/IterableEmbeddedViewType.ts b/src/embedded/enums/IterableEmbeddedViewType.ts new file mode 100644 index 000000000..90a0b5d7e --- /dev/null +++ b/src/embedded/enums/IterableEmbeddedViewType.ts @@ -0,0 +1,11 @@ +/** + * The view type for an embedded message. + */ +export enum IterableEmbeddedViewType { + /** The embedded view is a banner */ + Banner = 0, + /** The embedded view is a card */ + Card = 1, + /** The embedded view is a notification */ + Notification = 2, +} diff --git a/src/embedded/enums/index.ts b/src/embedded/enums/index.ts new file mode 100644 index 000000000..511ad021b --- /dev/null +++ b/src/embedded/enums/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedViewType'; diff --git a/src/embedded/index.ts b/src/embedded/index.ts index 15eb796c9..967e49dbe 100644 --- a/src/embedded/index.ts +++ b/src/embedded/index.ts @@ -1,2 +1,4 @@ export * from './classes'; +export * from './components'; +export * from './enums'; export * from './types'; diff --git a/src/embedded/types/IterableEmbeddedComponentProps.ts b/src/embedded/types/IterableEmbeddedComponentProps.ts new file mode 100644 index 000000000..9f2b17670 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedComponentProps.ts @@ -0,0 +1,9 @@ +import type { IterableEmbeddedMessage } from './IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from './IterableEmbeddedMessageElementsButton'; +import type { IterableEmbeddedViewConfig } from './IterableEmbeddedViewConfig'; + +export interface IterableEmbeddedComponentProps { + message: IterableEmbeddedMessage; + config?: IterableEmbeddedViewConfig | null; + onButtonClick?: (button: IterableEmbeddedMessageElementsButton) => void; +} diff --git a/src/embedded/types/IterableEmbeddedViewConfig.ts b/src/embedded/types/IterableEmbeddedViewConfig.ts new file mode 100644 index 000000000..6a41edd8a --- /dev/null +++ b/src/embedded/types/IterableEmbeddedViewConfig.ts @@ -0,0 +1,27 @@ +import type { ColorValue } from 'react-native'; + +/** + * Represents view-level styling configuration for an embedded view. + */ +export interface IterableEmbeddedViewConfig { + /** Background color hex (e.g., 0xFF0000) */ + backgroundColor?: ColorValue; + /** Border color hex */ + borderColor?: ColorValue; + /** Border width in pixels */ + borderWidth?: number; + /** Corner radius in points */ + borderCornerRadius?: number; + /** Primary button background color hex */ + primaryBtnBackgroundColor?: ColorValue; + /** Primary button text color hex */ + primaryBtnTextColor?: ColorValue; + /** Secondary button background color hex */ + secondaryBtnBackgroundColor?: ColorValue; + /** Secondary button text color hex */ + secondaryBtnTextColor?: ColorValue; + /** Title text color hex */ + titleTextColor?: ColorValue; + /** Body text color hex */ + bodyTextColor?: ColorValue; +} diff --git a/src/embedded/types/index.ts b/src/embedded/types/index.ts index 29b809ebf..66deee21e 100644 --- a/src/embedded/types/index.ts +++ b/src/embedded/types/index.ts @@ -1,5 +1,7 @@ +export * from './IterableEmbeddedComponentProps'; export * from './IterableEmbeddedMessage'; export * from './IterableEmbeddedMessageElements'; export * from './IterableEmbeddedMessageElementsButton'; export * from './IterableEmbeddedMessageElementsText'; export * from './IterableEmbeddedMessageMetadata'; +export * from './IterableEmbeddedViewConfig'; diff --git a/src/index.tsx b/src/index.tsx index 75c8489ec..b4ba8f5cf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,6 +32,17 @@ export type { IterableEdgeInsetDetails, IterableRetryPolicy, } from './core/types'; +export { + IterableEmbeddedManager, + IterableEmbeddedView, + IterableEmbeddedViewType, + type IterableEmbeddedComponentProps, + type IterableEmbeddedMessage, + type IterableEmbeddedMessageElements, + type IterableEmbeddedMessageElementsButton, + type IterableEmbeddedMessageElementsText, + type IterableEmbeddedViewConfig, +} from './embedded'; export { IterableHtmlInAppContent, IterableInAppCloseSource, @@ -59,7 +70,3 @@ export { type IterableInboxProps, type IterableInboxRowViewModel, } from './inbox'; -export { - IterableEmbeddedManager, - type IterableEmbeddedMessage, -} from './embedded';