react-native-fincra-checkout 1.0.0
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.
- package/README.md +309 -0
- package/lib/commonjs/checkout/FincraCheckout.js +249 -0
- package/lib/commonjs/components/FincraInlineCheckout.js +385 -0
- package/lib/commonjs/components/FincraWebViewCheckout.js +300 -0
- package/lib/commonjs/index.js +23 -0
- package/lib/commonjs/inline/JsBridge.js +91 -0
- package/lib/commonjs/inline/htmlGenerator.js +111 -0
- package/lib/commonjs/types/index.js +27 -0
- package/lib/commonjs/utils/UrlHandler.js +113 -0
- package/lib/typescript/checkout/FincraCheckout.d.ts +121 -0
- package/lib/typescript/components/FincraInlineCheckout.d.ts +21 -0
- package/lib/typescript/components/FincraWebViewCheckout.d.ts +18 -0
- package/lib/typescript/index.d.ts +5 -0
- package/lib/typescript/inline/JsBridge.d.ts +33 -0
- package/lib/typescript/inline/htmlGenerator.d.ts +21 -0
- package/lib/typescript/types/index.d.ts +142 -0
- package/lib/typescript/utils/UrlHandler.d.ts +44 -0
- package/package.json +113 -0
- package/src/checkout/FincraCheckout.tsx +329 -0
- package/src/components/FincraInlineCheckout.tsx +490 -0
- package/src/components/FincraWebViewCheckout.tsx +415 -0
- package/src/index.ts +31 -0
- package/src/inline/JsBridge.ts +108 -0
- package/src/inline/htmlGenerator.ts +138 -0
- package/src/types/index.ts +165 -0
- package/src/utils/UrlHandler.ts +124 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Alert,
|
|
5
|
+
BackHandler,
|
|
6
|
+
Platform,
|
|
7
|
+
StatusBar,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
Text,
|
|
10
|
+
TouchableOpacity,
|
|
11
|
+
View,
|
|
12
|
+
} from 'react-native';
|
|
13
|
+
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
14
|
+
import { WebView } from 'react-native-webview';
|
|
15
|
+
import type {
|
|
16
|
+
WebViewMessageEvent,
|
|
17
|
+
WebViewErrorEvent,
|
|
18
|
+
WebViewHttpErrorEvent,
|
|
19
|
+
} from 'react-native-webview/lib/WebViewTypes';
|
|
20
|
+
import type {
|
|
21
|
+
InlineCheckoutConfig,
|
|
22
|
+
FincraPaymentError,
|
|
23
|
+
FincraPaymentResponse,
|
|
24
|
+
} from '../types';
|
|
25
|
+
import { generateInlineHtml } from '../inline/htmlGenerator';
|
|
26
|
+
import { FincraBridgeEvent, parseMessage } from '../inline/JsBridge';
|
|
27
|
+
|
|
28
|
+
// ─── FincraInlineCheckout ─────────────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// Declarative component for the Inline JavaScript checkout flow.
|
|
31
|
+
// Mirrors InlineCheckout widget in inline_checkout.dart
|
|
32
|
+
//
|
|
33
|
+
// Loads a self-contained HTML page that bootstraps the Fincra JS SDK,
|
|
34
|
+
// communicates results back via window.ReactNativeWebView.postMessage(),
|
|
35
|
+
// and implements a 15-second timeout fallback.
|
|
36
|
+
|
|
37
|
+
/** 15 seconds in milliseconds — mirrors Flutter's `Timer(Duration(seconds: 15), ...)` */
|
|
38
|
+
const INIT_TIMEOUT_MS = 15_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A transparent WebView that runs the Fincra inline JavaScript SDK.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <FincraInlineCheckout
|
|
46
|
+
* publicKey="pk_live_xxxx"
|
|
47
|
+
* amount={5000}
|
|
48
|
+
* currency="NGN"
|
|
49
|
+
* customerEmail="user@example.com"
|
|
50
|
+
* customerName="John Doe"
|
|
51
|
+
* customerPhoneNumber="08012345678"
|
|
52
|
+
* feeBearer="customer"
|
|
53
|
+
* onSuccess={(res) => console.log(res.reference)}
|
|
54
|
+
* onCancelled={() => navigation.goBack()}
|
|
55
|
+
* />
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function FincraInlineCheckout({
|
|
59
|
+
headerTitle = 'Secure Checkout',
|
|
60
|
+
headerBackgroundColor = '#FFFFFF',
|
|
61
|
+
headerTintColor = '#000000',
|
|
62
|
+
showCancelConfirmationDialog = false,
|
|
63
|
+
loadingComponent,
|
|
64
|
+
closeIcon,
|
|
65
|
+
renderError,
|
|
66
|
+
onSuccess,
|
|
67
|
+
onFailed,
|
|
68
|
+
onCancelled,
|
|
69
|
+
// Payment fields — everything the HTML generator needs
|
|
70
|
+
...paymentConfig
|
|
71
|
+
}: InlineCheckoutConfig) {
|
|
72
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
73
|
+
const [errorState, setErrorState] = useState<FincraPaymentError | null>(null);
|
|
74
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
75
|
+
const webViewRef = useRef<WebView<object> | null>(null);
|
|
76
|
+
const hasCompleted = useRef(false);
|
|
77
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
78
|
+
|
|
79
|
+
// Fix #3: Memoize HTML generation — prevents WebView reload on parent re-renders.
|
|
80
|
+
// The HTML is intentionally generated once per mount; payment params are immutable.
|
|
81
|
+
const configJson = JSON.stringify(paymentConfig);
|
|
82
|
+
const html = useMemo(
|
|
83
|
+
() => generateInlineHtml(JSON.parse(configJson)),
|
|
84
|
+
[configJson]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// ── 15-second init timeout ──────────────────────────────────────────────────
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
timeoutRef.current = setTimeout(() => {
|
|
90
|
+
if (!hasCompleted.current) {
|
|
91
|
+
const err: FincraPaymentError = {
|
|
92
|
+
code: 'timeout',
|
|
93
|
+
message:
|
|
94
|
+
'Fincra Checkout failed to load. Please check your internet connection.',
|
|
95
|
+
};
|
|
96
|
+
setErrorState(err);
|
|
97
|
+
setIsLoading(false);
|
|
98
|
+
}
|
|
99
|
+
}, INIT_TIMEOUT_MS);
|
|
100
|
+
|
|
101
|
+
return () => {
|
|
102
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
103
|
+
};
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
// Fix #10: Stable ref for handleCancellation so the BackHandler effect
|
|
107
|
+
// doesn't need it as a dependency and never captures a stale closure.
|
|
108
|
+
const handleCancellationRef = useRef<() => void>(() => {});
|
|
109
|
+
|
|
110
|
+
// ── Android back button ─────────────────────────────────────────────────────
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (Platform.OS !== 'android') return;
|
|
113
|
+
const subscription = BackHandler.addEventListener(
|
|
114
|
+
'hardwareBackPress',
|
|
115
|
+
() => {
|
|
116
|
+
handleCancellationRef.current();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
return () => subscription.remove();
|
|
121
|
+
}, []); // safe: always calls through the ref
|
|
122
|
+
|
|
123
|
+
// ── WebView error ───────────────────────────────────────────────────────────
|
|
124
|
+
const handleError = useCallback(
|
|
125
|
+
(syntheticEvent: WebViewErrorEvent): void => {
|
|
126
|
+
if (hasCompleted.current) return;
|
|
127
|
+
const { nativeEvent } = syntheticEvent;
|
|
128
|
+
const err: FincraPaymentError = {
|
|
129
|
+
code: String(nativeEvent.code ?? 'webview_error'),
|
|
130
|
+
message: nativeEvent.description ?? 'A WebView error occurred.',
|
|
131
|
+
};
|
|
132
|
+
setErrorState(err);
|
|
133
|
+
setIsLoading(false);
|
|
134
|
+
},
|
|
135
|
+
[setErrorState, setIsLoading]
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// ── HTTP error ──────────────────────────────────────────────────────────────
|
|
139
|
+
const handleHttpError = useCallback(
|
|
140
|
+
(syntheticEvent: WebViewHttpErrorEvent): void => {
|
|
141
|
+
if (hasCompleted.current) return;
|
|
142
|
+
const { nativeEvent } = syntheticEvent;
|
|
143
|
+
const err: FincraPaymentError = {
|
|
144
|
+
code: String(nativeEvent.statusCode ?? 'http_error'),
|
|
145
|
+
message: nativeEvent.description ?? 'A WebView HTTP error occurred.',
|
|
146
|
+
};
|
|
147
|
+
setErrorState(err);
|
|
148
|
+
setIsLoading(false);
|
|
149
|
+
},
|
|
150
|
+
[setErrorState, setIsLoading]
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ── Retry handler ───────────────────────────────────────────────────────────
|
|
154
|
+
const handleRetry = useCallback(() => {
|
|
155
|
+
setErrorState(null);
|
|
156
|
+
setIsLoading(true);
|
|
157
|
+
setReloadKey((key) => key + 1);
|
|
158
|
+
}, [setErrorState, setIsLoading, setReloadKey]);
|
|
159
|
+
|
|
160
|
+
// ── JS Bridge message handler ───────────────────────────────────────────────
|
|
161
|
+
const handleMessage = useCallback(
|
|
162
|
+
(event: WebViewMessageEvent) => {
|
|
163
|
+
if (hasCompleted.current) return;
|
|
164
|
+
|
|
165
|
+
const msg = parseMessage(event.nativeEvent.data);
|
|
166
|
+
|
|
167
|
+
switch (msg.event) {
|
|
168
|
+
case FincraBridgeEvent.Ready:
|
|
169
|
+
// SDK loaded — clear timeout and hide the loading spinner
|
|
170
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
171
|
+
setIsLoading(false);
|
|
172
|
+
break;
|
|
173
|
+
|
|
174
|
+
case FincraBridgeEvent.Success:
|
|
175
|
+
hasCompleted.current = true;
|
|
176
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
177
|
+
if (msg.data && 'reference' in msg.data) {
|
|
178
|
+
onSuccess?.(msg.data as FincraPaymentResponse);
|
|
179
|
+
} else {
|
|
180
|
+
onCancelled?.();
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
|
|
184
|
+
case FincraBridgeEvent.Closed:
|
|
185
|
+
hasCompleted.current = true;
|
|
186
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
187
|
+
onCancelled?.();
|
|
188
|
+
break;
|
|
189
|
+
|
|
190
|
+
case FincraBridgeEvent.Error: {
|
|
191
|
+
hasCompleted.current = true;
|
|
192
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
193
|
+
const errorMessage =
|
|
194
|
+
msg.data && 'message' in msg.data
|
|
195
|
+
? (msg.data as { message: string }).message
|
|
196
|
+
: 'An unknown error occurred';
|
|
197
|
+
const err: FincraPaymentError = {
|
|
198
|
+
code: 'fincra_sdk_error',
|
|
199
|
+
message: errorMessage,
|
|
200
|
+
};
|
|
201
|
+
onFailed?.(err);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case FincraBridgeEvent.Unknown:
|
|
206
|
+
default:
|
|
207
|
+
// Ignore — mirrors Flutter's `case FincraBridgeEvent.unknown: break;`
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
[onSuccess, onFailed, onCancelled, setIsLoading]
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// ── Cancellation ────────────────────────────────────────────────────────────
|
|
215
|
+
// Fix #4: Use static Alert import — no dynamic require() needed.
|
|
216
|
+
const handleCancellation = useCallback(() => {
|
|
217
|
+
if (hasCompleted.current) return;
|
|
218
|
+
|
|
219
|
+
if (showCancelConfirmationDialog) {
|
|
220
|
+
Alert.alert(
|
|
221
|
+
'Cancel Payment?',
|
|
222
|
+
'Are you sure you want to cancel this payment?',
|
|
223
|
+
[
|
|
224
|
+
{ text: 'No', style: 'cancel' },
|
|
225
|
+
{
|
|
226
|
+
text: 'Yes',
|
|
227
|
+
style: 'destructive',
|
|
228
|
+
onPress: () => {
|
|
229
|
+
if (!hasCompleted.current) {
|
|
230
|
+
hasCompleted.current = true;
|
|
231
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
232
|
+
onCancelled?.();
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
]
|
|
237
|
+
);
|
|
238
|
+
} else {
|
|
239
|
+
hasCompleted.current = true;
|
|
240
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
241
|
+
onCancelled?.();
|
|
242
|
+
}
|
|
243
|
+
}, [showCancelConfirmationDialog, onCancelled]);
|
|
244
|
+
|
|
245
|
+
// Keep the ref in sync with the latest handleCancellation (Fix #10)
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
handleCancellationRef.current = handleCancellation;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── Computed status bar style (Fix #11) ─────────────────────────────────────
|
|
251
|
+
const statusBarStyle =
|
|
252
|
+
headerTintColor === '#000000' ? 'dark-content' : 'light-content';
|
|
253
|
+
|
|
254
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
255
|
+
return (
|
|
256
|
+
<SafeAreaView style={styles.container}>
|
|
257
|
+
<StatusBar
|
|
258
|
+
barStyle={statusBarStyle}
|
|
259
|
+
backgroundColor={headerBackgroundColor}
|
|
260
|
+
/>
|
|
261
|
+
|
|
262
|
+
{/* ── Header bar — shown during loading so the user can abort ── */}
|
|
263
|
+
<View
|
|
264
|
+
style={[styles.header, { backgroundColor: headerBackgroundColor }]}
|
|
265
|
+
>
|
|
266
|
+
<TouchableOpacity
|
|
267
|
+
style={styles.closeButton}
|
|
268
|
+
onPress={handleCancellation}
|
|
269
|
+
accessibilityLabel="Close checkout"
|
|
270
|
+
accessibilityRole="button"
|
|
271
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
272
|
+
>
|
|
273
|
+
{closeIcon ?? (
|
|
274
|
+
<Text style={[styles.closeIcon, { color: headerTintColor }]}>
|
|
275
|
+
✕
|
|
276
|
+
</Text>
|
|
277
|
+
)}
|
|
278
|
+
</TouchableOpacity>
|
|
279
|
+
<Text
|
|
280
|
+
style={[styles.headerTitle, { color: headerTintColor }]}
|
|
281
|
+
numberOfLines={1}
|
|
282
|
+
>
|
|
283
|
+
{headerTitle}
|
|
284
|
+
</Text>
|
|
285
|
+
<View style={styles.closeButton} />
|
|
286
|
+
</View>
|
|
287
|
+
|
|
288
|
+
{/* ── WebView running Fincra inline JS SDK ── */}
|
|
289
|
+
<View style={styles.webViewContainer}>
|
|
290
|
+
<WebView
|
|
291
|
+
key={reloadKey}
|
|
292
|
+
ref={webViewRef}
|
|
293
|
+
// Fix #14: restrict to HTTPS + about:blank only (removed wildcard)
|
|
294
|
+
originWhitelist={['https://*', 'about:blank']}
|
|
295
|
+
source={{ html }}
|
|
296
|
+
style={styles.webView}
|
|
297
|
+
javaScriptEnabled
|
|
298
|
+
domStorageEnabled
|
|
299
|
+
onMessage={handleMessage}
|
|
300
|
+
onError={handleError}
|
|
301
|
+
onHttpError={handleHttpError}
|
|
302
|
+
// Inject the ReactNativeWebView bridge shim so older WKWebView versions work
|
|
303
|
+
injectedJavaScriptBeforeContentLoaded={WEBVIEW_BRIDGE_SHIM}
|
|
304
|
+
// Allow the external CDN script to load
|
|
305
|
+
mixedContentMode="always"
|
|
306
|
+
// Fix #15: removed allowFileAccess and allowUniversalAccessFromFileURLs —
|
|
307
|
+
// the HTML is served as an inline blob, not a file:// URL, so these are
|
|
308
|
+
// unnecessary and allowUniversalAccessFromFileURLs is a security footgun.
|
|
309
|
+
/>
|
|
310
|
+
|
|
311
|
+
{/* ── Loading overlay ── */}
|
|
312
|
+
{isLoading && !errorState && (
|
|
313
|
+
<View style={styles.loadingOverlay}>
|
|
314
|
+
<View style={styles.loadingCard}>
|
|
315
|
+
{loadingComponent ?? (
|
|
316
|
+
<ActivityIndicator size="large" color="#0066FF" />
|
|
317
|
+
)}
|
|
318
|
+
</View>
|
|
319
|
+
</View>
|
|
320
|
+
)}
|
|
321
|
+
|
|
322
|
+
{/* ── Error Recovery overlay ── */}
|
|
323
|
+
{errorState && (
|
|
324
|
+
<View style={styles.errorOverlay}>
|
|
325
|
+
{renderError ? (
|
|
326
|
+
renderError(errorState, handleRetry)
|
|
327
|
+
) : (
|
|
328
|
+
<View style={styles.errorContainer}>
|
|
329
|
+
<Text style={styles.errorIcon}>⚠️</Text>
|
|
330
|
+
<Text style={styles.errorTitle}>Connection Error</Text>
|
|
331
|
+
<Text style={styles.errorMessage}>{errorState.message}</Text>
|
|
332
|
+
<TouchableOpacity
|
|
333
|
+
style={styles.retryButton}
|
|
334
|
+
onPress={handleRetry}
|
|
335
|
+
accessibilityRole="button"
|
|
336
|
+
accessibilityLabel="Retry loading checkout"
|
|
337
|
+
>
|
|
338
|
+
<Text style={styles.retryButtonText}>Retry</Text>
|
|
339
|
+
</TouchableOpacity>
|
|
340
|
+
<TouchableOpacity
|
|
341
|
+
style={styles.cancelButton}
|
|
342
|
+
onPress={handleCancellation}
|
|
343
|
+
accessibilityRole="button"
|
|
344
|
+
accessibilityLabel="Cancel checkout"
|
|
345
|
+
>
|
|
346
|
+
<Text style={styles.cancelButtonText}>Cancel</Text>
|
|
347
|
+
</TouchableOpacity>
|
|
348
|
+
</View>
|
|
349
|
+
)}
|
|
350
|
+
</View>
|
|
351
|
+
)}
|
|
352
|
+
</View>
|
|
353
|
+
</SafeAreaView>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─── WebView Bridge Shim ───────────────────────────────────────────────────────
|
|
358
|
+
// Ensures `window.ReactNativeWebView` is available before page scripts run.
|
|
359
|
+
const WEBVIEW_BRIDGE_SHIM = `
|
|
360
|
+
(function() {
|
|
361
|
+
if (!window.ReactNativeWebView) {
|
|
362
|
+
window.ReactNativeWebView = {
|
|
363
|
+
postMessage: function(msg) {
|
|
364
|
+
window.webkit && window.webkit.messageHandlers &&
|
|
365
|
+
window.webkit.messageHandlers.ReactNativeWebView &&
|
|
366
|
+
window.webkit.messageHandlers.ReactNativeWebView.postMessage(msg);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
})();
|
|
371
|
+
true;
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
// ─── Styles ────────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
const styles = StyleSheet.create({
|
|
377
|
+
container: {
|
|
378
|
+
flex: 1,
|
|
379
|
+
backgroundColor: '#FFFFFF',
|
|
380
|
+
},
|
|
381
|
+
header: {
|
|
382
|
+
flexDirection: 'row',
|
|
383
|
+
alignItems: 'center',
|
|
384
|
+
justifyContent: 'space-between',
|
|
385
|
+
height: 56,
|
|
386
|
+
paddingHorizontal: 12,
|
|
387
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
388
|
+
borderBottomColor: '#E5E7EB',
|
|
389
|
+
elevation: 2,
|
|
390
|
+
shadowColor: '#000',
|
|
391
|
+
shadowOffset: { width: 0, height: 1 },
|
|
392
|
+
shadowOpacity: 0.08,
|
|
393
|
+
shadowRadius: 2,
|
|
394
|
+
},
|
|
395
|
+
closeButton: {
|
|
396
|
+
width: 40,
|
|
397
|
+
height: 40,
|
|
398
|
+
alignItems: 'center',
|
|
399
|
+
justifyContent: 'center',
|
|
400
|
+
},
|
|
401
|
+
closeIcon: {
|
|
402
|
+
fontSize: 18,
|
|
403
|
+
fontWeight: '600',
|
|
404
|
+
},
|
|
405
|
+
headerTitle: {
|
|
406
|
+
flex: 1,
|
|
407
|
+
textAlign: 'center',
|
|
408
|
+
fontSize: 16,
|
|
409
|
+
fontWeight: '600',
|
|
410
|
+
letterSpacing: 0.2,
|
|
411
|
+
},
|
|
412
|
+
webViewContainer: {
|
|
413
|
+
flex: 1,
|
|
414
|
+
},
|
|
415
|
+
webView: {
|
|
416
|
+
flex: 1,
|
|
417
|
+
backgroundColor: 'transparent',
|
|
418
|
+
},
|
|
419
|
+
loadingOverlay: {
|
|
420
|
+
...StyleSheet.absoluteFillObject,
|
|
421
|
+
alignItems: 'center',
|
|
422
|
+
justifyContent: 'center',
|
|
423
|
+
backgroundColor: 'rgba(255,255,255,0.85)',
|
|
424
|
+
},
|
|
425
|
+
loadingCard: {
|
|
426
|
+
padding: 24,
|
|
427
|
+
backgroundColor: '#FFFFFF',
|
|
428
|
+
borderRadius: 12,
|
|
429
|
+
elevation: 4,
|
|
430
|
+
shadowColor: '#000',
|
|
431
|
+
shadowOffset: { width: 0, height: 2 },
|
|
432
|
+
shadowOpacity: 0.12,
|
|
433
|
+
shadowRadius: 8,
|
|
434
|
+
},
|
|
435
|
+
errorOverlay: {
|
|
436
|
+
...StyleSheet.absoluteFillObject,
|
|
437
|
+
backgroundColor: '#FFFFFF',
|
|
438
|
+
alignItems: 'center',
|
|
439
|
+
justifyContent: 'center',
|
|
440
|
+
padding: 24,
|
|
441
|
+
},
|
|
442
|
+
errorContainer: {
|
|
443
|
+
alignItems: 'center',
|
|
444
|
+
maxWidth: 320,
|
|
445
|
+
width: '100%',
|
|
446
|
+
},
|
|
447
|
+
errorIcon: {
|
|
448
|
+
fontSize: 48,
|
|
449
|
+
marginBottom: 16,
|
|
450
|
+
},
|
|
451
|
+
errorTitle: {
|
|
452
|
+
fontSize: 20,
|
|
453
|
+
fontWeight: '700',
|
|
454
|
+
color: '#1F2937',
|
|
455
|
+
marginBottom: 8,
|
|
456
|
+
textAlign: 'center',
|
|
457
|
+
},
|
|
458
|
+
errorMessage: {
|
|
459
|
+
fontSize: 14,
|
|
460
|
+
color: '#6B7280',
|
|
461
|
+
textAlign: 'center',
|
|
462
|
+
marginBottom: 24,
|
|
463
|
+
lineHeight: 20,
|
|
464
|
+
},
|
|
465
|
+
retryButton: {
|
|
466
|
+
backgroundColor: '#0066FF',
|
|
467
|
+
paddingVertical: 12,
|
|
468
|
+
paddingHorizontal: 32,
|
|
469
|
+
borderRadius: 8,
|
|
470
|
+
width: '100%',
|
|
471
|
+
alignItems: 'center',
|
|
472
|
+
marginBottom: 12,
|
|
473
|
+
},
|
|
474
|
+
retryButtonText: {
|
|
475
|
+
color: '#FFFFFF',
|
|
476
|
+
fontSize: 16,
|
|
477
|
+
fontWeight: '600',
|
|
478
|
+
},
|
|
479
|
+
cancelButton: {
|
|
480
|
+
paddingVertical: 12,
|
|
481
|
+
paddingHorizontal: 32,
|
|
482
|
+
width: '100%',
|
|
483
|
+
alignItems: 'center',
|
|
484
|
+
},
|
|
485
|
+
cancelButtonText: {
|
|
486
|
+
color: '#6B7280',
|
|
487
|
+
fontSize: 15,
|
|
488
|
+
fontWeight: '500',
|
|
489
|
+
},
|
|
490
|
+
});
|