@trustchex/react-native-sdk 1.497.0 → 1.500.1

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 (32) hide show
  1. package/lib/module/Screens/Static/ResultScreen.js +104 -11
  2. package/lib/module/Shared/Components/EIDScanner.js +28 -3
  3. package/lib/module/Shared/Components/ErrorBoundary.js +144 -0
  4. package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +5 -0
  5. package/lib/module/Shared/Components/IdentityDocumentCamera.js +163 -4
  6. package/lib/module/Translation/Resources/en.js +13 -0
  7. package/lib/module/Translation/Resources/tr.js +13 -0
  8. package/lib/module/Trustchex.js +52 -49
  9. package/lib/module/version.js +1 -1
  10. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  11. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  12. package/lib/typescript/src/Shared/Components/ErrorBoundary.d.ts +17 -0
  13. package/lib/typescript/src/Shared/Components/ErrorBoundary.d.ts.map +1 -0
  14. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts +1 -0
  15. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -1
  16. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  17. package/lib/typescript/src/Translation/Resources/en.d.ts +13 -0
  18. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  19. package/lib/typescript/src/Translation/Resources/tr.d.ts +13 -0
  20. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  21. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  22. package/lib/typescript/src/version.d.ts +1 -1
  23. package/package.json +1 -1
  24. package/src/Screens/Static/ResultScreen.tsx +145 -29
  25. package/src/Shared/Components/EIDScanner.tsx +38 -0
  26. package/src/Shared/Components/ErrorBoundary.tsx +151 -0
  27. package/src/Shared/Components/IdentityDocumentCamera.constants.ts +5 -0
  28. package/src/Shared/Components/IdentityDocumentCamera.tsx +220 -27
  29. package/src/Translation/Resources/en.ts +16 -0
  30. package/src/Translation/Resources/tr.ts +16 -0
  31. package/src/Trustchex.tsx +64 -59
  32. package/src/version.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
