@savers_app/react-native-sandbox-sdk 1.2.6 → 1.2.8

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.
Files changed (36) hide show
  1. package/README.md +22 -151
  2. package/lib/module/core/runtime.js +14 -0
  3. package/lib/module/core/runtime.js.map +1 -1
  4. package/lib/module/index.js +2 -0
  5. package/lib/module/index.js.map +1 -1
  6. package/lib/module/services/url/urlGenerator.js +20 -6
  7. package/lib/module/services/url/urlGenerator.js.map +1 -1
  8. package/lib/module/services/webview/DualWebViewBridgeController.js +267 -0
  9. package/lib/module/services/webview/DualWebViewBridgeController.js.map +1 -0
  10. package/lib/module/services/webview/dualWebViewBridge.types.js +19 -0
  11. package/lib/module/services/webview/dualWebViewBridge.types.js.map +1 -0
  12. package/lib/module/utils/config.js +2 -0
  13. package/lib/module/utils/config.js.map +1 -1
  14. package/lib/typescript/src/core/runtime.d.ts +4 -0
  15. package/lib/typescript/src/core/runtime.d.ts.map +1 -1
  16. package/lib/typescript/src/index.d.ts +2 -0
  17. package/lib/typescript/src/index.d.ts.map +1 -1
  18. package/lib/typescript/src/services/url/urlGenerator.d.ts +0 -1
  19. package/lib/typescript/src/services/url/urlGenerator.d.ts.map +1 -1
  20. package/lib/typescript/src/services/webview/DualWebViewBridgeController.d.ts +15 -0
  21. package/lib/typescript/src/services/webview/DualWebViewBridgeController.d.ts.map +1 -0
  22. package/lib/typescript/src/services/webview/dualWebViewBridge.types.d.ts +20 -0
  23. package/lib/typescript/src/services/webview/dualWebViewBridge.types.d.ts.map +1 -0
  24. package/lib/typescript/src/utils/config.d.ts.map +1 -1
  25. package/package.json +17 -9
  26. package/src/core/runtime.ts +21 -0
  27. package/src/index.tsx +2 -0
  28. package/src/services/url/urlGenerator.ts +18 -7
  29. package/src/services/webview/DualWebViewBridgeController.tsx +330 -0
  30. package/src/services/webview/dualWebViewBridge.types.ts +32 -0
  31. package/src/utils/config.ts +1 -0
  32. package/lib/module/services/device/location.js +0 -47
  33. package/lib/module/services/device/location.js.map +0 -1
  34. package/lib/typescript/src/services/device/location.d.ts +0 -8
  35. package/lib/typescript/src/services/device/location.d.ts.map +0 -1
  36. package/src/services/device/location.ts +0 -51
