@kernaq/verify-react-native 1.0.2

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,1198 @@
1
+ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
2
+ import { Platform, Alert, Linking, View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Image, Animated, Modal, SafeAreaView, ScrollView } from 'react-native';
3
+ import { PERMISSIONS, check, RESULTS, request } from 'react-native-permissions';
4
+ import { useCameraDevice, useCameraPermission, Camera } from 'react-native-vision-camera';
5
+
6
+ const DEFAULT_LOCALE = {
7
+ intro_title: 'Verify your identity',
8
+ intro_body: "This takes about 60 seconds. You'll need your ID document and a clear view of your face.",
9
+ intro_cta: 'Get started',
10
+ doc_title: 'Scan your document',
11
+ doc_instruction: 'Hold your ID flat inside the frame. Make sure it\'s well-lit and all four corners are visible.',
12
+ doc_capture_btn: 'Capture',
13
+ doc_retake_btn: 'Retake',
14
+ doc_next_btn: 'Looks good',
15
+ selfie_title: 'Take a selfie',
16
+ selfie_instruction: 'Look straight at the camera. Keep your face inside the oval.',
17
+ selfie_capture_btn: 'Take selfie',
18
+ selfie_retake_btn: 'Retake',
19
+ selfie_next_btn: 'Looks good',
20
+ liveness_title: 'Liveness check',
21
+ liveness_instruction: 'Press start, then slowly turn your head left and right.',
22
+ liveness_start_btn: 'Start recording',
23
+ liveness_recording: 'Recording…',
24
+ processing_title: 'Verifying your identity',
25
+ processing_subtitle: 'This usually takes a few seconds.',
26
+ result_pass_title: 'Verification passed',
27
+ result_pass_body: 'Your identity has been verified successfully.',
28
+ result_fail_title: 'Verification failed',
29
+ result_fail_body: 'We were unable to verify your identity. Please try again.',
30
+ result_review_title: 'Under review',
31
+ result_review_body: 'Your submission is being reviewed. This usually takes a few minutes.',
32
+ result_retry_btn: 'Try again',
33
+ error_camera_denied: 'Camera access denied. Please allow camera permissions in settings.',
34
+ error_quality: 'Image quality too low. Try better lighting or hold the camera steady.',
35
+ error_network: 'Network error. Please check your connection and try again.'
36
+ };
37
+
38
+ function resolveTheme(theme) {
39
+ const dark = theme?.mode === 'dark';
40
+ return {
41
+ accent: theme?.accentColor ?? '#111827',
42
+ bg: theme?.backgroundColor ?? (dark ? '#1a1a2e' : '#ffffff'),
43
+ text: theme?.textColor ?? (dark ? '#f9fafb' : '#111827'),
44
+ subtext: theme?.subtextColor ?? (dark ? '#9ca3af' : '#6b7280'),
45
+ border: theme?.borderColor ?? (dark ? '#374151' : '#e5e7eb'),
46
+ card: theme?.cardColor ?? (dark ? '#111827' : '#f9fafb'),
47
+ radius: theme?.borderRadius ?? 12
48
+ };
49
+ }
50
+
51
+ function usePermissions() {
52
+ const [status, setStatus] = useState('unknown');
53
+ const cameraPermission = Platform.OS === 'ios' ? PERMISSIONS.IOS.CAMERA : PERMISSIONS.ANDROID.CAMERA;
54
+ const micPermission = Platform.OS === 'ios' ? PERMISSIONS.IOS.MICROPHONE : PERMISSIONS.ANDROID.RECORD_AUDIO;
55
+ const requestPermissions = useCallback(async ()=>{
56
+ try {
57
+ const camResult = await check(cameraPermission);
58
+ if (camResult === RESULTS.GRANTED) {
59
+ // Also request mic for liveness video
60
+ await request(micPermission);
61
+ setStatus('granted');
62
+ return true;
63
+ }
64
+ if (camResult === RESULTS.BLOCKED) {
65
+ setStatus('blocked');
66
+ Alert.alert('Camera Permission Required', 'Camera access is blocked. Please enable it in your device settings.', [
67
+ {
68
+ text: 'Cancel',
69
+ style: 'cancel'
70
+ },
71
+ {
72
+ text: 'Open Settings',
73
+ onPress: ()=>Linking.openSettings()
74
+ }
75
+ ]);
76
+ return false;
77
+ }
78
+ // Request camera
79
+ const result = await request(cameraPermission);
80
+ if (result === RESULTS.GRANTED) {
81
+ await request(micPermission);
82
+ setStatus('granted');
83
+ return true;
84
+ }
85
+ setStatus('denied');
86
+ return false;
87
+ } catch {
88
+ setStatus('denied');
89
+ return false;
90
+ }
91
+ }, [
92
+ cameraPermission,
93
+ micPermission
94
+ ]);
95
+ return {
96
+ status,
97
+ requestPermissions
98
+ };
99
+ }
100
+
101
+ function IntroStep({ theme, locale, onStart }) {
102
+ const s = styles$6(theme);
103
+ return /*#__PURE__*/ React.createElement(View, {
104
+ style: s.container
105
+ }, /*#__PURE__*/ React.createElement(View, {
106
+ style: s.steps
107
+ }, [
108
+ {
109
+ icon: '🪪',
110
+ label: 'Document'
111
+ },
112
+ {
113
+ icon: '🤳',
114
+ label: 'Selfie'
115
+ },
116
+ {
117
+ icon: '👁️',
118
+ label: 'Liveness'
119
+ }
120
+ ].map((item)=>/*#__PURE__*/ React.createElement(View, {
121
+ key: item.label,
122
+ style: s.step
123
+ }, /*#__PURE__*/ React.createElement(View, {
124
+ style: s.stepIcon
125
+ }, /*#__PURE__*/ React.createElement(Text, {
126
+ style: s.stepEmoji
127
+ }, item.icon)), /*#__PURE__*/ React.createElement(Text, {
128
+ style: s.stepLabel
129
+ }, item.label)))), /*#__PURE__*/ React.createElement(Text, {
130
+ style: s.title
131
+ }, locale.intro_title), /*#__PURE__*/ React.createElement(Text, {
132
+ style: s.body
133
+ }, locale.intro_body), /*#__PURE__*/ React.createElement(TouchableOpacity, {
134
+ style: s.btn,
135
+ onPress: onStart,
136
+ activeOpacity: 0.85
137
+ }, /*#__PURE__*/ React.createElement(Text, {
138
+ style: s.btnText
139
+ }, locale.intro_cta)));
140
+ }
141
+ const styles$6 = (t)=>StyleSheet.create({
142
+ container: {
143
+ padding: 24
144
+ },
145
+ steps: {
146
+ flexDirection: 'row',
147
+ justifyContent: 'center',
148
+ gap: 20,
149
+ marginBottom: 28
150
+ },
151
+ step: {
152
+ alignItems: 'center',
153
+ gap: 8
154
+ },
155
+ stepIcon: {
156
+ width: 56,
157
+ height: 56,
158
+ borderRadius: 14,
159
+ backgroundColor: t.card,
160
+ borderWidth: 1,
161
+ borderColor: t.border,
162
+ alignItems: 'center',
163
+ justifyContent: 'center'
164
+ },
165
+ stepEmoji: {
166
+ fontSize: 24
167
+ },
168
+ stepLabel: {
169
+ fontSize: 12,
170
+ color: t.subtext
171
+ },
172
+ title: {
173
+ fontSize: 22,
174
+ fontWeight: '700',
175
+ color: t.text,
176
+ marginBottom: 10
177
+ },
178
+ body: {
179
+ fontSize: 14,
180
+ color: t.subtext,
181
+ lineHeight: 21,
182
+ marginBottom: 28
183
+ },
184
+ btn: {
185
+ backgroundColor: t.accent,
186
+ borderRadius: t.radius,
187
+ paddingVertical: 14,
188
+ alignItems: 'center'
189
+ },
190
+ btnText: {
191
+ color: '#fff',
192
+ fontSize: 15,
193
+ fontWeight: '600'
194
+ }
195
+ });
196
+
197
+ function useCamera(facing = 'back') {
198
+ const cameraRef = useRef(null);
199
+ const device = useCameraDevice(facing);
200
+ const { hasPermission } = useCameraPermission();
201
+ const capturePhoto = useCallback(async ()=>{
202
+ if (!cameraRef.current) return null;
203
+ try {
204
+ const photo = await cameraRef.current.takePhoto({
205
+ flash: 'off',
206
+ enableShutterSound: false
207
+ });
208
+ return {
209
+ uri: `file://${photo.path}`,
210
+ width: photo.width,
211
+ height: photo.height
212
+ };
213
+ } catch {
214
+ return null;
215
+ }
216
+ }, []);
217
+ const startRecording = useCallback((onFinished, onError)=>{
218
+ if (!cameraRef.current) return;
219
+ cameraRef.current.startRecording({
220
+ flash: 'off',
221
+ onRecordingFinished: (video)=>{
222
+ onFinished({
223
+ uri: `file://${video.path}`,
224
+ duration: video.duration
225
+ });
226
+ },
227
+ onRecordingError: onError
228
+ });
229
+ }, []);
230
+ const stopRecording = useCallback(async ()=>{
231
+ if (!cameraRef.current) return;
232
+ await cameraRef.current.stopRecording();
233
+ }, []);
234
+ return {
235
+ cameraRef,
236
+ device,
237
+ hasPermission,
238
+ capturePhoto,
239
+ startRecording,
240
+ stopRecording
241
+ };
242
+ }
243
+
244
+ function DocumentStep({ theme, locale, onCapture }) {
245
+ const { cameraRef, device, capturePhoto } = useCamera('back');
246
+ const [preview, setPreview] = React.useState(null);
247
+ const [capturing, setCapturing] = React.useState(false);
248
+ const s = styles$5(theme);
249
+ const handleCapture = async ()=>{
250
+ setCapturing(true);
251
+ const result = await capturePhoto();
252
+ setCapturing(false);
253
+ if (result) setPreview(result.uri);
254
+ };
255
+ const handleConfirm = ()=>{
256
+ if (preview) onCapture(preview);
257
+ };
258
+ if (!device) {
259
+ return /*#__PURE__*/ React.createElement(View, {
260
+ style: s.container
261
+ }, /*#__PURE__*/ React.createElement(ActivityIndicator, {
262
+ color: theme.accent
263
+ }));
264
+ }
265
+ return /*#__PURE__*/ React.createElement(View, {
266
+ style: s.container
267
+ }, /*#__PURE__*/ React.createElement(Text, {
268
+ style: s.title
269
+ }, locale.doc_title), /*#__PURE__*/ React.createElement(Text, {
270
+ style: s.subtitle
271
+ }, locale.doc_instruction), preview ? /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(Image, {
272
+ source: {
273
+ uri: preview
274
+ },
275
+ style: s.preview,
276
+ resizeMode: "cover"
277
+ }), /*#__PURE__*/ React.createElement(TouchableOpacity, {
278
+ style: s.btnPrimary,
279
+ onPress: handleConfirm,
280
+ activeOpacity: 0.85
281
+ }, /*#__PURE__*/ React.createElement(Text, {
282
+ style: s.btnText
283
+ }, locale.doc_next_btn)), /*#__PURE__*/ React.createElement(TouchableOpacity, {
284
+ style: s.btnSecondary,
285
+ onPress: ()=>setPreview(null),
286
+ activeOpacity: 0.75
287
+ }, /*#__PURE__*/ React.createElement(Text, {
288
+ style: s.btnSecondaryText
289
+ }, locale.doc_retake_btn))) : /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(View, {
290
+ style: s.viewport
291
+ }, /*#__PURE__*/ React.createElement(Camera, {
292
+ ref: cameraRef,
293
+ style: StyleSheet.absoluteFill,
294
+ device: device,
295
+ isActive: true,
296
+ photo: true
297
+ }), /*#__PURE__*/ React.createElement(View, {
298
+ style: s.frameGuide,
299
+ pointerEvents: "none"
300
+ })), /*#__PURE__*/ React.createElement(TouchableOpacity, {
301
+ style: [
302
+ s.btnPrimary,
303
+ capturing && s.btnDisabled
304
+ ],
305
+ onPress: handleCapture,
306
+ disabled: capturing,
307
+ activeOpacity: 0.85
308
+ }, capturing ? /*#__PURE__*/ React.createElement(ActivityIndicator, {
309
+ color: "#fff"
310
+ }) : /*#__PURE__*/ React.createElement(Text, {
311
+ style: s.btnText
312
+ }, locale.doc_capture_btn))));
313
+ }
314
+ const styles$5 = (t)=>StyleSheet.create({
315
+ container: {
316
+ padding: 24
317
+ },
318
+ title: {
319
+ fontSize: 20,
320
+ fontWeight: '700',
321
+ color: t.text,
322
+ marginBottom: 8
323
+ },
324
+ subtitle: {
325
+ fontSize: 14,
326
+ color: t.subtext,
327
+ lineHeight: 20,
328
+ marginBottom: 16
329
+ },
330
+ viewport: {
331
+ width: '100%',
332
+ aspectRatio: 4 / 3,
333
+ borderRadius: t.radius,
334
+ overflow: 'hidden',
335
+ backgroundColor: '#000',
336
+ marginBottom: 16,
337
+ position: 'relative'
338
+ },
339
+ frameGuide: {
340
+ position: 'absolute',
341
+ top: '8%',
342
+ left: '5%',
343
+ right: '5%',
344
+ bottom: '8%',
345
+ borderWidth: 2,
346
+ borderColor: 'rgba(255,255,255,0.7)',
347
+ borderRadius: 8
348
+ },
349
+ preview: {
350
+ width: '100%',
351
+ aspectRatio: 4 / 3,
352
+ borderRadius: t.radius,
353
+ marginBottom: 16,
354
+ borderWidth: 1,
355
+ borderColor: t.border
356
+ },
357
+ btnPrimary: {
358
+ backgroundColor: t.accent,
359
+ borderRadius: t.radius,
360
+ paddingVertical: 14,
361
+ alignItems: 'center',
362
+ marginBottom: 10
363
+ },
364
+ btnSecondary: {
365
+ backgroundColor: t.card,
366
+ borderRadius: t.radius,
367
+ paddingVertical: 14,
368
+ alignItems: 'center',
369
+ borderWidth: 1,
370
+ borderColor: t.border
371
+ },
372
+ btnDisabled: {
373
+ opacity: 0.6
374
+ },
375
+ btnText: {
376
+ color: '#fff',
377
+ fontSize: 15,
378
+ fontWeight: '600'
379
+ },
380
+ btnSecondaryText: {
381
+ color: t.text,
382
+ fontSize: 15,
383
+ fontWeight: '600'
384
+ }
385
+ });
386
+
387
+ function SelfieStep({ theme, locale, onCapture, onBack }) {
388
+ const { cameraRef, device, capturePhoto } = useCamera('front');
389
+ const [preview, setPreview] = React.useState(null);
390
+ const [capturing, setCapturing] = React.useState(false);
391
+ const s = styles$4(theme);
392
+ const handleCapture = async ()=>{
393
+ setCapturing(true);
394
+ const result = await capturePhoto();
395
+ setCapturing(false);
396
+ if (result) setPreview(result.uri);
397
+ };
398
+ if (!device) return /*#__PURE__*/ React.createElement(ActivityIndicator, {
399
+ color: theme.accent,
400
+ style: {
401
+ margin: 40
402
+ }
403
+ });
404
+ return /*#__PURE__*/ React.createElement(View, {
405
+ style: s.container
406
+ }, /*#__PURE__*/ React.createElement(Text, {
407
+ style: s.title
408
+ }, locale.selfie_title), /*#__PURE__*/ React.createElement(Text, {
409
+ style: s.subtitle
410
+ }, locale.selfie_instruction), preview ? /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(Image, {
411
+ source: {
412
+ uri: preview
413
+ },
414
+ style: s.preview,
415
+ resizeMode: "cover"
416
+ }), /*#__PURE__*/ React.createElement(TouchableOpacity, {
417
+ style: s.btnPrimary,
418
+ onPress: ()=>onCapture(preview),
419
+ activeOpacity: 0.85
420
+ }, /*#__PURE__*/ React.createElement(Text, {
421
+ style: s.btnText
422
+ }, locale.selfie_next_btn)), /*#__PURE__*/ React.createElement(TouchableOpacity, {
423
+ style: s.btnSecondary,
424
+ onPress: ()=>setPreview(null),
425
+ activeOpacity: 0.75
426
+ }, /*#__PURE__*/ React.createElement(Text, {
427
+ style: s.btnSecondaryText
428
+ }, locale.selfie_retake_btn))) : /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(View, {
429
+ style: s.viewport
430
+ }, /*#__PURE__*/ React.createElement(Camera, {
431
+ ref: cameraRef,
432
+ style: StyleSheet.absoluteFill,
433
+ device: device,
434
+ isActive: true,
435
+ photo: true
436
+ }), /*#__PURE__*/ React.createElement(View, {
437
+ style: s.ovalGuide,
438
+ pointerEvents: "none"
439
+ })), /*#__PURE__*/ React.createElement(TouchableOpacity, {
440
+ style: [
441
+ s.btnPrimary,
442
+ capturing && s.btnDisabled
443
+ ],
444
+ onPress: handleCapture,
445
+ disabled: capturing,
446
+ activeOpacity: 0.85
447
+ }, capturing ? /*#__PURE__*/ React.createElement(ActivityIndicator, {
448
+ color: "#fff"
449
+ }) : /*#__PURE__*/ React.createElement(Text, {
450
+ style: s.btnText
451
+ }, locale.selfie_capture_btn)), /*#__PURE__*/ React.createElement(TouchableOpacity, {
452
+ style: s.btnSecondary,
453
+ onPress: onBack,
454
+ activeOpacity: 0.75
455
+ }, /*#__PURE__*/ React.createElement(Text, {
456
+ style: s.btnSecondaryText
457
+ }, locale.doc_retake_btn))));
458
+ }
459
+ const styles$4 = (t)=>StyleSheet.create({
460
+ container: {
461
+ padding: 24
462
+ },
463
+ title: {
464
+ fontSize: 20,
465
+ fontWeight: '700',
466
+ color: t.text,
467
+ marginBottom: 8
468
+ },
469
+ subtitle: {
470
+ fontSize: 14,
471
+ color: t.subtext,
472
+ lineHeight: 20,
473
+ marginBottom: 16
474
+ },
475
+ viewport: {
476
+ width: '100%',
477
+ aspectRatio: 3 / 4,
478
+ borderRadius: t.radius,
479
+ overflow: 'hidden',
480
+ backgroundColor: '#000',
481
+ marginBottom: 16,
482
+ position: 'relative',
483
+ alignSelf: 'center',
484
+ maxWidth: 280
485
+ },
486
+ ovalGuide: {
487
+ position: 'absolute',
488
+ top: '8%',
489
+ left: '12%',
490
+ right: '12%',
491
+ bottom: '8%',
492
+ borderWidth: 2,
493
+ borderColor: 'rgba(255,255,255,0.7)',
494
+ borderRadius: 999
495
+ },
496
+ preview: {
497
+ width: 240,
498
+ height: 320,
499
+ borderRadius: t.radius,
500
+ marginBottom: 16,
501
+ alignSelf: 'center',
502
+ borderWidth: 1,
503
+ borderColor: t.border
504
+ },
505
+ btnPrimary: {
506
+ backgroundColor: t.accent,
507
+ borderRadius: t.radius,
508
+ paddingVertical: 14,
509
+ alignItems: 'center',
510
+ marginBottom: 10
511
+ },
512
+ btnSecondary: {
513
+ backgroundColor: t.card,
514
+ borderRadius: t.radius,
515
+ paddingVertical: 14,
516
+ alignItems: 'center',
517
+ borderWidth: 1,
518
+ borderColor: t.border
519
+ },
520
+ btnDisabled: {
521
+ opacity: 0.6
522
+ },
523
+ btnText: {
524
+ color: '#fff',
525
+ fontSize: 15,
526
+ fontWeight: '600'
527
+ },
528
+ btnSecondaryText: {
529
+ color: t.text,
530
+ fontSize: 15,
531
+ fontWeight: '600'
532
+ }
533
+ });
534
+
535
+ function LivenessStep({ theme, locale, duration, onCapture, onBack }) {
536
+ const { cameraRef, device, startRecording, stopRecording } = useCamera('front');
537
+ const [recording, setRecording] = useState(false);
538
+ const [countdown, setCountdown] = useState(Math.ceil(duration / 1000));
539
+ const timerRef = useRef(null);
540
+ const pulseAnim = useRef(new Animated.Value(1)).current;
541
+ const s = styles$3(theme);
542
+ // Pulse animation for recording indicator
543
+ useEffect(()=>{
544
+ if (recording) {
545
+ Animated.loop(Animated.sequence([
546
+ Animated.timing(pulseAnim, {
547
+ toValue: 0.3,
548
+ duration: 600,
549
+ useNativeDriver: true
550
+ }),
551
+ Animated.timing(pulseAnim, {
552
+ toValue: 1,
553
+ duration: 600,
554
+ useNativeDriver: true
555
+ })
556
+ ])).start();
557
+ } else {
558
+ pulseAnim.setValue(1);
559
+ }
560
+ }, [
561
+ recording,
562
+ pulseAnim
563
+ ]);
564
+ const handleStart = ()=>{
565
+ if (!cameraRef.current) return;
566
+ setRecording(true);
567
+ setCountdown(Math.ceil(duration / 1000));
568
+ startRecording((video)=>{
569
+ setRecording(false);
570
+ onCapture(video.uri);
571
+ }, ()=>setRecording(false));
572
+ // Countdown tick
573
+ let remaining = duration / 1000;
574
+ timerRef.current = setInterval(()=>{
575
+ remaining -= 0.1;
576
+ setCountdown(Math.max(0, Math.ceil(remaining)));
577
+ if (remaining <= 0) {
578
+ if (timerRef.current) clearInterval(timerRef.current);
579
+ stopRecording();
580
+ }
581
+ }, 100);
582
+ };
583
+ useEffect(()=>{
584
+ return ()=>{
585
+ if (timerRef.current) clearInterval(timerRef.current);
586
+ };
587
+ }, []);
588
+ if (!device) return /*#__PURE__*/ React.createElement(ActivityIndicator, {
589
+ color: theme.accent,
590
+ style: {
591
+ margin: 40
592
+ }
593
+ });
594
+ return /*#__PURE__*/ React.createElement(View, {
595
+ style: s.container
596
+ }, /*#__PURE__*/ React.createElement(Text, {
597
+ style: s.title
598
+ }, locale.liveness_title), /*#__PURE__*/ React.createElement(Text, {
599
+ style: s.subtitle
600
+ }, recording ? locale.liveness_recording : locale.liveness_instruction), /*#__PURE__*/ React.createElement(View, {
601
+ style: s.viewport
602
+ }, /*#__PURE__*/ React.createElement(Camera, {
603
+ ref: cameraRef,
604
+ style: StyleSheet.absoluteFill,
605
+ device: device,
606
+ isActive: true,
607
+ video: true,
608
+ audio: true
609
+ }), /*#__PURE__*/ React.createElement(View, {
610
+ style: s.ovalGuide,
611
+ pointerEvents: "none"
612
+ }), recording && /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(View, {
613
+ style: s.recBadge
614
+ }, /*#__PURE__*/ React.createElement(Animated.View, {
615
+ style: [
616
+ s.recDot,
617
+ {
618
+ opacity: pulseAnim
619
+ }
620
+ ]
621
+ }), /*#__PURE__*/ React.createElement(Text, {
622
+ style: s.recText
623
+ }, "REC")), /*#__PURE__*/ React.createElement(View, {
624
+ style: s.countdownBadge
625
+ }, /*#__PURE__*/ React.createElement(Text, {
626
+ style: s.countdownText
627
+ }, countdown)))), !recording && /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(TouchableOpacity, {
628
+ style: s.btnPrimary,
629
+ onPress: handleStart,
630
+ activeOpacity: 0.85
631
+ }, /*#__PURE__*/ React.createElement(Text, {
632
+ style: s.btnText
633
+ }, locale.liveness_start_btn)), /*#__PURE__*/ React.createElement(TouchableOpacity, {
634
+ style: s.btnSecondary,
635
+ onPress: onBack,
636
+ activeOpacity: 0.75
637
+ }, /*#__PURE__*/ React.createElement(Text, {
638
+ style: s.btnSecondaryText
639
+ }, locale.selfie_retake_btn))));
640
+ }
641
+ const styles$3 = (t)=>StyleSheet.create({
642
+ container: {
643
+ padding: 24
644
+ },
645
+ title: {
646
+ fontSize: 20,
647
+ fontWeight: '700',
648
+ color: t.text,
649
+ marginBottom: 8
650
+ },
651
+ subtitle: {
652
+ fontSize: 14,
653
+ color: t.subtext,
654
+ lineHeight: 20,
655
+ marginBottom: 16
656
+ },
657
+ viewport: {
658
+ width: '100%',
659
+ aspectRatio: 3 / 4,
660
+ borderRadius: t.radius,
661
+ overflow: 'hidden',
662
+ backgroundColor: '#000',
663
+ marginBottom: 16,
664
+ alignSelf: 'center',
665
+ maxWidth: 280,
666
+ position: 'relative'
667
+ },
668
+ ovalGuide: {
669
+ position: 'absolute',
670
+ top: '8%',
671
+ left: '12%',
672
+ right: '12%',
673
+ bottom: '8%',
674
+ borderWidth: 2,
675
+ borderColor: 'rgba(255,255,255,0.7)',
676
+ borderRadius: 999
677
+ },
678
+ recBadge: {
679
+ position: 'absolute',
680
+ top: 10,
681
+ left: 10,
682
+ flexDirection: 'row',
683
+ alignItems: 'center',
684
+ backgroundColor: 'rgba(220,38,38,0.85)',
685
+ borderRadius: 999,
686
+ paddingHorizontal: 10,
687
+ paddingVertical: 4,
688
+ gap: 6
689
+ },
690
+ recDot: {
691
+ width: 8,
692
+ height: 8,
693
+ borderRadius: 4,
694
+ backgroundColor: '#fff'
695
+ },
696
+ recText: {
697
+ color: '#fff',
698
+ fontSize: 12,
699
+ fontWeight: '700'
700
+ },
701
+ countdownBadge: {
702
+ position: 'absolute',
703
+ bottom: 12,
704
+ alignSelf: 'center',
705
+ width: 44,
706
+ height: 44,
707
+ borderRadius: 22,
708
+ backgroundColor: 'rgba(0,0,0,0.5)',
709
+ alignItems: 'center',
710
+ justifyContent: 'center'
711
+ },
712
+ countdownText: {
713
+ color: '#fff',
714
+ fontSize: 18,
715
+ fontWeight: '700'
716
+ },
717
+ btnPrimary: {
718
+ backgroundColor: t.accent,
719
+ borderRadius: t.radius,
720
+ paddingVertical: 14,
721
+ alignItems: 'center',
722
+ marginBottom: 10
723
+ },
724
+ btnSecondary: {
725
+ backgroundColor: t.card,
726
+ borderRadius: t.radius,
727
+ paddingVertical: 14,
728
+ alignItems: 'center',
729
+ borderWidth: 1,
730
+ borderColor: t.border
731
+ },
732
+ btnText: {
733
+ color: '#fff',
734
+ fontSize: 15,
735
+ fontWeight: '600'
736
+ },
737
+ btnSecondaryText: {
738
+ color: t.text,
739
+ fontSize: 15,
740
+ fontWeight: '600'
741
+ }
742
+ });
743
+
744
+ function ProcessingStep({ theme, locale }) {
745
+ const spinAnim = useRef(new Animated.Value(0)).current;
746
+ const s = styles$2(theme);
747
+ useEffect(()=>{
748
+ Animated.loop(Animated.timing(spinAnim, {
749
+ toValue: 1,
750
+ duration: 800,
751
+ useNativeDriver: true
752
+ })).start();
753
+ }, [
754
+ spinAnim
755
+ ]);
756
+ const spin = spinAnim.interpolate({
757
+ inputRange: [
758
+ 0,
759
+ 1
760
+ ],
761
+ outputRange: [
762
+ '0deg',
763
+ '360deg'
764
+ ]
765
+ });
766
+ return /*#__PURE__*/ React.createElement(View, {
767
+ style: s.container
768
+ }, /*#__PURE__*/ React.createElement(Animated.View, {
769
+ style: [
770
+ s.spinner,
771
+ {
772
+ transform: [
773
+ {
774
+ rotate: spin
775
+ }
776
+ ]
777
+ }
778
+ ]
779
+ }), /*#__PURE__*/ React.createElement(Text, {
780
+ style: s.title
781
+ }, locale.processing_title), /*#__PURE__*/ React.createElement(Text, {
782
+ style: s.subtitle
783
+ }, locale.processing_subtitle));
784
+ }
785
+ const styles$2 = (t)=>StyleSheet.create({
786
+ container: {
787
+ padding: 48,
788
+ alignItems: 'center',
789
+ gap: 16
790
+ },
791
+ spinner: {
792
+ width: 52,
793
+ height: 52,
794
+ borderRadius: 26,
795
+ borderWidth: 4,
796
+ borderColor: t.border,
797
+ borderTopColor: t.accent,
798
+ marginBottom: 8
799
+ },
800
+ title: {
801
+ fontSize: 18,
802
+ fontWeight: '700',
803
+ color: t.text,
804
+ textAlign: 'center'
805
+ },
806
+ subtitle: {
807
+ fontSize: 14,
808
+ color: t.subtext,
809
+ textAlign: 'center'
810
+ }
811
+ });
812
+
813
+ const ICONS = {
814
+ pass: '✅',
815
+ fail: '❌',
816
+ review: '⏳'
817
+ };
818
+ const ICON_BG = {
819
+ pass: '#d1fae5',
820
+ fail: '#fee2e2',
821
+ review: '#fef3c7'
822
+ };
823
+ function ResultStep({ theme, locale, result, error, onRetry }) {
824
+ const s = styles$1(theme);
825
+ if (!result) {
826
+ return /*#__PURE__*/ React.createElement(View, {
827
+ style: s.container
828
+ }, /*#__PURE__*/ React.createElement(View, {
829
+ style: [
830
+ s.iconWrap,
831
+ {
832
+ backgroundColor: '#fee2e2'
833
+ }
834
+ ]
835
+ }, /*#__PURE__*/ React.createElement(Text, {
836
+ style: s.icon
837
+ }, "❌")), /*#__PURE__*/ React.createElement(Text, {
838
+ style: s.title
839
+ }, locale.result_fail_title), /*#__PURE__*/ React.createElement(Text, {
840
+ style: s.body
841
+ }, error ?? locale.result_fail_body), /*#__PURE__*/ React.createElement(TouchableOpacity, {
842
+ style: s.btn,
843
+ onPress: onRetry,
844
+ activeOpacity: 0.85
845
+ }, /*#__PURE__*/ React.createElement(Text, {
846
+ style: s.btnText
847
+ }, locale.result_retry_btn)));
848
+ }
849
+ const titles = {
850
+ pass: locale.result_pass_title,
851
+ fail: locale.result_fail_title,
852
+ review: locale.result_review_title
853
+ };
854
+ const bodies = {
855
+ pass: locale.result_pass_body,
856
+ fail: locale.result_fail_body,
857
+ review: locale.result_review_body
858
+ };
859
+ return /*#__PURE__*/ React.createElement(View, {
860
+ style: s.container
861
+ }, /*#__PURE__*/ React.createElement(View, {
862
+ style: [
863
+ s.iconWrap,
864
+ {
865
+ backgroundColor: ICON_BG[result.verdict]
866
+ }
867
+ ]
868
+ }, /*#__PURE__*/ React.createElement(Text, {
869
+ style: s.icon
870
+ }, ICONS[result.verdict])), /*#__PURE__*/ React.createElement(Text, {
871
+ style: s.title
872
+ }, titles[result.verdict]), /*#__PURE__*/ React.createElement(Text, {
873
+ style: s.body
874
+ }, bodies[result.verdict]), result.verdict === 'fail' && /*#__PURE__*/ React.createElement(TouchableOpacity, {
875
+ style: s.btn,
876
+ onPress: onRetry,
877
+ activeOpacity: 0.85
878
+ }, /*#__PURE__*/ React.createElement(Text, {
879
+ style: s.btnText
880
+ }, locale.result_retry_btn)));
881
+ }
882
+ const styles$1 = (t)=>StyleSheet.create({
883
+ container: {
884
+ padding: 40,
885
+ alignItems: 'center',
886
+ gap: 12
887
+ },
888
+ iconWrap: {
889
+ width: 76,
890
+ height: 76,
891
+ borderRadius: 38,
892
+ alignItems: 'center',
893
+ justifyContent: 'center',
894
+ marginBottom: 8
895
+ },
896
+ icon: {
897
+ fontSize: 34
898
+ },
899
+ title: {
900
+ fontSize: 20,
901
+ fontWeight: '700',
902
+ color: t.text,
903
+ textAlign: 'center'
904
+ },
905
+ body: {
906
+ fontSize: 14,
907
+ color: t.subtext,
908
+ textAlign: 'center',
909
+ lineHeight: 21
910
+ },
911
+ btn: {
912
+ marginTop: 8,
913
+ backgroundColor: t.accent,
914
+ borderRadius: t.radius,
915
+ paddingVertical: 14,
916
+ paddingHorizontal: 32,
917
+ alignItems: 'center'
918
+ },
919
+ btnText: {
920
+ color: '#fff',
921
+ fontSize: 15,
922
+ fontWeight: '600'
923
+ }
924
+ });
925
+
926
+ const STEP_INDEX = {
927
+ intro: -1,
928
+ document: 0,
929
+ selfie: 1,
930
+ liveness: 2,
931
+ processing: 3,
932
+ result: 3
933
+ };
934
+ function KernaqVerify({ visible, apiKey, backendUrl, country = 'KEN', reference, sandbox = false, livenessDuration = 3000, documentTypes = [
935
+ 'national_id',
936
+ 'passport'
937
+ ], theme: themeProp, locale: localeProp, onCancel, onComplete, onError }) {
938
+ const [step, setStep] = useState('intro');
939
+ const [capture, setCapture] = useState({});
940
+ const [result, setResult] = useState(null);
941
+ const [error, setError] = useState(null);
942
+ const theme = useMemo(()=>resolveTheme(themeProp), [
943
+ themeProp
944
+ ]);
945
+ const locale = useMemo(()=>({
946
+ ...DEFAULT_LOCALE,
947
+ ...localeProp
948
+ }), [
949
+ localeProp
950
+ ]);
951
+ const { requestPermissions } = usePermissions();
952
+ const s = styles(theme);
953
+ const reset = useCallback(()=>{
954
+ setStep('intro');
955
+ setCapture({});
956
+ setResult(null);
957
+ setError(null);
958
+ }, []);
959
+ const goto = useCallback(async (next)=>{
960
+ if (next === 'document' || next === 'selfie' || next === 'liveness') {
961
+ const granted = await requestPermissions();
962
+ if (!granted) {
963
+ setError(locale.error_camera_denied);
964
+ return;
965
+ }
966
+ }
967
+ setStep(next);
968
+ if (next === 'processing') {
969
+ submit();
970
+ }
971
+ // eslint-disable-next-line react-hooks/exhaustive-deps
972
+ }, [
973
+ capture,
974
+ locale
975
+ ]);
976
+ const submit = useCallback(async ()=>{
977
+ const { documentUri, selfieUri, videoUri } = capture;
978
+ if (!documentUri || !selfieUri) {
979
+ setError('Missing captures — please restart.');
980
+ setStep('result');
981
+ return;
982
+ }
983
+ const base = (backendUrl ?? 'https://api.identity.kernaq.com/v1').replace(/\/$/, '');
984
+ const form = new FormData();
985
+ form.append('document', {
986
+ uri: documentUri,
987
+ name: 'document.jpg',
988
+ type: 'image/jpeg'
989
+ });
990
+ form.append('selfie', {
991
+ uri: selfieUri,
992
+ name: 'selfie.jpg',
993
+ type: 'image/jpeg'
994
+ });
995
+ if (videoUri) {
996
+ form.append('video', {
997
+ uri: videoUri,
998
+ name: 'liveness.mp4',
999
+ type: 'video/mp4'
1000
+ });
1001
+ }
1002
+ form.append('document_type', documentTypes[0] ?? 'national_id');
1003
+ form.append('country', country);
1004
+ form.append('reference', reference ?? `kq_${Date.now()}`);
1005
+ if (sandbox) form.append('sandbox', 'true');
1006
+ const headers = {
1007
+ 'Content-Type': 'multipart/form-data'
1008
+ };
1009
+ if (apiKey) headers['X-API-Key'] = apiKey;
1010
+ try {
1011
+ const res = await fetch(`${base}/verify`, {
1012
+ method: 'POST',
1013
+ headers,
1014
+ body: form
1015
+ });
1016
+ const body = await res.json();
1017
+ if (!res.ok) throw new Error(body['message'] ?? `HTTP ${res.status}`);
1018
+ const r = {
1019
+ verificationId: body['verification_id'],
1020
+ reference: body['reference'],
1021
+ verdict: body['verdict'],
1022
+ score: body['score'] ?? 0,
1023
+ faceMatch: body['face_match'] ?? false,
1024
+ isLive: body['is_live'] ?? false,
1025
+ documentFields: body['document_fields']
1026
+ };
1027
+ setResult(r);
1028
+ setStep('result');
1029
+ onComplete?.(r);
1030
+ } catch (err) {
1031
+ const msg = err.message;
1032
+ setError(`${locale.error_network} ${msg}`);
1033
+ setStep('result');
1034
+ onError?.({
1035
+ code: 'network_error',
1036
+ message: msg
1037
+ });
1038
+ }
1039
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1040
+ }, [
1041
+ capture
1042
+ ]);
1043
+ const handleCancel = ()=>{
1044
+ reset();
1045
+ onCancel?.();
1046
+ };
1047
+ const progressSteps = [
1048
+ 'document',
1049
+ 'selfie',
1050
+ 'liveness',
1051
+ 'processing'
1052
+ ];
1053
+ const currentIdx = STEP_INDEX[step];
1054
+ return /*#__PURE__*/ React.createElement(Modal, {
1055
+ visible: visible,
1056
+ animationType: "slide",
1057
+ presentationStyle: "pageSheet",
1058
+ onRequestClose: handleCancel
1059
+ }, /*#__PURE__*/ React.createElement(SafeAreaView, {
1060
+ style: [
1061
+ s.safeArea,
1062
+ {
1063
+ backgroundColor: theme.bg
1064
+ }
1065
+ ]
1066
+ }, /*#__PURE__*/ React.createElement(View, {
1067
+ style: s.header
1068
+ }, /*#__PURE__*/ React.createElement(Text, {
1069
+ style: s.logo
1070
+ }, "Kernaq"), step !== 'processing' && /*#__PURE__*/ React.createElement(TouchableOpacity, {
1071
+ onPress: handleCancel,
1072
+ style: s.closeBtn,
1073
+ hitSlop: {
1074
+ top: 8,
1075
+ bottom: 8,
1076
+ left: 8,
1077
+ right: 8
1078
+ }
1079
+ }, /*#__PURE__*/ React.createElement(Text, {
1080
+ style: s.closeText
1081
+ }, "✕"))), currentIdx >= 0 && /*#__PURE__*/ React.createElement(View, {
1082
+ style: s.progress
1083
+ }, progressSteps.map((_, i)=>/*#__PURE__*/ React.createElement(View, {
1084
+ key: i,
1085
+ style: [
1086
+ s.progressDot,
1087
+ i < currentIdx && {
1088
+ backgroundColor: theme.accent,
1089
+ opacity: 0.4
1090
+ },
1091
+ i === currentIdx && {
1092
+ backgroundColor: theme.accent
1093
+ }
1094
+ ]
1095
+ }))), /*#__PURE__*/ React.createElement(ScrollView, {
1096
+ style: s.scroll,
1097
+ contentContainerStyle: s.scrollContent,
1098
+ keyboardShouldPersistTaps: "handled",
1099
+ bounces: false
1100
+ }, step === 'intro' && /*#__PURE__*/ React.createElement(IntroStep, {
1101
+ theme: theme,
1102
+ locale: locale,
1103
+ onStart: ()=>goto('document')
1104
+ }), step === 'document' && /*#__PURE__*/ React.createElement(DocumentStep, {
1105
+ theme: theme,
1106
+ locale: locale,
1107
+ onCapture: (uri)=>{
1108
+ setCapture((c)=>({
1109
+ ...c,
1110
+ documentUri: uri
1111
+ }));
1112
+ goto('selfie');
1113
+ }
1114
+ }), step === 'selfie' && /*#__PURE__*/ React.createElement(SelfieStep, {
1115
+ theme: theme,
1116
+ locale: locale,
1117
+ onCapture: (uri)=>{
1118
+ setCapture((c)=>({
1119
+ ...c,
1120
+ selfieUri: uri
1121
+ }));
1122
+ goto('liveness');
1123
+ },
1124
+ onBack: ()=>setStep('document')
1125
+ }), step === 'liveness' && /*#__PURE__*/ React.createElement(LivenessStep, {
1126
+ theme: theme,
1127
+ locale: locale,
1128
+ duration: livenessDuration,
1129
+ onCapture: (uri)=>{
1130
+ setCapture((c)=>({
1131
+ ...c,
1132
+ videoUri: uri
1133
+ }));
1134
+ goto('processing');
1135
+ },
1136
+ onBack: ()=>setStep('selfie')
1137
+ }), step === 'processing' && /*#__PURE__*/ React.createElement(ProcessingStep, {
1138
+ theme: theme,
1139
+ locale: locale
1140
+ }), step === 'result' && /*#__PURE__*/ React.createElement(ResultStep, {
1141
+ theme: theme,
1142
+ locale: locale,
1143
+ result: result,
1144
+ error: error,
1145
+ onRetry: ()=>{
1146
+ reset();
1147
+ setStep('intro');
1148
+ }
1149
+ }))));
1150
+ }
1151
+ const styles = (t)=>StyleSheet.create({
1152
+ safeArea: {
1153
+ flex: 1
1154
+ },
1155
+ header: {
1156
+ flexDirection: 'row',
1157
+ alignItems: 'center',
1158
+ justifyContent: 'space-between',
1159
+ paddingHorizontal: 20,
1160
+ paddingTop: Platform.OS === 'android' ? 16 : 8,
1161
+ paddingBottom: 8
1162
+ },
1163
+ logo: {
1164
+ fontSize: 14,
1165
+ fontWeight: '700',
1166
+ color: t.accent,
1167
+ letterSpacing: 1.2,
1168
+ textTransform: 'uppercase'
1169
+ },
1170
+ closeBtn: {
1171
+ padding: 4
1172
+ },
1173
+ closeText: {
1174
+ fontSize: 18,
1175
+ color: t.subtext
1176
+ },
1177
+ progress: {
1178
+ flexDirection: 'row',
1179
+ gap: 6,
1180
+ paddingHorizontal: 20,
1181
+ paddingBottom: 8
1182
+ },
1183
+ progressDot: {
1184
+ flex: 1,
1185
+ height: 3,
1186
+ borderRadius: 999,
1187
+ backgroundColor: t.border
1188
+ },
1189
+ scroll: {
1190
+ flex: 1
1191
+ },
1192
+ scrollContent: {
1193
+ flexGrow: 1
1194
+ }
1195
+ });
1196
+
1197
+ export { KernaqVerify };
1198
+ //# sourceMappingURL=index.esm.js.map