diff --git a/example/package.json b/example/package.json index 0492ea04..a51cc2b6 100644 --- a/example/package.json +++ b/example/package.json @@ -13,8 +13,10 @@ "cleanIos": "yarn clean && yarn pods" }, "dependencies": { + "@react-native-clipboard/clipboard": "^1.16.3", "react": "19.1.0", - "react-native": "0.80.1" + "react-native": "0.80.1", + "react-native-snackbar": "^2.9.0" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/example/src/App.tsx b/example/src/App.tsx index 6ef33c30..3a179f14 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,414 +1,200 @@ -import { Text, View, StyleSheet, Image, ActivityIndicator, TouchableOpacity, Alert, SafeAreaView } from 'react-native'; -import Qonversion, { NoCodesAction, Product } from '@qonversion/react-native-sdk'; -import { NoCodesConfigBuilder, NoCodes, QonversionConfigBuilder, LaunchMode, Environment, EntitlementsCacheLifetime } from '@qonversion/react-native-sdk'; -import React, { Component } from 'react'; -import type NoCodesError from '../../src/dto/NoCodesError'; +import React, { useReducer, useEffect } from 'react'; +import { + View, + StyleSheet, + TouchableOpacity, + Text, + SafeAreaView, + Alert, +} from 'react-native'; +import { + AppContext, + initialState, + appReducer, + getCurrentScreen, +} from './store/AppStore'; +import Qonversion, { + QonversionConfigBuilder, + LaunchMode, + Environment, + EntitlementsCacheLifetime, +} from '@qonversion/react-native-sdk'; +import MainScreen from './screens/MainScreen'; +import ProductsScreen from './screens/ProductsScreen'; +import ProductDetailScreen from './screens/ProductDetailScreen'; +import EntitlementsScreen from './screens/EntitlementsScreen'; +import EntitlementDetailScreen from './screens/EntitlementDetailScreen'; +import OfferingsScreen from './screens/OfferingsScreen'; +import RemoteConfigsScreen from './screens/RemoteConfigsScreen'; +import UserScreen from './screens/UserScreen'; +import NoCodesScreen from './screens/NoCodesScreen'; +import OtherScreen from './screens/OtherScreen'; const ProjectKey = 'PV77YHL7qnGvsdmpTs7gimsxUvY-Znl2'; -const InAppProductId = 'in_app'; -const SubscriptionProductId = 'weekly'; -const NoCodeScreenContextKey = 'kamo_test'; -interface QonversionSampleState { - inAppButtonTitle: string; - subscriptionButtonTitle: string; - loading: boolean; - checkEntitlementsHidden: boolean; - subscriptionProduct: Product | null; - inAppProduct: Product | null; -} - -export class QonversionSample extends React.PureComponent<{}, QonversionSampleState> { - constructor(props: any) { - super(props); - - this.state = { - inAppButtonTitle: 'Loading...', - subscriptionButtonTitle: 'Loading...', - loading: true, - checkEntitlementsHidden: true, - subscriptionProduct: null, - inAppProduct: null, - }; - - // eslint-disable-next-line consistent-this - const outerClassRef = this; // necessary for anonymous classes to access this. - const config = new QonversionConfigBuilder(ProjectKey, LaunchMode.SUBSCRIPTION_MANAGEMENT) - .setEnvironment(Environment.SANDBOX) - .setEntitlementsCacheLifetime(EntitlementsCacheLifetime.MONTH) - .setEntitlementsUpdateListener({ - onEntitlementsUpdated(entitlements: any) { - console.log('Entitlements updated!', entitlements); - outerClassRef.handleEntitlements(entitlements); - }, - }) - .setProxyURL("api-eu.qonversion.io") - .build(); - Qonversion.initialize(config); - - // Initialize NoCodes - const noCodesConfig = new NoCodesConfigBuilder(ProjectKey) - .setNoCodesListener({ - onScreenShown: (id: string) => { - console.log('No-Codes screen shown:', id); - }, - onActionStartedExecuting: (action: NoCodesAction) => { - console.log('No-Codes starts executing action:', action); - }, - onActionFailedToExecute: (action: NoCodesAction) => { - console.log('No-Codes failed to execute action:', action); - }, - onActionFinishedExecuting: (action: NoCodesAction) => { - console.log('No-Codes finished executing action:', action); - }, - onFinished: () => { - console.log('No-Codes flow finished'); - }, - onScreenFailedToLoad: (error: NoCodesError) => { - console.log('No-Codes failed to load screen:', error); - NoCodes.getSharedInstance().close(); - } - }) - .build(); - NoCodes.initialize(noCodesConfig); +// Main App Component +const App: React.FC = () => { + const [state, dispatch] = useReducer(appReducer, initialState); + + const loadUserInfo = async () => { + try { + console.log('🔄 [Qonversion] Starting userInfo() call...'); + const userInfo = await Qonversion.getSharedInstance().userInfo(); + console.log('✅ [Qonversion] userInfo() call successful:', userInfo); + dispatch({ type: 'SET_USER_INFO', payload: userInfo }); + } catch (error: any) { + console.error('❌ [Qonversion] userInfo() call failed:', error); + // Don't show alert for userInfo errors as they're not critical + } + }; - Qonversion.getSharedInstance().setPromoPurchasesDelegate({ - onPromoPurchaseReceived: async (productId: any, promoPurchaseExecutor: any) => { + useEffect(() => { + // Initialize Qonversion SDK only once per session + if (!state.isQonversionInitialized) { + const initializeQonversion = async () => { try { - const entitlements = await promoPurchaseExecutor(productId); - console.log('Promo purchase completed. Entitlements: ', entitlements); - outerClassRef.handleEntitlements(entitlements); - } catch (e) { - console.log('Promo purchase failed.'); + console.log('🔄 [Qonversion] Starting SDK initialization...'); + dispatch({ + type: 'SET_QONVERSION_INIT_STATUS', + payload: 'initializing', + }); + + console.log('🔄 [Qonversion] Building config...'); + const config = new QonversionConfigBuilder( + ProjectKey, + LaunchMode.SUBSCRIPTION_MANAGEMENT + ) + .setEnvironment(Environment.SANDBOX) + .setEntitlementsCacheLifetime(EntitlementsCacheLifetime.MONTH) + .setEntitlementsUpdateListener({ + onEntitlementsUpdated(entitlements: any) { + console.log( + '📡 [Qonversion] Entitlements updated via listener:', + entitlements + ); + dispatch({ type: 'SET_ENTITLEMENTS', payload: entitlements }); + }, + }) + .setProxyURL('api-eu.qonversion.io') + .build(); + console.log('✅ [Qonversion] Config built successfully:', config); + + console.log('🔄 [Qonversion] Initializing SDK...'); + Qonversion.initialize(config); + console.log('✅ [Qonversion] SDK initialized successfully'); + dispatch({ type: 'SET_QONVERSION_INIT_STATUS', payload: 'success' }); + dispatch({ type: 'SET_QONVERSION_INITIALIZED', payload: true }); + + // Load initial user info asynchronously + loadUserInfo(); + } catch (error: any) { + console.error('❌ [Qonversion] SDK initialization failed:', error); + dispatch({ type: 'SET_QONVERSION_INIT_STATUS', payload: 'error' }); + Alert.alert( + 'Initialization Error', + error.message || 'Failed to initialize Qonversion SDK' + ); } - }, - }); - Qonversion.getSharedInstance().checkEntitlements().then((entitlements: any) => { - this.handleEntitlements(entitlements); - }); - } + }; - handleEntitlements(entitlements: any) { - let checkActiveEntitlementsButtonHidden = this.state.checkEntitlementsHidden; - if (entitlements.size > 0) { - const entitlementsValues = Array.from(entitlements.values() as any[]); - checkActiveEntitlementsButtonHidden = !entitlementsValues.some((item: any) => item.isActive === true); + initializeQonversion(); } - Qonversion.getSharedInstance().products().then((products: any) => { - let inAppTitle = this.state.inAppButtonTitle; - let subscriptionButtonTitle = this.state.subscriptionButtonTitle; - - const inApp = products.get(InAppProductId); - if (inApp) { - inAppTitle = 'Buy for ' + inApp.prettyPrice; - - const entitlement = entitlements.get('Test Entitlement'); - if (entitlement) { - inAppTitle = entitlement.isActive ? 'Purchased' : inAppTitle; - } - } - - const subscription = products.get(SubscriptionProductId); - if (subscription) { - subscriptionButtonTitle = 'Subscribe for ' - + subscription.prettyPrice - + ' / ' - + subscription.subscriptionPeriod.unitCount - + ' ' - + subscription.subscriptionPeriod.unit; - - const entitlement = entitlements.get('plus'); - if (entitlement) { - subscriptionButtonTitle = entitlement.isActive ? 'Purchased' : subscriptionButtonTitle; - } - } - - this.setState({ - loading: false, - inAppButtonTitle: inAppTitle, - subscriptionButtonTitle: subscriptionButtonTitle, - checkEntitlementsHidden: checkActiveEntitlementsButtonHidden, - subscriptionProduct: subscription, - inAppProduct: inApp, - }); - }); - } - - render() { - return ( - - - - {'Build in-app\nsubscriptions'} - - - {'without server code'} - - {this.state.loading && - - } - - Start with 3 - days free trial - { - if (!this.state.subscriptionProduct) { - Alert.alert( - 'Error', - 'Purchasing product not found - id ' + SubscriptionProductId, - [ - {text: 'OK'}, - ], - {cancelable: true} - ); - return; - } - this.setState({loading: true}); - Qonversion.getSharedInstance().purchaseProduct(this.state.subscriptionProduct).then(() => { - this.setState({loading: false, subscriptionButtonTitle: 'Purchased'}); - }).catch(error => { - this.setState({loading: false}); - - if (!error.userCanceled) { - Alert.alert( - 'Error', - error.message, - [ - {text: 'OK'}, - ], - {cancelable: true} - ); - } - }); - }} - > - {this.state.subscriptionButtonTitle} - - { - if (!this.state.inAppProduct) { - Alert.alert( - 'Error', - 'Purchasing product not found - id ' + InAppProductId, - [ - {text: 'OK'}, - ], - {cancelable: true} - ); - return; - } - this.setState({loading: true}); - Qonversion.getSharedInstance().purchaseProduct(this.state.inAppProduct).then(() => { - this.setState({loading: false, inAppButtonTitle: 'Purchased'}); - }).catch(error => { - this.setState({loading: false}); - - if (!error.userCanceled) { - Alert.alert( - 'Error', - error.message, - [ - {text: 'OK'}, - ], - {cancelable: true} - ); - } - }); - }} - > - {this.state.inAppButtonTitle} - - { - this.setState({loading: true}); - Qonversion.getSharedInstance().restore().then((entitlements: any) => { - this.setState({loading: false}); - - let checkActiveEntitlementsButtonHidden = this.state.checkEntitlementsHidden; - let inAppTitle = this.state.inAppButtonTitle; - let subscriptionButtonTitle = this.state.subscriptionButtonTitle; - if (entitlements.size > 0) { - const entitlementsValues = Array.from(entitlements.values() as any[]); - checkActiveEntitlementsButtonHidden = entitlementsValues.some((item: any) => item.isActive === true); - - const standartEntitlement = entitlements.get('Test Entitlement'); - if (standartEntitlement && standartEntitlement.isActive) { - inAppTitle = 'Restored'; - } - - const plusEntitlement = entitlements.get('plus'); - if (plusEntitlement && plusEntitlement.isActive) { - subscriptionButtonTitle = 'Restored'; - } - } else { - Alert.alert( - 'Error', - 'No purchases to restore', - [ - {text: 'OK'}, - ], - {cancelable: true} - ); + }, [state.isQonversionInitialized]); + + const renderScreen = () => { + const currentScreen = getCurrentScreen(state); + switch (currentScreen) { + case 'main': + return ; + case 'products': + return ; + case 'productDetail': + return state.selectedProduct ? ( + + ) : ( + + ); + case 'entitlements': + return ; + case 'entitlementDetail': + return state.selectedEntitlement ? ( + + ) : ( + + ); + case 'offerings': + return ; + case 'remoteConfigs': + return ; + case 'user': + return ; + case 'noCodes': + return ; + case 'other': + return ; + default: + return ; + } + }; + + return ( + + + + {getCurrentScreen(state) !== 'main' && ( + { + if (getCurrentScreen(state) === 'productDetail') { + dispatch({ type: 'SET_SELECTED_PRODUCT', payload: null }); } - this.setState({ - inAppButtonTitle: inAppTitle, - subscriptionButtonTitle: subscriptionButtonTitle, - checkEntitlementsHidden: checkActiveEntitlementsButtonHidden, - }); - }).catch(error => { - this.setState({loading: false}); - Alert.alert( - 'Error', - error.message, - [ - {text: 'OK'}, - ], - {cancelable: true} - ); - }); - }} - > - Restore purchases - - { - if (this.state.checkEntitlementsHidden) { - return; - } - this.setState({loading: true}); - Qonversion.getSharedInstance().checkEntitlements().then((entitlements: any) => { - this.setState({loading: false}); - if (entitlements.size > 0) { - const entitlementsValues = Array.from(entitlements.values() as any[]); - const activeEntitlements = entitlementsValues.filter((item: any) => item.isActive === true); - if (activeEntitlements.length > 0) { - Alert.alert( - 'Active entitlements', - activeEntitlements.map((item: any) => item.entitlementId).join('\n'), - [ - {text: 'OK'}, - ], - {cancelable: true} - ); - } else { - Alert.alert( - 'Active entitlements', - 'No active entitlements', - [ - {text: 'OK'}, - ], - {cancelable: true} - ); - } - } else { - Alert.alert( - 'Active entitlements', - 'No entitlements', - [ - {text: 'OK'}, - ], - {cancelable: true} - ); + if (getCurrentScreen(state) === 'entitlementDetail') { + dispatch({ type: 'SET_SELECTED_ENTITLEMENT', payload: null }); } - }).catch(error => { - this.setState({loading: false}); - Alert.alert( - 'Error', - error.message, - [ - {text: 'OK'}, - ], - {cancelable: true} - ); - }); - }} - > - Check active entitlements - - { - NoCodes.getSharedInstance().showScreen(NoCodeScreenContextKey as any) - }} - > - Show NoCodes Screen - + dispatch({ type: 'POP_SCREEN' }); + }} + > + ← Back + + )} + + {getCurrentScreen(state) === 'main' + ? 'Qonversion SDK Demo' + : getCurrentScreen(state).charAt(0).toUpperCase() + + getCurrentScreen(state).slice(1)} + - - ); - } -} + {renderScreen()} + + + ); +}; const styles = StyleSheet.create({ - subscriptionButton: { - backgroundColor: '#0f0f0f', - borderRadius: 8, - marginHorizontal: 16, - marginTop: 16, - height: 56, - justifyContent: 'center', - alignItems: 'center', + safeArea: { + flex: 1, + backgroundColor: '#f5f5f5', }, - inAppButton: { - backgroundColor: '#ffffff', - borderRadius: 8, - marginHorizontal: 16, - marginTop: 16, - height: 56, - justifyContent: 'center', + header: { + flexDirection: 'row', alignItems: 'center', - borderWidth: 1, - borderColor: '#0f0f0f', - }, - additionalButton: { + padding: 16, backgroundColor: '#ffffff', - borderRadius: 8, - marginHorizontal: 16, - marginTop: 16, - height: 56, - justifyContent: 'center', - alignItems: 'center', - borderWidth: 1, - borderColor: '#0f0f0f', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', }, - buttonTitle: { - color: '#ffffff', - fontSize: 17, - fontWeight: '600', + backButton: { + marginRight: 16, }, - inAppButtonTitle: { - color: '#0f0f0f', - fontSize: 17, - fontWeight: '600', + backButtonText: { + fontSize: 16, + color: '#007AFF', }, - additionalButtonsText: { - color: '#0f0f0f', - fontSize: 17, - fontWeight: '600', + headerTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#000000', }, }); -export default class App extends Component { - render() { - return ( - <> - - - - - ); - } -} +export default App; diff --git a/example/src/components/ProductCard/index.tsx b/example/src/components/ProductCard/index.tsx new file mode 100644 index 00000000..dec379cb --- /dev/null +++ b/example/src/components/ProductCard/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Text, TouchableOpacity } from 'react-native'; +import { Product } from '@qonversion/react-native-sdk'; +import { AppContext } from '../../store/AppStore'; +import styles from './styles'; + +interface ProductCardProps { + product: Product; +} + +const ProductCard: React.FC = ({ product }) => { + const context = React.useContext(AppContext); + if (!context) return null; + const { dispatch } = context; + + const handlePress = () => { + dispatch({ type: 'SET_SELECTED_PRODUCT', payload: product }); + dispatch({ type: 'PUSH_SCREEN', payload: 'productDetail' }); + }; + + return ( + + {product.qonversionId} + Store ID: {product.storeId} + + Base Plan ID: {product.basePlanId} + + Type: {product.type} + + Price: {product.prettyPrice || 'N/A'} + + + ); +}; + +export default ProductCard; diff --git a/example/src/components/ProductCard/styles.ts b/example/src/components/ProductCard/styles.ts new file mode 100644 index 00000000..4f337780 --- /dev/null +++ b/example/src/components/ProductCard/styles.ts @@ -0,0 +1,28 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + listItem: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 12, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + listItemTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + color: '#000000', + }, + listItemSubtitle: { + fontSize: 14, + color: '#666666', + marginBottom: 4, + }, +}); diff --git a/example/src/components/SkeletonLoader.tsx b/example/src/components/SkeletonLoader.tsx new file mode 100644 index 00000000..7c476677 --- /dev/null +++ b/example/src/components/SkeletonLoader.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; + +const SkeletonLoader: React.FC = () => ( + + + + + +); + +const styles = StyleSheet.create({ + skeletonContainer: { + flex: 1, + padding: 16, + }, + skeletonItem: { + height: 60, + backgroundColor: '#e0e0e0', + borderRadius: 8, + marginBottom: 12, + }, +}); + +export default SkeletonLoader; diff --git a/example/src/components/UserInfoSection/index.tsx b/example/src/components/UserInfoSection/index.tsx new file mode 100644 index 00000000..ed3eeda9 --- /dev/null +++ b/example/src/components/UserInfoSection/index.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Text, View, TouchableOpacity } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import Snackbar from 'react-native-snackbar'; +import { User } from '@qonversion/react-native-sdk'; +import styles from './styles'; + +interface UserInfoSectionProps { + userInfo: User | null; + title?: string; + showCopyButtons?: boolean; +} + +const UserInfoSection: React.FC = ({ + userInfo, + title = 'User Information:', + showCopyButtons = true, +}) => { + if (!userInfo) { + return null; + } + + const copyToClipboard = (text: string, label: string) => { + Clipboard.setString(text); + Snackbar.show({ + text: `${label} copied to clipboard`, + duration: Snackbar.LENGTH_SHORT, + }); + }; + + return ( + + {title} + + {showCopyButtons ? ( + <> + + copyToClipboard(userInfo?.qonversionId || '', 'Qonversion ID') + } + > + Qonversion ID: + + {userInfo?.qonversionId || 'Not available'} + + + + copyToClipboard(userInfo?.identityId || '', 'Identity ID') + } + > + Identity ID: + + {userInfo?.identityId || 'Anonymous'} + + + + ) : ( + <> + + ID: {userInfo?.identityId || 'Anonymous'} + + + Qonversion ID: {userInfo?.qonversionId || 'Not available'} + + + )} + + ); +}; + +export default UserInfoSection; diff --git a/example/src/components/UserInfoSection/styles.ts b/example/src/components/UserInfoSection/styles.ts new file mode 100644 index 00000000..4d07e863 --- /dev/null +++ b/example/src/components/UserInfoSection/styles.ts @@ -0,0 +1,47 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + userInfoContainer: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 20, + }, + userInfoTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + color: '#000000', + }, + userInfoText: { + fontSize: 14, + color: '#666666', + marginBottom: 4, + }, + userInfoRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 12, + backgroundColor: '#f8f9fa', + borderRadius: 6, + marginBottom: 8, + width: '100%', + }, + userInfoLabel: { + fontSize: 14, + fontWeight: '600', + color: '#333333', + flex: 1, + }, + userInfoValue: { + fontSize: 14, + color: '#007AFF', + fontWeight: '500', + flex: 2, + textAlign: 'right', + marginRight: 8, + flexWrap: 'wrap', + }, +}); diff --git a/example/src/index.ts b/example/src/index.ts new file mode 100644 index 00000000..c8aa9dec --- /dev/null +++ b/example/src/index.ts @@ -0,0 +1,21 @@ +// Store exports +export { + AppContext, + initialState, + appReducer, + type AppState, + type AppAction, +} from './store/AppStore'; + +// Component exports +export { default as SkeletonLoader } from './components/SkeletonLoader'; + +// Screen exports +export { default as MainScreen } from './screens/MainScreen'; +export { default as ProductsScreen } from './screens/ProductsScreen'; +export { default as EntitlementsScreen } from './screens/EntitlementsScreen'; +export { default as OfferingsScreen } from './screens/OfferingsScreen'; +export { default as RemoteConfigsScreen } from './screens/RemoteConfigsScreen'; +export { default as UserScreen } from './screens/UserScreen'; +export { default as NoCodesScreen } from './screens/NoCodesScreen'; +export { default as OtherScreen } from './screens/OtherScreen'; diff --git a/example/src/screens/EntitlementDetailScreen/index.tsx b/example/src/screens/EntitlementDetailScreen/index.tsx new file mode 100644 index 00000000..d4ba1c36 --- /dev/null +++ b/example/src/screens/EntitlementDetailScreen/index.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Text, View, ScrollView } from 'react-native'; +import styles from './styles'; + +interface EntitlementDetailScreenProps { + entitlement: any; +} + +const EntitlementDetailScreen: React.FC = ({ + entitlement, +}) => { + const formatDate = (date: Date) => { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }; + + const renderField = (label: string, value: any) => { + return ( + + {label}: + + {value !== null && value !== undefined ? String(value) : '-'} + + + ); + }; + + const renderDateField = (label: string, date: Date | undefined) => { + return ( + + {label}: + {date ? formatDate(date) : '-'} + + ); + }; + + return ( + + + {entitlement.id} + + + {entitlement.isActive ? 'Active' : 'Inactive'} + + + + + + Basic Information + {renderField('ID', entitlement.id)} + {renderField('Product ID', entitlement.productId)} + {renderField('Renew State', entitlement.renewState)} + {renderField('Source', entitlement.source)} + {renderField('Grant Type', entitlement.grantType)} + {renderField('Renews Count', entitlement.renewsCount)} + + + + Dates + {renderDateField('Started Date', entitlement.startedDate)} + {renderDateField('Expiration Date', entitlement.expirationDate)} + {renderDateField('Trial Start Date', entitlement.trialStartDate)} + {renderDateField('First Purchase Date', entitlement.firstPurchaseDate)} + {renderDateField('Last Purchase Date', entitlement.lastPurchaseDate)} + {renderDateField( + 'Auto Renew Disable Date', + entitlement.autoRenewDisableDate + )} + + + + Additional Information + {renderField( + 'Last Activated Offer Code', + entitlement.lastActivatedOfferCode + )} + {entitlement.transactions && entitlement.transactions.length > 0 && ( + + Transactions: + + {entitlement.transactions.length} transaction(s) + + + )} + + + ); +}; + +export default EntitlementDetailScreen; diff --git a/example/src/screens/EntitlementDetailScreen/styles.ts b/example/src/screens/EntitlementDetailScreen/styles.ts new file mode 100644 index 00000000..89131a85 --- /dev/null +++ b/example/src/screens/EntitlementDetailScreen/styles.ts @@ -0,0 +1,87 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + contentContainer: { + padding: 16, + paddingBottom: 32, + }, + header: { + backgroundColor: '#ffffff', + padding: 20, + borderRadius: 12, + marginBottom: 16, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + color: '#000000', + flex: 1, + }, + statusBadge: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + marginLeft: 12, + }, + statusActive: { + backgroundColor: '#34C759', + }, + statusInactive: { + backgroundColor: '#FF3B30', + }, + statusText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '600', + }, + section: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 12, + marginBottom: 16, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + sectionTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#000000', + marginBottom: 12, + }, + fieldContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + fieldLabel: { + fontSize: 14, + fontWeight: '600', + color: '#666666', + flex: 1, + }, + fieldValue: { + fontSize: 14, + color: '#000000', + flex: 2, + textAlign: 'right', + flexWrap: 'wrap', + }, +}); diff --git a/example/src/screens/EntitlementsScreen/index.tsx b/example/src/screens/EntitlementsScreen/index.tsx new file mode 100644 index 00000000..14286a3c --- /dev/null +++ b/example/src/screens/EntitlementsScreen/index.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { + Text, + View, + TouchableOpacity, + Alert, + ScrollView, + Platform, +} from 'react-native'; +import Qonversion, { Entitlement } from '@qonversion/react-native-sdk'; +import Snackbar from 'react-native-snackbar'; +import { AppContext } from '../../store/AppStore'; +import SkeletonLoader from '../../components/SkeletonLoader'; +import styles from './styles'; + +const EntitlementsScreen: React.FC = () => { + const context = React.useContext(AppContext); + if (!context) return null; + const { state, dispatch } = context; + + const loadEntitlements = async () => { + try { + console.log('🔄 [Qonversion] Starting checkEntitlements() call...'); + dispatch({ type: 'SET_LOADING', payload: true }); + const entitlements = + await Qonversion.getSharedInstance().checkEntitlements(); + console.log( + '✅ [Qonversion] checkEntitlements() call successful:', + entitlements + ); + dispatch({ type: 'SET_ENTITLEMENTS', payload: entitlements }); + } catch (error: any) { + console.error('❌ [Qonversion] checkEntitlements() call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const setEntitlementsListener = () => { + console.log('🔄 [Qonversion] Setting entitlements update listener...'); + Qonversion.getSharedInstance().setEntitlementsUpdateListener({ + onEntitlementsUpdated(entitlements: Map) { + console.log( + '📡 [Qonversion] Entitlements updated via listener:', + Object.fromEntries(entitlements) + ); + dispatch({ type: 'SET_ENTITLEMENTS', payload: entitlements }); + }, + }); + console.log( + '✅ [Qonversion] Entitlements update listener set successfully' + ); + Snackbar.show({ + text: 'Entitlements listener set successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + }; + + const restore = async () => { + try { + console.log('🔄 [Qonversion] Starting restore() call...'); + dispatch({ type: 'SET_LOADING', payload: true }); + const entitlements = await Qonversion.getSharedInstance().restore(); + console.log('✅ [Qonversion] restore() call successful:', entitlements); + dispatch({ type: 'SET_ENTITLEMENTS', payload: entitlements }); + Snackbar.show({ + text: 'Purchases restored successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error('❌ [Qonversion] restore() call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const syncHistoricalData = async () => { + try { + console.log('🔄 [Qonversion] Starting syncHistoricalData() call...'); + dispatch({ type: 'SET_LOADING', payload: true }); + Qonversion.getSharedInstance().syncHistoricalData(); + console.log('✅ [Qonversion] syncHistoricalData() call successful'); + Snackbar.show({ + text: 'Historical data synced successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error('❌ [Qonversion] syncHistoricalData() call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const syncStoreKit2Purchases = async () => { + if (Platform.OS !== 'ios') { + Alert.alert('Error', 'This method is iOS only'); + return; + } + try { + Qonversion.getSharedInstance().syncStoreKit2Purchases(); + Snackbar.show({ + text: 'StoreKit 2 purchases synced!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + Alert.alert('Error', error.message); + } + }; + + const syncPurchases = async () => { + if (Platform.OS !== 'android') { + Alert.alert('Error', 'This method is Android only'); + return; + } + try { + Qonversion.getSharedInstance().syncPurchases(); + Snackbar.show({ + text: 'Purchases synced!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + Alert.alert('Error', error.message); + } + }; + + const formatDate = (date: Date) => { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const renderContent = () => { + return ( + + + Load Entitlements + + + + + Set Entitlements Updated Listener + + + + + Restore + + + + Sync Historical Data + + + + + Sync StoreKit 2 Purchases (iOS Only) + + + + + Sync Purchases (Android Only) + + + {/* Entitlements Status Section */} + + {state.loading ? ( + // Loading state - show skeleton only for entitlements section + + Your Entitlements + + + ) : state.entitlements === null ? ( + // Not loaded state + + No Entitlements Loaded + + Tap "Load Entitlements" to fetch your current entitlements from + the server. + + + ) : state.entitlements.size === 0 ? ( + // Empty entitlements state + + No Entitlements Found + + You don't have any active entitlements. Try restoring purchases + or check your subscription status. + + + ) : ( + // Entitlements list + + Your Entitlements + {Array.from(state.entitlements.entries()).map( + ([id, entitlement]) => ( + { + dispatch({ + type: 'SET_SELECTED_ENTITLEMENT', + payload: entitlement, + }); + dispatch({ + type: 'PUSH_SCREEN', + payload: 'entitlementDetail', + }); + }} + > + {entitlement.id} + + Status: {entitlement.isActive ? 'Active' : 'Inactive'} + + + Started: {formatDate(entitlement.startedDate)} + + {entitlement.expirationDate && ( + + Expires: {formatDate(entitlement.expirationDate)} + + )} + + ) + )} + + )} + + + ); + }; + + return renderContent(); +}; + +export default EntitlementsScreen; diff --git a/example/src/screens/EntitlementsScreen/styles.ts b/example/src/screens/EntitlementsScreen/styles.ts new file mode 100644 index 00000000..bab9a169 --- /dev/null +++ b/example/src/screens/EntitlementsScreen/styles.ts @@ -0,0 +1,82 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + contentContainer: { + padding: 16, + paddingBottom: 32, + }, + button: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + marginBottom: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + listContainer: { + marginTop: 20, + }, + listItem: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 12, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + listItemTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + color: '#000000', + }, + listItemSubtitle: { + fontSize: 14, + color: '#666666', + marginBottom: 4, + }, + statusContainer: { + marginTop: 20, + }, + emptyStateContainer: { + backgroundColor: '#ffffff', + padding: 24, + borderRadius: 12, + alignItems: 'center', + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + emptyStateTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#000000', + marginBottom: 8, + textAlign: 'center', + }, + emptyStateText: { + fontSize: 14, + color: '#666666', + textAlign: 'center', + lineHeight: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#000000', + marginBottom: 12, + }, +}); diff --git a/example/src/screens/MainScreen/index.tsx b/example/src/screens/MainScreen/index.tsx new file mode 100644 index 00000000..83f2dd13 --- /dev/null +++ b/example/src/screens/MainScreen/index.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { Text, View, Image, TouchableOpacity, ScrollView } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import Snackbar from 'react-native-snackbar'; +import { AppContext } from '../../store/AppStore'; +import styles from './styles'; + +const MainScreen: React.FC = () => { + const context = React.useContext(AppContext); + if (!context) { + console.error('AppContext is null in MainScreen'); + return ( + + Error: AppContext not available + + ); + } + + const { state, dispatch } = context; + + const menuItems = [ + { id: 'products', title: 'Products' }, + { id: 'entitlements', title: 'Entitlements' }, + { id: 'offerings', title: 'Offerings' }, + { id: 'remoteConfigs', title: 'Remote Configs' }, + { id: 'user', title: 'User' }, + { id: 'noCodes', title: 'No-Codes' }, + { id: 'other', title: 'Other' }, + ]; + + return ( + + + Qonversion SDK Demo + + + + + {state.qonversionInitStatus === 'not_initialized' && + 'Initialization not completed'} + {state.qonversionInitStatus === 'initializing' && 'Initializing...'} + {state.qonversionInitStatus === 'success' && + 'Initialization successful'} + {state.qonversionInitStatus === 'error' && 'Initialization error'} + + + + {state.userInfo && ( + + Current User: + { + Clipboard.setString(state.userInfo?.qonversionId || ''); + Snackbar.show({ + text: 'Qonversion ID copied to clipboard', + duration: Snackbar.LENGTH_SHORT, + }); + }} + > + Qonversion ID: + + {state.userInfo?.qonversionId || 'Not available'} + + + { + Clipboard.setString(state.userInfo?.identityId || ''); + Snackbar.show({ + text: 'Identity ID copied to clipboard', + duration: Snackbar.LENGTH_SHORT, + }); + }} + > + Identity ID: + + {state.userInfo?.identityId || 'Anonymous'} + + + + )} + + + {menuItems.map((item) => ( + dispatch({ type: 'PUSH_SCREEN', payload: item.id })} + > + {item.title} + + ))} + + + ); +}; + +export default MainScreen; diff --git a/example/src/screens/MainScreen/styles.ts b/example/src/screens/MainScreen/styles.ts new file mode 100644 index 00000000..33563426 --- /dev/null +++ b/example/src/screens/MainScreen/styles.ts @@ -0,0 +1,135 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + padding: 16, + }, + contentContainer: { + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 32, + }, + logo: { + width: 100, + height: 100, + alignSelf: 'center', + marginVertical: 20, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 20, + color: '#000000', + }, + userInfoContainer: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 20, + }, + userInfoTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + color: '#000000', + }, + userInfoText: { + fontSize: 14, + color: '#666666', + marginBottom: 4, + }, + menuContainer: { + marginTop: 20, + width: '100%', + }, + menuItem: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 12, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + menuItemText: { + fontSize: 16, + fontWeight: '600', + color: '#000000', + }, + errorText: { + fontSize: 14, + color: '#FF3B30', + textAlign: 'center', + marginBottom: 20, + }, + button: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + marginBottom: 12, + alignItems: 'center', + minWidth: 120, + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + initIndicatorContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 20, + paddingHorizontal: 16, + }, + initIndicator: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 8, + }, + initIndicatorGray: { + backgroundColor: '#999999', + }, + initIndicatorGreen: { + backgroundColor: '#34C759', + }, + initIndicatorRed: { + backgroundColor: '#FF3B30', + }, + initIndicatorText: { + fontSize: 14, + color: '#666666', + fontWeight: '500', + }, + userInfoRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 12, + backgroundColor: '#f8f9fa', + borderRadius: 6, + marginBottom: 8, + width: '100%', + }, + userInfoLabel: { + fontSize: 14, + fontWeight: '600', + color: '#333333', + flex: 1, + }, + userInfoValue: { + fontSize: 14, + color: '#007AFF', + fontWeight: '500', + flex: 2, + textAlign: 'right', + marginRight: 8, + flexWrap: 'wrap', + }, +}); diff --git a/example/src/screens/NoCodesScreen/index.tsx b/example/src/screens/NoCodesScreen/index.tsx new file mode 100644 index 00000000..fb97c837 --- /dev/null +++ b/example/src/screens/NoCodesScreen/index.tsx @@ -0,0 +1,208 @@ +import React, { useState, useEffect } from 'react'; +import { + Text, + View, + TouchableOpacity, + Alert, + ScrollView, + TextInput, + Platform, +} from 'react-native'; +import { + NoCodesAction, + NoCodesConfigBuilder, + ScreenPresentationStyle, + ScreenPresentationConfig, + NoCodes, +} from '@qonversion/react-native-sdk'; +import type NoCodesError from '../../../../src/dto/NoCodesError'; +import { AppContext } from '../../store/AppStore'; +import styles from './styles'; +import Snackbar from 'react-native-snackbar'; + +const ProjectKey = 'PV77YHL7qnGvsdmpTs7gimsxUvY-Znl2'; + +const NoCodesScreen: React.FC = () => { + const context = React.useContext(AppContext); + if (!context) return null; + const { state, dispatch } = context; + + const [contextKey, setContextKey] = useState('kamo_test'); + const [presentationStyle, setPresentationStyle] = useState( + ScreenPresentationStyle.FULL_SCREEN + ); + const [animated, setAnimated] = useState(false); + + useEffect(() => { + // Initialize No-Codes SDK once + const initializeNoCodes = () => { + console.log('🔄 [NoCodes] Starting SDK initialization...'); + const noCodesConfig = new NoCodesConfigBuilder(ProjectKey) + .setNoCodesListener({ + onScreenShown: (id: string) => { + const event = `Screen shown: ${id}`; + console.log('📡 [NoCodes] Screen shown event:', id); + dispatch({ type: 'ADD_NOCODES_EVENT', payload: event }); + }, + onActionStartedExecuting: (action: NoCodesAction) => { + const event = `Action started: ${action.type}`; + console.log('📡 [NoCodes] Action started event:', action); + dispatch({ type: 'ADD_NOCODES_EVENT', payload: event }); + }, + onActionFailedToExecute: (action: NoCodesAction) => { + const event = `Action failed: ${action.type}`; + console.log('📡 [NoCodes] Action failed event:', action); + dispatch({ type: 'ADD_NOCODES_EVENT', payload: event }); + }, + onActionFinishedExecuting: (action: NoCodesAction) => { + const event = `Action finished: ${action.type}`; + console.log('📡 [NoCodes] Action finished event:', action); + dispatch({ type: 'ADD_NOCODES_EVENT', payload: event }); + }, + onFinished: () => { + const event = 'Flow finished'; + console.log('📡 [NoCodes] Flow finished event'); + dispatch({ type: 'ADD_NOCODES_EVENT', payload: event }); + }, + onScreenFailedToLoad: (error: NoCodesError) => { + const event = `Screen failed to load: ${error.description || error.code}`; + console.log('📡 [NoCodes] Screen failed to load event:', error); + dispatch({ type: 'ADD_NOCODES_EVENT', payload: event }); + NoCodes.getSharedInstance().close(); + }, + }) + .build(); + console.log('✅ [NoCodes] Config built successfully:', noCodesConfig); + + console.log('🔄 [NoCodes] Initializing SDK...'); + NoCodes.initialize(noCodesConfig); + console.log('✅ [NoCodes] SDK initialized successfully'); + }; + + initializeNoCodes(); + }, []); + + const showScreen = () => { + try { + console.log( + '🔄 [NoCodes] Starting showScreen() call with contextKey:', + contextKey + ); + NoCodes.getSharedInstance().showScreen(contextKey); + console.log('✅ [NoCodes] showScreen() call successful'); + } catch (error: any) { + console.error('❌ [NoCodes] showScreen() call failed:', error); + Alert.alert('Error', error.message); + } + }; + + const setPresentationConfig = () => { + try { + console.log( + '🔄 [NoCodes] Starting setScreenPresentationConfig() call with contextKey:', + contextKey, + 'config:', + { presentationStyle, animated } + ); + const config = new ScreenPresentationConfig(presentationStyle, animated); + + NoCodes.getSharedInstance().setScreenPresentationConfig( + config, + contextKey + ); + console.log('✅ [NoCodes] setScreenPresentationConfig() call successful'); + Snackbar.show({ + text: 'Presentation config set successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error( + '❌ [NoCodes] setScreenPresentationConfig() call failed:', + error + ); + Alert.alert('Error', error.message); + } + }; + + const close = () => { + try { + console.log('🔄 [NoCodes] Starting close() call...'); + NoCodes.getSharedInstance().close(); + console.log('✅ [NoCodes] close() call successful'); + Snackbar.show({ + text: 'No-Codes screen closed!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error('❌ [NoCodes] close() call failed:', error); + Alert.alert('Error', error.message); + } + }; + + return ( + + + Context Key: + + + Show No-Code Screen + + + + + Presentation Config + Screen Presentation Style: + {Object.values(ScreenPresentationStyle).map((style) => ( + setPresentationStyle(style)} + > + {style} + + ))} + + {Platform.OS === 'ios' && ( + setAnimated(!animated)} + > + Animated (iOS only) + + )} + + + Set Presentation Config + + + + + Close + + + + No-Codes Events: + + {state.noCodesEvents.map((event, index) => ( + + {event} + + ))} + + + + ); +}; + +export default NoCodesScreen; diff --git a/example/src/screens/NoCodesScreen/styles.ts b/example/src/screens/NoCodesScreen/styles.ts new file mode 100644 index 00000000..440c10c4 --- /dev/null +++ b/example/src/screens/NoCodesScreen/styles.ts @@ -0,0 +1,98 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + padding: 16, + paddingBottom: 32, + }, + contentContainer: { + paddingBottom: 32, + }, + inputContainer: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + color: '#000000', + }, + textInput: { + backgroundColor: '#ffffff', + borderWidth: 1, + borderColor: '#e0e0e0', + borderRadius: 8, + padding: 12, + marginBottom: 12, + fontSize: 16, + }, + button: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + marginBottom: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + sectionTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 12, + color: '#000000', + }, + radioButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + backgroundColor: '#ffffff', + borderRadius: 8, + marginBottom: 8, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + radioButtonSelected: { + borderColor: '#007AFF', + backgroundColor: '#f0f8ff', + }, + radioButtonText: { + fontSize: 14, + color: '#000000', + }, + checkbox: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + backgroundColor: '#ffffff', + borderRadius: 8, + marginBottom: 8, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + checkboxSelected: { + borderColor: '#007AFF', + backgroundColor: '#f0f8ff', + }, + checkboxText: { + fontSize: 14, + color: '#000000', + }, + eventsContainer: { + marginTop: 20, + }, + eventsList: { + maxHeight: 200, + backgroundColor: '#ffffff', + borderRadius: 8, + padding: 12, + }, + eventText: { + fontSize: 12, + color: '#666666', + marginBottom: 4, + }, +}); diff --git a/example/src/screens/OfferingsScreen/index.tsx b/example/src/screens/OfferingsScreen/index.tsx new file mode 100644 index 00000000..d4eea491 --- /dev/null +++ b/example/src/screens/OfferingsScreen/index.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Text, View, TouchableOpacity, Alert, ScrollView } from 'react-native'; +import Qonversion from '@qonversion/react-native-sdk'; +import { AppContext } from '../../store/AppStore'; +import SkeletonLoader from '../../components/SkeletonLoader'; +import ProductCard from '../../components/ProductCard'; +import styles from './styles'; + +const OfferingsScreen: React.FC = () => { + const context = React.useContext(AppContext); + if (!context) return null; + const { state, dispatch } = context; + + const loadOfferings = async () => { + try { + console.log('🔄 [Qonversion] Starting offerings() call...'); + dispatch({ type: 'SET_LOADING', payload: true }); + const offerings = await Qonversion.getSharedInstance().offerings(); + console.log('✅ [Qonversion] offerings() call successful:', offerings); + if (offerings) { + dispatch({ type: 'SET_OFFERINGS', payload: offerings }); + } + } catch (error: any) { + console.error('❌ [Qonversion] offerings() call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + if (state.loading) { + return ; + } + + return ( + + + Load Offerings + + + {state.offerings ? ( + + + Main Offering ID: {state.offerings.main?.id} + + + {state.offerings.availableOffering.map((offering) => ( + + {offering.id} + Tag: {offering.tag} + + {offering.products.length > 0 ? ( + + Products: + {offering.products.map((product) => ( + + ))} + + ) : ( + + No products in this offering + + )} + + ))} + + ) : ( + + No Offerings Loaded + + Tap the button above to load available offerings + + + )} + + ); +}; + +export default OfferingsScreen; diff --git a/example/src/screens/OfferingsScreen/styles.ts b/example/src/screens/OfferingsScreen/styles.ts new file mode 100644 index 00000000..f5140077 --- /dev/null +++ b/example/src/screens/OfferingsScreen/styles.ts @@ -0,0 +1,113 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + padding: 16, + paddingBottom: 32, + }, + contentContainer: { + paddingBottom: 32, + }, + button: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + marginBottom: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + listContainer: { + marginTop: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 12, + color: '#000000', + }, + listItem: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 12, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + listItemTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + color: '#000000', + }, + listItemSubtitle: { + fontSize: 14, + color: '#666666', + marginBottom: 4, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 40, + }, + emptyStateTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#666666', + marginBottom: 8, + textAlign: 'center', + }, + emptyStateSubtitle: { + fontSize: 14, + color: '#999999', + textAlign: 'center', + lineHeight: 20, + }, + offeringContainer: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 16, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + offeringTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 4, + color: '#000000', + }, + offeringSubtitle: { + fontSize: 14, + color: '#666666', + marginBottom: 12, + }, + productsContainer: { + marginTop: 8, + }, + productsTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 8, + color: '#333333', + }, + noProductsText: { + fontSize: 14, + color: '#999999', + fontStyle: 'italic', + marginTop: 8, + }, +}); diff --git a/example/src/screens/OtherScreen/index.tsx b/example/src/screens/OtherScreen/index.tsx new file mode 100644 index 00000000..bc2003ba --- /dev/null +++ b/example/src/screens/OtherScreen/index.tsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react'; +import { + Text, + View, + TouchableOpacity, + Alert, + ScrollView, + Platform, +} from 'react-native'; +import Qonversion from '@qonversion/react-native-sdk'; +import { AppContext } from '../../store/AppStore'; +import SkeletonLoader from '../../components/SkeletonLoader'; +import styles from './styles'; +import Snackbar from 'react-native-snackbar'; + +const OtherScreen: React.FC = () => { + const context = React.useContext(AppContext); + if (!context) return null; + const { state, dispatch } = context; + + const [fallbackAccessible, setFallbackAccessible] = useState( + null + ); + + const checkFallbackFile = async () => { + try { + console.log( + '🔄 [Qonversion] Starting isFallbackFileAccessible() call...' + ); + dispatch({ type: 'SET_LOADING', payload: true }); + const accessible = + await Qonversion.getSharedInstance().isFallbackFileAccessible(); + console.log( + '✅ [Qonversion] isFallbackFileAccessible() call successful:', + accessible + ); + setFallbackAccessible(accessible); + } catch (error: any) { + console.error( + '❌ [Qonversion] isFallbackFileAccessible() call failed:', + error + ); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const collectAdvertisingId = () => { + if (Platform.OS !== 'ios') { + Alert.alert('Error', 'This method is iOS only'); + return; + } + try { + console.log('🔄 [Qonversion] Starting collectAdvertisingId() call...'); + Qonversion.getSharedInstance().collectAdvertisingId(); + console.log('✅ [Qonversion] collectAdvertisingId() call successful'); + Snackbar.show({ + text: 'Advertising ID collected!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error( + '❌ [Qonversion] collectAdvertisingId() call failed:', + error + ); + Alert.alert('Error', error.message); + } + }; + + const collectAppleSearchAdsAttribution = () => { + if (Platform.OS !== 'ios') { + Alert.alert('Error', 'This method is iOS only'); + return; + } + try { + console.log( + '🔄 [Qonversion] Starting collectAppleSearchAdsAttribution() call...' + ); + Qonversion.getSharedInstance().collectAppleSearchAdsAttribution(); + console.log( + '✅ [Qonversion] collectAppleSearchAdsAttribution() call successful' + ); + Snackbar.show({ + text: 'Apple Search Ads attribution collected!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error( + '❌ [Qonversion] collectAppleSearchAdsAttribution() call failed:', + error + ); + Alert.alert('Error', error.message); + } + }; + + const presentCodeRedemptionSheet = () => { + if (Platform.OS !== 'ios') { + Alert.alert('Error', 'This method is iOS only'); + return; + } + try { + console.log( + '🔄 [Qonversion] Starting presentCodeRedemptionSheet() call...' + ); + Qonversion.getSharedInstance().presentCodeRedemptionSheet(); + console.log( + '✅ [Qonversion] presentCodeRedemptionSheet() call successful' + ); + Snackbar.show({ + text: 'Code redemption sheet presented!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error( + '❌ [Qonversion] presentCodeRedemptionSheet() call failed:', + error + ); + Alert.alert('Error', error.message); + } + }; + + if (state.loading) { + return ; + } + + return ( + + + Check Fallback File Accessibility + + + + Accessibility: + + + + + iOS Only Methods: + + Collect Advertising ID + + + + Collect Apple Search Ads Attribution + + + + Present Code Redemption Sheet + + + + ); +}; + +export default OtherScreen; diff --git a/example/src/screens/OtherScreen/styles.ts b/example/src/screens/OtherScreen/styles.ts new file mode 100644 index 00000000..c8b4edfd --- /dev/null +++ b/example/src/screens/OtherScreen/styles.ts @@ -0,0 +1,58 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + padding: 16, + paddingBottom: 32, + }, + contentContainer: { + paddingBottom: 32, + }, + button: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + marginBottom: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + indicatorContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 20, + }, + indicatorLabel: { + fontSize: 14, + fontWeight: '600', + marginRight: 12, + color: '#000000', + }, + indicator: { + width: 20, + height: 20, + borderRadius: 10, + }, + indicatorGray: { + backgroundColor: '#cccccc', + }, + indicatorGreen: { + backgroundColor: '#34C759', + }, + indicatorRed: { + backgroundColor: '#FF3B30', + }, + iosOnlyContainer: { + marginTop: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 12, + color: '#000000', + }, +}); diff --git a/example/src/screens/ProductDetailScreen/index.tsx b/example/src/screens/ProductDetailScreen/index.tsx new file mode 100644 index 00000000..a398d3cf --- /dev/null +++ b/example/src/screens/ProductDetailScreen/index.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { Text, View, TouchableOpacity, Alert, ScrollView } from 'react-native'; +import Snackbar from 'react-native-snackbar'; +import Qonversion, { Product } from '@qonversion/react-native-sdk'; +import { AppContext } from '../../store/AppStore'; +import SkeletonLoader from '../../components/SkeletonLoader'; +import styles from './styles'; + +interface ProductDetailScreenProps { + product: Product; +} + +const ProductDetailScreen: React.FC = ({ + product, +}) => { + const context = React.useContext(AppContext); + if (!context) return null; + const { state, dispatch } = context; + + const purchaseProduct = async () => { + try { + console.log( + '🔄 [Qonversion] Starting purchaseProduct() call with product:', + product + ); + dispatch({ type: 'SET_LOADING', payload: true }); + const entitlements = + await Qonversion.getSharedInstance().purchaseProduct(product); + console.log( + '✅ [Qonversion] purchaseProduct() call successful:', + entitlements + ); + dispatch({ type: 'SET_ENTITLEMENTS', payload: entitlements }); + Snackbar.show({ + text: 'Product purchased successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error('❌ [Qonversion] purchaseProduct() call failed:', error); + if (!error.userCanceled) { + Alert.alert('Error', error.message); + } + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + if (state.loading) { + return ; + } + + const renderField = (label: string, value: any) => { + if (value === null || value === undefined) return null; + return ( + + {label}: + {String(value)} + + ); + }; + + return ( + + + + {product.storeTitle || product.qonversionId} + + {product.prettyPrice && ( + {product.prettyPrice} + )} + + + + Basic Information + {renderField('Qonversion ID', product.qonversionId)} + {renderField('Store ID', product.storeId)} + {renderField('Base Plan ID', product.basePlanId)} + {renderField('Type', product.type)} + {renderField('Offering ID', product.offeringId)} + + + + Pricing + {renderField('Pretty Price', product.prettyPrice)} + {renderField('Price', product.price)} + {renderField('Currency Code', product.currencyCode)} + {renderField( + 'Pretty Introductory Price', + product.prettyIntroductoryPrice + )} + + + + Store Information + {renderField('Store Title', product.storeTitle)} + {renderField('Store Description', product.storeDescription)} + + + + Subscription Details + {product.subscriptionPeriod && ( + + Subscription Period: + + {product.subscriptionPeriod.unitCount}{' '} + {product.subscriptionPeriod.unit} + + + )} + {product.trialPeriod && ( + + Trial Period: + + {product.trialPeriod.unitCount} {product.trialPeriod.unit} + + + )} + + + + Purchase Product + + + ); +}; + +export default ProductDetailScreen; diff --git a/example/src/screens/ProductDetailScreen/styles.ts b/example/src/screens/ProductDetailScreen/styles.ts new file mode 100644 index 00000000..6c6746e7 --- /dev/null +++ b/example/src/screens/ProductDetailScreen/styles.ts @@ -0,0 +1,59 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + padding: 16, + }, + contentContainer: { + paddingBottom: 32, + }, + header: { + marginBottom: 20, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 8, + color: '#000000', + }, + price: { + fontSize: 18, + color: '#007AFF', + fontWeight: '600', + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 12, + color: '#000000', + }, + fieldContainer: { + marginBottom: 8, + }, + fieldLabel: { + fontSize: 14, + fontWeight: '600', + color: '#666666', + marginBottom: 2, + }, + fieldValue: { + fontSize: 16, + color: '#000000', + }, + purchaseButton: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginTop: 20, + }, + purchaseButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/example/src/screens/ProductsScreen/index.tsx b/example/src/screens/ProductsScreen/index.tsx new file mode 100644 index 00000000..4188d44a --- /dev/null +++ b/example/src/screens/ProductsScreen/index.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Text, View, TouchableOpacity, Alert, ScrollView } from 'react-native'; +import Qonversion from '@qonversion/react-native-sdk'; +import { AppContext } from '../../store/AppStore'; +import SkeletonLoader from '../../components/SkeletonLoader'; +import ProductCard from '../../components/ProductCard'; +import styles from './styles'; + +const ProductsScreen: React.FC = () => { + const context = React.useContext(AppContext); + if (!context) return null; + const { state, dispatch } = context; + + const loadProducts = async () => { + try { + console.log('🔄 [Qonversion] Starting products() call...'); + dispatch({ type: 'SET_LOADING', payload: true }); + const products = await Qonversion.getSharedInstance().products(); + console.log('✅ [Qonversion] products() call successful:', products); + dispatch({ type: 'SET_PRODUCTS', payload: products }); + } catch (error: any) { + console.error('❌ [Qonversion] products() call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + if (state.loading) { + return ; + } + + return ( + + + Load Products + + + {state.products && ( + + {Array.from(state.products.entries()).map(([id, product]) => ( + + ))} + + )} + + ); +}; + +export default ProductsScreen; diff --git a/example/src/screens/ProductsScreen/styles.ts b/example/src/screens/ProductsScreen/styles.ts new file mode 100644 index 00000000..0ac93214 --- /dev/null +++ b/example/src/screens/ProductsScreen/styles.ts @@ -0,0 +1,51 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + contentContainer: { + padding: 16, + paddingBottom: 32, + }, + button: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + marginBottom: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + listContainer: { + marginTop: 20, + }, + listItem: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 12, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + listItemTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + color: '#000000', + }, + listItemSubtitle: { + fontSize: 14, + color: '#666666', + marginBottom: 4, + }, +}); diff --git a/example/src/screens/RemoteConfigsScreen/index.tsx b/example/src/screens/RemoteConfigsScreen/index.tsx new file mode 100644 index 00000000..cd33f6c0 --- /dev/null +++ b/example/src/screens/RemoteConfigsScreen/index.tsx @@ -0,0 +1,219 @@ +import React, { useState } from 'react'; +import { + Text, + View, + TouchableOpacity, + Alert, + ScrollView, + TextInput, +} from 'react-native'; +import Qonversion, { RemoteConfigList } from '@qonversion/react-native-sdk'; +import { AppContext } from '../../store/AppStore'; +import SkeletonLoader from '../../components/SkeletonLoader'; +import styles from './styles'; +import Snackbar from 'react-native-snackbar'; + +const RemoteConfigsScreen: React.FC = () => { + const context = React.useContext(AppContext); + if (!context) return null; + const { state, dispatch } = context; + + const [contextKeys, setContextKeys] = useState(''); + const [singleContextKey, setSingleContextKey] = useState(''); + const [experimentId, setExperimentId] = useState(''); + const [groupId, setGroupId] = useState(''); + + const loadRemoteConfigList = async () => { + try { + console.log('🔄 [Qonversion] Starting remoteConfigList call...'); + dispatch({ type: 'SET_LOADING', payload: true }); + let remoteConfigs; + if (contextKeys.trim()) { + const keys = contextKeys.split(',').map((k) => k.trim()); + console.log( + '🔄 [Qonversion] Calling remoteConfigListForContextKeys with keys:', + keys + ); + remoteConfigs = + await Qonversion.getSharedInstance().remoteConfigListForContextKeys( + keys, + true + ); + console.log( + '✅ [Qonversion] remoteConfigListForContextKeys call successful:', + remoteConfigs + ); + } else { + console.log('🔄 [Qonversion] Calling remoteConfigList...'); + remoteConfigs = await Qonversion.getSharedInstance().remoteConfigList(); + console.log( + '✅ [Qonversion] remoteConfigList call successful:', + remoteConfigs + ); + } + dispatch({ type: 'SET_REMOTE_CONFIGS', payload: remoteConfigs }); + } catch (error: any) { + console.error('❌ [Qonversion] Remote config list call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const loadSingleRemoteConfig = async () => { + try { + console.log( + '🔄 [Qonversion] Starting remoteConfig call with contextKey:', + singleContextKey + ); + dispatch({ type: 'SET_LOADING', payload: true }); + const config = await Qonversion.getSharedInstance().remoteConfig( + singleContextKey || undefined + ); + console.log('✅ [Qonversion] remoteConfig call successful:', config); + // Create a RemoteConfigList with a single config + const remoteConfigList = new RemoteConfigList([config]); + dispatch({ type: 'SET_REMOTE_CONFIGS', payload: remoteConfigList }); + } catch (error: any) { + console.error('❌ [Qonversion] remoteConfig call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const attachToExperiment = async () => { + try { + console.log( + '🔄 [Qonversion] Starting attachUserToExperiment call with experimentId:', + experimentId, + 'groupId:', + groupId + ); + await Qonversion.getSharedInstance().attachUserToExperiment( + experimentId, + groupId + ); + console.log('✅ [Qonversion] attachUserToExperiment call successful'); + Snackbar.show({ + text: 'User attached to experiment!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error( + '❌ [Qonversion] attachUserToExperiment call failed:', + error + ); + Alert.alert('Error', error.message); + } + }; + + const detachFromExperiment = async () => { + try { + console.log( + '🔄 [Qonversion] Starting detachUserFromExperiment call with experimentId:', + experimentId + ); + await Qonversion.getSharedInstance().detachUserFromExperiment( + experimentId + ); + console.log('✅ [Qonversion] detachUserFromExperiment call successful'); + Snackbar.show({ + text: 'User detached from experiment!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error( + '❌ [Qonversion] detachUserFromExperiment call failed:', + error + ); + Alert.alert('Error', error.message); + } + }; + + if (state.loading) { + return ; + } + + return ( + + + Context Keys (comma-separated): + + + Get Remote Config List + + + + + Single Context Key: + + + Get Remote Config + + + + + Experiment ID: + + Group ID: + + + Attach To Experiment + + + Detach From Experiment + + + + {state.remoteConfigs && ( + + {state.remoteConfigs.remoteConfigs.map((config, index) => ( + + + Context Key: {config.source.contextKey || 'empty'} + + + Source: {config.source.name} + + + Type: {config.source.type} + + + Payload: {JSON.stringify(config.payload)} + + + ))} + + )} + + ); +}; + +export default RemoteConfigsScreen; diff --git a/example/src/screens/RemoteConfigsScreen/styles.ts b/example/src/screens/RemoteConfigsScreen/styles.ts new file mode 100644 index 00000000..d1eb7c2c --- /dev/null +++ b/example/src/screens/RemoteConfigsScreen/styles.ts @@ -0,0 +1,67 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + padding: 16, + paddingBottom: 32, + }, + contentContainer: { + paddingBottom: 32, + }, + inputContainer: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + color: '#000000', + }, + textInput: { + backgroundColor: '#ffffff', + borderWidth: 1, + borderColor: '#e0e0e0', + borderRadius: 8, + padding: 12, + marginBottom: 12, + fontSize: 16, + }, + button: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + marginBottom: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + listContainer: { + marginTop: 20, + }, + listItem: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 12, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + listItemTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 4, + color: '#000000', + }, + listItemSubtitle: { + fontSize: 14, + color: '#666666', + marginBottom: 4, + }, +}); diff --git a/example/src/screens/UserScreen/index.tsx b/example/src/screens/UserScreen/index.tsx new file mode 100644 index 00000000..cf3ca6e7 --- /dev/null +++ b/example/src/screens/UserScreen/index.tsx @@ -0,0 +1,377 @@ +import React, { useState } from 'react'; +import { + Text, + View, + TouchableOpacity, + Alert, + ScrollView, + TextInput, +} from 'react-native'; +import Qonversion, { + UserPropertyKey, + AttributionProvider, +} from '@qonversion/react-native-sdk'; +import { AppContext } from '../../store/AppStore'; +import SkeletonLoader from '../../components/SkeletonLoader'; +import UserInfoSection from '../../components/UserInfoSection'; +import styles from './styles'; +import Snackbar from 'react-native-snackbar'; + +const UserScreen: React.FC = () => { + const [identityId, setIdentityId] = useState(''); + const [propertyKey, setPropertyKey] = useState(''); + const [propertyValue, setPropertyValue] = useState(''); + const [attributionData, setAttributionData] = useState(''); + const [userProperties, setUserProperties] = useState(null); + + // New state for radio button selection + const [selectedPropertyKey, setSelectedPropertyKey] = useState< + UserPropertyKey | 'custom' + >('custom'); + const [selectedAttributionProvider, setSelectedAttributionProvider] = + useState(AttributionProvider.APPSFLYER); + + const context = React.useContext(AppContext); + if (!context) return null; + const { state, dispatch } = context; + + const identify = async () => { + try { + console.log( + '🔄 [Qonversion] Starting identify() call with identityId:', + identityId + ); + dispatch({ type: 'SET_LOADING', payload: true }); + const userInfo = + await Qonversion.getSharedInstance().identify(identityId); + console.log('✅ [Qonversion] identify() call successful:', userInfo); + dispatch({ type: 'SET_USER_INFO', payload: userInfo }); + Snackbar.show({ + text: 'User identified successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error('❌ [Qonversion] identify() call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const logout = async () => { + try { + console.log('🔄 [Qonversion] Starting logout() call...'); + Qonversion.getSharedInstance().logout(); + console.log('✅ [Qonversion] logout() call successful'); + + console.log('🔄 [Qonversion] Starting userInfo() call after logout...'); + const userInfo = await Qonversion.getSharedInstance().userInfo(); + console.log( + '✅ [Qonversion] userInfo() call after logout successful:', + userInfo + ); + dispatch({ type: 'SET_USER_INFO', payload: userInfo }); + Snackbar.show({ + text: 'User logged out successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error('❌ [Qonversion] Logout process failed:', error); + Alert.alert('Error', error.message); + } + }; + + const loadUserInfo = async () => { + try { + console.log('🔄 [Qonversion] Starting userInfo() call...'); + dispatch({ type: 'SET_LOADING', payload: true }); + const userInfo = await Qonversion.getSharedInstance().userInfo(); + console.log('✅ [Qonversion] userInfo() call successful:', userInfo); + dispatch({ type: 'SET_USER_INFO', payload: userInfo }); + } catch (error: any) { + console.error('❌ [Qonversion] userInfo() call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const loadUserProperties = async () => { + try { + console.log('🔄 [Qonversion] Starting userProperties() call...'); + dispatch({ type: 'SET_LOADING', payload: true }); + const properties = await Qonversion.getSharedInstance().userProperties(); + console.log( + '✅ [Qonversion] userProperties() call successful:', + properties + ); + setUserProperties(properties); + Snackbar.show({ + text: 'User properties loaded successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } catch (error: any) { + console.error('❌ [Qonversion] userProperties() call failed:', error); + Alert.alert('Error', error.message); + } finally { + dispatch({ type: 'SET_LOADING', payload: false }); + } + }; + + const setUserProperty = async () => { + try { + if (propertyValue) { + if (selectedPropertyKey === 'custom') { + // For custom properties, use setCustomUserProperty + if (propertyKey && propertyValue) { + console.log( + '🔄 [Qonversion] Starting setCustomUserProperty() call with key:', + propertyKey, + 'value:', + propertyValue + ); + Qonversion.getSharedInstance().setCustomUserProperty( + propertyKey, + propertyValue + ); + console.log( + '✅ [Qonversion] setCustomUserProperty() call successful' + ); + Snackbar.show({ + text: 'Custom user property set successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } + } else { + // For predefined properties, use setUserProperty + console.log( + '🔄 [Qonversion] Starting setUserProperty() call with key:', + selectedPropertyKey, + 'value:', + propertyValue + ); + Qonversion.getSharedInstance().setUserProperty( + selectedPropertyKey as UserPropertyKey, + propertyValue + ); + console.log('✅ [Qonversion] setUserProperty() call successful'); + Snackbar.show({ + text: 'User property set successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } + } + } catch (error: any) { + console.error('❌ [Qonversion] Property setting failed:', error); + Alert.alert('Error', error.message); + } + }; + + const sendAttribution = async () => { + try { + if (attributionData && selectedAttributionProvider) { + const data = JSON.parse(attributionData); + console.log( + '🔄 [Qonversion] Starting attribution() call with provider:', + selectedAttributionProvider, + 'data:', + data + ); + Qonversion.getSharedInstance().attribution( + data, + selectedAttributionProvider as AttributionProvider + ); + console.log('✅ [Qonversion] attribution() call successful'); + Snackbar.show({ + text: 'Attribution data sent successfully!', + duration: Snackbar.LENGTH_SHORT, + }); + } + } catch (error: any) { + console.error('❌ [Qonversion] attribution() call failed:', error); + Alert.alert('Error', error.message); + } + }; + + const renderRadioButton = ( + label: string, + value: string, + selectedValue: string, + onSelect: (value: any) => void + ) => ( + onSelect(value)} + > + + {selectedValue === value && } + + {label} + + ); + + if (state.loading) { + return ; + } + + const canIdentify = !state.userInfo?.identityId; + const canLogout = !!state.userInfo?.identityId; + + return ( + + + + + Identity ID: + + + Identify + + + Logout + + + User Info + + + + + Properties + + User Properties + + {userProperties && ( + + User Properties: + + + Key + + + Value + + + + {userProperties.properties.map((property: any, index: number) => ( + + + {property.key} + + + {property.value} + + + ))} + + + )} + + Property Key: + + {/* UserPropertyKey Radio Buttons */} + + {Object.values(UserPropertyKey) + .filter((key) => key !== UserPropertyKey.CUSTOM) + .map((key) => + renderRadioButton( + key, + key, + selectedPropertyKey, + setSelectedPropertyKey + ) + )} + {renderRadioButton( + 'Custom (Manual Input)', + 'custom', + selectedPropertyKey, + setSelectedPropertyKey + )} + + + {selectedPropertyKey === 'custom' && ( + + )} + + Property Value: + + + Set Property + + + + + Attribution + Attribution Data (JSON): + + + Attribution Provider: + + {/* AttributionProvider Radio Buttons */} + + {Object.values(AttributionProvider).map((provider) => + renderRadioButton( + provider, + provider, + selectedAttributionProvider, + setSelectedAttributionProvider + ) + )} + + + + Send Attribution + + + + ); +}; + +export default UserScreen; diff --git a/example/src/screens/UserScreen/styles.ts b/example/src/screens/UserScreen/styles.ts new file mode 100644 index 00000000..9003d0f8 --- /dev/null +++ b/example/src/screens/UserScreen/styles.ts @@ -0,0 +1,194 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + flex: 1, + padding: 16, + paddingBottom: 32, + }, + contentContainer: { + paddingBottom: 32, + }, + userInfoContainer: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 20, + }, + userInfoTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + color: '#000000', + }, + userInfoText: { + fontSize: 14, + color: '#666666', + marginBottom: 4, + }, + inputContainer: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + color: '#000000', + }, + textInput: { + backgroundColor: '#ffffff', + borderWidth: 1, + borderColor: '#e0e0e0', + borderRadius: 8, + padding: 12, + marginBottom: 12, + fontSize: 16, + }, + multilineInput: { + height: 80, + textAlignVertical: 'top', + }, + button: { + backgroundColor: '#007AFF', + padding: 16, + borderRadius: 8, + marginBottom: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + disabledButton: { + backgroundColor: '#cccccc', + }, + sectionTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 12, + color: '#000000', + }, + userPropertiesContainer: { + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginBottom: 20, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + userPropertiesTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 12, + color: '#000000', + }, + userPropertyItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + userPropertyKey: { + fontSize: 14, + fontWeight: '600', + color: '#333333', + flex: 1, + }, + userPropertyValue: { + fontSize: 14, + color: '#666666', + flex: 2, + textAlign: 'right', + flexWrap: 'wrap', + }, + // Radio button styles + radioGroup: { + marginBottom: 12, + }, + radioButtonContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 4, + }, + radioButton: { + width: 20, + height: 20, + borderRadius: 10, + borderWidth: 2, + borderColor: '#007AFF', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + radioButtonSelected: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: '#007AFF', + }, + radioButtonLabel: { + fontSize: 14, + color: '#000000', + flex: 1, + }, + // Table styles + tableHeader: { + flexDirection: 'row', + backgroundColor: '#f8f9fa', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + borderRadius: 8, + marginBottom: 8, + alignItems: 'center', + justifyContent: 'center', + }, + tableHeaderKey: { + flex: 2, + fontSize: 14, + fontWeight: 'bold', + color: '#000000', + textAlignVertical: 'center', + }, + tableHeaderValue: { + flex: 3, + fontSize: 14, + fontWeight: 'bold', + color: '#000000', + textAlignVertical: 'center', + }, + tableContainer: { + // Removed maxHeight to allow natural expansion + }, + tableRow: { + flexDirection: 'row', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + alignItems: 'center', + justifyContent: 'center', + minHeight: 50, + }, + tableRowEven: { + backgroundColor: '#f8f9fa', + }, + tableCellKey: { + flex: 2, + fontSize: 14, + color: '#333333', + fontWeight: '500', + textAlignVertical: 'center', + }, + tableCellValue: { + flex: 3, + fontSize: 14, + color: '#666666', + textAlignVertical: 'center', + }, +}); diff --git a/example/src/store/AppStore.ts b/example/src/store/AppStore.ts new file mode 100644 index 00000000..9f2d5c6f --- /dev/null +++ b/example/src/store/AppStore.ts @@ -0,0 +1,126 @@ +import React from 'react'; +import { + Offerings, + Product, + RemoteConfigList, + User, +} from '@qonversion/react-native-sdk'; +import Entitlement from '../../../src/dto/Entitlement'; + +// Global Store (Redux-like pattern) +export interface AppState { + products: Map | null; + offerings: Offerings | null; + entitlements: Map | null; + remoteConfigs: RemoteConfigList | null; + userInfo: User | null; + loading: boolean; + navigationStack: string[]; + noCodesEvents: string[]; + qonversionInitStatus: + | 'not_initialized' + | 'initializing' + | 'success' + | 'error'; + selectedProduct: Product | null; + selectedEntitlement: Entitlement | null; + isQonversionInitialized: boolean; +} + +export type AppAction = + | { type: 'SET_PRODUCTS'; payload: Map } + | { type: 'SET_OFFERINGS'; payload: Offerings } + | { type: 'SET_ENTITLEMENTS'; payload: Map } + | { type: 'SET_REMOTE_CONFIGS'; payload: RemoteConfigList } + | { type: 'SET_USER_INFO'; payload: User } + | { type: 'SET_LOADING'; payload: boolean } + | { type: 'PUSH_SCREEN'; payload: string } + | { type: 'POP_SCREEN' } + | { type: 'REPLACE_SCREEN'; payload: string } + | { type: 'ADD_NOCODES_EVENT'; payload: string } + | { + type: 'SET_QONVERSION_INIT_STATUS'; + payload: 'not_initialized' | 'initializing' | 'success' | 'error'; + } + | { type: 'SET_SELECTED_PRODUCT'; payload: Product | null } + | { type: 'SET_SELECTED_ENTITLEMENT'; payload: Entitlement | null } + | { type: 'SET_QONVERSION_INITIALIZED'; payload: boolean }; + +export const initialState: AppState = { + products: null, + offerings: null, + entitlements: null, + remoteConfigs: null, + userInfo: null, + loading: false, + navigationStack: ['main'], + noCodesEvents: [], + qonversionInitStatus: 'not_initialized', + selectedProduct: null, + selectedEntitlement: null, + isQonversionInitialized: false, +}; + +export function appReducer(state: AppState, action: AppAction): AppState { + switch (action.type) { + case 'SET_PRODUCTS': + return { ...state, products: action.payload }; + case 'SET_OFFERINGS': + return { ...state, offerings: action.payload }; + case 'SET_ENTITLEMENTS': + return { ...state, entitlements: action.payload }; + case 'SET_REMOTE_CONFIGS': + return { ...state, remoteConfigs: action.payload }; + case 'SET_USER_INFO': + return { ...state, userInfo: action.payload }; + case 'SET_LOADING': + return { ...state, loading: action.payload }; + case 'PUSH_SCREEN': + return { + ...state, + navigationStack: [...state.navigationStack, action.payload], + }; + case 'POP_SCREEN': + return { + ...state, + navigationStack: + state.navigationStack.length > 1 + ? state.navigationStack.slice(0, -1) + : state.navigationStack, + }; + case 'REPLACE_SCREEN': + return { + ...state, + navigationStack: + state.navigationStack.length > 0 + ? [...state.navigationStack.slice(0, -1), action.payload] + : [action.payload], + }; + case 'ADD_NOCODES_EVENT': + return { + ...state, + noCodesEvents: [...state.noCodesEvents, action.payload], + }; + case 'SET_QONVERSION_INIT_STATUS': + return { ...state, qonversionInitStatus: action.payload }; + case 'SET_SELECTED_PRODUCT': + return { ...state, selectedProduct: action.payload }; + case 'SET_SELECTED_ENTITLEMENT': + return { ...state, selectedEntitlement: action.payload }; + case 'SET_QONVERSION_INITIALIZED': + return { ...state, isQonversionInitialized: action.payload }; + default: + return state; + } +} + +// Helper function to get current screen from navigation stack +export const getCurrentScreen = (state: AppState): string => { + return state.navigationStack[state.navigationStack.length - 1] || 'main'; +}; + +// Global store context +export const AppContext = React.createContext<{ + state: AppState; + dispatch: React.Dispatch; +} | null>(null); diff --git a/example/yarn.lock b/example/yarn.lock index a29fcdd9..ea77c58a 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -1779,6 +1779,7 @@ __metadata: "@babel/core": ^7.25.2 "@babel/preset-env": ^7.25.3 "@babel/runtime": ^7.25.0 + "@react-native-clipboard/clipboard": ^1.16.3 "@react-native-community/cli": 19.0.0 "@react-native-community/cli-platform-android": 19.0.0 "@react-native-community/cli-platform-ios": 19.0.0 @@ -1789,9 +1790,27 @@ __metadata: react: 19.1.0 react-native: 0.80.1 react-native-builder-bob: 0.40.13 + react-native-snackbar: ^2.9.0 languageName: unknown linkType: soft +"@react-native-clipboard/clipboard@npm:^1.16.3": + version: 1.16.3 + resolution: "@react-native-clipboard/clipboard@npm:1.16.3" + peerDependencies: + react: ">= 16.9.0" + react-native: ">= 0.61.5" + react-native-macos: ">= 0.61.0" + react-native-windows: ">= 0.61.0" + peerDependenciesMeta: + react-native-macos: + optional: true + react-native-windows: + optional: true + checksum: 3d9944fc2c4acbecf917e752cc36ec92c4dcdb590c94c81c78c24df9ddd4b02448e252eb39e0949000b01046cfdfe2b03e1676c5e3ac0fe7eb3bf6b649970c27 + languageName: node + linkType: hard + "@react-native-community/cli-clean@npm:19.0.0": version: 19.0.0 resolution: "@react-native-community/cli-clean@npm:19.0.0" @@ -5738,6 +5757,16 @@ __metadata: languageName: node linkType: hard +"react-native-snackbar@npm:^2.9.0": + version: 2.9.0 + resolution: "react-native-snackbar@npm:2.9.0" + peerDependencies: + react: ">=16" + react-native: ">=0.60" + checksum: ea80b525306fd207e390c7852cf8a4d8d0345c1c48c56713316e1f28e4676f3307f381ed3b80ff966e4ecaa6db1041079e92f9816100df7d4f39ca4f365cc9d5 + languageName: node + linkType: hard + "react-native@npm:0.80.1": version: 0.80.1 resolution: "react-native@npm:0.80.1" diff --git a/yarn.lock b/yarn.lock index df26e71c..392d1a1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2301,6 +2301,7 @@ __metadata: "@babel/core": ^7.25.2 "@babel/preset-env": ^7.25.3 "@babel/runtime": ^7.25.0 + "@react-native-clipboard/clipboard": ^1.16.3 "@react-native-community/cli": 19.0.0 "@react-native-community/cli-platform-android": 19.0.0 "@react-native-community/cli-platform-ios": 19.0.0 @@ -2311,6 +2312,7 @@ __metadata: react: 19.1.0 react-native: 0.80.1 react-native-builder-bob: 0.40.13 + react-native-snackbar: ^2.9.0 languageName: unknown linkType: soft @@ -2345,6 +2347,23 @@ __metadata: languageName: unknown linkType: soft +"@react-native-clipboard/clipboard@npm:^1.16.3": + version: 1.16.3 + resolution: "@react-native-clipboard/clipboard@npm:1.16.3" + peerDependencies: + react: ">= 16.9.0" + react-native: ">= 0.61.5" + react-native-macos: ">= 0.61.0" + react-native-windows: ">= 0.61.0" + peerDependenciesMeta: + react-native-macos: + optional: true + react-native-windows: + optional: true + checksum: 3d9944fc2c4acbecf917e752cc36ec92c4dcdb590c94c81c78c24df9ddd4b02448e252eb39e0949000b01046cfdfe2b03e1676c5e3ac0fe7eb3bf6b649970c27 + languageName: node + linkType: hard + "@react-native-community/cli-clean@npm:15.0.0-alpha.2": version: 15.0.0-alpha.2 resolution: "@react-native-community/cli-clean@npm:15.0.0-alpha.2" @@ -9214,6 +9233,16 @@ __metadata: languageName: node linkType: hard +"react-native-snackbar@npm:^2.9.0": + version: 2.9.0 + resolution: "react-native-snackbar@npm:2.9.0" + peerDependencies: + react: ">=16" + react-native: ">=0.60" + checksum: ea80b525306fd207e390c7852cf8a4d8d0345c1c48c56713316e1f28e4676f3307f381ed3b80ff966e4ecaa6db1041079e92f9816100df7d4f39ca4f365cc9d5 + languageName: node + linkType: hard + "react-native@npm:0.79.2": version: 0.79.2 resolution: "react-native@npm:0.79.2"