4
- import { Alert, Image, Platform, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';
4
+ import { Image, Platform, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';
5
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
6
  import AppContext from "../../Shared/Contexts/AppContext.js";
7
7
  import httpClient, { getSessionToken } from "../../Shared/Libs/http-client.js";
@@ -48,13 +48,27 @@ const collectDiagnosticImages = doc => {
48
48
  import { trackError, trackFunnelStep, useScreenTracking } from "../../Shared/Libs/analytics.utils.js";
49
49
  import { analyticsService } from "../../Shared/Services/AnalyticsService.js";
50
50
  import { AnalyticsEventName, AnalyticsEventCategory } from "../../Shared/Types/analytics.types.js";
51
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
51
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
52
52
  const ResultScreen = () => {
53
53
  const appContext = useContext(AppContext);
54
54
  const [isSubmitting, setIsSubmitting] = useState(false);
55
55
  const [progress, setProgress] = useState(0);
56
+ // Set when submit() fails — drives a recoverable error UI with a "Retry"
57
+ // button instead of wiping the whole flow back to the start (which forced a
58
+ // full re-scan + re-liveness on any transient final-step failure).
59
+ const [submitFailed, setSubmitFailed] = useState(false);
56
60
  const navigationManagerRef = React.useRef(null);
57
61
  const hasSubmittedRef = React.useRef(false);
62
+ // Tracks which submit steps already succeeded so a retry RESUMES from the
63
+ // failed step instead of re-running earlier ones. submit() is a chain of
64
+ // non-idempotent POSTs (consent/document/media/videos), so a naive full
65
+ // retry after a partial success could create duplicate server-side records.
66
+ const completedSteps = React.useRef({
67
+ consent: false,
68
+ document: false,
69
+ media: false,
70
+ verbalConsentVideos: false
71
+ });
58
72
  const [shouldShowDemoData, setShouldShowDemoData] = useState(false);
59
73
  const [deviceIdentifier, setDeviceIdentifier] = useState('');
60
74
  const {
@@ -383,29 +397,48 @@ const ResultScreen = () => {
383
397
  const submit = useCallback(async identificationInfo => {
384
398
  try {
385
399
  setIsSubmitting(true);
400
+ setSubmitFailed(false);
386
401
  const identificationId = identificationInfo.identificationId;
387
402
  if (!identificationId) {
388
403
  throw new Error('IdentificationId not found');
389
404
  }
390
405
  const alreadyUploaded = !!identificationInfo.mediaUploadedDuringVideoCall;
391
406
  const sessionKey = await runWithRetry(() => getSessionKey(apiUrl, appContext.identificationInfo.sessionId));
407
+
408
+ // createIdentification is effectively idempotent (creates/verifies the
409
+ // record), so it's safe to re-run on retry without a guard.
392
410
  if (!alreadyUploaded) {
393
411
  await runWithRetry(() => createIdentification(identificationId));
394
412
  }
395
413
  setProgress(20);
396
- await runWithRetry(() => submitIdentificationConsent(identificationId, sessionKey));
414
+
415
+ // Each POST below is guarded by completedSteps so a retry skips work
416
+ // that already committed server-side (avoiding duplicate records).
417
+ if (!completedSteps.current.consent) {
418
+ await runWithRetry(() => submitIdentificationConsent(identificationId, sessionKey));
419
+ completedSteps.current.consent = true;
420
+ }
397
421
  setProgress(30);
398
422
  if (!alreadyUploaded) {
399
423
  const scannedIdentityDocument = identificationInfo.scannedDocument;
400
- if (scannedIdentityDocument && scannedIdentityDocument.documentType !== 'UNKNOWN') {
424
+ if (!completedSteps.current.document && scannedIdentityDocument && scannedIdentityDocument.documentType !== 'UNKNOWN') {
401
425
  await runWithRetry(() => submitIdentificationDocument(identificationId, scannedIdentityDocument, sessionKey));
426
+ completedSteps.current.document = true;
402
427
  }
403
428
  setProgress(40);
404
- const livenessDetection = identificationInfo.livenessDetection;
405
- await runWithRetry(() => uploadIdentificationMedia(identificationId, scannedIdentityDocument, livenessDetection, false));
429
+ if (!completedSteps.current.media) {
430
+ const livenessDetection = identificationInfo.livenessDetection;
431
+ await runWithRetry(() => uploadIdentificationMedia(identificationId, scannedIdentityDocument, livenessDetection, false));
432
+ completedSteps.current.media = true;
433
+ }
434
+ }
435
+ if (!completedSteps.current.verbalConsentVideos) {
436
+ await runWithRetry(() => uploadVerbalConsentVideos(identificationId, identificationInfo.verbalConsentVideos));
437
+ completedSteps.current.verbalConsentVideos = true;
406
438
  }
407
- await runWithRetry(() => uploadVerbalConsentVideos(identificationId, identificationInfo.verbalConsentVideos));
408
439
  setProgress(90);
440
+
441
+ // finishIdentification is a PUT (idempotent) — always safe to re-run.
409
442
  await runWithRetry(() => finishIdentification(identificationId, sessionKey));
410
443
  setProgress(100);
411
444
  setIsSubmitting(false);
@@ -422,16 +455,21 @@ const ResultScreen = () => {
422
455
  // Track verification failure
423
456
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
424
457
  trackError('VERIFICATION_SUBMISSION_FAILED', errorMessage, 'result_screen', 'critical', {
425
- recoverable: false,
458
+ recoverable: true,
426
459
  userAction: 'submit_verification'
427
460
  }).catch(() => {});
428
461
  analyticsService.trackEvent(AnalyticsEventName.VERIFICATION_FAILED, AnalyticsEventCategory.VERIFICATION, {
429
462
  sessionId: appContext.identificationInfo.sessionId || '',
430
463
  errorMessage
431
464
  }).catch(() => {});
432
- Alert.alert(t('general.error'), t('resultScreen.submissionFailed'));
465
+
466
+ // Show a recoverable error UI with a Retry button instead of resetting
467
+ // the whole flow. The collected data is still intact in AppContext, and
468
+ // completedSteps ensures a retry resumes from the failed step. We notify
469
+ // the host via onError but do NOT tear the flow down — the user can
470
+ // retry without re-scanning their document or redoing liveness.
433
471
  appContext.onError?.(t('resultScreen.submissionFailed'));
434
- navigationManagerRef.current?.reset();
472
+ setSubmitFailed(true);
435
473
  }
436
474
  }, [createIdentification, apiUrl, uploadIdentificationMedia, uploadVerbalConsentVideos, finishIdentification, submitIdentificationDocument, submitIdentificationConsent, t, appContext]);
437
475
  useEffect(() => {
@@ -464,7 +502,35 @@ const ResultScreen = () => {
464
502
  style: styles.container,
465
503
  children: /*#__PURE__*/_jsxs(View, {
466
504
  style: styles.container,
467
- children: [isSubmitting ? /*#__PURE__*/_jsxs(_Fragment, {
505
+ children: [submitFailed && !isSubmitting ? /*#__PURE__*/_jsxs(View, {
506
+ style: styles.submitErrorContainer,
507
+ children: [/*#__PURE__*/_jsx(Text, {
508
+ style: styles.submitErrorTitle,
509
+ children: t('resultScreen.submissionFailedTitle')
510
+ }), /*#__PURE__*/_jsx(Text, {
511
+ style: styles.submitErrorMessage,
512
+ children: t('resultScreen.submissionFailedRetryHint')
513
+ }), /*#__PURE__*/_jsx(StyledButton, {
514
+ mode: "contained",
515
+ onPress: () => submit(appContext.identificationInfo),
516
+ style: styles.submitRetryButton,
517
+ children: t('resultScreen.retrySubmission')
518
+ }), /*#__PURE__*/_jsx(View, {
519
+ style: styles.submitReportButton,
520
+ children: /*#__PURE__*/_jsx(DiagnosticReportButton, {
521
+ baseUrl: appContext.baseUrl,
522
+ sessionId: appContext.identificationInfo?.sessionId,
523
+ scan: {
524
+ documentType: appContext.identificationInfo?.scannedDocument?.documentType,
525
+ dataSource: appContext.identificationInfo?.scannedDocument?.dataSource,
526
+ mrzText: appContext.identificationInfo?.scannedDocument?.mrzText,
527
+ mrzFields: appContext.identificationInfo?.scannedDocument?.mrzFields,
528
+ barcodeValue: appContext.identificationInfo?.scannedDocument?.barcodeValue
529
+ },
530
+ images: collectDiagnosticImages(appContext.identificationInfo?.scannedDocument)
531
+ })
532
+ })]
533
+ }) : isSubmitting ? /*#__PURE__*/_jsxs(_Fragment, {
468
534
  children: [/*#__PURE__*/_jsx(Text, {
469
535
  style: styles.sectionHeader,
470
536
  children: t('resultScreen.submitting')
@@ -819,6 +885,33 @@ const styles = StyleSheet.create({
819
885
  textAlign: 'center',
820
886
  textTransform: 'capitalize'
821
887
  },
888
+ submitErrorContainer: {
889
+ flex: 1,
890
+ justifyContent: 'center',
891
+ alignItems: 'center',
892
+ paddingHorizontal: 24
893
+ },
894
+ submitErrorTitle: {
895
+ fontSize: 20,
896
+ fontWeight: 'bold',
897
+ color: 'black',
898
+ textAlign: 'center',
899
+ marginBottom: 12
900
+ },
901
+ submitErrorMessage: {
902
+ fontSize: 16,
903
+ color: '#666',
904
+ textAlign: 'center',
905
+ lineHeight: 22,
906
+ marginBottom: 28
907
+ },
908
+ submitRetryButton: {
909
+ minWidth: 220
910
+ },
911
+ submitReportButton: {
912
+ marginTop: 16,
913
+ minWidth: 220
914
+ },
822
915
  sectionText: {
823
916
  fontSize: 18,
824
917
  color: 'black',
@@ -15,6 +15,7 @@ import { normalizeFromMRZInfo } from "../Libs/documentDataNormalizer.js";
15
15
  import { useTranslation } from 'react-i18next';
16
16
  import AppContext from "../Contexts/AppContext.js";
17
17
  import StyledButton from "./StyledButton.js";
18
+ import DiagnosticReportButton from "./DiagnosticReportButton.js";
18
19
  import LottieView from 'lottie-react-native';
19
20
  import { getLocalizedCountryName } from "../Libs/country-display.utils.js";
20
21
  import { useKeepAwake } from "../Libs/native-keep-awake.utils.js";
@@ -87,6 +88,10 @@ const EIDScanner = ({
87
88
  }
88
89
  }, []);
89
90
  const [isScanned, setIsScanned] = React.useState(false);
91
+ // Set when an NFC read fails — surfaces a "report issue" support channel next
92
+ // to the retry button (a failed chip read is the most common point a user
93
+ // genuinely needs help). Cleared when a new read starts.
94
+ const [hasScanFailed, setHasScanFailed] = React.useState(false);
90
95
  const [hasGuideShown, setHasGuideShown] = React.useState(false);
91
96
  const {
92
97
  t,
@@ -144,6 +149,7 @@ const EIDScanner = ({
144
149
  setAttemptNumber(currentAttempt);
145
150
  setIsScanning(true);
146
151
  setIsScanned(false);
152
+ setHasScanFailed(false);
147
153
  setProgress(0);
148
154
  setProgressStage('');
149
155
  resetLastMessage();
@@ -260,6 +266,9 @@ const EIDScanner = ({
260
266
  setDocumentName(builtName);
261
267
  setIsScanned(true);
262
268
  }
269
+ // Defensive: clear any prior-failure flag on a successful read so the
270
+ // report affordance can't linger if the UI returns to this state.
271
+ setHasScanFailed(false);
263
272
  await trackEIDScanComplete(docType, scanDuration, currentAttempt).catch(() => {});
264
273
  } else {
265
274
  // eidReader resolved without throwing but produced no data. Without
@@ -279,6 +288,7 @@ const EIDScanner = ({
279
288
  Alert.alert(t('general.error'), t('eidScannerScreen.invalidMRZFields'));
280
289
  }
281
290
  } catch (error) {
291
+ setHasScanFailed(true);
282
292
  const scanDuration = Date.now() - startTime;
283
293
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
284
294
 
@@ -593,13 +603,25 @@ const EIDScanner = ({
593
603
  })), hasNfc && isEnabled && isScanning && progress === 0 && /*#__PURE__*/_jsx(Text, {
594
604
  style: styles.keepStillHint,
595
605
  children: t('eidScannerScreen.keepStill')
596
- }), hasNfc && isEnabled && !isScanning && !isScanned && /*#__PURE__*/_jsx(View, {
606
+ }), hasNfc && isEnabled && !isScanning && !isScanned && /*#__PURE__*/_jsxs(View, {
597
607
  style: styles.buttonsContainer,
598
- children: /*#__PURE__*/_jsx(StyledButton, {
608
+ children: [/*#__PURE__*/_jsx(StyledButton, {
599
609
  mode: "contained",
600
610
  onPress: readNFC,
601
611
  children: t('eidScannerScreen.startScanning')
602
- })
612
+ }), hasScanFailed && /*#__PURE__*/_jsx(View, {
613
+ style: styles.reportButtonWrapper,
614
+ children: /*#__PURE__*/_jsx(DiagnosticReportButton, {
615
+ baseUrl: appContext.baseUrl,
616
+ sessionId: appContext.identificationInfo?.sessionId,
617
+ scan: {
618
+ documentType: appContext.identificationInfo?.scannedDocument?.documentType,
619
+ dataSource: appContext.identificationInfo?.scannedDocument?.dataSource,
620
+ mrzText: appContext.identificationInfo?.scannedDocument?.mrzText,
621
+ mrzFields: appContext.identificationInfo?.scannedDocument?.mrzFields
622
+ }
623
+ })
624
+ })]
603
625
  }), hasNfc && isEnabled && isScanned && !documentMRZInfo && /*#__PURE__*/_jsx(View, {
604
626
  style: styles.buttonsContainer,
605
627
  children: /*#__PURE__*/_jsx(StyledButton, {
@@ -679,6 +701,9 @@ const styles = StyleSheet.create({
679
701
  flexDirection: 'column',
680
702
  gap: 10
681
703
  },
704
+ reportButtonWrapper: {
705
+ marginTop: 4
706
+ },
682
707
  mainText: {
683
708
  fontSize: 20,
684
709
  fontWeight: 'bold',
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+
3
+ import React, { useContext } from 'react';
4
+ import { View, Text, StyleSheet } from 'react-native';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { useTheme } from "../Contexts/ThemeContext.js";
7
+ import AppContext from "../Contexts/AppContext.js";
8
+ import StyledButton from "./StyledButton.js";
9
+ import { trackError } from "../Libs/analytics.utils.js";
10
+ import { logError } from "../Libs/debug.utils.js";
11
+
12
+ /**
13
+ * Themed fallback UI for an unrecoverable render error. A functional child so it
14
+ * can use the theme + translation hooks (the boundary itself must be a class).
15
+ */
16
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
17
+ function ErrorFallback({
18
+ onRetry
19
+ }) {
20
+ const {
21
+ colors
22
+ } = useTheme();
23
+ const {
24
+ t
25
+ } = useTranslation();
26
+ return /*#__PURE__*/_jsx(View, {
27
+ style: [styles.container, {
28
+ backgroundColor: colors.background
29
+ }],
30
+ children: /*#__PURE__*/_jsxs(View, {
31
+ style: styles.content,
32
+ children: [/*#__PURE__*/_jsx(Text, {
33
+ style: [styles.title, {
34
+ color: colors.text
35
+ }],
36
+ children: t('errorBoundary.title')
37
+ }), /*#__PURE__*/_jsx(Text, {
38
+ style: [styles.message, {
39
+ color: colors.textSecondary
40
+ }],
41
+ children: t('errorBoundary.message')
42
+ }), /*#__PURE__*/_jsx(StyledButton, {
43
+ mode: "contained",
44
+ onPress: onRetry,
45
+ style: styles.button,
46
+ children: t('errorBoundary.tryAgain')
47
+ })]
48
+ })
49
+ });
50
+ }
51
+ class ErrorBoundaryInner extends React.Component {
52
+ state = {
53
+ hasError: false,
54
+ resetKey: 0
55
+ };
56
+ static getDerivedStateFromError() {
57
+ return {
58
+ hasError: true
59
+ };
60
+ }
61
+ componentDidCatch(error, info) {
62
+ logError('ErrorBoundary', 'Caught render error', error);
63
+ // Telemetry — best-effort, never let reporting throw.
64
+ trackError('SDK_RENDER_ERROR', error.message, 'error_boundary', 'critical', {
65
+ error,
66
+ component: 'error_boundary',
67
+ recoverable: true,
68
+ additionalData: {
69
+ componentStack: info.componentStack ?? ''
70
+ }
71
+ }).catch(() => {});
72
+ // Notify the host integration that the SDK hit an unrecoverable error.
73
+ this.props.onError?.(error.message);
74
+ }
75
+ handleRetry = () => {
76
+ // Bump resetKey so the children below re-mount with a fresh key — a real
77
+ // recovery, not just a re-render of the same (possibly still-broken) tree.
78
+ this.setState(prev => ({
79
+ hasError: false,
80
+ resetKey: prev.resetKey + 1
81
+ }));
82
+ };
83
+ render() {
84
+ if (this.state.hasError) {
85
+ return /*#__PURE__*/_jsx(ErrorFallback, {
86
+ onRetry: this.handleRetry
87
+ });
88
+ }
89
+ return /*#__PURE__*/_jsx(React.Fragment, {
90
+ children: this.props.children
91
+ }, this.state.resetKey);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Catches unexpected render/runtime errors anywhere in the SDK's screen tree so
97
+ * an unhandled throw shows a recoverable fallback instead of unmounting to a
98
+ * white screen / crashing the host app. The SDK was otherwise entirely
99
+ * functional with no crash protection.
100
+ *
101
+ * Placed inside ThemeProvider + AppContext.Provider so the fallback can theme
102
+ * itself and notify the host via `onError`. This thin functional wrapper reads
103
+ * `onError` from context (a hook, which the class can't use) and passes it to
104
+ * the class as a prop. "Try Again" re-mounts the subtree (the navigator returns
105
+ * to its initial route).
106
+ */
107
+ export default function ErrorBoundary({
108
+ children
109
+ }) {
110
+ const {
111
+ onError
112
+ } = useContext(AppContext);
113
+ return /*#__PURE__*/_jsx(ErrorBoundaryInner, {
114
+ onError: onError,
115
+ children: children
116
+ });
117
+ }
118
+ const styles = StyleSheet.create({
119
+ container: {
120
+ flex: 1,
121
+ justifyContent: 'center',
122
+ alignItems: 'center',
123
+ padding: 24
124
+ },
125
+ content: {
126
+ alignItems: 'center',
127
+ maxWidth: 360
128
+ },
129
+ title: {
130
+ fontSize: 22,
131
+ fontWeight: 'bold',
132
+ textAlign: 'center',
133
+ marginBottom: 12
134
+ },
135
+ message: {
136
+ fontSize: 16,
137
+ textAlign: 'center',
138
+ lineHeight: 22,
139
+ marginBottom: 28
140
+ },
141
+ button: {
142
+ minWidth: 200
143
+ }
144
+ });
@@ -14,6 +14,11 @@ export const MIN_MLI_FACE_SIZE_PERCENT = 0.01; // MLI (Multi Layer Image): min 1
14
14
 
15
15
  export const REQUIRED_CONSISTENT_MRZ_READS = 2;
16
16
  export const REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS = 3;
17
+
18
+ // After this long on a single scan step without completing it, surface the
19
+ // "Having trouble?" prompt so the user isn't stranded on a live camera with no
20
+ // timeout, retry, or help. Per-step (re-armed on each step transition).
21
+ export const HELP_PROMPT_DELAY_MS = 25000;
17
22
  export const SIGNATURE_REGEX = /s[gi]g?n[au]?t[u]?r|imz[a]s?/i;
18
23
  export const SIGNATURE_TEXT_REGEX = /signature|imza|İmza/i;
19
24
  export const MRZ_BLOCK_PATTERN = /[A-Z0-9<]{8,}.*</i;
@@ -2,7 +2,7 @@
2
2
 
3
3
  /* eslint-disable react-native/no-inline-styles */
4
4
  import React, { useEffect, useState, useRef, useCallback } from 'react';
5
- import { View, StyleSheet, Text as TextView, Platform, StatusBar, Vibration, Linking, ActivityIndicator, Animated } from 'react-native';
5
+ import { View, StyleSheet, Text as TextView, Platform, StatusBar, Vibration, Linking, ActivityIndicator, Animated, TouchableOpacity } from 'react-native';
6
6
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
7
7
  import { TrustchexCamera } from "./TrustchexCamera.js";
8
8
  import { NativeModules } from 'react-native';
@@ -16,6 +16,7 @@ import { debugLog, logError, isDebugEnabled } from "../Libs/debug.utils.js";
16
16
  import { diagnostics } from "../Libs/diagnostics.js";
17
17
  import LottieView from 'lottie-react-native';
18
18
  import StyledButton from "./StyledButton.js";
19
+ import DiagnosticReportButton from "./DiagnosticReportButton.js";
19
20
  import { SafeAreaView } from 'react-native-safe-area-context';
20
21
  import { speak, resetLastMessage } from "../Libs/tts.utils.js";
21
22
  import AppContext from "../Contexts/AppContext.js";
@@ -23,7 +24,7 @@ import { useTheme } from "../Contexts/ThemeContext.js";
23
24
  import DebugOverlay, { TestModePanel } from "./DebugOverlay.js";
24
25
  import { getStatusMessage, getFrameToScreenTransform, transformBoundsToScreen, getScanAreaBounds, angleBetweenPoints, detectDocumentType, determineDocumentTypeToSet, areMRZFieldsEqual, hasRequiredMRZFields, validateFacePosition, isPassportDocumentCode } from "./IdentityDocumentCamera.utils.js";
25
26
  import { handlePassportFlow, handleIDFrontFlow, handleIDBackFlow, getNextStepAfterHologram } from "./IdentityDocumentCamera.flows.js";
26
- import { HOLOGRAM_IMAGE_COUNT, HOLOGRAM_DETECTION_THRESHOLD, HOLOGRAM_DETECTION_RETRY_COUNT, HOLOGRAM_CAPTURE_INTERVAL, HOLOGRAM_MAX_FRAMES_WITHOUT_FACE, MIN_BRIGHTNESS_THRESHOLD, MAX_BRIGHTNESS_THRESHOLD, FACE_EDGE_MARGIN_PERCENT, MAX_CONSECUTIVE_QUALITY_FAILURES, REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS, SIGNATURE_TEXT_REGEX, MRZ_BLOCK_PATTERN, MIN_CARD_FACE_SIZE_PERCENT } from "./IdentityDocumentCamera.constants.js";
27
+ import { HOLOGRAM_IMAGE_COUNT, HOLOGRAM_DETECTION_THRESHOLD, HOLOGRAM_DETECTION_RETRY_COUNT, HOLOGRAM_CAPTURE_INTERVAL, HOLOGRAM_MAX_FRAMES_WITHOUT_FACE, MIN_BRIGHTNESS_THRESHOLD, MAX_BRIGHTNESS_THRESHOLD, FACE_EDGE_MARGIN_PERCENT, MAX_CONSECUTIVE_QUALITY_FAILURES, REQUIRED_CONSISTENT_DOCTYPE_DETECTIONS, HELP_PROMPT_DELAY_MS, SIGNATURE_TEXT_REGEX, MRZ_BLOCK_PATTERN, MIN_CARD_FACE_SIZE_PERCENT } from "./IdentityDocumentCamera.constants.js";
27
28
 
28
29
  // Re-export types for backward compatibility
29
30
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
@@ -77,6 +78,13 @@ const IdentityDocumentCamera = ({
77
78
  // cleared when a frame passes the brightness check (a fresh, glare-free frame).
78
79
  const [isGlareDetected, setIsGlareDetected] = useState(false);
79
80
  const [hasGuideShown, setHasGuideShown] = useState(false);
81
+ // "Having trouble?" escape: if a scan step doesn't complete within
82
+ // HELP_PROMPT_DELAY_MS the user is otherwise stuck on a live camera forever
83
+ // (no timeout/retry/help). We surface an unobtrusive prompt, then an opt-in
84
+ // help sheet with tips / retry / torch.
85
+ const [showHelpPrompt, setShowHelpPrompt] = useState(false);
86
+ const [helpSheetVisible, setHelpSheetVisible] = useState(false);
87
+ const stepStartedAt = useRef(Date.now());
80
88
  const [status, setStatus] = useState('SEARCHING');
81
89
  // True once the multi-frame voted MRZ consensus is STABLE + valid (the same
82
90
  // reading matched across `stabilityTarget` consecutive frames, with a clear
@@ -285,6 +293,25 @@ const IdentityDocumentCamera = ({
285
293
  }
286
294
  }, [status]);
287
295
 
296
+ // "Having trouble?" stuck-detection. The scan is frame-driven with no
297
+ // wall-clock timeout, so a document that never reads leaves the user stranded.
298
+ // Re-arm per step (keyed on nextStep) and per scan-start (hasGuideShown); show
299
+ // the prompt once the current step has been open longer than the threshold.
300
+ // Never arm on the terminal COMPLETED step.
301
+ useEffect(() => {
302
+ setShowHelpPrompt(false);
303
+ stepStartedAt.current = Date.now();
304
+ if (!hasGuideShown || nextStep === 'COMPLETED') {
305
+ return;
306
+ }
307
+ const interval = setInterval(() => {
308
+ if (Date.now() - stepStartedAt.current >= HELP_PROMPT_DELAY_MS) {
309
+ setShowHelpPrompt(true);
310
+ }
311
+ }, 1000);
312
+ return () => clearInterval(interval);
313
+ }, [nextStep, hasGuideShown]);
314
+
288
315
  // Disable face detection when scanning back side (no face expected, avoids false positives)
289
316
  useEffect(() => {
290
317
  if (nextStep === 'SCAN_ID_BACK') {
@@ -1852,9 +1879,16 @@ const IdentityDocumentCamera = ({
1852
1879
  style: [styles.topZone, {
1853
1880
  paddingTop: insets.top
1854
1881
  }],
1855
- children: [nextStep !== 'COMPLETED' && status !== 'SCANNED' && detectedDocumentType !== 'UNKNOWN' && /*#__PURE__*/_jsx(TextView, {
1882
+ children: [nextStep !== 'COMPLETED' && status !== 'SCANNED' && /*#__PURE__*/_jsx(TextView, {
1856
1883
  style: styles.stepIndicator,
1857
- children: nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ? `${t('identityDocumentCamera.frontSide')} • ${t('identityDocumentCamera.stepProgress', {
1884
+ children: nextStep === 'SCAN_ID_FRONT_OR_PASSPORT' ?
1885
+ // Show "Front side • 1/N" from the START — even before the
1886
+ // document type is recognized — so the user immediately knows
1887
+ // to present the photo side and how many steps remain. Until
1888
+ // the type is known we assume the ID-card path (the common
1889
+ // case); the total corrects to the passport count once a
1890
+ // passport is detected.
1891
+ `${t('identityDocumentCamera.frontSide')} • ${t('identityDocumentCamera.stepProgress', {
1858
1892
  current: 1,
1859
1893
  total: onlyMRZScan ? detectedDocumentType === 'PASSPORT' ? 1 : 2 : detectedDocumentType === 'PASSPORT' ? 2 : 3
1860
1894
  })}` : nextStep === 'SCAN_HOLOGRAM' ? `${t('identityDocumentCamera.hologramCheck')} • ${t('identityDocumentCamera.stepProgress', {
@@ -1864,6 +1898,12 @@ const IdentityDocumentCamera = ({
1864
1898
  current: 3,
1865
1899
  total: 3
1866
1900
  })}` : ''
1901
+ }), nextStep === 'SCAN_HOLOGRAM' && status !== 'SCANNED' && /*#__PURE__*/_jsx(TextView, {
1902
+ style: styles.hologramProgress,
1903
+ children: t('identityDocumentCamera.hologramProgress', {
1904
+ current: hologramImageCount,
1905
+ total: HOLOGRAM_IMAGE_COUNT
1906
+ })
1867
1907
  }), /*#__PURE__*/_jsx(AnimatedText, {
1868
1908
  style: [styles.topZoneText,
1869
1909
  // Priority order for coloring (later styles override earlier ones)
@@ -1935,6 +1975,68 @@ const IdentityDocumentCamera = ({
1935
1975
  elementsOutsideScanArea: elementsOutsideScanArea
1936
1976
  }), testMode && testModeData && /*#__PURE__*/_jsx(TestModePanel, {
1937
1977
  mrzText: testModeData.mrzText
1978
+ }), showHelpPrompt && !helpSheetVisible && nextStep !== 'COMPLETED' && /*#__PURE__*/_jsx(TouchableOpacity, {
1979
+ style: styles.helpPrompt,
1980
+ accessibilityRole: "button",
1981
+ onPress: () => setHelpSheetVisible(true),
1982
+ children: /*#__PURE__*/_jsx(TextView, {
1983
+ style: styles.helpPromptText,
1984
+ children: t('identityDocumentCamera.havingTrouble')
1985
+ })
1986
+ }), helpSheetVisible && /*#__PURE__*/_jsx(View, {
1987
+ style: styles.helpBackdrop,
1988
+ children: /*#__PURE__*/_jsxs(View, {
1989
+ style: styles.helpCard,
1990
+ children: [/*#__PURE__*/_jsx(TextView, {
1991
+ style: styles.helpTitle,
1992
+ children: t('identityDocumentCamera.troubleTitle')
1993
+ }), /*#__PURE__*/_jsx(TextView, {
1994
+ style: styles.helpTip,
1995
+ children: `• ${t('identityDocumentCamera.guidePoint1')}`
1996
+ }), /*#__PURE__*/_jsx(TextView, {
1997
+ style: styles.helpTip,
1998
+ children: `• ${t('identityDocumentCamera.guidePoint2')}`
1999
+ }), /*#__PURE__*/_jsx(TextView, {
2000
+ style: styles.helpTip,
2001
+ children: `• ${t('identityDocumentCamera.guidePoint3')}`
2002
+ }), isBrightnessLow && nextStep !== 'SCAN_HOLOGRAM' && /*#__PURE__*/_jsx(StyledButton, {
2003
+ mode: "outlined",
2004
+ onPress: () => {
2005
+ setIsTorchOn(!isTorchOnRef.current);
2006
+ },
2007
+ style: styles.helpButton,
2008
+ children: isTorchOn ? t('identityDocumentCamera.turnOffTorch') : t('identityDocumentCamera.turnOnTorch')
2009
+ }), /*#__PURE__*/_jsx(StyledButton, {
2010
+ mode: "contained",
2011
+ onPress: () => {
2012
+ // Full restart: send the flow back to the first step (so a
2013
+ // stuck back/hologram step truly restarts, matching the label)
2014
+ // and re-show the guide, which deactivates + resets scan state
2015
+ // via the focus effect.
2016
+ setHelpSheetVisible(false);
2017
+ setShowHelpPrompt(false);
2018
+ setNextStep('SCAN_ID_FRONT_OR_PASSPORT');
2019
+ setDetectedDocumentType('UNKNOWN');
2020
+ setHasGuideShown(false);
2021
+ },
2022
+ style: styles.helpButton,
2023
+ children: t('identityDocumentCamera.troubleRetry')
2024
+ }), /*#__PURE__*/_jsx(View, {
2025
+ style: styles.helpButton,
2026
+ children: /*#__PURE__*/_jsx(DiagnosticReportButton, {
2027
+ baseUrl: appContext.baseUrl,
2028
+ sessionId: appContext.identificationInfo?.sessionId,
2029
+ scan: {
2030
+ documentType: detectedDocumentType
2031
+ }
2032
+ })
2033
+ }), /*#__PURE__*/_jsx(StyledButton, {
2034
+ mode: "text",
2035
+ onPress: () => setHelpSheetVisible(false),
2036
+ style: styles.helpButton,
2037
+ children: t('identityDocumentCamera.troubleDismiss')
2038
+ })]
2039
+ })
1938
2040
  })]
1939
2041
  })]
1940
2042
  });
@@ -1988,6 +2090,13 @@ const styles = StyleSheet.create({
1988
2090
  fontWeight: '500',
1989
2091
  marginBottom: 4
1990
2092
  },
2093
+ hologramProgress: {
2094
+ color: '#4CAF50',
2095
+ fontSize: 14,
2096
+ textAlign: 'center',
2097
+ fontWeight: '600',
2098
+ marginBottom: 4
2099
+ },
1991
2100
  topZoneText: {
1992
2101
  color: 'white',
1993
2102
  fontSize: 20,
@@ -2023,6 +2132,56 @@ const styles = StyleSheet.create({
2023
2132
  bottom: '36%',
2024
2133
  backgroundColor: '#00000099'
2025
2134
  },
2135
+ helpPrompt: {
2136
+ position: 'absolute',
2137
+ bottom: 40,
2138
+ alignSelf: 'center',
2139
+ backgroundColor: 'rgba(0,0,0,0.65)',
2140
+ paddingVertical: 10,
2141
+ paddingHorizontal: 20,
2142
+ borderRadius: 22
2143
+ },
2144
+ helpPromptText: {
2145
+ color: 'white',
2146
+ fontSize: 15,
2147
+ fontWeight: '600'
2148
+ },
2149
+ helpBackdrop: {
2150
+ position: 'absolute',
2151
+ top: 0,
2152
+ left: 0,
2153
+ right: 0,
2154
+ bottom: 0,
2155
+ backgroundColor: 'rgba(0,0,0,0.55)',
2156
+ alignItems: 'center',
2157
+ justifyContent: 'center',
2158
+ padding: 24,
2159
+ zIndex: 10
2160
+ },
2161
+ helpCard: {
2162
+ width: '100%',
2163
+ maxWidth: 340,
2164
+ backgroundColor: 'white',
2165
+ borderRadius: 16,
2166
+ paddingVertical: 24,
2167
+ paddingHorizontal: 20
2168
+ },
2169
+ helpTitle: {
2170
+ fontSize: 18,
2171
+ fontWeight: 'bold',
2172
+ color: '#111827',
2173
+ textAlign: 'center',
2174
+ marginBottom: 16
2175
+ },
2176
+ helpTip: {
2177
+ fontSize: 15,
2178
+ color: '#374151',
2179
+ lineHeight: 22,
2180
+ marginBottom: 8
2181
+ },
2182
+ helpButton: {
2183
+ marginTop: 12
2184
+ },
2026
2185
  bottomZone: {
2027
2186
  position: 'absolute',
2028
2187
  top: '64%',