@cmscure/react-native-cmscure-sdk
Version:
React Native SDK for CMSCure. Manage UI content, styles, and data stores in real-time.
454 lines (392 loc) • 15.8 kB
JavaScript
import {
NativeModules,
NativeEventEmitter,
Platform,
Image, View, ActivityIndicator, I18nManager
} from 'react-native';
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
useMemo
} from 'react';
const { CMSCureSDK } = NativeModules;
if (!CMSCureSDK) {
throw new Error(
'CMSCureSDK native module is not available. ' +
'Please check your installation and rebuild your app.'
);
}
const eventEmitter = new NativeEventEmitter(CMSCureSDK);
// ---------------------------------------------------------------------------
// Language Direction & Display Utilities (pure JS — no native bridge needed)
// ---------------------------------------------------------------------------
const RTL_LANGUAGES = new Set([
'ar', 'he', 'fa', 'ur', 'ps', 'sd', 'ku', 'yi', 'ckb', 'dv', 'ug',
]);
/** Returns 'RTL' or 'LTR' for a given language code. */
export const LanguageDirection = {
direction: (code) => {
const base = (code || 'en').toLowerCase().split(/[-_]/)[0];
return RTL_LANGUAGES.has(base) ? 'RTL' : 'LTR';
},
isRTL: (code) => {
const base = (code || 'en').toLowerCase().split(/[-_]/)[0];
return RTL_LANGUAGES.has(base);
},
};
const FLAGS = {
af: '🇿🇦', am: '🇪🇹', ar: '🇸🇦', az: '🇦🇿',
be: '🇧🇾', bg: '🇧🇬', bn: '🇧🇩', bs: '🇧🇦',
ca: '🇪🇸', cs: '🇨🇿', cy: '🏴', da: '🇩🇰',
de: '🇩🇪', el: '🇬🇷', en: '🇺🇸', es: '🇪🇸',
et: '🇪🇪', eu: '🇪🇸', fa: '🇮🇷', fi: '🇫🇮',
fil: '🇵🇭', fr: '🇫🇷', ga: '🇮🇪', gl: '🇪🇸',
gu: '🇮🇳', ha: '🇳🇬', he: '🇮🇱', hi: '🇮🇳',
hr: '🇭🇷', hu: '🇭🇺', hy: '🇦🇲', id: '🇮🇩',
ig: '🇳🇬', is: '🇮🇸', it: '🇮🇹', ja: '🇯🇵',
jv: '🇮🇩', ka: '🇬🇪', kk: '🇰🇿', km: '🇰🇭',
kn: '🇮🇳', ko: '🇰🇷', ku: '🇮🇶', ky: '🇰🇬',
lo: '🇱🇦', lt: '🇱🇹', lv: '🇱🇻', mg: '🇲🇬',
mk: '🇲🇰', ml: '🇮🇳', mn: '🇲🇳', mr: '🇮🇳',
ms: '🇲🇾', mt: '🇲🇹', my: '🇲🇲', nb: '🇳🇴',
ne: '🇳🇵', nl: '🇳🇱', nn: '🇳🇴', no: '🇳🇴',
or: '🇮🇳', pa: '🇮🇳', pl: '🇵🇱', ps: '🇦🇫',
pt: '🇵🇹', ro: '🇷🇴', ru: '🇷🇺', rw: '🇷🇼',
sd: '🇵🇰', si: '🇱🇰', sk: '🇸🇰', sl: '🇸🇮',
so: '🇸🇴', sq: '🇦🇱', sr: '🇷🇸', sv: '🇸🇪',
sw: '🇹🇿', ta: '🇮🇳', te: '🇮🇳', tg: '🇹🇯',
th: '🇹🇭', tk: '🇹🇲', tl: '🇵🇭', tr: '🇹🇷',
uk: '🇺🇦', ur: '🇵🇰', uz: '🇺🇿', vi: '🇻🇳',
xh: '🇿🇦', yi: '🇮🇱', yo: '🇳🇬', zh: '🇨🇳',
zu: '🇿🇦',
'en-gb': '🇬🇧', 'en-au': '🇦🇺', 'en-ca': '🇨🇦', 'en-in': '🇮🇳',
'es-mx': '🇲🇽', 'es-ar': '🇦🇷', 'fr-ca': '🇨🇦', 'fr-be': '🇧🇪',
'pt-br': '🇧🇷', 'zh-tw': '🇹🇼', 'zh-hk': '🇭🇰',
};
const DISPLAY_NAMES = {
af: 'Afrikaans', am: 'Amharic', ar: 'Arabic', az: 'Azerbaijani',
be: 'Belarusian', bg: 'Bulgarian', bn: 'Bengali', bs: 'Bosnian',
ca: 'Catalan', cs: 'Czech', cy: 'Welsh', da: 'Danish',
de: 'German', el: 'Greek', en: 'English', es: 'Spanish',
et: 'Estonian', eu: 'Basque', fa: 'Persian', fi: 'Finnish',
fil: 'Filipino', fr: 'French', ga: 'Irish', gl: 'Galician',
gu: 'Gujarati', ha: 'Hausa', he: 'Hebrew', hi: 'Hindi',
hr: 'Croatian', hu: 'Hungarian', hy: 'Armenian', id: 'Indonesian',
ig: 'Igbo', is: 'Icelandic', it: 'Italian', ja: 'Japanese',
jv: 'Javanese', ka: 'Georgian', kk: 'Kazakh', km: 'Khmer',
kn: 'Kannada', ko: 'Korean', ku: 'Kurdish', ky: 'Kyrgyz',
lo: 'Lao', lt: 'Lithuanian', lv: 'Latvian', mg: 'Malagasy',
mk: 'Macedonian', ml: 'Malayalam', mn: 'Mongolian', mr: 'Marathi',
ms: 'Malay', mt: 'Maltese', my: 'Burmese', nb: 'Norwegian Bokmål',
ne: 'Nepali', nl: 'Dutch', nn: 'Norwegian Nynorsk', no: 'Norwegian',
or: 'Odia', pa: 'Punjabi', pl: 'Polish', ps: 'Pashto',
pt: 'Portuguese', ro: 'Romanian', ru: 'Russian', rw: 'Kinyarwanda',
sd: 'Sindhi', si: 'Sinhala', sk: 'Slovak', sl: 'Slovenian',
so: 'Somali', sq: 'Albanian', sr: 'Serbian', sv: 'Swedish',
sw: 'Swahili', ta: 'Tamil', te: 'Telugu', tg: 'Tajik',
th: 'Thai', tk: 'Turkmen', tl: 'Tagalog', tr: 'Turkish',
uk: 'Ukrainian', ur: 'Urdu', uz: 'Uzbek', vi: 'Vietnamese',
xh: 'Xhosa', yi: 'Yiddish', yo: 'Yoruba', zh: 'Chinese',
zu: 'Zulu',
'en-gb': 'English (UK)', 'en-au': 'English (Australia)', 'en-ca': 'English (Canada)',
'en-in': 'English (India)', 'es-mx': 'Spanish (Mexico)', 'es-ar': 'Spanish (Argentina)',
'fr-ca': 'French (Canada)', 'fr-be': 'French (Belgium)', 'pt-br': 'Portuguese (Brazil)',
'zh-tw': 'Chinese (Traditional)', 'zh-hk': 'Chinese (Hong Kong)',
};
/** Flag emoji and display name helpers. */
export const CureLanguageDisplay = {
flag: (code) => {
const lower = (code || '').toLowerCase();
return FLAGS[lower] || FLAGS[lower.split(/[-_]/)[0]] || '🌐';
},
displayName: (code) => {
const lower = (code || '').toLowerCase();
return DISPLAY_NAMES[lower] || DISPLAY_NAMES[lower.split(/[-_]/)[0]] || code.toUpperCase();
},
};
// ---------------------------------------------------------------------------
// DataStoreItem convenience helper
// ---------------------------------------------------------------------------
/**
* Wraps a raw DataStoreItem from the native bridge with typed accessors.
* Matches iOS/Android SDK convenience getters (string, int, double, bool, ctaURL).
*/
const wrapDataStoreItem = (raw) => {
if (!raw) return raw;
const data = raw.data || {};
return {
...raw,
/** Returns the localised/string value for a key, or null. */
string: (key) => {
const v = data[key];
return v?.localizedString ?? v?.stringValue ?? null;
},
/** Returns the integer value for a key, or null. */
int: (key) => {
const v = data[key];
if (v?.intValue != null) return v.intValue;
if (v?.stringValue != null) { const n = parseInt(v.stringValue, 10); return isNaN(n) ? null : n; }
return null;
},
/** Returns the double/float value for a key, or null. */
double: (key) => {
const v = data[key];
if (v?.doubleValue != null) return v.doubleValue;
if (v?.intValue != null) return v.intValue;
if (v?.stringValue != null) { const n = parseFloat(v.stringValue); return isNaN(n) ? null : n; }
return null;
},
/** Returns the boolean value for a key, or null. */
bool: (key) => {
const v = data[key];
if (v?.boolValue != null) return v.boolValue;
return null;
},
/** CTA / deep-link URL if a `cta_url` field exists and is non-empty. */
get ctaURL() {
const v = data.cta_url;
const url = v?.stringValue ?? v?.localizedString ?? null;
return url && url.trim().length > 0 ? url : null;
},
};
};
/** Wraps an array of raw items. */
const wrapDataStoreItems = (items) => (items || []).map(wrapDataStoreItem);
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const CMSCureContext = createContext(null);
// ---------------------------------------------------------------------------
// Core SDK API
// ---------------------------------------------------------------------------
export const Cure = {
configure: async (config) => {
const {
projectId,
apiKey,
projectSecret,
enableAutoRealTimeUpdates = true,
} = config;
if (!projectId || !apiKey || !projectSecret) {
throw new Error('CMSCure configuration requires projectId, apiKey, and projectSecret.');
}
if (Platform.OS === 'ios') {
return CMSCureSDK.configure(projectId, apiKey, projectSecret);
}
return CMSCureSDK.configure(
projectId,
apiKey,
projectSecret,
enableAutoRealTimeUpdates,
);
},
setLanguage: (languageCode) => CMSCureSDK.setLanguage(languageCode),
getLanguage: () => CMSCureSDK.getLanguage(),
availableLanguages: () => CMSCureSDK.availableLanguages(),
translation: (key, tab) => CMSCureSDK.translation(key, tab),
colorValue: (key) => CMSCureSDK.colorValue(key),
imageURL: (key) => CMSCureSDK.imageURL(key),
getStoreItems: async (apiIdentifier) => {
const raw = await CMSCureSDK.getStoreItems(apiIdentifier);
return wrapDataStoreItems(raw);
},
syncStore: async (apiIdentifier) => {
const raw = await CMSCureSDK.syncStore(apiIdentifier);
return wrapDataStoreItems(raw);
},
sync: (screenName) => CMSCureSDK.sync(screenName),
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const CMSCureConstants = {
ALL_SCREENS_UPDATED: '__ALL_SCREENS_UPDATED__',
COLORS_UPDATED: '__colors__',
IMAGES_UPDATED: '__images__',
};
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export const CMSCureProvider = ({ children, config }) => {
const [isInitialized, setIsInitialized] = useState(false);
const updateCounterRef = useRef(0);
const [, forceRender] = useState(false);
useEffect(() => {
let isMounted = true;
const initializeSDK = async () => {
if (config && isMounted) {
try {
await Cure.configure(config);
await Promise.all([
Cure.sync('__colors__'),
Cure.sync('__images__'),
]);
if (isMounted) {
setIsInitialized(true);
}
} catch (error) {
console.error('CMSCureProvider: Failed to initialize CMSCure SDK:', error);
}
}
};
initializeSDK();
const subscription = eventEmitter.addListener('CMSCureContentUpdate', (event) => {
if (!isMounted) return;
updateCounterRef.current = { id: event.identifier, time: Date.now() };
forceRender((c) => !c);
});
return () => {
isMounted = false;
subscription?.remove();
};
}, [config]);
const contextValue = useMemo(
() => ({
isInitialized,
updateCounter: updateCounterRef.current,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[isInitialized, updateCounterRef.current],
);
if (!isInitialized) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<CMSCureContext.Provider value={contextValue}>
{children}
</CMSCureContext.Provider>
);
};
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
const useCMSCureContext = () => {
const context = useContext(CMSCureContext);
if (!context) {
throw new Error('useCMSCure hooks must be used within CMSCureProvider');
}
return context;
};
/** Reactive CMS translation string. Auto-updates on content changes. */
export const useCureString = (key, tab, defaultValue = '') => {
const { updateCounter } = useCMSCureContext();
const [value, setValue] = useState(defaultValue);
useEffect(() => {
let mounted = true;
Cure.translation(key, tab)
.then((result) => { if (mounted) setValue(result || defaultValue); })
.catch(() => { if (mounted) setValue(defaultValue); });
return () => { mounted = false; };
}, [key, tab, defaultValue, updateCounter]);
return value;
};
/** Reactive CMS color hex string. Auto-updates on content changes. */
export const useCureColor = (key, defaultValue = '#000000') => {
const { updateCounter } = useCMSCureContext();
const [value, setValue] = useState(defaultValue);
useEffect(() => {
let mounted = true;
Cure.colorValue(key)
.then((result) => { if (mounted) setValue(result || defaultValue); })
.catch(() => { if (mounted) setValue(defaultValue); });
return () => { mounted = false; };
}, [key, defaultValue, updateCounter]);
return value;
};
/** Reactive CMS image URL. Pass `tab` for translation-based images, omit for global images. */
export const useCureImage = (key, tab = null) => {
const { updateCounter } = useCMSCureContext();
const [value, setValue] = useState(null);
useEffect(() => {
let mounted = true;
const promise = tab ? Cure.translation(key, tab) : Cure.imageURL(key);
promise
.then((result) => { if (mounted) setValue(result || null); })
.catch(() => { if (mounted) setValue(null); });
return () => { mounted = false; };
}, [key, tab, updateCounter]);
return value;
};
/** Reactive CMS data store. Returns `{ items, isLoading }` where each item has typed accessors. */
export const useCureDataStore = (apiIdentifier) => {
const { updateCounter } = useCMSCureContext();
const [state, setState] = useState({ items: [], isLoading: true });
const initialSyncDone = useRef(false);
useEffect(() => {
let isMounted = true;
const syncAndSet = async () => {
if (isMounted) setState((s) => ({ ...s, isLoading: true }));
try {
const items = await Cure.syncStore(apiIdentifier);
if (isMounted) setState({ items, isLoading: false });
} catch (error) {
console.error(`CMSCure: Failed to sync '${apiIdentifier}':`, error);
if (isMounted) {
const cached = await Cure.getStoreItems(apiIdentifier);
setState({ items: cached, isLoading: false });
}
}
};
const refreshFromCache = async () => {
try {
const cached = await Cure.getStoreItems(apiIdentifier);
if (isMounted) setState((s) => ({ ...s, items: cached }));
} catch (error) {
console.error(`CMSCure: Failed to refresh '${apiIdentifier}' from cache:`, error);
}
};
if (!initialSyncDone.current) {
initialSyncDone.current = true;
syncAndSet();
} else if (
updateCounter?.id === apiIdentifier ||
updateCounter?.id === CMSCureConstants.ALL_SCREENS_UPDATED
) {
refreshFromCache();
}
return () => { isMounted = false; };
}, [apiIdentifier, updateCounter]);
return state;
};
/** Returns the current language code and RTL flag, re-renders on language changes. */
export const useCureLanguage = () => {
const { updateCounter } = useCMSCureContext();
const [lang, setLang] = useState({ code: 'en', isRTL: false, flag: '🇺🇸', displayName: 'English' });
useEffect(() => {
let mounted = true;
Cure.getLanguage()
.then((code) => {
if (mounted) {
setLang({
code,
isRTL: LanguageDirection.isRTL(code),
flag: CureLanguageDisplay.flag(code),
displayName: CureLanguageDisplay.displayName(code),
});
}
})
.catch(() => {});
return () => { mounted = false; };
}, [updateCounter]);
return lang;
};
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/** Renders a CMS-managed image from a URL. Returns null when url is falsy. */
export const CureSDKImage = ({ url, style, ...props }) => {
if (!url) return null;
return <Image source={{ uri: url }} style={style} {...props} />;
};
// ---------------------------------------------------------------------------
// Default export
// ---------------------------------------------------------------------------
export default Cure;