@@ -0,0 +1,330 @@
1
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { Image, StyleSheet, TouchableOpacity, View } from 'react-native';
3
+ import WebView from 'react-native-webview';
4
+ import {
5
+ type DualWebViewBridgeEnvelope,
6
+ parseDualWebViewEnvelope,
7
+ } from './dualWebViewBridge.types';
8
+
9
+ const DEFAULT_TRAVEL_PORTAL_URL = 'https://sandbox.travelercashback.com';
10
+
11
+ /** Must match `applicationNameForUserAgent` on clo-app WebViews (DualWebViewBridgeController). */
12
+ const WEBVIEW_UA_MARKER = 'CloAppWebView';
13
+
14
+ /** Legacy / Hub: open travel from first WebView without JSON envelope. */
15
+ const TRAVEL_DEEP_LINK = 'saversapp://travel';
16
+ const LEGACY_OPEN_TRAVEL_ACTIONS = ['OPEN_TRAVEL_PORTAL', 'OPEN_TRAVEL'];
17
+
18
+ export type Surface = 'savers' | 'travel';
19
+
20
+ export type DualWebViewBridgeControllerProps = {
21
+ saversAppUrl: string;
22
+ travelPortalUrl?: string;
23
+ partnerLogo?: string;
24
+ renderTravelBackIcon?: () => React.ReactNode;
25
+ initialSurface?: Surface;
26
+ onSaversSdkMessage?: (raw: string, postBack: (data: unknown) => void) => void;
27
+ onLoadEndSavers?: () => void;
28
+ onLoadEndTravel?: () => void;
29
+ };
30
+
31
+ function buildInjectNativeBridgeScript(
32
+ detail: Record<string, unknown>
33
+ ): string {
34
+ return `
35
+ (function(){
36
+ var detail = ${JSON.stringify(detail)};
37
+ window.dispatchEvent(new CustomEvent('nativeBridge', { detail: detail }));
38
+ true;
39
+ })();
40
+ `;
41
+ }
42
+
43
+ export const DualWebViewBridgeController: React.FC<
44
+ DualWebViewBridgeControllerProps
45
+ > = ({
46
+ saversAppUrl,
47
+ travelPortalUrl = DEFAULT_TRAVEL_PORTAL_URL,
48
+ partnerLogo,
49
+ renderTravelBackIcon,
50
+ initialSurface = 'savers',
51
+ onSaversSdkMessage,
52
+ onLoadEndSavers,
53
+ onLoadEndTravel,
54
+ }) => {
55
+ const saversRef = useRef<WebView>(null);
56
+ const travelRef = useRef<WebView>(null);
57
+
58
+ const [surface, setSurface] = useState<Surface>(initialSurface);
59
+ const [saversKey, setSaversKey] = useState(0);
60
+ const [travelKey, setTravelKey] = useState(0);
61
+
62
+ const openTravel = useCallback(() => {
63
+ setSurface('travel');
64
+ setTravelKey((k) => k + 1);
65
+ }, []);
66
+
67
+ const backToSavers = useCallback(() => {
68
+ setSurface('savers');
69
+ setSaversKey((k) => k + 1);
70
+ }, []);
71
+
72
+ const relayTo = useCallback(
73
+ (
74
+ target: 'savers' | 'travel',
75
+ payload: unknown,
76
+ from: 'savers' | 'travel'
77
+ ) => {
78
+ const detail = {
79
+ bridge: 'dual-webview',
80
+ from,
81
+ action: 'relay' as const,
82
+ payload,
83
+ };
84
+ const ref = target === 'savers' ? saversRef : travelRef;
85
+ ref.current?.injectJavaScript(buildInjectNativeBridgeScript(detail));
86
+ },
87
+ []
88
+ );
89
+
90
+ const handleBridgeEnvelope = useCallback(
91
+ (env: DualWebViewBridgeEnvelope): boolean => {
92
+ switch (env.action) {
93
+ case 'open_travel':
94
+ openTravel();
95
+ return true;
96
+ case 'close_travel':
97
+ backToSavers();
98
+ return true;
99
+ case 'relay':
100
+ if (env.to === 'savers') {
101
+ relayTo('savers', env.payload, env.from);
102
+ return true;
103
+ }
104
+ if (env.to === 'travel') {
105
+ relayTo('travel', env.payload, env.from);
106
+ return true;
107
+ }
108
+ return false;
109
+ default:
110
+ return false;
111
+ }
112
+ },
113
+ [backToSavers, openTravel, relayTo]
114
+ );
115
+
116
+ const tryLegacyOpenTravel = useCallback(
117
+ (raw: string): boolean => {
118
+ if (!raw || typeof raw !== 'string') {
119
+ return false;
120
+ }
121
+ try {
122
+ const data = JSON.parse(raw);
123
+ if (
124
+ data?.action === 'OPEN_TRAVEL_PORTAL' ||
125
+ data?.type === 'OPEN_TRAVEL' ||
126
+ data?.openTravel === true
127
+ ) {
128
+ openTravel();
129
+ return true;
130
+ }
131
+ } catch {
132
+ /* ignore */
133
+ }
134
+ if (LEGACY_OPEN_TRAVEL_ACTIONS.some((s) => raw.includes(s))) {
135
+ openTravel();
136
+ return true;
137
+ }
138
+ return false;
139
+ },
140
+ [openTravel]
141
+ );
142
+
143
+ const onMessageSavers = useCallback(
144
+ (e: { nativeEvent: { data: string } }) => {
145
+ const raw = e?.nativeEvent?.data ?? '';
146
+ const env = parseDualWebViewEnvelope(raw);
147
+
148
+ if (env && env.from === 'savers' && handleBridgeEnvelope(env)) {
149
+ return;
150
+ }
151
+ if (tryLegacyOpenTravel(raw)) {
152
+ return;
153
+ }
154
+
155
+ const postBack = (data: unknown) => {
156
+ const detail = {
157
+ bridge: 'dual-webview',
158
+ from: 'native',
159
+ action: 'relay',
160
+ payload: data,
161
+ };
162
+ saversRef.current?.injectJavaScript(
163
+ buildInjectNativeBridgeScript(detail)
164
+ );
165
+ };
166
+ onSaversSdkMessage?.(raw, postBack);
167
+ },
168
+ [handleBridgeEnvelope, onSaversSdkMessage, tryLegacyOpenTravel]
169
+ );
170
+
171
+ const onMessageTravel = useCallback(
172
+ (e: { nativeEvent: { data: string } }) => {
173
+ const raw = e?.nativeEvent?.data ?? '';
174
+ const env = parseDualWebViewEnvelope(raw);
175
+
176
+ if (env && env.from === 'travel' && handleBridgeEnvelope(env)) {
177
+ return;
178
+ }
179
+ if (tryLegacyOpenTravel(raw)) {
180
+ return;
181
+ }
182
+ },
183
+ [handleBridgeEnvelope, tryLegacyOpenTravel]
184
+ );
185
+
186
+ const onShouldStartLoadSavers = useCallback(
187
+ (request: { url: string }) => {
188
+ const { url } = request;
189
+ if (url.startsWith(TRAVEL_DEEP_LINK) || url === 'saversapp://travel/') {
190
+ openTravel();
191
+ return false;
192
+ }
193
+ return true;
194
+ },
195
+ [openTravel]
196
+ );
197
+
198
+ const travelSource = useMemo(
199
+ () => ({ uri: travelPortalUrl }),
200
+ [travelPortalUrl]
201
+ );
202
+ const saversSource = useMemo(() => ({ uri: saversAppUrl }), [saversAppUrl]);
203
+
204
+ return (
205
+ <View style={styles.root}>
206
+ {surface === 'savers' ? (
207
+ <WebView
208
+ key={saversKey}
209
+ ref={saversRef}
210
+ source={saversSource}
211
+ originWhitelist={['*']}
212
+ applicationNameForUserAgent={WEBVIEW_UA_MARKER}
213
+ onMessage={onMessageSavers}
214
+ onShouldStartLoadWithRequest={onShouldStartLoadSavers}
215
+ onLoadEnd={onLoadEndSavers}
216
+ style={styles.webview}
217
+ sharedCookiesEnabled
218
+ incognito={false}
219
+ webviewDebuggingEnabled={__DEV__}
220
+ />
221
+ ) : null}
222
+
223
+ {surface === 'travel' ? (
224
+ <View style={styles.travelColumn}>
225
+ <View style={styles.header}>
226
+ <TouchableOpacity
227
+ onPress={backToSavers}
228
+ hitSlop={16}
229
+ accessibilityRole="button"
230
+ accessibilityLabel="Back"
231
+ style={styles.backButton}
232
+ >
233
+ {renderTravelBackIcon ? (
234
+ renderTravelBackIcon()
235
+ ) : (
236
+ <View style={styles.backIconWrapper}>
237
+ <View style={styles.backIconShaft} />
238
+ <View style={styles.backIconHead} />
239
+ </View>
240
+ )}
241
+ </TouchableOpacity>
242
+
243
+ <View style={styles.headerSpacer} />
244
+
245
+ {partnerLogo ? (
246
+ <Image
247
+ source={{ uri: partnerLogo }}
248
+ resizeMode="contain"
249
+ style={styles.logo}
250
+ />
251
+ ) : (
252
+ <View style={styles.logoPlaceholder} />
253
+ )}
254
+ </View>
255
+ <WebView
256
+ key={travelKey}
257
+ ref={travelRef}
258
+ source={travelSource}
259
+ originWhitelist={['*']}
260
+ applicationNameForUserAgent={WEBVIEW_UA_MARKER}
261
+ onMessage={onMessageTravel}
262
+ onLoadEnd={onLoadEndTravel}
263
+ style={styles.webview}
264
+ sharedCookiesEnabled
265
+ incognito={false}
266
+ webviewDebuggingEnabled={__DEV__}
267
+ />
268
+ </View>
269
+ ) : null}
270
+ </View>
271
+ );
272
+ };
273
+
274
+ const styles = StyleSheet.create({
275
+ root: { flex: 1 },
276
+ travelColumn: { flex: 1 },
277
+ webview: { flex: 1 },
278
+ header: {
279
+ flexDirection: 'row',
280
+ alignItems: 'center',
281
+ backgroundColor: '#fff',
282
+ paddingHorizontal: 16,
283
+ paddingTop: 12,
284
+ paddingBottom: 12,
285
+ borderBottomWidth: StyleSheet.hairlineWidth,
286
+ borderBottomColor: '#E5E5E5',
287
+ },
288
+ backButton: {
289
+ width: 32,
290
+ height: 32,
291
+ justifyContent: 'center',
292
+ alignItems: 'center',
293
+ },
294
+ backIconWrapper: {
295
+ width: 20,
296
+ height: 16,
297
+ justifyContent: 'center',
298
+ position: 'relative',
299
+ },
300
+ backIconShaft: {
301
+ position: 'absolute',
302
+ left: 6,
303
+ right: 0,
304
+ height: 2,
305
+ backgroundColor: '#111827',
306
+ borderRadius: 1,
307
+ },
308
+ backIconHead: {
309
+ width: 10,
310
+ height: 10,
311
+ borderLeftWidth: 2,
312
+ borderBottomWidth: 2,
313
+ borderColor: '#111827',
314
+ transform: [{ rotate: '45deg' }],
315
+ marginLeft: 1,
316
+ },
317
+ headerSpacer: {
318
+ flex: 1,
319
+ },
320
+ logo: {
321
+ width: 120,
322
+ height: 32,
323
+ },
324
+ logoPlaceholder: {
325
+ width: 120,
326
+ height: 32,
327
+ },
328
+ });
329
+
330
+ export default DualWebViewBridgeController;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Messages posted from either WebView via ReactNativeWebView.postMessage(string).
3
+ * Pages should JSON.stringify envelopes before posting.
4
+ */
5
+ export type DualWebViewBridgeEnvelope = {
6
+ bridge: 'dual-webview';
7
+ /** Who sent this message (set by the page). */
8
+ from: 'savers' | 'travel';
9
+ /**
10
+ * relay — forward payload to the other WebView via injectJavaScript + CustomEvent.
11
+ * open_travel — show travel surface (reloads travel WebView).
12
+ * close_travel — same as native back (reloads Savers WebView).
13
+ */
14
+ action: 'relay' | 'open_travel' | 'close_travel';
15
+ /** For relay: which surface should receive the event. */
16
+ to?: 'savers' | 'travel';
17
+ payload?: unknown;
18
+ };
19
+
20
+ export function parseDualWebViewEnvelope(
21
+ raw: string
22
+ ): DualWebViewBridgeEnvelope | null {
23
+ try {
24
+ const o = JSON.parse(raw) as DualWebViewBridgeEnvelope;
25
+ if (o?.bridge === 'dual-webview' && o?.from && o?.action) {
26
+ return o;
27
+ }
28
+ } catch {
29
+ /* not JSON */
30
+ }
31
+ return null;
32
+ }
@@ -7,6 +7,7 @@ const injectedEnv = 'sandbox' as string;
7
7
  const DEFAULT_ENVIRONMENT: Environment =
