diff --git a/example/src/components/Embedded/Embedded.styles.ts b/example/src/components/Embedded/Embedded.styles.ts index a1e4f6257..a1fb26b7e 100644 --- a/example/src/components/Embedded/Embedded.styles.ts +++ b/example/src/components/Embedded/Embedded.styles.ts @@ -7,9 +7,13 @@ import { container, hr, input, + modalButton, + modalButtons, + modalContent, + modalOverlay, subtitle, title, - utilityColors, + utilityColors } from '../../constants'; const styles = StyleSheet.create({ @@ -26,6 +30,15 @@ const styles = StyleSheet.create({ inputContainer: { marginVertical: 10, }, + jsonEditor: { + ...input, + fontSize: 12, + height: 220, + }, + modalButton, + modalButtons, + modalContent, + modalOverlay, subtitle: { ...subtitle, textAlign: 'center' }, text: { textAlign: 'center' }, textInput: input, diff --git a/example/src/components/Embedded/Embedded.tsx b/example/src/components/Embedded/Embedded.tsx index 4ac18a6f4..189293d37 100644 --- a/example/src/components/Embedded/Embedded.tsx +++ b/example/src/components/Embedded/Embedded.tsx @@ -1,4 +1,6 @@ import { + Alert, + Modal, ScrollView, Text, TextInput, @@ -9,6 +11,7 @@ import { useCallback, useState } from 'react'; import { Iterable, type IterableEmbeddedMessage, + type IterableEmbeddedViewConfig, IterableEmbeddedView, IterableEmbeddedViewType, } from '@iterable/react-native-sdk'; @@ -16,6 +19,9 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import styles from './Embedded.styles'; +const DEFAULT_CONFIG_JSON = `{ +}`; + export const Embedded = () => { const [placementIdsInput, setPlacementIdsInput] = useState(''); const [embeddedMessages, setEmbeddedMessages] = useState< @@ -23,6 +29,10 @@ export const Embedded = () => { >([]); const [selectedViewType, setSelectedViewType] = useState(IterableEmbeddedViewType.Banner); + const [viewConfig, setViewConfig] = + useState(null); + const [configEditorVisible, setConfigEditorVisible] = useState(false); + const [configJson, setConfigJson] = useState(DEFAULT_CONFIG_JSON); // Parse placement IDs from input const parsedPlacementIds = placementIdsInput @@ -55,6 +65,27 @@ export const Embedded = () => { }); }, [idsToFetch]); + const openConfigEditor = useCallback(() => { + setConfigJson( + viewConfig ? JSON.stringify(viewConfig, null, 2) : DEFAULT_CONFIG_JSON + ); + setConfigEditorVisible(true); + }, [viewConfig]); + + const applyConfig = useCallback(() => { + try { + const parsed = JSON.parse(configJson) as IterableEmbeddedViewConfig; + setViewConfig(parsed); + setConfigEditorVisible(false); + } catch { + Alert.alert('Error', 'Invalid JSON'); + } + }, [configJson]); + + const closeConfigEditor = useCallback(() => { + setConfigEditorVisible(false); + }, []); + return ( Embedded @@ -142,6 +173,9 @@ export const Embedded = () => { End session + + Set view config + Placement IDs (comma-separated): { + + + + + + + Cancel + + + Apply + + + + + @@ -167,6 +235,7 @@ export const Embedded = () => { key={message.metadata.messageId} viewType={selectedViewType} message={message} + config={viewConfig} /> ))} diff --git a/example/src/constants/styles/index.ts b/example/src/constants/styles/index.ts index b8c3bac5e..225ee4903 100644 --- a/example/src/constants/styles/index.ts +++ b/example/src/constants/styles/index.ts @@ -2,5 +2,6 @@ export * from './colors'; export * from './containers'; export * from './formElements'; export * from './miscElements'; +export * from './modal'; export * from './shadows'; export * from './typography'; diff --git a/example/src/constants/styles/modal.ts b/example/src/constants/styles/modal.ts new file mode 100644 index 000000000..07f513a96 --- /dev/null +++ b/example/src/constants/styles/modal.ts @@ -0,0 +1,43 @@ +import type { TextStyle, ViewStyle } from "react-native"; +import { colors } from "./colors"; + +export const modalTitle: TextStyle = { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + textAlign: 'center', +}; + +export const modalOverlay: ViewStyle = { + backgroundColor: 'rgba(0,0,0,0.5)', + flex: 1, + justifyContent: 'center', + padding: 20, +}; + +export const modalContent: ViewStyle = { + backgroundColor: colors.backgroundPrimary, + borderRadius: 12, + maxHeight: '80%', + padding: 16, +}; + +export const modalButtons: ViewStyle = { + flexDirection: 'row', + gap: 12, + justifyContent: 'flex-end', +}; + +export const modalButton: ViewStyle = { + flex: 1, +}; + +export const modalButtonText: TextStyle = { + color: colors.brandCyan, + fontSize: 14, + fontWeight: '600', +}; + +export const modalButtonTextSelected: TextStyle = { + color: colors.backgroundPrimary, +}; diff --git a/src/embedded/components/IterableEmbeddedView.tsx b/src/embedded/components/IterableEmbeddedView.tsx index c123c94b4..7234e3904 100644 --- a/src/embedded/components/IterableEmbeddedView.tsx +++ b/src/embedded/components/IterableEmbeddedView.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { View, Text } from 'react-native'; import { IterableEmbeddedViewType } from '../enums/IterableEmbeddedViewType'; @@ -6,6 +7,7 @@ import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; import { IterableEmbeddedCard } from './IterableEmbeddedCard'; import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; +import { useEmbeddedView } from '../hooks/useEmbeddedView/useEmbeddedView'; /** * The props for the IterableEmbeddedView component. @@ -43,5 +45,41 @@ export const IterableEmbeddedView = ({ } }, [viewType]); - return Cmp ? : null; + const { parsedStyles } = useEmbeddedView(viewType, props); + + return Cmp ? ( + + + parsedStyles.backgroundColor: {String(parsedStyles.backgroundColor)} + + parsedStyles.borderColor: {String(parsedStyles.borderColor)} + parsedStyles.borderWidth: {parsedStyles.borderWidth} + + parsedStyles.borderCornerRadius: {parsedStyles.borderCornerRadius} + + + parsedStyles.primaryBtnBackgroundColor:{' '} + {String(parsedStyles.primaryBtnBackgroundColor)} + + + parsedStyles.primaryBtnTextColor:{' '} + {String(parsedStyles.primaryBtnTextColor)} + + + parsedStyles.secondaryBtnBackgroundColor:{' '} + {String(parsedStyles.secondaryBtnBackgroundColor)} + + + parsedStyles.secondaryBtnTextColor:{' '} + {String(parsedStyles.secondaryBtnTextColor)} + + + parsedStyles.titleTextColor: {String(parsedStyles.titleTextColor)} + + + parsedStyles.bodyTextColor: {String(parsedStyles.bodyTextColor)} + + + + ) : null; }; diff --git a/src/embedded/hooks/index.ts b/src/embedded/hooks/index.ts new file mode 100644 index 000000000..cbca753d9 --- /dev/null +++ b/src/embedded/hooks/index.ts @@ -0,0 +1 @@ +export * from './useEmbeddedView'; diff --git a/src/embedded/hooks/useEmbeddedView/embeddedViewDefaults.ts b/src/embedded/hooks/useEmbeddedView/embeddedViewDefaults.ts new file mode 100644 index 000000000..f20879388 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/embeddedViewDefaults.ts @@ -0,0 +1,85 @@ +export const embeddedBackgroundColors = { + notification: '#ffffff', + card: '#ffffff', + banner: '#ffffff', +}; + +export const embeddedBorderColors = { + notification: '#E0DEDF', + card: '#E0DEDF', + banner: '#E0DEDF', +}; + +export const embeddedPrimaryBtnBackgroundColors = { + notification: '#6A266D', + card: 'transparent', + banner: '#6A266D', +}; + +export const embeddedPrimaryBtnTextColors = { + notification: '#ffffff', + card: '#79347F', + banner: '#ffffff', +}; + +export const embeddedSecondaryBtnBackgroundColors = { + notification: 'transparent', + card: 'transparent', + banner: 'transparent', +}; + +export const embeddedSecondaryBtnTextColors = { + notification: '#79347F', + card: '#79347F', + banner: '#79347F', +}; + +export const embeddedTitleTextColors = { + notification: '#3D3A3B', + card: '#3D3A3B', + banner: '#3D3A3B', +}; + +export const embeddedBodyTextColors = { + notification: '#787174', + card: '#787174', + banner: '#787174', +}; + +export const embeddedBorderRadius = { + notification: 8, + card: 6, + banner: 8, +}; + +export const embeddedBorderWidth = { + notification: 1, + card: 1, + banner: 1, +}; + +export const embeddedMediaImageBorderColors = { + notification: '#E0DEDF', + card: '#E0DEDF', + banner: '#E0DEDF', +}; + +export const embeddedMediaImageBackgroundColors = { + notification: '#F5F4F4', + card: '#F5F4F4', + banner: '#F5F4F4', +}; + +export const embeddedStyles = { + backgroundColor: embeddedBackgroundColors, + bodyText: embeddedBodyTextColors, + borderColor: embeddedBorderColors, + borderCornerRadius: embeddedBorderRadius, + borderWidth: embeddedBorderWidth, + mediaImageBorder: embeddedMediaImageBorderColors, + primaryBtnBackgroundColor: embeddedPrimaryBtnBackgroundColors, + primaryBtnTextColor: embeddedPrimaryBtnTextColors, + secondaryBtnBackground: embeddedSecondaryBtnBackgroundColors, + secondaryBtnTextColor: embeddedSecondaryBtnTextColors, + titleText: embeddedTitleTextColors, +}; diff --git a/src/embedded/hooks/useEmbeddedView/getStyles.test.ts b/src/embedded/hooks/useEmbeddedView/getStyles.test.ts new file mode 100644 index 000000000..33d725cbe --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/getStyles.test.ts @@ -0,0 +1,156 @@ +import { getStyles } from './getStyles'; +import { IterableEmbeddedViewType } from '../../enums'; +import { + embeddedBackgroundColors, + embeddedBorderColors, + embeddedBorderRadius, + embeddedBorderWidth, + embeddedPrimaryBtnBackgroundColors, + embeddedPrimaryBtnTextColors, + embeddedSecondaryBtnBackgroundColors, + embeddedSecondaryBtnTextColors, + embeddedTitleTextColors, + embeddedBodyTextColors, +} from './embeddedViewDefaults'; + +describe('getStyles', () => { + describe('default styles by view type (no config)', () => { + it('returns Notification defaults when viewType is Notification', () => { + const result = getStyles(IterableEmbeddedViewType.Notification); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.notification); + expect(result.borderColor).toBe(embeddedBorderColors.notification); + expect(result.borderWidth).toBe(embeddedBorderWidth.notification); + expect(result.borderCornerRadius).toBe(embeddedBorderRadius.notification); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.notification); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.notification); + expect(result.secondaryBtnBackgroundColor).toBe(embeddedSecondaryBtnBackgroundColors.notification); + expect(result.secondaryBtnTextColor).toBe(embeddedSecondaryBtnTextColors.notification); + expect(result.titleTextColor).toBe(embeddedTitleTextColors.notification); + expect(result.bodyTextColor).toBe(embeddedBodyTextColors.notification); + }); + + it('returns Card defaults when viewType is Card', () => { + const result = getStyles(IterableEmbeddedViewType.Card); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.card); + expect(result.borderColor).toBe(embeddedBorderColors.card); + expect(result.borderWidth).toBe(embeddedBorderWidth.card); + expect(result.borderCornerRadius).toBe(embeddedBorderRadius.card); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.card); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.card); + expect(result.secondaryBtnBackgroundColor).toBe(embeddedSecondaryBtnBackgroundColors.card); + expect(result.secondaryBtnTextColor).toBe(embeddedSecondaryBtnTextColors.card); + expect(result.titleTextColor).toBe(embeddedTitleTextColors.card); + expect(result.bodyTextColor).toBe(embeddedBodyTextColors.card); + }); + + it('returns Banner defaults when viewType is Banner', () => { + const result = getStyles(IterableEmbeddedViewType.Banner); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.banner); + expect(result.borderColor).toBe(embeddedBorderColors.banner); + expect(result.borderWidth).toBe(embeddedBorderWidth.banner); + expect(result.borderCornerRadius).toBe(embeddedBorderRadius.banner); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.banner); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.banner); + expect(result.secondaryBtnBackgroundColor).toBe(embeddedSecondaryBtnBackgroundColors.banner); + expect(result.secondaryBtnTextColor).toBe(embeddedSecondaryBtnTextColors.banner); + expect(result.titleTextColor).toBe(embeddedTitleTextColors.banner); + expect(result.bodyTextColor).toBe(embeddedBodyTextColors.banner); + }); + + it('returns Banner defaults for unknown viewType (default branch)', () => { + const unknownViewType = 999 as IterableEmbeddedViewType; + const result = getStyles(unknownViewType); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.banner); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.banner); + }); + }); + + describe('with null or undefined config', () => { + it('returns defaults when config is null', () => { + const result = getStyles(IterableEmbeddedViewType.Notification, null); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.notification); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.notification); + }); + + it('returns defaults when config is undefined', () => { + const result = getStyles(IterableEmbeddedViewType.Card, undefined); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.card); + }); + }); + + describe('config overrides defaults', () => { + it('uses config values when provided, overrides all style keys', () => { + const config = { + backgroundColor: '#000000', + borderColor: '#111111', + borderWidth: 2, + borderCornerRadius: 10, + primaryBtnBackgroundColor: '#222222', + primaryBtnTextColor: '#333333', + secondaryBtnBackgroundColor: '#444444', + secondaryBtnTextColor: '#555555', + titleTextColor: '#666666', + bodyTextColor: '#777777', + }; + + const result = getStyles(IterableEmbeddedViewType.Notification, config); + + expect(result.backgroundColor).toBe('#000000'); + expect(result.borderColor).toBe('#111111'); + expect(result.borderWidth).toBe(2); + expect(result.borderCornerRadius).toBe(10); + expect(result.primaryBtnBackgroundColor).toBe('#222222'); + expect(result.primaryBtnTextColor).toBe('#333333'); + expect(result.secondaryBtnBackgroundColor).toBe('#444444'); + expect(result.secondaryBtnTextColor).toBe('#555555'); + expect(result.titleTextColor).toBe('#666666'); + expect(result.bodyTextColor).toBe('#777777'); + }); + + it('overrides only provided config keys, rest use view-type defaults', () => { + const config = { + backgroundColor: '#abc', + borderCornerRadius: 12, + }; + + const result = getStyles(IterableEmbeddedViewType.Card, config); + + expect(result.backgroundColor).toBe('#abc'); + expect(result.borderCornerRadius).toBe(12); + expect(result.borderColor).toBe(embeddedBorderColors.card); + expect(result.borderWidth).toBe(embeddedBorderWidth.card); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.card); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.card); + expect(result.secondaryBtnBackgroundColor).toBe(embeddedSecondaryBtnBackgroundColors.card); + expect(result.secondaryBtnTextColor).toBe(embeddedSecondaryBtnTextColors.card); + expect(result.titleTextColor).toBe(embeddedTitleTextColors.card); + expect(result.bodyTextColor).toBe(embeddedBodyTextColors.card); + }); + }); + + describe('return shape', () => { + it('returns an object with all expected style keys', () => { + const result = getStyles(IterableEmbeddedViewType.Banner); + + expect(result).toMatchObject({ + backgroundColor: expect.any(String), + borderColor: expect.any(String), + borderWidth: expect.any(Number), + borderCornerRadius: expect.any(Number), + primaryBtnBackgroundColor: expect.any(String), + primaryBtnTextColor: expect.any(String), + secondaryBtnBackgroundColor: expect.any(String), + secondaryBtnTextColor: expect.any(String), + titleTextColor: expect.any(String), + bodyTextColor: expect.any(String), + }); + expect(Object.keys(result)).toHaveLength(10); + }); + }); +}); diff --git a/src/embedded/hooks/useEmbeddedView/getStyles.ts b/src/embedded/hooks/useEmbeddedView/getStyles.ts new file mode 100644 index 000000000..16aa2f616 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/getStyles.ts @@ -0,0 +1,81 @@ +import type { IterableEmbeddedViewConfig } from '../../types/IterableEmbeddedViewConfig'; +import { embeddedStyles } from './embeddedViewDefaults'; +import { IterableEmbeddedViewType } from '../../enums'; + +/** + * Get the default style for the embedded view type. + * + * @param viewType - The type of view to render. + * @param colors - The colors to use for the default style. + * @returns The default style. + */ +const getDefaultStyle = ( + viewType: IterableEmbeddedViewType, + colors: { + banner: T; + card: T; + notification: T; + } +): T => { + switch (viewType) { + case IterableEmbeddedViewType.Notification: + return colors.notification; + case IterableEmbeddedViewType.Card: + return colors.card; + default: + return colors.banner; + } +}; + +/** + * Get the style for the embedded view type. + * + * If a style is provided in the config, it will take precedence over the default style. + * + * @param viewType - The type of view to render. + * @param c - The config to use for the styles. + * @returns The styles. + * + * @example + * const styles = getStyles(IterableEmbeddedViewType.Notification, { + * backgroundColor: '#000000', + * borderColor: '#000000', + * borderWidth: 1, + * borderCornerRadius: 10, + * primaryBtnBackgroundColor: '#000000', + * primaryBtnTextColor: '#000000', + * }); + */ +export const getStyles = ( + viewType: IterableEmbeddedViewType, + c?: IterableEmbeddedViewConfig | null +) => { + return { + backgroundColor: + c?.backgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.backgroundColor), + borderColor: + c?.borderColor ?? getDefaultStyle(viewType, embeddedStyles.borderColor), + borderWidth: + c?.borderWidth ?? getDefaultStyle(viewType, embeddedStyles.borderWidth), + borderCornerRadius: + c?.borderCornerRadius ?? + getDefaultStyle(viewType, embeddedStyles.borderCornerRadius), + primaryBtnBackgroundColor: + c?.primaryBtnBackgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.primaryBtnBackgroundColor), + primaryBtnTextColor: + c?.primaryBtnTextColor ?? + getDefaultStyle(viewType, embeddedStyles.primaryBtnTextColor), + secondaryBtnBackgroundColor: + c?.secondaryBtnBackgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.secondaryBtnBackground), + secondaryBtnTextColor: + c?.secondaryBtnTextColor ?? + getDefaultStyle(viewType, embeddedStyles.secondaryBtnTextColor), + titleTextColor: + c?.titleTextColor ?? getDefaultStyle(viewType, embeddedStyles.titleText), + bodyTextColor: + c?.bodyTextColor ?? getDefaultStyle(viewType, embeddedStyles.bodyText), + }; +}; diff --git a/src/embedded/hooks/useEmbeddedView/index.ts b/src/embedded/hooks/useEmbeddedView/index.ts new file mode 100644 index 000000000..bf1a77d44 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/index.ts @@ -0,0 +1,2 @@ +export * from './useEmbeddedView'; +export { useEmbeddedView as default } from './useEmbeddedView'; diff --git a/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts new file mode 100644 index 000000000..eb4997782 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; +import { IterableEmbeddedViewType } from '../../enums'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; +import { getStyles } from './getStyles'; + +/** + * This hook is used to manage the lifecycle of an embedded view. + * + * @param viewType - The type of view to render. + * @param props - The props for the embedded view. + * @returns The embedded view. + * + * @example + * const \{ parsedStyles \} = useEmbeddedView(IterableEmbeddedViewType.Notification, \{ + * message, + * config, + * onButtonClick, + * onMessageClick, + * \}); + * + * return ( + * + * \{parsedStyles.backgroundColor\} + * + * ); + */ +export const useEmbeddedView = ( + /** The type of view to render. */ + viewType: IterableEmbeddedViewType, + /** The props for the embedded view. */ + { + config, + }: IterableEmbeddedComponentProps +) => { + const parsedStyles = useMemo(() => { + return getStyles(viewType, config); + }, [viewType, config]); + + return { + parsedStyles, + }; +};