From e0ee12011dfa1baeef8fc360c9031bde42afd376 Mon Sep 17 00:00:00 2001 From: Jade Date: Tue, 24 Feb 2026 18:20:53 +0000 Subject: [PATCH] feat: release --- CHANGELOG.md | 30 ++ MainSDK.js | 554 +++++++++++++++++++++++++++++++---- index.js | 7 + lib/push/PushOrchestrator.js | 15 +- package.json | 2 +- tests/review.test.js | 83 ++++++ tests/search.test.js | 11 + types/productTypes.js | 263 +++++++++++++++++ utils/search-blank.js | 2 +- 9 files changed, 912 insertions(+), 55 deletions(-) create mode 100644 tests/review.test.js create mode 100644 types/productTypes.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e19ef3..9f3854a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## 4.0.6 (2026-02-24) + + +* [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.5 (2026-02-04) diff --git a/MainSDK.js b/MainSDK.js index 089dcdf..3e51fae 100644 --- a/MainSDK.js +++ b/MainSDK.js @@ -36,9 +36,51 @@ import { blankSearchRequest } from './utils' import { isOverOneWeekAgo } from './utils' import { getStorageKey } from './utils' import { SDK_API_URL } from './index' +import { parseCartItem, parseProductInfo, parseProductsListResponse } from './types/productTypes' import { prepareAndShow, registerSDK } from './components/Popup/SdkPopupOverlay' import PopupLogic from './lib/popup' +/** Minimum allowed NPS/review rate (1–10). */ +const REVIEW_RATE_MIN = 1 +/** Maximum allowed NPS/review rate (1–10). */ +const REVIEW_RATE_MAX = 10 + +/** Source tracking TTL in seconds (48 hours, matches iOS). */ +const SOURCE_TTL_SECONDS = 48 * 60 * 60 +const SOURCE_STORAGE_KEYS = { + timeStartSave: 'timeStartSave', + recomendedCode: 'recomendedCode', + recomendedType: 'recomendedType', +} + +const PUSH_PERMISSION_WAIT_TIMEOUT_MS = 12000 +const PUSH_PERMISSION_STATE_WAIT_POLL_MS = 200 + +/** + * Reads saved source params from AsyncStorage. If valid (within 48h), returns { source: { from, code } }; otherwise clears storage and returns {}. + * @param {string} shop_id + * @returns {Promise<{ source?: { from: string, code: string } }>} + */ +async function getSourceParams(shop_id) { + const keyTime = getStorageKey(SOURCE_STORAGE_KEYS.timeStartSave, shop_id) + const keyCode = getStorageKey(SOURCE_STORAGE_KEYS.recomendedCode, shop_id) + const keyType = getStorageKey(SOURCE_STORAGE_KEYS.recomendedType, shop_id) + try { + const [[, timeVal], [, codeVal], [, typeVal]] = await AsyncStorage.multiGet([keyTime, keyCode, keyType]) + const timeStart = timeVal != null ? Number(timeVal) : NaN + const savedCode = codeVal ?? '' + const savedType = typeVal ?? '' + const nowSec = Date.now() / 1000 + if (Number.isNaN(timeStart) || savedCode === '' || savedType === '' || nowSec - timeStart > SOURCE_TTL_SECONDS) { + await AsyncStorage.multiRemove([keyTime, keyCode, keyType]) + return {} + } + return { source: { from: savedType, code: savedCode } } + } catch (_) { + return {} + } +} + /** * @typedef {Object} Event * @property {string} type @@ -119,10 +161,18 @@ class MainSDK extends Performer { * @type {(popup: any, sdk: MainSDK) => void | null} */ this.popupPresentationDelegate = null - + // Firebase is initialized automatically by native modules // Initialize messaging lazily when needed this.messaging = null + this._pushPermissionState = 'idle' + this._pushPermissionPromise = null + // In-memory token cache: avoids AsyncStorage race when getToken() is called + // immediately after initPushToken() (savePushToken runs after backend response). + this._tokenCache = null + // Tracks the in-flight initPush() promise so getToken() can await it + // when called concurrently (e.g. autoSendPushToken race in init()). + this._initPushPromise = null /** * Internal push orchestration (device registration, token fetch, tracking subscriptions). @@ -162,6 +212,139 @@ class MainSDK extends Performer { command() } + /** + * @param {string} context + * @param {any} error + * @returns {void} + */ + _logFirebaseError(context, error) { + const code = error?.code || 'unknown' + const message = error?.message || String(error) + console.warn(`[Firebase][${context}] code=${code} message=${message}`, error) + } + + /** + * @param {'idle' | 'requesting' | 'granted' | 'denied'} nextState + * @param {string} source + * @returns {void} + */ + _setPushPermissionState(nextState, source) { + const prevState = this._pushPermissionState + this._pushPermissionState = nextState + if (DEBUG && prevState !== nextState) { + console.log( + `[PushPermission] state: ${prevState} -> ${nextState} (source=${source})` + ) + } + } + + /** + * @param {'permission_denied' | 'permission_timeout' | 'messaging_unavailable' | 'token_empty' | 'push_token_error'} reason + * @param {Record} [extra] + * @returns {void} + */ + _logPushNullReason(reason, extra = {}) { + console.warn('[PushToken] Returning null', { + reason, + permissionState: this._pushPermissionState, + ...extra, + }) + } + + /** + * @returns {Promise} + */ + async _requestPushPermissionInternal() { + if (Platform.OS === 'android') { + // Android < 13 (API < 33): POST_NOTIFICATIONS permission does not exist. + // FCM token is always available; no permission dialog is needed. + if (Platform.Version < 33) { + if (DEBUG) console.log('[PushPermission] Android < 13: permission not required, auto-grant') + return true + } + + try { + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS + ) + return granted === PermissionsAndroid.RESULTS.GRANTED + } catch (err) { + this._logFirebaseError('PermissionsAndroid.request', err) + return false + } + } + + // iOS / other platforms: use notifee + try { + const settings = await notifee.requestPermission() + + if (settings.authorizationStatus === AuthorizationStatus.DENIED) { + if (DEBUG) console.log('User denied permissions request') + return false + } + + if ( + settings.authorizationStatus === AuthorizationStatus.AUTHORIZED || + settings.authorizationStatus === AuthorizationStatus.PROVISIONAL + ) { + if (DEBUG) console.log('User granted permissions request') + return true + } + + return false + } catch (error) { + this._logFirebaseError('notifee.requestPermission', error) + return false + } + } + + /** + * @returns {Promise} + */ + async _requestPushPermissionOnce() { + if (this._pushPermissionPromise) { + return this._pushPermissionPromise + } + + this._setPushPermissionState('requesting', 'request_start') + this._pushPermissionPromise = (async () => { + const granted = await this._requestPushPermissionInternal() + this._setPushPermissionState(granted ? 'granted' : 'denied', 'request_end') + return granted + })().finally(() => { + this._pushPermissionPromise = null + }) + + return this._pushPermissionPromise + } + + /** + * @param {number} timeoutMs + * @returns {Promise<'granted' | 'denied' | 'timeout'>} + */ + async _waitForPushPermissionResolution(timeoutMs = PUSH_PERMISSION_WAIT_TIMEOUT_MS) { + if (this._pushPermissionState === 'granted') return 'granted' + if (this._pushPermissionState === 'denied') return 'denied' + if (this._pushPermissionState !== 'requesting') return 'denied' + + if (DEBUG) { + console.log( + `[PushPermission] waiting for decision (timeoutMs=${timeoutMs})` + ) + } + + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (this._pushPermissionState === 'granted') return 'granted' + if (this._pushPermissionState === 'denied') return 'denied' + await new Promise((resolve) => + setTimeout(resolve, PUSH_PERMISSION_STATE_WAIT_POLL_MS) + ) + } + + return 'timeout' + } + async initializeSegment() { const key = getStorageKey('segment', this.shop_id) const segments = ['A', 'B'] @@ -190,7 +373,7 @@ class MainSDK extends Performer { // Firebase initialization is the host app responsibility. this.messaging = getMessaging() } catch (error) { - console.warn('Firebase messaging initialization failed:', error) + this._logFirebaseError('getMessaging', error) this.messaging = null } } @@ -354,10 +537,20 @@ class MainSDK extends Performer { } /** + * Returns the current push token. + * If initPush() is actively running (e.g. triggered by autoSendPushToken during init()), + * waits for it to complete instead of racing against it. * @returns {Promise} */ getToken = async () => { try { + if (this._initPushPromise) { + // Join the in-flight initPush() — no deadlock because initPushToken() + // inside initPush() does not read _initPushPromise. + const token = await this._initPushPromise + if (DEBUG) console.log(token) + return typeof token === 'string' ? token : null + } const token = await this.initPushToken() if (DEBUG) console.log(token) return token ?? null @@ -394,6 +587,8 @@ class MainSDK extends Performer { this.push(async () => { try { const queryParams = await convertParams(event, options) + const sourceParams = await getSourceParams(this.shop_id) + Object.assign(queryParams, sourceParams) const response = await request('push', this.shop_id, { headers: { 'Content-Type': 'application/json' }, method: 'POST', @@ -424,6 +619,8 @@ class MainSDK extends Performer { if (options) { queryParams = Object.assign(queryParams, options) } + const sourceParams = await getSourceParams(this.shop_id) + Object.assign(queryParams, sourceParams) const response = await request('push/custom', this.shop_id, { headers: { 'Content-Type': 'application/json' }, @@ -443,6 +640,24 @@ class MainSDK extends Performer { }) } + /** + * Saves recommendation source (type and code). For the next 48 hours, track() and trackEvent() will automatically include source in params. + * @param {string} source - Source type (e.g. 'dynamic', 'full_search', 'instant_search', 'stories', 'chain', 'transactional', 'bulk', 'web_push_digest'). + * @param {string} code - Source code (e.g. recommender block code, search query, stories code). + */ + trackSource(source, code) { + const shop_id = this.shop_id + const keyTime = getStorageKey(SOURCE_STORAGE_KEYS.timeStartSave, shop_id) + const keyCode = getStorageKey(SOURCE_STORAGE_KEYS.recomendedCode, shop_id) + const keyType = getStorageKey(SOURCE_STORAGE_KEYS.recomendedType, shop_id) + const timeSec = Math.floor(Date.now() / 1000) + AsyncStorage.multiSet([ + [keyTime, String(timeSec)], + [keyCode, String(code ?? '')], + [keyType, String(source ?? '')], + ]).catch(() => {}) + } + /** * Track user clicked/tapped on notification * @param {NotificationEventOptions} options @@ -798,6 +1013,121 @@ class MainSDK extends Performer { }) } + /** + * Returns cart items from the API as typed CartItem array (productId, quantity). + * For raw API response use cart(). + * @returns {Promise} + */ + getProductsFromCart() { + return new Promise((resolve, reject) => { + this.push(async () => { + try { + const res = await request('products/cart', this.shop_id, { + params: { shop_id: this.shop_id }, + }) + if (res instanceof Error) { + reject(res) + return + } + const items = res?.data?.items ?? res?.items ?? [] + const parsed = Array.isArray(items) ? items.map(parseCartItem) : [] + resolve(parsed) + } catch (e) { + reject(e) + } + }) + }) + } + + /** + * Fetches full product info by id. + * @param {string} id - Product id (item_id). + * @returns {Promise} + */ + getProductInfo(id) { + return new Promise((resolve, reject) => { + this.push(async () => { + try { + const res = await request('products/get', this.shop_id, { + params: { + shop_id: this.shop_id, + item_id: id, + }, + }) + if (res instanceof Error) { + reject(res) + return + } + resolve(parseProductInfo(res)) + } catch (e) { + reject(e) + } + }) + }) + } + + /** + * Fetches products list with optional filters (brands, merchants, categories, etc.). + * @param {Object} [options] + * @param {string} [options.brands] + * @param {string} [options.merchants] + * @param {string} [options.categories] + * @param {string} [options.locations] + * @param {number} [options.limit] + * @param {number} [options.page] + * @param {Record} [options.filters] - Sent as JSON string to API. + * @returns {Promise} + */ + getProductsList(options = {}) { + return new Promise((resolve, reject) => { + this.push(async () => { + try { + const params = { shop_id: this.shop_id } + if (options.brands != null) params.brands = options.brands + if (options.merchants != null) params.merchants = options.merchants + if (options.categories != null) params.categories = options.categories + if (options.locations != null) params.locations = options.locations + if (options.limit != null) params.limit = String(options.limit) + if (options.page != null) params.page = String(options.page) + if (options.filters != null && typeof options.filters === 'object') { + params.filters = JSON.stringify(options.filters) + } + const res = await request('products', this.shop_id, { params }) + if (res instanceof Error) { + reject(res) + return + } + resolve(parseProductsListResponse(res)) + } catch (e) { + reject(e) + } + }) + }) + } + + /** + * Clears local cart product state keys (prefix cart.product.) from AsyncStorage. + * Use when resetting "in cart" UI state, e.g. after order or logout. + * @returns {Promise} + */ + resetCartProductStates() { + return new Promise((resolve, reject) => { + this.push(async () => { + try { + const prefix = getStorageKey('cart.product.', this.shop_id) + const allKeys = await AsyncStorage.getAllKeys() + const keysToRemove = allKeys.filter((k) => typeof k === 'string' && k.startsWith(prefix)) + if (keysToRemove.length > 0) { + await AsyncStorage.multiRemove(keysToRemove) + } + resolve() + } catch (e) { + reject(e) + } + }) + }) + } + /** * Executes a search with the given parameters. * @@ -835,6 +1165,102 @@ class MainSDK extends Performer { return blankSearchRequest(this.shop_id, this.stream) } + /** + * Sends a review (NPS) to the API. + * + * @param {number} rate - Rating value, must be between 1 and 10 (inclusive). + * @param {string} [channel] - Channel code (e.g. 'android_app', 'ios_app', 'web_popup', 'email'). If omitted or empty, uses 'android_app' or 'ios_app' from platform. + * @param {string} category - Category identifier (e.g. 'order'). + * @param {string} [orderId] - Optional order identifier. + * @param {string} [comment] - Optional comment text. + * @returns {Promise} - Resolves on success, rejects on validation or request error. + */ + review(rate, channel, category, orderId, comment) { + return new Promise((resolve, reject) => { + const numRate = Number(rate) + if (typeof rate !== 'number' && typeof rate !== 'string') { + reject(new Error('Error: rating can be between 1 and 10 only')) + return + } + if (Number.isNaN(numRate) || numRate < REVIEW_RATE_MIN || numRate > REVIEW_RATE_MAX) { + reject(new Error('Error: rating can be between 1 and 10 only')) + return + } + this.push(async () => { + try { + const channelValue = (channel != null && channel !== '') + ? channel + : (this.stream === 'android' ? 'android_app' : 'ios_app') + await request('nps/create', this.shop_id, { + method: 'POST', + params: { + shop_id: this.shop_id, + stream: this.stream, + rate: numRate, + channel: channelValue, + category: category ?? '', + order_id: orderId ?? '', + comment: comment ?? '', + }, + }) + resolve() + } catch (error) { + reject(error) + } + }) + }) + } + + /** + * Fetches the list of NPS channels available for the shop (for use with review). + * + * @returns {Promise>>} - A promise that resolves with the list of channels. + */ + getNpsChannels() { + return new Promise((resolve, reject) => { + this.push(() => { + try { + request('nps/channels', this.shop_id, { + method: 'GET', + params: { + shop_id: this.shop_id, + stream: this.stream, + }, + }).then((res) => { + resolve(Array.isArray(res) ? res : (res?.channels ?? res ?? [])) + }).catch(reject) + } catch (error) { + reject(error) + } + }) + }) + } + + /** + * Fetches the list of NPS categories available for the shop (for use with review). + * + * @returns {Promise>>} - A promise that resolves with the list of categories. + */ + getNpsCategories() { + return new Promise((resolve, reject) => { + this.push(() => { + try { + request('nps/categories', this.shop_id, { + method: 'GET', + params: { + shop_id: this.shop_id, + stream: this.stream, + }, + }).then((res) => { + resolve(Array.isArray(res) ? res : (res?.categories ?? res ?? [])) + }).catch(reject) + } catch (error) { + reject(error) + } + }) + }) + } + /** * @param {Record} params * @returns {void} @@ -981,38 +1407,7 @@ class MainSDK extends Performer { * @returns {Promise} */ async getPushPermission() { - let result = false - if (Platform.OS === 'android' && Platform.Version >= 33) { - try { - const granted = await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS - ? PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS - : PermissionsAndroid.PERMISSIONS.POST_NOTIFICATION - ) - result = granted === PermissionsAndroid.RESULTS.GRANTED - } catch (err) { - if (DEBUG) console.error('Android permissions error:', err) - } - } else { - const settings = await notifee.requestPermission() - - if (settings.authorizationStatus === AuthorizationStatus.DENIED) { - if (DEBUG) console.log('User denied permissions request') - return false - } else if ( - settings.authorizationStatus === AuthorizationStatus.AUTHORIZED - ) { - if (DEBUG) console.log('User granted permissions request') - return true - } else if ( - settings.authorizationStatus === AuthorizationStatus.PROVISIONAL - ) { - if (DEBUG) console.log('User provisionally granted permissions request') - return true - } - - return false - } + return this._requestPushPermissionOnce() } /** @@ -1020,32 +1415,66 @@ class MainSDK extends Performer { * @returns {Promise} */ async initPushToken(removeOld = false) { - let savedToken = await getSavedPushToken(this.shop_id) if (removeOld) { await this.deleteToken() - savedToken = false + this._tokenCache = null + } + + // Fast path: in-memory cache populated on the same JS run (avoids AsyncStorage race). + if (!removeOld && this._tokenCache) { + if (DEBUG) console.log('FCM token from memory cache: ', this._tokenCache) + await this._pushOrchestrator.ensureTrackingSubscriptions() + return this._tokenCache } + const savedToken = await getSavedPushToken(this.shop_id) if (savedToken) { if (DEBUG) console.log('Old valid FCM token: ', savedToken) + this._tokenCache = savedToken // Even when token is already cached, ensure tracking subscriptions are installed. await this._pushOrchestrator.ensureTrackingSubscriptions() return savedToken } + if (Platform.OS === 'android' && this._pushPermissionState === 'requesting') { + const permissionDecision = await this._waitForPushPermissionResolution() + if (permissionDecision === 'denied') { + this._logPushNullReason('permission_denied') + return null + } + if (permissionDecision === 'timeout') { + this._logPushNullReason('permission_timeout', { + timeoutMs: PUSH_PERMISSION_WAIT_TIMEOUT_MS, + }) + return null + } + } + const messaging = this._ensureMessaging() if (!messaging) { - console.warn('Firebase messaging not available') + this._logPushNullReason('messaging_unavailable') return null } - const token = await this._pushOrchestrator.fetchToken({ - messaging, - pushType: this._push_type, - platformOS: Platform.OS, - }) + let token = null + try { + token = await this._pushOrchestrator.fetchToken({ + messaging, + pushType: this._push_type, + platformOS: Platform.OS, + }) + } catch (error) { + this._logFirebaseError('initPushToken.fetchToken', error) + this._logPushNullReason('push_token_error') + return null + } - if (!token) return null + if (!token) { + this._logPushNullReason('token_empty') + return null + } + // Cache immediately so getToken() called right after won't race with AsyncStorage write. + this._tokenCache = token this.setPushTokenNotification(token) return token } @@ -1079,7 +1508,7 @@ class MainSDK extends Performer { * @param {boolean | Function} notifyClick * @param {boolean | Function} notifyReceive * @param {boolean | Function} notifyBgReceive - * @returns {Promise} + * @returns {Promise} The token on success, null if permission denied/token unavailable, false if already locked. */ async initPush( notifyClick = false, @@ -1094,6 +1523,12 @@ class MainSDK extends Performer { if (notifyReceive) this.pushReceivedListener = notifyReceive if (notifyBgReceive) this.pushBgReceivedListener = notifyBgReceive + // If this exact initPush() call is already in-flight (e.g. concurrent button taps), + // join the existing promise instead of starting a duplicate run. + if (this._initPushPromise) { + return this._initPushPromise + } + const lock = await initLocker(this.shop_id) if ( lock && @@ -1106,15 +1541,31 @@ class MainSDK extends Performer { return false } - await setInitLocker(true, this.shop_id) - const granted = await this.getPushPermission() - if (!granted) return false + this._initPushPromise = (async () => { + try { + await setInitLocker(true, this.shop_id) - await this.initPushChannel() - await this.initPushToken(false) + const granted = await this.getPushPermission() + if (!granted) { + await setInitLocker(false, this.shop_id) + return null + } + + await this.initPushChannel() + const token = await this.initPushToken(false) + + if (!token) { + await setInitLocker(false, this.shop_id) + } + + await this._pushOrchestrator.ensureTrackingSubscriptions() + return token ?? null + } finally { + this._initPushPromise = null + } + })() - await this._pushOrchestrator.ensureTrackingSubscriptions() - return true + return this._initPushPromise } /** @@ -1187,6 +1638,7 @@ class MainSDK extends Performer { * @returns {Promise} */ async deleteToken() { + this._tokenCache = null return savePushToken(false, this.shop_id).then(async () => { const messaging = this._ensureMessaging() if (messaging) { diff --git a/index.js b/index.js index 18057a7..5f947f0 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,13 @@ export const SDK_API_URL = 'https://api.personaclick.com/'; export const SDK_STORAGE_NAME = '@PersonaClick'; export const SDK_PUSH_CHANNEL = 'PersonaClick'; +export { + parseCartItem, + parseProduct, + parseProductInfo, + parseProductsListResponse, +} from './types/productTypes'; + class PersonaClick extends MainSDK{ constructor(shop_id, stream, debug = false, autoSendPushToken = true) { super(shop_id, stream, debug, autoSendPushToken); diff --git a/lib/push/PushOrchestrator.js b/lib/push/PushOrchestrator.js index 5d301ad..c22105d 100644 --- a/lib/push/PushOrchestrator.js +++ b/lib/push/PushOrchestrator.js @@ -85,7 +85,7 @@ export default class PushOrchestrator { // If SDK knows we are not registered, treat it as a hard failure. // Otherwise (unknown/older versions) ignore and let token fetch decide. if (isRegistered === false) throw e - if (this._deps.isDebug()) console.log('registerDeviceForRemoteMessages failed', e) + console.warn('[Firebase][registerDeviceForRemoteMessages] failed', e) } } @@ -119,13 +119,17 @@ export default class PushOrchestrator { } if (typeof token !== 'string' || token.length === 0) { + console.warn('[Firebase][fetchToken] empty/invalid token received', { + platformOS, + pushType, + }) return null } await this.ensureTrackingSubscriptions() return token } catch (error) { - console.log('initPushToken error', error) + console.error('[Firebase][fetchToken] initPushToken error', error) return null } finally { this._tokenPromise = null @@ -149,6 +153,13 @@ export default class PushOrchestrator { const messaging = this._deps.getMessaging() const shopId = this._deps.getShopId() + if (!messaging) { + // Firebase is not initialized yet — skip Firebase subscriptions and do NOT + // mark as subscribed so the next call can retry once Firebase is ready. + console.warn('[PushOrchestrator] messaging unavailable, Firebase subscriptions skipped (will retry)') + return false + } + if (messaging && !this._unsubOnMessage) { const unsub = this._deps.onMessage(messaging, async (remoteMessage) => { const messageId = remoteMessage?.messageId diff --git a/package.json b/package.json index c7e6172..cf96fc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@personaclick/rn-sdk", - "version": "4.0.5", + "version": "4.0.6", "description": "PersonaClick React Native SDK", "type": "module", "exports": { diff --git a/tests/review.test.js b/tests/review.test.js new file mode 100644 index 0000000..e129028 --- /dev/null +++ b/tests/review.test.js @@ -0,0 +1,83 @@ +import REES46 from '../index.js' + +jest.mock('react-native-device-info', () => ({})) +jest.mock('@react-native-firebase/messaging', () => ({})) +jest.mock('@react-native-async-storage/async-storage', () => ({})) + +const mockRequest = jest.fn() +jest.mock('../lib/client.js', () => { + const actual = jest.requireActual('../lib/client.js') + return { + ...actual, + request: (...args) => mockRequest(...args), + } +}) + +describe('Review', () => { + let sdk + + beforeEach(() => { + sdk = new REES46('357382bf66ac0ce2f1722677c59511', 'android', true) + jest.spyOn(sdk, 'push').mockImplementation((callback) => { + callback() + }) + mockRequest.mockResolvedValue(undefined) + mockRequest.mockClear() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('should call review with valid rate and resolve', async () => { + await sdk.review(5, 'mobile', 'order') + + expect(mockRequest).toHaveBeenCalledWith( + 'nps/create', + '357382bf66ac0ce2f1722677c59511', + expect.objectContaining({ + method: 'POST', + params: expect.objectContaining({ + rate: 5, + channel: 'mobile', + category: 'order', + order_id: '', + comment: '', + }), + }) + ) + }) + + test('should call review with optional orderId and comment', async () => { + await sdk.review(8, 'mobile', 'order', 'order_123', 'Great experience') + + expect(mockRequest).toHaveBeenCalledWith( + 'nps/create', + '357382bf66ac0ce2f1722677c59511', + expect.objectContaining({ + method: 'POST', + params: expect.objectContaining({ + rate: 8, + channel: 'mobile', + category: 'order', + order_id: 'order_123', + comment: 'Great experience', + }), + }) + ) + }) + + test('should reject when rate is less than 1', async () => { + await expect(sdk.review(0, 'mobile', 'order')).rejects.toThrow( + 'Error: rating can be between 1 and 10 only' + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + + test('should reject when rate is greater than 10', async () => { + await expect(sdk.review(11, 'mobile', 'order')).rejects.toThrow( + 'Error: rating can be between 1 and 10 only' + ) + expect(mockRequest).not.toHaveBeenCalled() + }) +}) diff --git a/tests/search.test.js b/tests/search.test.js index 63eb6b3..1954576 100644 --- a/tests/search.test.js +++ b/tests/search.test.js @@ -51,4 +51,15 @@ describe('Search', () => { expect(error.message).toContain('Request failed with status code 400') } }) + + test('should call searchBlank and resolve with suggests, last_queries, products', async () => { + const response = await sdk.searchBlank() + + expect(response).toHaveProperty('suggests') + expect(response).toHaveProperty('last_queries') + expect(response).toHaveProperty('products') + expect(Array.isArray(response.suggests)).toBe(true) + expect(Array.isArray(response.last_queries)).toBe(true) + expect(Array.isArray(response.products)).toBe(true) + }) }) diff --git a/types/productTypes.js b/types/productTypes.js new file mode 100644 index 0000000..776200b --- /dev/null +++ b/types/productTypes.js @@ -0,0 +1,263 @@ +'use strict'; + +/** + * Cart item from products/cart API. + * @typedef {Object} CartItem + * @property {string} productId - Product unique id (from API uniqid). + * @property {number} quantity - Item quantity. + */ + +/** + * Parses a single cart item from API response (uniqid, quantity). + * @param {Record} json - Raw item from data.items. + * @returns {CartItem} + */ +export function parseCartItem(json) { + if (!json || typeof json !== 'object') { + return { productId: '', quantity: 1 }; + } + return { + productId: json.uniqid ?? json.id ?? '', + quantity: typeof json.quantity === 'number' ? json.quantity : 1, + }; +} + +/** + * Category (used in ProductInfo). + * @typedef {Object} Category + * @property {string} id + * @property {string} name + * @property {string} [url] + * @property {string} [alias] + * @property {string} [parentId] + * @property {number} [count] + */ + +function parseCategory(json) { + if (!json || typeof json !== 'object') return { id: '', name: '' }; + return { + id: json.id ?? '', + name: json.name ?? '', + url: json.url, + alias: json.alias, + parentId: json.parent ?? json.parentId, + count: json.count, + }; +} + +/** + * Filter (used in ProductsListResponse). + * @typedef {Object} Filter + * @property {number} count + * @property {Record} values + */ +function parseFilter(json) { + if (!json || typeof json !== 'object') return { count: 0, values: {} }; + return { + count: json.count ?? 0, + values: json.values && typeof json.values === 'object' ? json.values : {}, + }; +} + +/** + * Price range (used in ProductsListResponse). + * @typedef {Object} PriceRange + * @property {number} min + * @property {number} max + */ +function parsePriceRange(json) { + if (!json || typeof json !== 'object') return { min: 0, max: 0 }; + return { + min: typeof json.min === 'number' ? json.min : 0, + max: typeof json.max === 'number' ? json.max : 0, + }; +} + +/** + * Product (short form in list responses). + * @typedef {Object} Product + * @property {string} id + * @property {string} [barcode] + * @property {string} name + * @property {string} brand + * @property {string} [model] + * @property {string} [description] + * @property {string} imageUrl + * @property {string} resizedImageUrl + * @property {Record} [resizedImages] + * @property {string} url + * @property {string} [deeplinkIos] + * @property {number} price + * @property {string} priceFormatted + * @property {number} [priceFull] + * @property {string} [priceFullFormatted] + * @property {number} [oldPrice] + * @property {string} [oldPriceFormatted] + * @property {number} [oldPriceFull] + * @property {string} [oldPriceFullFormatted] + * @property {string} currency + * @property {number} [salesRate] + * @property {number} [discount] + * @property {number} [relativeSalesRate] + * @property {boolean} [isNew] + * @property {Array>} [params] + */ +export function parseProduct(json) { + if (!json || typeof json !== 'object') { + return { + id: '', + name: '', + brand: '', + imageUrl: '', + resizedImageUrl: '', + resizedImages: {}, + url: '', + deeplinkIos: '', + price: 0, + priceFormatted: '', + currency: '', + }; + } + return { + id: json.id ?? json.uniqid ?? '', + barcode: json.barcode ?? '', + name: json.name ?? '', + brand: json.brand ?? '', + model: json.model ?? '', + description: json.description ?? '', + imageUrl: json.image_url ?? '', + resizedImageUrl: json.picture ?? json.resizedImageUrl ?? '', + resizedImages: json.image_url_resized && typeof json.image_url_resized === 'object' ? json.image_url_resized : {}, + url: json.url ?? '', + deeplinkIos: json.deeplink_ios ?? json.deeplinkIos ?? '', + price: typeof json.price === 'number' ? json.price : 0, + priceFormatted: json.price_formatted ?? '', + priceFull: json.price_full, + priceFullFormatted: json.price_full_formatted, + oldPrice: json.oldprice, + oldPriceFormatted: json.oldprice_formatted, + oldPriceFull: json.oldprice_full, + oldPriceFullFormatted: json.oldprice_full_formatted, + currency: json.currency ?? '', + salesRate: json.sales_rate ?? 0, + discount: json.discount ?? 0, + relativeSalesRate: json.relative_sales_rate ?? 0, + isNew: json.is_new, + params: json.params, + }; +} + +/** + * Product info (full details from products/get). + * @typedef {Object} ProductInfo + * @property {string} id - From API uniqid. + * @property {string} name + * @property {string} brand + * @property {string} [model] + * @property {string} [description] + * @property {string} imageUrl + * @property {string} resizedImageUrl + * @property {Record} [resizedImages] + * @property {string} url + * @property {string} [deeplinkIos] + * @property {Category[]} categories + * @property {number} price + * @property {string} priceFormatted + * @property {number} [priceFull] + * @property {string} [priceFullFormatted] + * @property {number} [oldPrice] + * @property {string} [oldPriceFormatted] + * @property {number} [oldPriceFull] + * @property {string} [oldPriceFullFormatted] + * @property {string} currency + * @property {number} [salesRate] + * @property {number} [discount] + * @property {number} [relativeSalesRate] + * @property {string} [barcode] + * @property {boolean} [isNew] + * @property {Array>} [params] + */ +export function parseProductInfo(json) { + if (!json || typeof json !== 'object') { + return { + id: '', + name: '', + brand: '', + imageUrl: '', + resizedImageUrl: '', + resizedImages: {}, + url: '', + deeplinkIos: '', + categories: [], + price: 0, + priceFormatted: '', + currency: '', + }; + } + const categoriesJson = Array.isArray(json.categories) ? json.categories : []; + return { + id: json.uniqid ?? json.id ?? '', + name: json.name ?? '', + brand: json.brand ?? '', + model: json.model ?? '', + description: json.description ?? '', + imageUrl: json.image_url ?? '', + resizedImageUrl: json.picture ?? json.resizedImageUrl ?? '', + resizedImages: json.image_url_resized && typeof json.image_url_resized === 'object' ? json.image_url_resized : {}, + url: json.url ?? '', + deeplinkIos: json.deeplink_ios ?? json.deeplinkIos ?? '', + categories: categoriesJson.map(parseCategory), + price: typeof json.price === 'number' ? json.price : 0, + priceFormatted: json.price_formatted ?? '', + priceFull: json.price_full, + priceFullFormatted: json.price_full_formatted, + oldPrice: json.oldprice, + oldPriceFormatted: json.oldprice_formatted, + oldPriceFull: json.oldprice_full, + oldPriceFullFormatted: json.oldprice_full_formatted, + currency: json.currency ?? '', + salesRate: json.sales_rate ?? 0, + discount: json.discount ?? 0, + relativeSalesRate: json.relative_sales_rate ?? 0, + barcode: json.barcode ?? '', + isNew: json.is_new, + params: json.params, + }; +} + +/** + * Products list response (from products API). + * @typedef {Object} ProductsListResponse + * @property {string[]} [brands] + * @property {Record} [filters] + * @property {PriceRange} [priceRange] + * @property {Product[]} products + * @property {number} productsTotal + */ +export function parseProductsListResponse(json) { + if (!json || typeof json !== 'object') { + return { products: [], productsTotal: 0 }; + } + const brandsRaw = json.brands; + const brands = Array.isArray(brandsRaw) + ? brandsRaw.map((b) => (b && typeof b === 'object' && 'name' in b ? b.name : String(b))) + : []; + + const filtersRaw = json.filters; + const filters = filtersRaw && typeof filtersRaw === 'object' && !Array.isArray(filtersRaw) + ? Object.fromEntries( + Object.entries(filtersRaw) + .filter(([, v]) => v && typeof v === 'object') + .map(([k, v]) => [k, parseFilter(v)]) + ) + : undefined; + + const productsJson = Array.isArray(json.products) ? json.products : []; + return { + brands: brands.length > 0 ? brands : undefined, + filters: filters && Object.keys(filters).length > 0 ? filters : undefined, + priceRange: json.price_range ? parsePriceRange(json.price_range) : undefined, + products: productsJson.map(parseProduct), + productsTotal: typeof json.products_total === 'number' ? json.products_total : 0, + }; +} diff --git a/utils/search-blank.js b/utils/search-blank.js index bef383f..f11033a 100644 --- a/utils/search-blank.js +++ b/utils/search-blank.js @@ -10,7 +10,7 @@ import { request } from '../lib/client'; export const blankSearchRequest = (shop_id, stream) => { return new Promise((resolve, reject) => { try { - request('search/blank', { + request('search/blank', shop_id, { params: { shop_id, stream,