@tenqube/visual-reward-react-native 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ import { VisualRewardError } from './types';
2
+ import type {
3
+ RemoteConfig,
4
+ RewardHandler,
5
+ VisualRewardInitializeOptions,
6
+ VisualRewardOpenOptions,
7
+ } from './types';
8
+ import { readCache, writeCache, setStorage, AsyncStorageLike } from './ConfigCache';
9
+
10
+ const OPEN_TIMEOUT_MS = 5_000;
11
+ // config fetch 연결/읽기 타임아웃 (다른 플랫폼과 동일: 10초).
12
+ const CONFIG_FETCH_TIMEOUT_MS = 10_000;
13
+
14
+ export class VisualReward {
15
+ private static readonly CONFIG_BASE_URL = 'https://visual.reward.tenqube.com';
16
+ private static _handler: RewardHandler | null = null;
17
+ private static _remoteConfig: RemoteConfig | null = null;
18
+ private static _configPromise: Promise<RemoteConfig> | null = null;
19
+ private static _initGeneration = 0;
20
+ private static _debugEnabled = false;
21
+ private static _userId = '';
22
+ private static _openTimeoutMs = OPEN_TIMEOUT_MS;
23
+
24
+ private constructor() {}
25
+
26
+ /** @internal Portal이 마운트될 때 핸들러를 등록합니다. */
27
+ static _register(handler: RewardHandler): void {
28
+ VisualReward._handler = handler;
29
+ }
30
+
31
+ /** @internal Portal이 언마운트될 때 핸들러를 해제합니다. */
32
+ static _unregister(): void {
33
+ VisualReward._handler = null;
34
+ }
35
+
36
+ /**
37
+ * SDK를 초기화합니다. App.tsx의 useEffect 등 앱 시작 시 한 번만 호출합니다.
38
+ */
39
+ static initialize(options: VisualRewardInitializeOptions): void {
40
+ const {
41
+ appKey,
42
+ timeoutMs = 5_000,
43
+ onSuccess,
44
+ onFailure,
45
+ } = options;
46
+ VisualReward._initGeneration++;
47
+ const generation = VisualReward._initGeneration;
48
+ VisualReward._openTimeoutMs = timeoutMs;
49
+ VisualReward._remoteConfig = null;
50
+ VisualReward._configPromise = VisualReward.fetchConfig(appKey, VisualReward.CONFIG_BASE_URL);
51
+ VisualReward._configPromise
52
+ .then((config) => {
53
+ if (VisualReward._initGeneration !== generation) return;
54
+ VisualReward._remoteConfig = config;
55
+ onSuccess?.();
56
+ })
57
+ .catch((e) => {
58
+ if (VisualReward._initGeneration !== generation) return;
59
+ onFailure?.(e);
60
+ });
61
+ }
62
+
63
+ /** WebView 디버깅 활성화 여부를 설정합니다. initialize() 전에 호출합니다. */
64
+ static setDebugEnabled(enabled: boolean): void {
65
+ VisualReward._debugEnabled = enabled;
66
+ }
67
+
68
+ /** AsyncStorage 구현체를 주입합니다. initialize() 전에 호출합니다. */
69
+ static setStorage(storage: AsyncStorageLike): void {
70
+ setStorage(storage);
71
+ }
72
+
73
+ /** 사용자 식별자를 설정합니다. open() 전에 반드시 호출합니다. */
74
+ static setUserId(userId: string): void {
75
+ VisualReward._userId = userId;
76
+ }
77
+
78
+ /**
79
+ * 리워드 웹뷰를 엽니다.
80
+ *
81
+ * 호스트 앱 루트에 `<VisualRewardContainer />`이 마운트되어 있어야 합니다.
82
+ */
83
+ static async open(options: VisualRewardOpenOptions): Promise<void> {
84
+ const { serviceName, locale: localeOption, extra, onClose, onError } = options;
85
+ // flutter/kotlin/swift 와 1:1: open 은 예외를 던지지 않고 모든 오류를 onError 로 전달한다.
86
+ try {
87
+ const userId = VisualReward._userId;
88
+ if (!userId) {
89
+ throw new VisualRewardError(
90
+ 'USER_ID_NOT_SET',
91
+ 'setUserId()가 호출되지 않았습니다. open() 전에 setUserId()를 호출해주세요.',
92
+ );
93
+ }
94
+
95
+ if (!VisualReward._handler) {
96
+ throw new VisualRewardError(
97
+ 'NOT_INITIALIZED',
98
+ 'VisualRewardContainer가 마운트되지 않았습니다. 앱 루트에 <VisualRewardContainer />을 추가해주세요.',
99
+ );
100
+ }
101
+
102
+ const locale = localeOption ?? 'ko';
103
+ const config = await VisualReward.resolveConfig();
104
+ const url = VisualReward.buildFinalUrl(config.baseUrl, serviceName, userId, locale, extra);
105
+
106
+ VisualReward._handler({
107
+ url,
108
+ allowedDomains: config.allowedDomains,
109
+ debugEnabled: VisualReward._debugEnabled,
110
+ onClose,
111
+ });
112
+ } catch (e) {
113
+ const err =
114
+ e instanceof VisualRewardError
115
+ ? e
116
+ : new VisualRewardError('NETWORK_ERROR', `열기 실패: ${e}`, e);
117
+ onError?.(err);
118
+ }
119
+ }
120
+
121
+ private static async resolveConfig(): Promise<RemoteConfig> {
122
+ if (VisualReward._remoteConfig) return VisualReward._remoteConfig;
123
+ const promise = VisualReward._configPromise;
124
+ if (!promise) {
125
+ throw new VisualRewardError('NOT_INITIALIZED', 'initialize() 호출 없이 open() 호출됨');
126
+ }
127
+ const config = await Promise.race([
128
+ promise,
129
+ new Promise<never>((_, reject) =>
130
+ setTimeout(
131
+ () => reject(new VisualRewardError('INITIALIZE_TIMEOUT', 'initialize() 타임아웃')),
132
+ VisualReward._openTimeoutMs,
133
+ ),
134
+ ),
135
+ ]);
136
+ VisualReward._remoteConfig = config;
137
+ return config;
138
+ }
139
+
140
+ private static async fetchConfig(appKey: string, baseUrl: string): Promise<RemoteConfig> {
141
+ const cached = await readCache(appKey);
142
+ if (cached) return cached;
143
+
144
+ const url = `${baseUrl}/sdk/config/v1/${appKey}.json`;
145
+ // 다른 플랫폼과 동일하게 연결/읽기 10초 타임아웃 (초과 시 abort → NETWORK_ERROR).
146
+ const controller = new AbortController();
147
+ const timeoutId = setTimeout(() => controller.abort(), CONFIG_FETCH_TIMEOUT_MS);
148
+ try {
149
+ let response: Response;
150
+ try {
151
+ response = await fetch(url, { signal: controller.signal });
152
+ } catch (e) {
153
+ throw new VisualRewardError('NETWORK_ERROR', `네트워크 오류: ${e}`, e);
154
+ }
155
+ if (response.status === 404) {
156
+ throw new VisualRewardError('INVALID_APP_KEY', '존재하지 않는 앱 키');
157
+ }
158
+ if (!response.ok) {
159
+ throw new VisualRewardError('SERVER_ERROR', `서버 오류: ${response.status}`, response.status);
160
+ }
161
+ try {
162
+ const config = (await response.json()) as RemoteConfig;
163
+ await writeCache(appKey, config);
164
+ return config;
165
+ } catch (e) {
166
+ throw new VisualRewardError('PARSE_ERROR', `응답 파싱 오류: ${e}`, e);
167
+ }
168
+ } finally {
169
+ clearTimeout(timeoutId);
170
+ }
171
+ }
172
+
173
+ private static buildFinalUrl(
174
+ baseUrl: string,
175
+ serviceName: string,
176
+ userId: string,
177
+ locale: string,
178
+ extra?: string,
179
+ ): string {
180
+ const url = new URL(`${baseUrl}${serviceName}/`);
181
+ // 예약 키는 base 쿼리에 이미 있어도 덮어쓴다 (flutter/kotlin/swift 와 동일).
182
+ url.searchParams.delete('userId');
183
+ url.searchParams.delete('hl');
184
+ url.searchParams.delete('extra');
185
+ url.searchParams.set('userId', userId);
186
+ url.searchParams.set('hl', locale);
187
+ if (extra !== undefined && extra !== null && extra !== '') url.searchParams.set('extra', extra);
188
+ return url.toString();
189
+ }
190
+ }
@@ -0,0 +1,121 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import {
3
+ BackHandler,
4
+ Modal,
5
+ Platform,
6
+ StatusBar,
7
+ StyleSheet,
8
+ View,
9
+ } from "react-native";
10
+ import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
11
+ import RewardWebView from "./RewardWebView";
12
+ import { VisualReward } from "./VisualReward";
13
+ import type { RewardWebViewHandle, RewardWebViewProps } from "./types";
14
+
15
+ /**
16
+ * VisualRewardContainer
17
+ *
18
+ * 앱 루트에 한 번만 추가합니다. `VisualReward.open()` 호출 시 리워드 웹뷰를 Modal 로 렌더링합니다.
19
+ *
20
+ * 항상 최상단에 뜨는 것을 크로스플랫폼으로 보장하기 위해 `Modal`(별도 window)을 사용합니다.
21
+ * (비-Modal 오버레이는 react-navigation native-stack 의 네이티브 화면에 가려질 수 있음)
22
+ * Modal window 는 RN `StatusBar` API 로 아이콘 색을 못 바꾸므로, 상단 safe-area 를 다크로 두고
23
+ * 밝은 상태바 아이콘이 보이도록 한다.
24
+ *
25
+ * @example
26
+ * // App.tsx
27
+ * import { VisualRewardContainer } from '@tenqube/visual-reward-react-native';
28
+ * export default function App() {
29
+ * return (
30
+ * <>
31
+ * <YourNavigator />
32
+ * <VisualRewardContainer />
33
+ * </>
34
+ * );
35
+ * }
36
+ */
37
+ const VisualRewardContainer: React.FC = () => {
38
+ const [options, setOptions] = useState<RewardWebViewProps | null>(null);
39
+ const visible = options !== null;
40
+ const rewardWebViewRef = useRef<RewardWebViewHandle>(null);
41
+ const optionsRef = useRef<RewardWebViewProps | null>(null);
42
+ optionsRef.current = options;
43
+
44
+ // VisualReward 싱글톤에 핸들러 등록
45
+ useEffect(() => {
46
+ VisualReward._register((opts) => {
47
+ setOptions(opts);
48
+ });
49
+ return () => {
50
+ VisualReward._unregister();
51
+ };
52
+ }, []);
53
+
54
+ const handleClose = useCallback(() => {
55
+ optionsRef.current?.onClose?.();
56
+ setOptions(null);
57
+ }, []);
58
+
59
+ // Android Back 키: RewardWebView 가 처리한 경우(true 반환) 닫지 않는다.
60
+ const handleBackPress = useCallback(() => {
61
+ const consumed = rewardWebViewRef.current?.handleBackPress() ?? false;
62
+ if (!consumed) {
63
+ handleClose();
64
+ }
65
+ return true; // 항상 true (Activity 종료 방지)
66
+ }, [handleClose]);
67
+
68
+ // Android BackHandler: Modal 이 열린 동안만 등록
69
+ useEffect(() => {
70
+ if (!visible || Platform.OS !== "android") return;
71
+ const subscription = BackHandler.addEventListener(
72
+ "hardwareBackPress",
73
+ handleBackPress,
74
+ );
75
+ return () => subscription.remove();
76
+ }, [visible, handleBackPress]);
77
+
78
+ return (
79
+ <Modal
80
+ visible={visible}
81
+ animationType="slide"
82
+ presentationStyle="fullScreen"
83
+ statusBarTranslucent={Platform.OS === "android"}
84
+ onRequestClose={handleBackPress}
85
+ >
86
+ {/* Modal 은 별도 window 라 자체 SafeAreaProvider 로 inset 을 다시 측정한다.
87
+ 상단 safe-area 를 흰색으로 두고 상태바 아이콘을 dark-content(검정)로 표시한다. */}
88
+ <SafeAreaProvider style={styles.safeArea}>
89
+ <StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
90
+ <SafeAreaView style={styles.safeArea} edges={["top", "bottom"]}>
91
+ <View style={styles.container}>
92
+ {options && (
93
+ <RewardWebView
94
+ ref={rewardWebViewRef}
95
+ key={options.url}
96
+ url={options.url}
97
+ allowedDomains={options.allowedDomains}
98
+ debugEnabled={options.debugEnabled ?? false}
99
+ onClose={handleClose}
100
+ />
101
+ )}
102
+ </View>
103
+ </SafeAreaView>
104
+ </SafeAreaProvider>
105
+ </Modal>
106
+ );
107
+ };
108
+
109
+ const styles = StyleSheet.create({
110
+ // safe-area(상태바/제스처바) 영역. 검정 상태바 아이콘이 보이도록 흰색.
111
+ safeArea: {
112
+ flex: 1,
113
+ backgroundColor: "#ffffff",
114
+ },
115
+ container: {
116
+ flex: 1,
117
+ backgroundColor: "#ffffff",
118
+ },
119
+ });
120
+
121
+ export default VisualRewardContainer;
package/src/types.ts ADDED
@@ -0,0 +1,66 @@
1
+ export type VisualRewardErrorCode =
2
+ | 'NETWORK_ERROR'
3
+ | 'INITIALIZE_TIMEOUT'
4
+ | 'INVALID_APP_KEY'
5
+ | 'SERVER_ERROR'
6
+ | 'PARSE_ERROR'
7
+ | 'NOT_INITIALIZED'
8
+ | 'USER_ID_NOT_SET';
9
+
10
+ export class VisualRewardError extends Error {
11
+ constructor(
12
+ public readonly code: VisualRewardErrorCode,
13
+ message: string,
14
+ public readonly cause?: unknown,
15
+ ) {
16
+ super(message);
17
+ this.name = 'VisualRewardError';
18
+ }
19
+ }
20
+
21
+ export interface VisualRewardInitializeOptions {
22
+ /** 앱 식별 키 */
23
+ appKey: string;
24
+ /** initialize() 완료 대기 타임아웃 (ms). 기본값 `5000`. */
25
+ timeoutMs?: number;
26
+ /** 초기화 성공 콜백 (선택). */
27
+ onSuccess?: () => void;
28
+ /** 초기화 실패 콜백 (선택). */
29
+ onFailure?: (error: VisualRewardError) => void;
30
+ }
31
+
32
+ export interface VisualRewardOpenOptions {
33
+ /** 지면 식별자 */
34
+ serviceName: string;
35
+ /** 언어 코드. 기본값 `'ko'` (선택). */
36
+ locale?: string;
37
+ /** 추가 파라미터 (옵션) */
38
+ extra?: string;
39
+ /** 웹뷰가 닫힐 때 호출되는 콜백. */
40
+ onClose?: () => void;
41
+ /** 리워드 웹뷰 열기 실패 시 호출되는 콜백. (open 은 예외를 던지지 않음) */
42
+ onError?: (error: VisualRewardError) => void;
43
+ }
44
+
45
+ /** RewardWebView 내부에서 사용하는 props */
46
+ export interface RewardWebViewProps {
47
+ url: string;
48
+ allowedDomains: string[];
49
+ debugEnabled: boolean;
50
+ onClose?: () => void;
51
+ }
52
+
53
+ /** VisualRewardContainer 내부에서 사용하는 핸들러 타입 */
54
+ export type RewardHandler = (options: RewardWebViewProps) => void;
55
+
56
+ /** forwardRef로 노출되는 RewardWebView 핸들 */
57
+ export interface RewardWebViewHandle {
58
+ /** Back 키 처리. true를 반환하면 기본 동작 차단. */
59
+ handleBackPress: () => boolean;
60
+ }
61
+
62
+ /** 서버 config 응답 */
63
+ export interface RemoteConfig {
64
+ baseUrl: string;
65
+ allowedDomains: string[];
66
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,24 @@
1
+ /** 문자열 시작에 `scheme://` 가 있는지 판별 (쿼리스트링 내부의 `://` 오탐 방지 위해 `^` 앵커). */
2
+ const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\//;
3
+
4
+ /**
5
+ * URL에 스킴이 없으면 `https://`를 붙여 반환합니다.
6
+ * Kotlin/Swift/Flutter SDK 의 `SdkConfig.normalizeUrl` 과 1:1.
7
+ */
8
+ export function normalizeUrl(url: string | null | undefined): string {
9
+ const raw = (url ?? '').trim();
10
+ if (!raw) return raw;
11
+ return SCHEME_REGEX.test(raw) ? raw : `https://${raw}`;
12
+ }
13
+
14
+ /**
15
+ * host가 allowedDomains 내에 속하는지 확인합니다.
16
+ * 예) host="reward.tenqube.com", base="tenqube.com" → true
17
+ */
18
+ export function isAllowedDomain(host: string, allowedDomains: string[]): boolean {
19
+ const h = host.toLowerCase();
20
+ return allowedDomains.some((base) => {
21
+ const b = base.toLowerCase();
22
+ return h === b || h.endsWith(`.${b}`);
23
+ });
24
+ }
@@ -0,0 +1,20 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "visual-reward-react-native"
7
+ s.version = package["version"]
8
+ s.summary = "Tenqube Visual Reward SDK for React Native"
9
+ s.homepage = "https://tenqube.com"
10
+ s.license = "MIT"
11
+ s.authors = { "tenqube" => "dev@tenqube.com" }
12
+ s.platforms = { :ios => "13.0" }
13
+ s.source = { :git => "https://github.com/tenqube/visual-reward-sdk-react-native.git", :tag => "#{s.version}" }
14
+
15
+ s.source_files = "ios/**/*.{h,m,mm,swift}"
16
+
17
+ # GMA / AdPopcorn 은 리플렉션으로만 참조하므로 컴파일 의존성 없음(옵셔널).
18
+ # react-native-webview 의 WKWebView 는 뷰 계층 탐색으로 찾으므로 의존성 불필요.
19
+ s.dependency "React-Core"
20
+ end