diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fbb59a..5e19ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## 4.0.5 (2026-02-04) + + +* [DEV-359] feat!(main): support expo apps, rm native module. Kudos https://github.com/ahmetkuslular (dc9762c) + + +### Bug Fixes + +* add .js extensions to ESM imports in tests and mock native modules (41317ad) +* **gcm:** message variability (544eb08) + + +### Features + +* **common:** bump version (ce7e70d) +* **sdk:** bump device-info (1d1f10e) +* **sdk:** getToken to use correct token (d07df14) +* **sdk:** in-app push notifications (#51) (6fa0296) +* **sdk:** include types for exclude_brands (#50) (cd1d301) +* **sdk:** rm jest (d0cce5d) +* **sdk:** sid token generation (4a34846) + + +### BREAKING CHANGES + +* rm react-native-device-info, dynamically import if +needed + + + ## 4.0.4 (2026-01-28) diff --git a/MainSDK.js b/MainSDK.js index c548791..089dcdf 100644 --- a/MainSDK.js +++ b/MainSDK.js @@ -36,6 +36,8 @@ import { blankSearchRequest } from './utils' import { isOverOneWeekAgo } from './utils' import { getStorageKey } from './utils' import { SDK_API_URL } from './index' +import { prepareAndShow, registerSDK } from './components/Popup/SdkPopupOverlay' +import PopupLogic from './lib/popup' /** * @typedef {Object} Event @@ -110,6 +112,13 @@ class MainSDK extends Performer { this.lastMessageIds = [] this.autoSendPushToken = autoSendPushToken this.deviceInfo = deviceInfo + + /** + * Popup presentation delegate. + * If set, SDK will NOT show popups automatically and will forward popup payload to host app. + * @type {(popup: any, sdk: MainSDK) => void | null} + */ + this.popupPresentationDelegate = null // Firebase is initialized automatically by native modules // Initialize messaging lazily when needed @@ -225,74 +234,63 @@ class MainSDK extends Performer { const storageData = await getData(this.shop_id) if (DEBUG) console.log('[SDK Init] Storage data:', storageData) - let response = null + if (DEBUG) console.log('[SDK Init] Making init request to API...') + + let did = '' + // First try to get did from cache if (storageData?.did) { - if (DEBUG) console.log('[SDK Init] Using cached device ID:', storageData.did) - this.deviceId = storageData.did - response = storageData - if ( - !storageData?.seance || - !storageData?.expires || - new Date().getTime() > storageData?.expires - ) { - response.sid = response.seance = generateSid() - if (DEBUG) console.log('[SDK Init] Generated new session ID:', response.sid) - } + did = storageData.did + if (DEBUG) console.log('[SDK Init] Using cached device ID for request:', did) + } else if (this.deviceInfo && this.deviceInfo.id) { + did = this.deviceInfo.id } else { - if (DEBUG) console.log('[SDK Init] Making init request to API...') - let did = '' - - if (this.deviceInfo && this.deviceInfo.id) { - did = this.deviceInfo.id - } else { - try { - const DeviceInfo = await import('react-native-device-info') - did = - Platform.OS === 'android' - ? await DeviceInfo.getAndroidId() - : (await DeviceInfo.syncUniqueId()) || '' - } catch (e) { - console.error( - `Device ID is not present in init args, but also 'react-native-device-info' is not present: ${JSON.stringify( - e, - undefined, - 2 - )}` - ) - did = '' - } + try { + const DeviceInfo = await import('react-native-device-info') + did = + Platform.OS === 'android' + ? await DeviceInfo.getAndroidId() + : (await DeviceInfo.syncUniqueId()) || '' + } catch (e) { + console.error( + `Device ID is not present in init args, but also 'react-native-device-info' is not present: ${JSON.stringify( + e, + undefined, + 2 + )}` + ) + did = '' } + } - const params = { - shop_id: this.shop_id, - stream: this.stream, - } - if (did) { - params.did = did - } + const params = { + shop_id: this.shop_id, + stream: this.stream, + } + if (did) { + params.did = did + } - response = await request('init', this.shop_id, { params }) - - if (DEBUG) console.log('[SDK Init] API response:', response) - - // Check if response is an error - if (response instanceof Error || (response && response.message)) { - const error = response instanceof Error ? response : new Error(response.message || 'Init request failed') - if (DEBUG) console.error('[SDK Init] API error:', error) - this.initialized = false - throw error - } + const response = await request('init', this.shop_id, { params }) + + if (DEBUG) console.log('[SDK Init] API response:', JSON.stringify(response, null, 2)) + + // Check if response is an error + if (response instanceof Error || (response && response.message)) { + const error = response instanceof Error ? response : new Error(response.message || 'Init request failed') + if (DEBUG) console.error('[SDK Init] API error:', error) + this.initialized = false + throw error } - if (!response || (!response.did && !storageData?.did)) { + if (!response || !response.did) { const error = new Error('Invalid response from init: missing device ID') if (DEBUG) console.error('[SDK Init] Error:', error, 'Response:', response) this.initialized = false throw error } - const didToUse = response?.did || storageData?.did || '' + const didToUse = response.did this.deviceId = didToUse this.userSeance = response?.seance || response?.sid || '' if (!this.segment && response?.segment) { @@ -305,6 +303,13 @@ class MainSDK extends Performer { this.initialized = true if (DEBUG) console.log('[SDK Init] SDK initialized successfully!') + // Log full response for debugging + if (DEBUG) console.log('[SDK Init] Full response before popup check:', JSON.stringify(response, null, 2)) + + // Check for popup in init response + if (DEBUG) console.log('[SDK Init] Checking for popup in response...') + await this.checkAndShowPopup(response) + // Initialize messaging after SDK is initialized this._initMessaging() this.performQueue() @@ -389,7 +394,7 @@ class MainSDK extends Performer { this.push(async () => { try { const queryParams = await convertParams(event, options) - return await request('push', this.shop_id, { + const response = await request('push', this.shop_id, { headers: { 'Content-Type': 'application/json' }, method: 'POST', params: { @@ -398,6 +403,9 @@ class MainSDK extends Performer { ...queryParams, }, }) + // Check for popup in response + await this.checkAndShowPopup(response) + return response } catch (error) { return error } @@ -417,7 +425,7 @@ class MainSDK extends Performer { queryParams = Object.assign(queryParams, options) } - return await request('push/custom', this.shop_id, { + const response = await request('push/custom', this.shop_id, { headers: { 'Content-Type': 'application/json' }, method: 'POST', params: { @@ -426,6 +434,9 @@ class MainSDK extends Performer { ...queryParams, }, }) + // Check for popup in response + await this.checkAndShowPopup(response) + return response } catch (error) { return error } @@ -464,7 +475,7 @@ class MainSDK extends Performer { notificationTrack(event, options) { this.push(async () => { try { - return await request(`track/${event}`, this.shop_id, { + const res = await request(`track/${event}`, this.shop_id, { method: 'POST', headers: { 'Content-Type': 'application/json' }, params: { @@ -473,6 +484,9 @@ class MainSDK extends Performer { ...options, }, }) + // Check for popup in response + await this.checkAndShowPopup(res) + return res } catch (error) { return error } @@ -486,18 +500,19 @@ class MainSDK extends Performer { */ recommend(recommender_code, options) { return new Promise((resolve, reject) => { - this.push(() => { + this.push(async () => { try { - request(`recommend/${recommender_code}`, this.shop_id, { + const res = await request(`recommend/${recommender_code}`, this.shop_id, { params: { shop_id: this.shop_id, stream: this.stream, recommender_code, ...options, }, - }).then((res) => { - resolve(res) }) + // Check for popup in response + await this.checkAndShowPopup(res) + resolve(res) } catch (error) { reject(error) } @@ -526,14 +541,6 @@ class MainSDK extends Performer { did: deviceId, } - console.log('[getStories] Making request with params:', { - url: `stories/${code}`, - shop_id: this.shop_id, - did: deviceId, - code: code, - fullUrl: `${SDK_API_URL}stories/${code}` - }) - request(`stories/${code}`, this.shop_id, { params: requestParams, }).then((res) => { @@ -608,33 +615,6 @@ class MainSDK extends Performer { }) } - console.log('[getStories] API response received:', { - status: res?.status, - hasStories: !!res?.stories, - storiesCount: res?.stories?.length || 0, - stories: res?.stories?.map((story, idx) => ({ - index: idx, - id: story.id, - name: story.name, - avatar: story.avatar, - slidesCount: story.slides?.length || 0, - slides: story.slides?.map((slide, slideIdx) => ({ - slideIndex: slideIdx, - id: slide.id, - background: slide.background, - backgroundColor: slide.backgroundColor, - background_color: slide.background_color, - type: slide.type, - elementsCount: slide.elements?.length || 0, - elements: slide.elements?.map(el => ({ - type: el.type, - title: el.title, - textInput: el.textInput - })) - })) - })) - }) - // Check for duplicate stories if (res?.stories && res.stories.length > 0) { const storyIds = res.stories.map(s => s.id) @@ -660,7 +640,6 @@ class MainSDK extends Performer { } } - console.log('[getStories] Full API response:', JSON.stringify(res, null, 2)) resolve(res) }).catch((error) => { console.error('[getStories] Request error:', error) @@ -722,7 +701,9 @@ class MainSDK extends Performer { code: code, event: 'view', }, - }).then((res) => { + }).then(async (res) => { + // Check for popup in response + await this.checkAndShowPopup(res) resolve(res) }).catch((error) => { reject(error) @@ -768,13 +749,6 @@ class MainSDK extends Performer { return } - // Debug logging - console.log('trackStoryClick called with:', { - storyId: numericStoryId, - slideId: numericSlideId, - code: code - }) - request('track/stories', this.shop_id, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -789,7 +763,9 @@ class MainSDK extends Performer { code: code, event: 'click', }, - }).then((res) => { + }).then(async (res) => { + // Check for popup in response + await this.checkAndShowPopup(res) resolve(res) }).catch((error) => { reject(error) @@ -831,17 +807,18 @@ class MainSDK extends Performer { */ search(options) { return new Promise((resolve, reject) => { - this.push(() => { + this.push(async () => { try { - request('search', this.shop_id, { + const res = await request('search', this.shop_id, { params: { shop_id: this.shop_id, stream: this.stream, ...options, }, - }).then((res) => { - resolve(res) }) + // Check for popup in response + await this.checkAndShowPopup(res) + resolve(res) } catch (error) { reject(error) } @@ -876,7 +853,7 @@ class MainSDK extends Performer { } } try { - return await request('profile/set', this.shop_id, { + const res = await request('profile/set', this.shop_id, { headers: { 'Content-Type': 'application/json' }, method: 'POST', params: { @@ -885,6 +862,9 @@ class MainSDK extends Performer { ...params, }, }) + // Check for popup in response + await this.checkAndShowPopup(res) + return res } catch (error) { return error } @@ -1011,7 +991,7 @@ class MainSDK extends Performer { ) result = granted === PermissionsAndroid.RESULTS.GRANTED } catch (err) { - console.log(err) + if (DEBUG) console.error('Android permissions error:', err) } } else { const settings = await notifee.requestPermission() @@ -1343,6 +1323,93 @@ class MainSDK extends Performer { async showInAppNotification(params) { NotificationManager.showNotification(params) } + + /** + * Track popup shown event and mark popup as shown in storage. + * Intended for host app usage in delegate mode (when popup is presented by the host app). + * + * @param {number} popupId + * @returns {Promise} + */ + async trackPopupShown(popupId) { + if (!popupId || typeof popupId !== 'number') { + console.warn('[MainSDK] trackPopupShown: invalid popupId') + return + } + + try { + const shopId = this.shop_id + const storageData = await getData(shopId) + const deviceId = storageData?.did || this.deviceId || '' + const seance = storageData?.seance || storageData?.sid || this.userSeance || '' + + await PopupLogic.trackPopupShown(popupId, shopId, deviceId, seance) + await PopupLogic.markPopupAsShown(popupId, shopId) + } catch (error) { + console.error('[MainSDK] trackPopupShown error:', error) + } + } + + /** + * Check response for popup data and show popup if present + * @param {Object} response - API response + * @param {boolean} manual - Whether popup is shown manually + * @returns {Promise} + */ + async checkAndShowPopup(response, manual = false) { + if (DEBUG) console.log('[MainSDK] checkAndShowPopup called, response:', response ? 'exists' : 'null', 'initialized:', this.initialized) + + if (!response || !this.initialized) { + if (DEBUG) console.log('[MainSDK] checkAndShowPopup skipped: response=', !!response, 'initialized=', this.initialized) + return + } + + // Check if response contains popup data + // Popup should have id and either html or components + if (DEBUG) console.log('[MainSDK] Checking for popup in response, response.popup:', response.popup ? 'exists' : 'missing') + + if (response.popup && typeof response.popup === 'object' && response.popup.id) { + if (DEBUG) console.log('[MainSDK] Popup found with id:', response.popup.id) + + const hasHtml = response.popup.html && typeof response.popup.html === 'string' + const hasComponents = response.popup.components && (typeof response.popup.components === 'string' || typeof response.popup.components === 'object') + + if (DEBUG) console.log('[MainSDK] Popup data check - hasHtml:', hasHtml, 'hasComponents:', hasComponents) + + if (hasHtml || hasComponents) { + // Delegate mode: if delegate is set, do not auto-present popups. + // Host app is responsible for UI and tracking `popup/showed`. + if (this.popupPresentationDelegate && typeof this.popupPresentationDelegate === 'function') { + try { + const popupId = response.popup.id + const wasShown = await PopupLogic.wasPopupShown(popupId, this.shop_id) + if (wasShown) { + if (DEBUG) console.log(`[MainSDK] Popup ${popupId} was already shown, skipping delegate call`) + return + } + + if (DEBUG) console.log('[MainSDK] Popup detected in response, forwarding to delegate...', JSON.stringify(response.popup, null, 2)) + this.popupPresentationDelegate(response.popup, this) + } catch (error) { + if (DEBUG) console.error('[MainSDK] Error forwarding popup to delegate:', error) + } + return + } + + if (DEBUG) console.log('[MainSDK] Popup detected in response, preparing to show...', JSON.stringify(response.popup, null, 2)) + try { + await prepareAndShow(this, response.popup, manual) + if (DEBUG) console.log('[MainSDK] prepareAndShow completed') + } catch (error) { + if (DEBUG) console.error('[MainSDK] Error showing popup:', error) + } + } else { + if (DEBUG) console.warn('[MainSDK] Popup data found but missing both html and components') + } + } else { + if (DEBUG) console.log('[MainSDK] No popup found in response') + } + } } export default MainSDK diff --git a/components/Popup/Popup.js b/components/Popup/Popup.js new file mode 100644 index 0000000..3fb6d12 --- /dev/null +++ b/components/Popup/Popup.js @@ -0,0 +1,420 @@ +import React, { useState, useEffect, useRef } from 'react' +import { + View, + Modal, + StyleSheet, + Dimensions, + Animated, + TouchableOpacity, + Text, + Image, + Platform, + Linking, + ScrollView, +} from 'react-native' +import PopupLogic from '../../lib/popup' +import { DEBUG } from '../../MainSDK' + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window') + +/** + * Popup Component + * Displays popup using native React Native components (like Android/iOS SDK) + * Uses structured data from components and popupActions instead of HTML + * + * @param {Object} props + * @param {boolean} props.visible - Whether popup is visible + * @param {Object} props.popupData - Popup data from server + * @param {Function} props.onClose - Callback when popup is closed + * @param {Object} props.sdk - SDK instance + */ +export default function Popup({ visible, popupData, onClose, sdk }) { + const slideAnim = useRef(new Animated.Value(0)).current + const fadeAnim = useRef(new Animated.Value(0)).current + + const popupId = popupData?.id + const position = popupData?.position || 'fixed_bottom' + const components = PopupLogic.parseComponents(popupData?.components) + const popupActions = PopupLogic.parsePopupActions(popupData?.popup_actions) + + // Extract data from components + const title = components?.header || '' + const message = components?.text || '' + const imageUrl = components?.image || null + const buttonTextFromComponents = components?.button || null + + // Extract button texts from popupActions (no default for close — match JS SDK: only show Close button when server sends button_text) + const closeButtonText = popupActions?.close?.button_text ?? null + const linkButtonText = popupActions?.link?.button_text || null + const subscribeButtonText = popupActions?.pushSubscribe?.button_text || + popupActions?.system_mobile_push_subscribe?.button_text || + null + + // Main button text only from popup_actions (match iOS/JS: no fallback to components.button when actions are empty) + const confirmButtonText = subscribeButtonText || linkButtonText || null + const declineButtonText = confirmButtonText && closeButtonText ? closeButtonText : null + + // Animate popup based on position + useEffect(() => { + if (visible) { + if (position === 'slide_right' || position === 'slide_left') { + // Slide animation + Animated.timing(slideAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start() + } else { + // Fade animation for top and fixed_bottom + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start() + } + } else { + // Reset animations + slideAnim.setValue(0) + fadeAnim.setValue(0) + } + }, [visible, position]) + + /** + * Handle confirm button click. + * Action is chosen by which popup_action provided the confirm button text (subscribe > link). + * Only run push when subscribeButtonText is set; do not use system_mobile_push_subscribe presence alone. + */ + const handleConfirmClick = async () => { + if (subscribeButtonText) { + await handlePushSubscription() + } else if (linkButtonText && popupActions?.link) { + await handleLinkClick() + } else if (buttonTextFromComponents) { + handleClose() + } + } + + /** + * Handle push subscription + */ + const handlePushSubscription = async () => { + if (!sdk) return + + try { + // Request push permission and get token + if (sdk.initPushToken) { + const token = await sdk.initPushToken(false) + if (token) { + // Token will be sent automatically by SDK + if (DEBUG) console.log('[Popup] Push subscription successful') + } + } + + // Check if we should show success message or close + if (components?.products === '1' && components?.successfully_enabled === '1') { + // Show success message (could be implemented as state change) + if (DEBUG) console.log('[Popup] Subscription successful, showing success message') + } else { + // Close popup + handleClose() + } + } catch (error) { + if (DEBUG) console.error('[Popup] Error subscribing to push:', error) + handleClose() + } + } + + /** + * Handle link button click + */ + const handleLinkClick = async () => { + if (!popupActions?.link) return + + try { + // Platform-specific link, fallback to link_web when empty (same as iOS) + const platformLink = Platform.OS === 'ios' + ? popupActions.link.link_ios + : (Platform.OS === 'android' ? popupActions.link.link_android : null) + const link = (platformLink && platformLink.trim() !== '') + ? platformLink.trim() + : (popupActions.link.link_web || '').trim() + + if (link && link.startsWith('http')) { + await Linking.openURL(link) + } else { + console.warn('[Popup] Link button: no valid URL (empty or not http(s)):', link || '(empty)') + } + } catch (error) { + console.warn('[Popup] Failed to open URL:', error?.message || error) + } finally { + handleClose() + } + } + + /** + * Handle close + */ + const handleClose = () => { + // Animate out + if (position === 'slide_right' || position === 'slide_left') { + Animated.timing(slideAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(() => { + onClose?.() + }) + } else { + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(() => { + onClose?.() + }) + } + } + + // Calculate animation styles based on position + const getAnimationStyle = () => { + if (position === 'slide_right') { + return { + transform: [ + { + translateX: slideAnim.interpolate({ + inputRange: [0, 1], + outputRange: [screenWidth, 0], + }), + }, + ], + } + } else if (position === 'slide_left') { + return { + transform: [ + { + translateX: slideAnim.interpolate({ + inputRange: [0, 1], + outputRange: [-screenWidth, 0], + }), + }, + ], + } + } else { + return { + opacity: fadeAnim, + } + } + } + + // Get container style based on position + const getContainerStyle = () => { + const baseStyle = [styles.container, getAnimationStyle()] + + if (position === 'top') { + return [...baseStyle, styles.containerTop] + } else if (position === 'centered') { + return [...baseStyle, styles.containerCentered] + } else if (position === 'fixed_bottom' || position === 'slide_right' || position === 'slide_left') { + return [...baseStyle, styles.containerBottom] + } + + // Default to centered if position is not recognized + return [...baseStyle, styles.containerCentered] + } + + if (!visible || !popupData) { + return null + } + + return ( + + + + true} + > + {/* Close button (X) */} + + × + + + + {/* Image */} + {imageUrl && ( + + )} + + + {/* Title */} + {title ? ( + {title} + ) : null} + + {/* Message */} + {message ? ( + {message} + ) : null} + + {/* Buttons */} + + {confirmButtonText && ( + + {confirmButtonText} + + )} + + {declineButtonText && ( + + {declineButtonText} + + )} + + + + + + + + ) +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + alignItems: 'center', + }, + backdropCentered: { + justifyContent: 'center', + }, + container: { + width: screenWidth * 0.9, + maxWidth: 400, + maxHeight: screenHeight * 0.8, + minHeight: 140, + backgroundColor: 'white', + borderRadius: 12, + overflow: 'hidden', + flexShrink: 1, + }, + containerTop: { + position: 'absolute', + top: 20, + alignSelf: 'center', + }, + containerBottom: { + position: 'absolute', + bottom: 20, + alignSelf: 'center', + }, + containerCentered: { + alignSelf: 'center', + }, + modalContent: { + backgroundColor: 'white', + borderRadius: 12, + flexShrink: 1, + }, + closeButton: { + position: 'absolute', + top: 10, + right: 10, + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(0, 0, 0, 0.1)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + }, + closeButtonText: { + fontSize: 24, + color: '#666', + lineHeight: 24, + }, + scrollView: {}, + scrollViewContent: { + padding: 0, + }, + topImage: { + width: '100%', + height: 200, + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + }, + body: { + padding: 20, + paddingTop: 40, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + color: '#000', + marginBottom: 12, + textAlign: 'center', + }, + message: { + fontSize: 16, + color: '#666', + marginBottom: 20, + textAlign: 'center', + lineHeight: 22, + }, + buttonsContainer: { + flexDirection: 'row', + gap: 12, + marginTop: 8, + }, + button: { + flex: 1, + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + confirmButton: { + backgroundColor: '#007AFF', + }, + confirmButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + declineButton: { + backgroundColor: '#E5E5EA', + }, + declineButtonText: { + color: '#000', + fontSize: 16, + fontWeight: '500', + }, +}) diff --git a/components/Popup/SdkPopupOverlay.js b/components/Popup/SdkPopupOverlay.js new file mode 100644 index 0000000..967f4c0 --- /dev/null +++ b/components/Popup/SdkPopupOverlay.js @@ -0,0 +1,222 @@ +import React, { useState, useCallback, useEffect } from 'react' +import Popup from './Popup' +import PopupLogic from '../../lib/popup' + +const POPUP_CLOSE_ANIMATION_DELAY_MS = 300 + +/** + * Global SdkPopupOverlay singleton. + * Holds popup queue + visibility state for the overlay UI. + */ +class SdkPopupOverlaySingleton { + constructor() { + this.currentPopup = null + this.isVisible = false + this.popupQueue = [] + this.sdkInstance = null + this.updateCallback = null + } + + /** + * Register SDK instance + * @param {Object} sdk - MainSDK instance + */ + registerSDK(sdk) { + if (!sdk) { + console.warn('[SdkPopupOverlay] SDK instance is required') + return + } + + this.sdkInstance = sdk + + // Trigger update to show overlay component (if mounted) + if (this.updateCallback) { + this.updateCallback() + } else { + console.warn('[SdkPopupOverlay] No update callback registered - overlay component may not be mounted') + } + } + + /** + * Set update callback for React component + * @param {Function | null} callback - Callback to trigger component update + */ + setUpdateCallback(callback) { + this.updateCallback = callback + } + + /** + * Show popup + * @param {Object} popupData - Popup data from server + */ + showPopup(popupData) { + if (!popupData || !popupData.id) { + console.warn('[SdkPopupOverlay] showPopup: invalid popup data') + return + } + + // Check if popup has data to display (html or components) + const hasHtml = popupData.html && typeof popupData.html === 'string' + const hasComponents = + popupData.components && + (typeof popupData.components === 'string' || typeof popupData.components === 'object') + + if (!hasHtml && !hasComponents) { + console.warn('[SdkPopupOverlay] showPopup: popup has no display data') + return + } + + // If there's already a popup showing, queue this one + if (this.isVisible && this.currentPopup) { + this.popupQueue.push(popupData) + return + } + + this.currentPopup = popupData + this.isVisible = true + + // Trigger component update + if (this.updateCallback) { + this.updateCallback() + } else { + console.error('[SdkPopupOverlay] showPopup: no update callback - overlay component is not mounted!') + } + } + + /** + * Close current popup + */ + closePopup() { + this.isVisible = false + + // Trigger component update + if (this.updateCallback) { + this.updateCallback() + } + + // Show next popup from queue if any + setTimeout(() => { + if (this.popupQueue.length > 0) { + const nextPopup = this.popupQueue.shift() + this.currentPopup = nextPopup + this.isVisible = true + + if (this.updateCallback) { + this.updateCallback() + } + } else { + this.currentPopup = null + if (this.updateCallback) { + this.updateCallback() + } + } + }, POPUP_CLOSE_ANIMATION_DELAY_MS) + } + + /** + * Get current popup state + */ + getState() { + return { + currentPopup: this.currentPopup, + isVisible: this.isVisible, + sdk: this.sdkInstance, + } + } +} + +// Create global singleton instance +const sdkPopupOverlayInstance = new SdkPopupOverlaySingleton() + +/** + * Internal overlay component that renders popup UI + */ +function SdkPopupOverlayInternal() { + const [state, setState] = useState(() => sdkPopupOverlayInstance.getState()) + + useEffect(() => { + sdkPopupOverlayInstance.setUpdateCallback(() => { + setState(sdkPopupOverlayInstance.getState()) + }) + + const currentState = sdkPopupOverlayInstance.getState() + if (currentState.sdk && currentState.sdk !== state.sdk) { + setState(currentState) + } + + return () => { + sdkPopupOverlayInstance.setUpdateCallback(null) + } + }, []) + + const handleClose = useCallback(() => { + sdkPopupOverlayInstance.closePopup() + }, []) + + // Don't render if no SDK is registered + if (!state.sdk) return null + + return ( + + ) +} + +/** + * Get or create global overlay component reference (optional helper). + */ +let globalSdkPopupOverlayComponent = null + +export function getGlobalSdkPopupOverlayComponent() { + if (!globalSdkPopupOverlayComponent) { + globalSdkPopupOverlayComponent = SdkPopupOverlayInternal + } + return globalSdkPopupOverlayComponent +} + +/** + * Public overlay component (render this in your app for auto popup presentation). + * + * @param {Object} props + * @param {Object} props.sdk - MainSDK instance (optional, will use registered SDK if not provided) + */ +export default function SdkPopupOverlay({ sdk }) { + useEffect(() => { + if (sdk) { + sdkPopupOverlayInstance.registerSDK(sdk) + } + }, [sdk]) + + return +} + +/** + * Show popup programmatically (used internally by SDK). + * @param {Object} popupData + */ +export function showPopup(popupData) { + sdkPopupOverlayInstance.showPopup(popupData) +} + +/** + * Register SDK instance (called automatically from MainSDK constructor). + * @param {Object} sdkInstance + */ +export function registerSDK(sdkInstance) { + sdkPopupOverlayInstance.registerSDK(sdkInstance) +} + +/** + * Prepare and show popup (main entry point called from MainSDK). + * @param {Object} sdkInstance + * @param {Object} popupData + * @param {boolean} manual + */ +export async function prepareAndShow(sdkInstance, popupData, manual = false) { + await PopupLogic.prepare(sdkInstance, popupData, manual, showPopup) +} + diff --git a/index.js b/index.js index 5016952..18057a7 100644 --- a/index.js +++ b/index.js @@ -13,3 +13,8 @@ class PersonaClick extends MainSDK{ } export default PersonaClick; + +// Export popup overlay UI component for React Native apps. +// NOTE: this component must be rendered in your React tree if you want SDK to auto-present popups. +export { default as SdkPopupOverlay } from './components/Popup/SdkPopupOverlay' +export { registerSDK as registerSdkPopupOverlaySDK } from './components/Popup/SdkPopupOverlay' diff --git a/lib/popup.js b/lib/popup.js new file mode 100644 index 0000000..1272407 --- /dev/null +++ b/lib/popup.js @@ -0,0 +1,272 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { request } from './client' +import { getData } from './client' +import { getStorageKey } from '../utils' +import { DEBUG } from '../MainSDK' + +const POPUP_SHOWN_TTL_SECONDS = 60 +const POPUP_SHOWN_TTL_MS = POPUP_SHOWN_TTL_SECONDS * 1000 + +/** + * @typedef {'web_push' | string} Channel + */ + +/** + * @typedef {'top' | 'slide_right' | 'slide_left' | 'fixed_bottom'} Position + */ + +/** + * @typedef {'0' | '1'} WebPushSystem + */ + +/** + * @typedef {Object} PopupActionButton + * @property {string} button_text + * @property {string} [link_ios] + * @property {string} [link_web] + * @property {string} [link_android] + */ + +/** + * @typedef {Object} PopupData + * @property {Channel[]} channels + * @property {string} html + * @property {string | undefined} error + * @property {number} id + * @property {Position} position + * @property {number} delay + * @property {WebPushSystem} web_push_system + * @property {string} [popup_actions] + * @property {string} [components] + */ + +/** + * PopupLogic class for managing popup display logic + * Similar to PopupNew in JS SDK but adapted for React Native + */ +class PopupLogic { + /** + * Get popup storage key + * @param {number} popupId + * @param {string} shopId + * @returns {string} + */ + static getPopupStorageKey(popupId, shopId) { + return getStorageKey(`popup-${popupId}`, shopId) + } + + /** + * Get popup name + * @param {number} id + * @returns {string} + */ + static popupName(id) { + return `popup-${id}` + } + + /** + * Check if popup was already shown within last 60 seconds + * @param {number} popupId + * @param {string} shopId + * @returns {Promise} + */ + static async wasPopupShown(popupId, shopId) { + try { + const key = this.getPopupStorageKey(popupId, shopId) + const value = await AsyncStorage.getItem(key) + + // If no flag stored, popup was not shown + if (value !== 'showed') { + return false + } + + // Check expiration time + const expiresKey = getStorageKey(`popup-${popupId}-expires`, shopId) + const expiresAt = await AsyncStorage.getItem(expiresKey) + + if (!expiresAt) { + // No expiration stored, consider as expired (show popup) + return false + } + + const expiresTimestamp = parseInt(expiresAt, 10) + const now = Date.now() + + // If expired, popup can be shown again + if (now > expiresTimestamp) { + // Clean up expired flags + await AsyncStorage.removeItem(key) + await AsyncStorage.removeItem(expiresKey) + return false + } + + // Popup was shown and not expired yet + return true + } catch (error) { + console.error('[PopupLogic] Error checking popup shown:', error) + return false + } + } + + /** + * Mark popup as shown in storage + * @param {number} popupId + * @param {string} shopId + * @returns {Promise} + */ + static async markPopupAsShown(popupId, shopId) { + try { + const key = this.getPopupStorageKey(popupId, shopId) + // Store for TTL (same as JS SDK cookies) + await AsyncStorage.setItem(key, 'showed') + // Set expiration by storing timestamp + const expiresKey = getStorageKey(`popup-${popupId}-expires`, shopId) + const expiresAt = Date.now() + POPUP_SHOWN_TTL_MS + await AsyncStorage.setItem(expiresKey, expiresAt.toString()) + } catch (error) { + console.error('[PopupLogic] Error marking popup as shown:', error) + } + } + + /** + * Track popup shown event to API + * @param {number} popupId + * @param {string} shopId + * @param {string} deviceId + * @param {string} seance + * @returns {Promise} + */ + static async trackPopupShown(popupId, shopId, deviceId, seance) { + try { + const params = { + shop_id: shopId, + did: deviceId, + sid: seance, + seance: seance, + popup: popupId, + } + + await request('popup/showed', shopId, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + params, + }) + + if (DEBUG) console.log(`[PopupLogic] Tracked popup ${popupId} shown`) + } catch (error) { + console.error('[PopupLogic] Error tracking popup shown:', error) + } + } + + /** + * Get CSS URL for popup + * @param {number} popupId + * @param {string} shopToken + * @returns {string} + */ + static getPopupCssUrl(popupId, shopToken) { + // Use HTTPS for CSS loading (JS SDK uses protocol-relative URL) + return `https://api.personaclick.com/popup_css/${shopToken}_popup_${popupId}.css` + } + + /** + * Prepare and show popup + * @param {Object} sdkInstance - MainSDK instance + * @param {PopupData} popupData - Popup data from server + * @param {boolean} manual - Whether popup is shown manually + * @param {Function} showCallback - Callback to show popup UI component + * @returns {Promise} + */ + static async prepare(sdkInstance, popupData, manual = false, showCallback = null) { + if (!popupData || !popupData.id) { + if (!popupData?.error) { + console.error('[PopupLogic] Popup preparation failed: missing required data (id)') + } + return + } + + // Check if popup has data to display (html or components) + const hasHtml = popupData.html && typeof popupData.html === 'string' + const hasComponents = popupData.components && (typeof popupData.components === 'string' || typeof popupData.components === 'object') + + if (!hasHtml && !hasComponents) { + if (!popupData?.error) { + console.error('[PopupLogic] Popup preparation failed: missing both html and components') + } + return + } + + const popupId = popupData.id + const shopId = sdkInstance.shop_id + + // Check if popup was already shown (unless manual) + if (!manual) { + const wasShown = await this.wasPopupShown(popupId, shopId) + if (wasShown) { + if (DEBUG) console.log(`[PopupLogic] Popup ${popupId} was already shown, skipping`) + return + } + } + + // Get device ID and seance + const storageData = await getData(shopId) + const deviceId = storageData?.did || sdkInstance.deviceId || '' + const seance = storageData?.seance || storageData?.sid || sdkInstance.userSeance || '' + + // Track popup shown + await this.trackPopupShown(popupId, shopId, deviceId, seance) + + // Mark as shown in storage + await this.markPopupAsShown(popupId, shopId) + + // Call show callback if provided + if (showCallback && typeof showCallback === 'function') { + // Apply delay before showing + const delayMs = (popupData.delay || 0) * 1000 + setTimeout(() => { + showCallback(popupData) + }, delayMs) + } else { + console.error('[PopupLogic] No show callback provided for popup - popup will not be displayed!') + if (DEBUG) console.warn('[PopupLogic] No show callback provided for popup') + } + } + + /** + * Parse popup actions JSON + * @param {string} popupActions + * @returns {Object|null} + */ + static parsePopupActions(popupActions) { + if (!popupActions) return null + + try { + const actions = JSON.parse(popupActions) + if (!actions || typeof actions !== 'object' || Array.isArray(actions)) { + return null + } + return actions + } catch (error) { + console.error('[PopupLogic] Error parsing popup actions:', error) + return null + } + } + + /** + * Parse popup components JSON + * @param {string} components + * @returns {Object|null} + */ + static parseComponents(components) { + if (!components) return null + + try { + return JSON.parse(components) + } catch (error) { + console.error('[PopupLogic] Error parsing components:', error) + return null + } + } +} + +export default PopupLogic diff --git a/package.json b/package.json index 35792ef..c7e6172 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@personaclick/rn-sdk", - "version": "4.0.4", + "version": "4.0.5", "description": "PersonaClick React Native SDK", "type": "module", "exports": {