8
8
  injectedEnv === 'production' ? 'production' : 'sandbox';
9
9
 
10
+ /** Hosted merchant web origins; aligned with `SaversSdkHostedEnvironment` via `setEnvironment` in SDK init. */
10
11
  const ENV_URLS: Record<Environment, string> = {
11
12
  sandbox: 'https://testm.saversapp.com/',
12
13
  production: 'https://m.saversapp.com/',
@@ -1,47 +0,0 @@
1
- "use strict";
2
-
3
- // Platform Utilities
4
- import { isAndroid } from "../../utils/platformManager.js";
5
- import { resolveNativeDependencies } from "../../utils/dependencyManager.js";
6
-
7
- // Permissions
8
- import { ensureLocationPermission } from "../permissions/permissionManager.js";
9
- let locationProvider;
10
- export function setLocationProvider(provider) {
11
- locationProvider = provider;
12
- }
13
- export const fetchLocation = async () => {
14
- const {
15
- Geolocation
16
- } = resolveNativeDependencies();
17
- if (!Geolocation) {
18
- throw new Error('GEOLOCATION_NOT_LINKED: install and link @react-native-community/geolocation');
19
- }
20
- return new Promise((resolve, reject) => {
21
- Geolocation.getCurrentPosition(pos => resolve({
22
- lat: pos.coords.latitude,
23
- lng: pos.coords.longitude
24
- }), err => reject(err), {
25
- enableHighAccuracy: true,
26
- timeout: 10000
27
- });
28
- });
29
- };
30
- export async function getDeviceLocation() {
31
- if (locationProvider) {
32
- return locationProvider();
33
- }
34
- if (isAndroid()) {
35
- const granted = await ensureLocationPermission();
36
- if (!granted) {
37
- throw new Error('LOCATION_PERMISSION_DENIED');
38
- }
39
- }
40
- try {
41
- const loc = await fetchLocation();
42
- return loc;
43
- } catch {
44
- throw new Error('LOCATION_UNAVAILABLE');
45
- }
46
- }
47
- //# sourceMappingURL=location.js.map
@@ -1 +0,0 @@
1
- {"version":3,"names":["isAndroid","resolveNativeDependencies","ensureLocationPermission","locationProvider","setLocationProvider","provider","fetchLocation","Geolocation","Error","Promise","resolve","reject","getCurrentPosition","pos","lat","coords","latitude","lng","longitude","err","enableHighAccuracy","timeout","getDeviceLocation","granted","loc"],"sourceRoot":"../../../../src","sources":["services/device/location.ts"],"mappings":";;AAAA;AACA,SAASA,SAAS,QAAQ,gCAA6B;AACvD,SAASC,yBAAyB,QAAQ,kCAA+B;;AAEzE;AACA,SAASC,wBAAwB,QAAQ,qCAAkC;AAI3E,IAAIC,gBAAyE;AAE7E,OAAO,SAASC,mBAAmBA,CACjCC,QAAmD,EAC7C;EACNF,gBAAgB,GAAGE,QAAQ;AAC7B;AAEA,OAAO,MAAMC,aAAa,GAAG,MAAAA,CAAA,KAAqC;EAChE,MAAM;IAAEC;EAAY,CAAC,GAAGN,yBAAyB,CAAC,CAAC;EACnD,IAAI,CAACM,WAAW,EAAE;IAChB,MAAM,IAAIC,KAAK,CACb,8EACF,CAAC;EACH;EACA,OAAO,IAAIC,OAAO,CAAC,CAACC,OAAO,EAAEC,MAAM,KAAK;IACtCJ,WAAW,CAACK,kBAAkB,CAC3BC,GAAwD,IACvDH,OAAO,CAAC;MAAEI,GAAG,EAAED,GAAG,CAACE,MAAM,CAACC,QAAQ;MAAEC,GAAG,EAAEJ,GAAG,CAACE,MAAM,CAACG;IAAU,CAAC,CAAC,EACjEC,GAAY,IAAKR,MAAM,CAACQ,GAAG,CAAC,EAC7B;MAAEC,kBAAkB,EAAE,IAAI;MAAEC,OAAO,EAAE;IAAM,CAC7C,CAAC;EACH,CAAC,CAAC;AACJ,CAAC;AAED,OAAO,eAAeC,iBAAiBA,CAAA,EAAwC;EAC7E,IAAInB,gBAAgB,EAAE;IACpB,OAAOA,gBAAgB,CAAC,CAAC;EAC3B;EACA,IAAIH,SAAS,CAAC,CAAC,EAAE;IACf,MAAMuB,OAAO,GAAG,MAAMrB,wBAAwB,CAAC,CAAC;IAChD,IAAI,CAACqB,OAAO,EAAE;MACZ,MAAM,IAAIf,KAAK,CAAC,4BAA4B,CAAC;IAC/C;EACF;EACA,IAAI;IACF,MAAMgB,GAAG,GAAG,MAAMlB,aAAa,CAAC,CAAC;IACjC,OAAOkB,GAAG;EACZ,CAAC,CAAC,MAAM;IACN,MAAM,IAAIhB,KAAK,CAAC,sBAAsB,CAAC;EACzC;AACF","ignoreList":[]}
@@ -1,8 +0,0 @@
1
- export type DeviceLocation = {
2
- lat: number;
3
- lng: number;
4
- };
5
- export declare function setLocationProvider(provider: () => Promise<DeviceLocation | undefined>): void;
6
- export declare const fetchLocation: () => Promise<DeviceLocation>;
7
- export declare function getDeviceLocation(): Promise<DeviceLocation | undefined>;
8
- //# sourceMappingURL=location.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"location.d.ts","sourceRoot":"","sources":["../../../../../src/services/device/location.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,cAAc,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAI1D,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,GAClD,IAAI,CAEN;AAED,eAAO,MAAM,aAAa,QAAa,OAAO,CAAC,cAAc,CAe5D,CAAC;AAEF,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAgB7E"}
@@ -1,51 +0,0 @@
1
- // Platform Utilities
2
- import { isAndroid } from '../../utils/platformManager';
3
- import { resolveNativeDependencies } from '../../utils/dependencyManager';
4
-
5
- // Permissions
6
- import { ensureLocationPermission } from '../permissions/permissionManager';
7
-
8
- export type DeviceLocation = { lat: number; lng: number };
9
-
10
- let locationProvider: (() => Promise<DeviceLocation | undefined>) | undefined;
11
-
12
- export function setLocationProvider(
13
- provider: () => Promise<DeviceLocation | undefined>
14
- ): void {
15
- locationProvider = provider;
16
- }
17
-
18
- export const fetchLocation = async (): Promise<DeviceLocation> => {
19
- const { Geolocation } = resolveNativeDependencies();
20
- if (!Geolocation) {
21
- throw new Error(
22
- 'GEOLOCATION_NOT_LINKED: install and link @react-native-community/geolocation'
23
- );
24
- }
25
- return new Promise((resolve, reject) => {
26
- Geolocation.getCurrentPosition(
27
- (pos: { coords: { latitude: number; longitude: number } }) =>
28
- resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
29
- (err: unknown) => reject(err),
30
- { enableHighAccuracy: true, timeout: 10000 }
31
- );
32
- });
33
- };
34
-
35
- export async function getDeviceLocation(): Promise<DeviceLocation | undefined> {
36
- if (locationProvider) {
37
- return locationProvider();
38
- }
39
- if (isAndroid()) {
40
- const granted = await ensureLocationPermission();
41
- if (!granted) {
42
- throw new Error('LOCATION_PERMISSION_DENIED');
43
- }
44
- }
45
- try {
46
- const loc = await fetchLocation();
47
- return loc;
48
- } catch {
49
- throw new Error('LOCATION_UNAVAILABLE');
50
- }
51
- }