@myazahq/kyc-sdk-react-native 2.0.1 → 2.1.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 +111 -8
- package/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +14 -4
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
- package/app.plugin.js +102 -13
- package/ios/HybridMyazaEmrtd.swift +447 -0
- package/ios/HybridMyazaFaceDetector.swift +104 -2
- package/ios/HybridMyazaRectDetector.swift +103 -0
- package/ios/HybridMyazaTextRecognizer.swift +111 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
- package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
- package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
- package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
- package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
- package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
- package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
- package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
- package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
- package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
- package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
- package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
- package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
- package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
- package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
- package/package.json +16 -4
- package/src/MyazaKYC.tsx +115 -22
- package/src/capture/documentIdentity.ts +153 -0
- package/src/capture/documentSignals.ts +177 -0
- package/src/capture/documentTextGate.ts +171 -0
- package/src/capture/hints.ts +57 -0
- package/src/capture/index.ts +24 -0
- package/src/capture/rectDetector.ts +84 -0
- package/src/capture/rectGate.ts +91 -0
- package/src/capture/useAutoCapture.ts +198 -0
- package/src/components/CameraPermissionView.tsx +39 -3
- package/src/components/CameraViewfinder.tsx +226 -70
- package/src/components/ContactCodeEntry.tsx +139 -0
- package/src/components/CountryField.tsx +87 -0
- package/src/components/CountryFlag.tsx +15 -15
- package/src/components/DialCodePicker.tsx +186 -0
- package/src/components/DocumentCropper.tsx +1 -1
- package/src/components/DocumentReview.tsx +142 -0
- package/src/components/DocumentReviewSide.tsx +135 -0
- package/src/components/DocumentReviewZoom.tsx +119 -0
- package/src/components/ExpiryCountdown.tsx +52 -0
- package/src/components/FlashOverlay.tsx +156 -0
- package/src/components/Icon.tsx +67 -1
- package/src/components/KycFlow.tsx +66 -94
- package/src/components/KycSheet.tsx +148 -18
- package/src/components/MediaSourceSheet.tsx +163 -0
- package/src/components/MyazaButton.tsx +14 -2
- package/src/components/MyazaDateField.tsx +240 -0
- package/src/components/MyazaInput.tsx +73 -26
- package/src/components/MyazaSelect.tsx +283 -0
- package/src/components/MyazaWordmark.tsx +33 -0
- package/src/components/OptionRow.tsx +132 -0
- package/src/components/PhoneNumberInput.tsx +149 -0
- package/src/components/PoweredBy.tsx +89 -0
- package/src/components/ReadyPrimer.tsx +149 -0
- package/src/components/StepView.tsx +91 -0
- package/src/components/Typography.tsx +18 -3
- package/src/components/VerifiedNotice.tsx +38 -0
- package/src/components/WhatsAppIcon.tsx +29 -0
- package/src/components/WorkflowGate.tsx +128 -0
- package/src/components/brand-font.ts +113 -0
- package/src/components/flashHoleGeometry.ts +35 -0
- package/src/components/flow/FatalConfigError.tsx +56 -0
- package/src/components/fonts.ts +2 -19
- package/src/components/readyPrimerContent.ts +52 -0
- package/src/components/runtime.tsx +38 -43
- package/src/components/stepHeaderMeta.tsx +87 -0
- package/src/components/theme-provider.tsx +89 -0
- package/src/components/useWorkflowMount.ts +116 -0
- package/src/components/viewfinder/CardGuide.tsx +60 -0
- package/src/components/viewfinder/DocumentGhost.tsx +149 -0
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
- package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
- package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
- package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
- package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
- package/src/config/brand.ts +97 -0
- package/src/config/business.ts +179 -0
- package/src/config/businessSteps.ts +105 -0
- package/src/config/contact.ts +159 -0
- package/src/config/countryNames.g.ts +254 -0
- package/src/config/currencyFlags.ts +144 -0
- package/src/config/font-resolve.ts +78 -0
- package/src/config/idTypes.ts +98 -1
- package/src/config/keyPeople.ts +152 -0
- package/src/config/phone.ts +39 -0
- package/src/config/proofOfAddress.ts +75 -0
- package/src/config/questionnaire.ts +153 -0
- package/src/config/regions.ts +118 -0
- package/src/config/registrationHint.ts +87 -0
- package/src/config/stepOrder.ts +118 -0
- package/src/config/theme.ts +38 -2
- package/src/config/workflowMerge.ts +124 -0
- package/src/emrtd/bac.ts +128 -0
- package/src/emrtd/bytes.ts +127 -0
- package/src/emrtd/crypto.ts +163 -0
- package/src/emrtd/der.ts +192 -0
- package/src/emrtd/dg2.ts +111 -0
- package/src/emrtd/files.ts +215 -0
- package/src/emrtd/index.ts +30 -0
- package/src/emrtd/mrzKey.ts +54 -0
- package/src/emrtd/native.ts +79 -0
- package/src/emrtd/optionalRead.ts +42 -0
- package/src/emrtd/read.ts +224 -0
- package/src/emrtd/retry.ts +98 -0
- package/src/emrtd/secureMessaging.ts +178 -0
- package/src/emrtd/session.ts +200 -0
- package/src/emrtd/stages.ts +122 -0
- package/src/index.ts +110 -0
- package/src/lib/calendar.ts +71 -0
- package/src/lib/use-keyboard-inset.ts +34 -0
- package/src/liveness/challengeManager.ts +27 -1
- package/src/liveness/faceContinuity.ts +153 -0
- package/src/liveness/flashDetector.ts +145 -0
- package/src/liveness/flashReadyGate.ts +106 -0
- package/src/liveness/flashRunner.ts +118 -0
- package/src/liveness/integritySignals.ts +60 -0
- package/src/liveness/livenessState.ts +139 -0
- package/src/liveness/speech.ts +19 -1
- package/src/liveness/types.ts +48 -1
- package/src/liveness/useFlashPhase.ts +78 -0
- package/src/liveness/useLiveness.ts +260 -75
- package/src/liveness/visionCameraFaceDetector.ts +14 -0
- package/src/mrz/extract.ts +101 -0
- package/src/mrz/parse.ts +178 -0
- package/src/mrz/textRecognizer.ts +149 -0
- package/src/screens/ApplicantRoleStep.tsx +331 -0
- package/src/screens/BusinessDetailsStep.tsx +212 -0
- package/src/screens/BusinessDocumentSlot.tsx +167 -0
- package/src/screens/BusinessDocumentsStep.tsx +213 -0
- package/src/screens/BusinessKeyPeopleStep.tsx +151 -0
- package/src/screens/BusinessKeyPersonRow.tsx +170 -0
- package/src/screens/CompanyInfoFields.tsx +83 -0
- package/src/screens/ConsentStep.tsx +68 -68
- package/src/screens/ContactChannelChoice.tsx +133 -0
- package/src/screens/ContactCodeStep.tsx +130 -0
- package/src/screens/ContactDestinationField.tsx +70 -0
- package/src/screens/ContactFooterNote.tsx +34 -0
- package/src/screens/ContactVerificationStep.tsx +199 -0
- package/src/screens/ContactVerifiedPanel.tsx +36 -0
- package/src/screens/CountrySelectStep.tsx +210 -0
- package/src/screens/DocumentCaptureStep.tsx +190 -145
- package/src/screens/IdInputStep.tsx +19 -9
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/LivenessStep.tsx +181 -267
- package/src/screens/MrzScanView.tsx +173 -0
- package/src/screens/NfcStep.tsx +290 -0
- package/src/screens/ProofOfAddressStep.tsx +238 -0
- package/src/screens/QuestionnaireField.tsx +219 -0
- package/src/screens/QuestionnaireMoneyField.tsx +134 -0
- package/src/screens/QuestionnaireStep.tsx +76 -0
- package/src/screens/SubmittedStep.tsx +5 -0
- package/src/screens/consent/model.ts +109 -0
- package/src/screens/document/CameraPhase.tsx +150 -0
- package/src/screens/document/RequiredPill.tsx +60 -0
- package/src/screens/liveness/LightingBanner.tsx +50 -0
- package/src/screens/liveness/LivenessOutcome.tsx +109 -0
- package/src/screens/liveness/ProgressDots.tsx +67 -0
- package/src/screens/liveness/SelfiePreview.tsx +39 -0
- package/src/screens/liveness/constants.ts +31 -0
- package/src/screens/liveness/index.ts +8 -0
- package/src/screens/liveness/resolveGuidance.ts +27 -0
- package/src/screens/liveness/useSelfieUpload.ts +107 -0
- package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
- package/src/screens/nfc/NfcReadBody.tsx +63 -0
- package/src/screens/nfc/NfcReadProgress.tsx +96 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
- package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
- package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
- package/src/screens/nfc/availability.ts +73 -0
- package/src/screens/nfc/readErrorMessage.ts +93 -0
- package/src/screens/nfc/useChipPortrait.ts +58 -0
- package/src/screens/nfc/useNfcAvailability.ts +82 -0
- package/src/screens/useFlashHole.ts +105 -0
- package/src/screens/useFlashSequence.ts +96 -0
- package/src/screens/usePoaAttach.ts +121 -0
- package/src/services/api-types.ts +164 -0
- package/src/services/api-verify-types.ts +99 -0
- package/src/services/api.ts +72 -102
- package/src/services/cardCrop.ts +25 -10
- package/src/services/contactErrors.ts +43 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/documentPicker.ts +20 -0
- package/src/services/fingerprint-sources.ts +129 -0
- package/src/services/fingerprint.ts +101 -0
- package/src/services/haptics.ts +38 -0
- package/src/services/mediaCompress.ts +7 -2
- package/src/services/resolveUrl.ts +3 -2
- package/src/services/validators.ts +3 -0
- package/src/services/workflowGate.ts +138 -0
- package/src/specs/MyazaEmrtd.nitro.ts +114 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
- package/src/specs/MyazaRectDetector.nitro.ts +50 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
- package/src/store/derive.ts +225 -0
- package/src/store/kycStore.ts +250 -149
- package/src/store/state.ts +253 -0
- package/src/store/submit.ts +101 -0
- package/src/store/submitApplicant.ts +81 -0
- package/src/types/appearance.ts +113 -0
- package/src/types/business.ts +131 -0
- package/src/types/config.ts +150 -119
- package/src/types/id-types.ts +57 -0
- package/src/types/verification.ts +5 -0
- package/src/types/workflow.ts +141 -0
- package/src/utils/amountFormat.ts +73 -0
- package/src/utils/tokens.ts +4 -2
- package/src/__tests__/cardCrop.test.ts +0 -39
- package/src/__tests__/deviceMetadata.test.ts +0 -34
- package/src/__tests__/errors.test.ts +0 -34
- package/src/__tests__/flow.test.ts +0 -61
- package/src/__tests__/gestureDetector.test.ts +0 -37
- package/src/__tests__/liveness.test.ts +0 -112
- package/src/__tests__/resolveUrl.test.ts +0 -64
- package/src/__tests__/validators.test.ts +0 -38
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { Pressable, ScrollView, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
6
|
+
import { MyazaText } from '../components/Typography';
|
|
7
|
+
import { MyazaInput } from '../components/MyazaInput';
|
|
8
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
9
|
+
import { Icon } from '../components/Icon';
|
|
10
|
+
import { groupCountriesByRegion, regionCountryName } from '../config/regions';
|
|
11
|
+
import { countrySelectOptions } from '../store/derive';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Country selection for multi-region flows (`countries` has more than one).
|
|
15
|
+
//
|
|
16
|
+
// The pick sets the session's EFFECTIVE country, which every later step reads —
|
|
17
|
+
// the ID types on offer, the validators, the endpoints and chip capability all
|
|
18
|
+
// follow from it.
|
|
19
|
+
//
|
|
20
|
+
// Above a handful of countries a flat list stops being a chooser and becomes a
|
|
21
|
+
// scroll, so past SEARCH_THRESHOLD this switches to a searchable, region-grouped
|
|
22
|
+
// picker. Global Documents can offer ~200 countries.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Above this many offered countries the flat list becomes a searchable,
|
|
26
|
+
* region-grouped picker. Exported because the sheet has to know too: the
|
|
27
|
+
* picker pins its search box and therefore needs a non-scrolling body. */
|
|
28
|
+
export const COUNTRY_SEARCH_THRESHOLD = 5;
|
|
29
|
+
|
|
30
|
+
export const countrySelectMeta = {
|
|
31
|
+
title: 'Where was your ID issued?',
|
|
32
|
+
description: 'Choose the country that issued your identity document.',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function CountrySelectStep(): React.ReactElement {
|
|
36
|
+
const config = useKycConfig();
|
|
37
|
+
const store = useKycStore();
|
|
38
|
+
// Only an actual PICK counts as selected. `useEffectiveCountry` falls back to
|
|
39
|
+
// the workflow's primary country, which on this screen would render a
|
|
40
|
+
// highlighted row for a choice the user has not made yet.
|
|
41
|
+
const selected = useKyc((s) => s.selectedCountry);
|
|
42
|
+
|
|
43
|
+
// Multi-region flows carry `countries`; the KYB applicant leg does not, so
|
|
44
|
+
// it offers the org's GRANTED countries instead — see countrySelectOptions.
|
|
45
|
+
const serverConfig = useKyc((s) => s.serverConfig);
|
|
46
|
+
const options = useMemo(
|
|
47
|
+
() => countrySelectOptions({ config, serverConfig }),
|
|
48
|
+
[config, serverConfig],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const pick = (country: string): void => {
|
|
52
|
+
store.getState().setCountry(country);
|
|
53
|
+
store.getState().nextStep();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (options.length > COUNTRY_SEARCH_THRESHOLD) {
|
|
57
|
+
return <CountryRegionPicker countries={options} selected={selected} onPick={pick} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View>
|
|
62
|
+
{options.map((country) => (
|
|
63
|
+
<CountryRow
|
|
64
|
+
key={country}
|
|
65
|
+
code={country}
|
|
66
|
+
name={regionCountryName(country)}
|
|
67
|
+
selected={country === selected}
|
|
68
|
+
onPress={() => pick(country)}
|
|
69
|
+
/>
|
|
70
|
+
))}
|
|
71
|
+
</View>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function CountryRegionPicker({
|
|
76
|
+
countries,
|
|
77
|
+
selected,
|
|
78
|
+
onPick,
|
|
79
|
+
}: {
|
|
80
|
+
countries: string[];
|
|
81
|
+
selected: string | null;
|
|
82
|
+
onPick: (country: string) => void;
|
|
83
|
+
}): React.ReactElement {
|
|
84
|
+
const { colors } = useTheme();
|
|
85
|
+
const [query, setQuery] = useState('');
|
|
86
|
+
|
|
87
|
+
const groups = useMemo(() => {
|
|
88
|
+
const needle = query.trim().toLowerCase();
|
|
89
|
+
const filtered = needle
|
|
90
|
+
? countries.filter(
|
|
91
|
+
(code) =>
|
|
92
|
+
code.toLowerCase().includes(needle) ||
|
|
93
|
+
regionCountryName(code).toLowerCase().includes(needle),
|
|
94
|
+
)
|
|
95
|
+
: countries;
|
|
96
|
+
return groupCountriesByRegion(filtered);
|
|
97
|
+
}, [countries, query]);
|
|
98
|
+
|
|
99
|
+
const empty = groups.length === 0;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
// The search box stays put and the list owns its own scroll, so a
|
|
103
|
+
// ~240-country flow does not scroll the search field off the top. That
|
|
104
|
+
// relies on the SHEET rendering this step with `fillsViewport` — inside a
|
|
105
|
+
// scroll view the nested list below would take an unbounded height, outgrow
|
|
106
|
+
// the viewport, and drag the search box off the top with it.
|
|
107
|
+
<View style={{ flex: 1 }}>
|
|
108
|
+
<MyazaInput
|
|
109
|
+
value={query}
|
|
110
|
+
onChangeText={setQuery}
|
|
111
|
+
placeholder="Search countries…"
|
|
112
|
+
autoCapitalize="none"
|
|
113
|
+
prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
|
|
114
|
+
/>
|
|
115
|
+
<View style={{ height: spacing.md }} />
|
|
116
|
+
|
|
117
|
+
{empty ? (
|
|
118
|
+
<MyazaText variant="bodyMedium" style={{ textAlign: 'center', marginTop: spacing.lg }}>
|
|
119
|
+
No countries match your search.
|
|
120
|
+
</MyazaText>
|
|
121
|
+
) : (
|
|
122
|
+
// `nestedScrollEnabled` stays: Android refuses to scroll a nested
|
|
123
|
+
// same-direction list without it, and this is still nested on the
|
|
124
|
+
// flat-list path and inside any future scrolling parent.
|
|
125
|
+
<ScrollView
|
|
126
|
+
nestedScrollEnabled
|
|
127
|
+
keyboardShouldPersistTaps="handled"
|
|
128
|
+
showsVerticalScrollIndicator={false}
|
|
129
|
+
>
|
|
130
|
+
{groups.map((group) => (
|
|
131
|
+
<View key={group.region}>
|
|
132
|
+
{/* Uppercase, tracked-out section label — Flutter's region
|
|
133
|
+
header. Lowercase text at the default weight read as another
|
|
134
|
+
list row rather than a divider. */}
|
|
135
|
+
<MyazaText
|
|
136
|
+
variant="bodySmall"
|
|
137
|
+
color={colors.textSecondary}
|
|
138
|
+
style={{
|
|
139
|
+
fontWeight: '700',
|
|
140
|
+
letterSpacing: 0.6,
|
|
141
|
+
paddingHorizontal: spacing.xs,
|
|
142
|
+
paddingVertical: spacing.sm,
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{group.region.toUpperCase()}
|
|
146
|
+
</MyazaText>
|
|
147
|
+
{group.countries.map((entry) => (
|
|
148
|
+
<CountryRow
|
|
149
|
+
key={entry.code}
|
|
150
|
+
code={entry.code}
|
|
151
|
+
name={entry.name}
|
|
152
|
+
selected={entry.code === selected}
|
|
153
|
+
onPress={() => onPick(entry.code)}
|
|
154
|
+
/>
|
|
155
|
+
))}
|
|
156
|
+
</View>
|
|
157
|
+
))}
|
|
158
|
+
</ScrollView>
|
|
159
|
+
)}
|
|
160
|
+
</View>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function CountryRow({
|
|
165
|
+
code,
|
|
166
|
+
name,
|
|
167
|
+
selected,
|
|
168
|
+
onPress,
|
|
169
|
+
}: {
|
|
170
|
+
code: string;
|
|
171
|
+
name: string;
|
|
172
|
+
selected: boolean;
|
|
173
|
+
onPress: () => void;
|
|
174
|
+
}): React.ReactElement {
|
|
175
|
+
const { colors } = useTheme();
|
|
176
|
+
return (
|
|
177
|
+
<Pressable
|
|
178
|
+
onPress={onPress}
|
|
179
|
+
accessibilityRole="radio"
|
|
180
|
+
accessibilityState={{ checked: selected }}
|
|
181
|
+
accessibilityLabel={name}
|
|
182
|
+
// Matches the Flutter SDK's CountryOptionTile exactly — the two had
|
|
183
|
+
// drifted (24px flag / 12px padding here vs 32 / 16 there), which is why
|
|
184
|
+
// the same screen looked like a different component on each platform. The
|
|
185
|
+
// spacing and radius scales are identical across the SDKs, so there is
|
|
186
|
+
// nothing platform-specific to reconcile: `md` means 16 on both.
|
|
187
|
+
style={{
|
|
188
|
+
flexDirection: 'row',
|
|
189
|
+
alignItems: 'center',
|
|
190
|
+
padding: spacing.md,
|
|
191
|
+
borderRadius: radius.md,
|
|
192
|
+
borderWidth: selected ? 1.5 : 1,
|
|
193
|
+
borderColor: selected ? colors.primary : colors.border,
|
|
194
|
+
backgroundColor: selected ? colors.primary50 : colors.background,
|
|
195
|
+
marginBottom: spacing.sm,
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
<CountryFlag country={code} size={32} />
|
|
199
|
+
<View style={{ width: 12 }} />
|
|
200
|
+
<MyazaText variant="label" style={{ flex: 1, fontWeight: '500' }}>
|
|
201
|
+
{name}
|
|
202
|
+
</MyazaText>
|
|
203
|
+
<Icon
|
|
204
|
+
name="chevron-right"
|
|
205
|
+
size={18}
|
|
206
|
+
color={selected ? colors.primary : colors.textSecondary}
|
|
207
|
+
/>
|
|
208
|
+
</Pressable>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { Image, Pressable, View } from 'react-native';
|
|
2
|
+
import { Image, Platform, Pressable, View } from 'react-native';
|
|
3
3
|
import * as ImagePicker from 'expo-image-picker';
|
|
4
4
|
import { useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
|
|
5
|
+
import { initialWindowMetrics } from 'react-native-safe-area-context';
|
|
5
6
|
|
|
6
7
|
import { radius, spacing } from '../config/theme';
|
|
7
8
|
import { ID_TYPES, documentGuideAspect, getScanSides } from '../config/idTypes';
|
|
@@ -11,14 +12,18 @@ import { compressDocumentImage, compressVideo, cropCardRegion } from '../service
|
|
|
11
12
|
import { MAX_VIDEO_BYTES } from '../config/captureSettings';
|
|
12
13
|
import { KYCError } from '../types/verification';
|
|
13
14
|
import type { DocumentCapturePhase } from '../store/kycStore';
|
|
14
|
-
import { useKyc, useKycConfig, useTheme } from '../components/runtime';
|
|
15
|
+
import { useEffectiveCountry, useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
|
|
15
16
|
import { MyazaText } from '../components/Typography';
|
|
16
17
|
import { MyazaButton } from '../components/MyazaButton';
|
|
17
18
|
import { useToast } from '../components/toast';
|
|
18
19
|
import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
|
|
19
|
-
import {
|
|
20
|
-
import { CameraPermissionView, CameraUnavailableView } from '../components/CameraPermissionView';
|
|
20
|
+
import { CameraPhase } from './document/CameraPhase';
|
|
21
|
+
import { CameraPermissionView, CameraUnavailableView, CameraPermissionPrimingView } from '../components/CameraPermissionView';
|
|
22
|
+
import { ReadyPrimer } from '../components/ReadyPrimer';
|
|
23
|
+
import { READY_DOCUMENT } from '../components/readyPrimerContent';
|
|
21
24
|
import { DocumentCropper } from '../components/DocumentCropper';
|
|
25
|
+
import { DocumentReview } from '../components/DocumentReview';
|
|
26
|
+
import { scanMrzFromImage } from '../mrz/textRecognizer';
|
|
22
27
|
import { Icon } from '../components/Icon';
|
|
23
28
|
|
|
24
29
|
// Document capture — the RN mirror of the Flutter/web DocumentCaptureStep.
|
|
@@ -61,6 +66,16 @@ export function documentCaptureMeta(
|
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
/** Bound on how long Continue waits for the supplementary document video. */
|
|
70
|
+
const DOC_VIDEO_WAIT_MS = 8000;
|
|
71
|
+
|
|
72
|
+
/** Home-indicator / nav-bar clearance for the full-screen camera's controls. */
|
|
73
|
+
function cameraBottomInset(): number {
|
|
74
|
+
const sa = initialWindowMetrics?.insets;
|
|
75
|
+
if (Platform.OS === 'android') return (sa?.bottom ?? 0) + 12;
|
|
76
|
+
return sa?.bottom || 34;
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
export function DocumentCaptureStep(): React.ReactElement {
|
|
65
80
|
const { colors } = useTheme();
|
|
66
81
|
const toast = useToast();
|
|
@@ -68,7 +83,12 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
68
83
|
const selectedIdType = useKyc((s) => s.selectedIdType);
|
|
69
84
|
const api = useKyc((s) => s.api);
|
|
70
85
|
const setMediaId = useKyc((s) => s.setMediaId);
|
|
86
|
+
const setMrzScan = useKyc((s) => s.setMrzScan);
|
|
87
|
+
const mrzScan = useKyc((s) => s.mrzScan);
|
|
88
|
+
const country = useEffectiveCountry();
|
|
71
89
|
const setDocumentCapturePhase = useKyc((s) => s.setDocumentCapturePhase);
|
|
90
|
+
const setImmersiveCapture = useKyc((s) => s.setImmersiveCapture);
|
|
91
|
+
const store = useKycStore();
|
|
72
92
|
const nextStep = useKyc((s) => s.nextStep);
|
|
73
93
|
|
|
74
94
|
const allowUpload = config.allowDocumentUpload !== false;
|
|
@@ -96,13 +116,18 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
96
116
|
setDocumentCapturePhase(phase);
|
|
97
117
|
}, [phase, setDocumentCapturePhase]);
|
|
98
118
|
|
|
119
|
+
|
|
99
120
|
// ── Camera permission ──────────────────────────────────────────────────────
|
|
100
121
|
// `perm` is derived from the ASYNC requestPermission result, not synchronously
|
|
101
122
|
// from `hasPermission` — otherwise the brief window while the OS prompt is open
|
|
102
123
|
// (hasPermission still false) would read as "denied" and fire onError early.
|
|
124
|
+
// 'priming' shows the "Allow camera access" screen BEFORE the OS prompt
|
|
125
|
+
// (Stripe-style); the prompt only fires (→ 'requesting') once the user taps
|
|
126
|
+
// "Grant access".
|
|
103
127
|
const { hasPermission, requestPermission } = useCameraPermission();
|
|
104
|
-
const [perm, setPerm] = useState<'
|
|
105
|
-
|
|
128
|
+
const [perm, setPerm] = useState<'priming' | 'requesting' | 'granted' | 'denied'>(
|
|
129
|
+
hasPermission ? 'granted' : 'priming',
|
|
130
|
+
);
|
|
106
131
|
const permReportedRef = useRef(false);
|
|
107
132
|
|
|
108
133
|
// ── Camera availability ─────────────────────────────────────────────────────
|
|
@@ -123,19 +148,41 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
123
148
|
// No camera hardware at all → "Camera not available" (regardless of what the
|
|
124
149
|
// permission API says — on a camera-less sim it may even report denied).
|
|
125
150
|
const cameraUnavailable = cameraGrace && !device;
|
|
151
|
+
// Gate the camera path on the user acknowledging the primer. Shown once for
|
|
152
|
+
// the step, not per side — repeating it before the back of a card would be
|
|
153
|
+
// noise, not a warning.
|
|
154
|
+
const [ready, setReady] = useState(false);
|
|
155
|
+
const showPrimer = perm === 'priming' && !!device;
|
|
156
|
+
// Tell the shell when the full-bleed camera is on screen. Scoped tightly:
|
|
157
|
+
// only the live preview earns the whole display — the ready primer, the
|
|
158
|
+
// permission screens, the previews and the review all keep their chrome.
|
|
159
|
+
//
|
|
160
|
+
// No unmount cleanup: KycFlow already scopes the flag to this step, and a
|
|
161
|
+
// cleanup here would fire during any remount and fight the effect that had
|
|
162
|
+
// just raised it.
|
|
163
|
+
const cameraLive =
|
|
164
|
+
(phase === 'front' || phase === 'back') && ready && perm === 'granted' && !!device;
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
setImmersiveCapture(cameraLive);
|
|
167
|
+
}, [cameraLive, setImmersiveCapture]);
|
|
126
168
|
|
|
169
|
+
// Reflect an externally-granted permission.
|
|
127
170
|
useEffect(() => {
|
|
128
|
-
if (hasPermission)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
171
|
+
if (hasPermission) setPerm('granted');
|
|
172
|
+
}, [hasPermission]);
|
|
173
|
+
|
|
174
|
+
// Fire the real OS prompt only after the user taps "Grant access" (or retry).
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (perm !== 'requesting') return;
|
|
177
|
+
let cancelled = false;
|
|
134
178
|
void (async () => {
|
|
135
179
|
const granted = await requestPermission();
|
|
136
|
-
setPerm(granted ? 'granted' : 'denied');
|
|
180
|
+
if (!cancelled) setPerm(granted ? 'granted' : 'denied');
|
|
137
181
|
})();
|
|
138
|
-
|
|
182
|
+
return () => {
|
|
183
|
+
cancelled = true;
|
|
184
|
+
};
|
|
185
|
+
}, [perm, requestPermission]);
|
|
139
186
|
|
|
140
187
|
// A *genuine* permission denial requires a camera to exist but be blocked. On a
|
|
141
188
|
// camera-less sim the OS may report denied — that's "not available", not a
|
|
@@ -156,8 +203,7 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
156
203
|
}, [permissionDenied, config.onError]);
|
|
157
204
|
|
|
158
205
|
const retryPermission = useCallback(() => {
|
|
159
|
-
|
|
160
|
-
setPerm('checking');
|
|
206
|
+
setPerm('requesting');
|
|
161
207
|
}, []);
|
|
162
208
|
|
|
163
209
|
// ── Capture → compress → store for the current side ────────────────────────
|
|
@@ -167,6 +213,22 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
167
213
|
setUploadError(null);
|
|
168
214
|
try {
|
|
169
215
|
const compressed = await compressDocumentImage(rawUri);
|
|
216
|
+
// Read the MRZ off the FRONT while we have it. It is the key that
|
|
217
|
+
// unlocks the chip, and taking it from the photo the user just captured
|
|
218
|
+
// is what spares them scanning the same passport a second time.
|
|
219
|
+
// Best-effort in every sense: most documents have no MRZ, and one that
|
|
220
|
+
// does not read simply leaves the chip step to ask.
|
|
221
|
+
if (phase !== 'back') {
|
|
222
|
+
void scanMrzFromImage(compressed)
|
|
223
|
+
.then((scan) => {
|
|
224
|
+
if (scan) setMrzScan(scan);
|
|
225
|
+
})
|
|
226
|
+
// Explicit, even though the scanner already swallows its own
|
|
227
|
+
// errors: this sits inside the capture path, and the claim above
|
|
228
|
+
// that it is best-effort has to hold for anything that can reject
|
|
229
|
+
// — including a future change to the store setter.
|
|
230
|
+
.catch(() => undefined);
|
|
231
|
+
}
|
|
170
232
|
if (phase === 'back') {
|
|
171
233
|
setBackUri(compressed);
|
|
172
234
|
setPhase('review');
|
|
@@ -178,7 +240,7 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
178
240
|
setBusy(false);
|
|
179
241
|
}
|
|
180
242
|
},
|
|
181
|
-
[phase, isTwoSided],
|
|
243
|
+
[phase, isTwoSided, setMrzScan],
|
|
182
244
|
);
|
|
183
245
|
|
|
184
246
|
// Gallery upload: pick the raw photo (no native editor), then open the SDK's
|
|
@@ -187,13 +249,16 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
187
249
|
// (mirrors Flutter's cropCardRegion), then store. Gallery photos skip this —
|
|
188
250
|
// they're already cropped by the interactive cropper.
|
|
189
251
|
const captureFromCamera = useCallback(
|
|
190
|
-
async (rawUri: string, videoPath: string | null) => {
|
|
252
|
+
async (rawUri: string, videoPath: string | null, viewAr: number) => {
|
|
191
253
|
setBusy(true);
|
|
192
254
|
try {
|
|
193
255
|
// Stash the side's video (best-effort) before cropping/storing the still.
|
|
194
256
|
if (phase === 'back') backVideoRef.current = videoPath;
|
|
195
257
|
else frontVideoRef.current = videoPath;
|
|
196
|
-
|
|
258
|
+
// viewAr is the MEASURED preview box — full-screen capture shows a far
|
|
259
|
+
// taller slice than the framed 3:4 box the old constant assumed, which
|
|
260
|
+
// is why reviews came back wider than what the user framed.
|
|
261
|
+
const carded = await cropCardRegion(rawUri, guideAspect, viewAr).catch(() => rawUri);
|
|
197
262
|
await storeCapture(carded);
|
|
198
263
|
} finally {
|
|
199
264
|
setBusy(false);
|
|
@@ -214,6 +279,18 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
214
279
|
}, []);
|
|
215
280
|
|
|
216
281
|
// Best-effort document video upload — never blocks or fails the flow.
|
|
282
|
+
/**
|
|
283
|
+
* How long the flow will WAIT for a document video before moving on.
|
|
284
|
+
*
|
|
285
|
+
* The clip is supplementary evidence, but transcoding a 4K recording and
|
|
286
|
+
* pushing it over a phone hotspot is slow — and it used to be awaited with no
|
|
287
|
+
* bound, so the step sat on a spinner indefinitely after the photo had
|
|
288
|
+
* already uploaded fine. The user could not reach the chip step at all.
|
|
289
|
+
*
|
|
290
|
+
* The upload is NOT cancelled at the deadline: it keeps running in the
|
|
291
|
+
* background and still records its media id if it lands, usually well before
|
|
292
|
+
* submission. Only the WAIT is bounded.
|
|
293
|
+
*/
|
|
217
294
|
const uploadDocVideo = useCallback(
|
|
218
295
|
async (
|
|
219
296
|
videoPath: string | null,
|
|
@@ -221,15 +298,21 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
221
298
|
mediaKey: 'documentFrontVideo' | 'documentBackVideo',
|
|
222
299
|
) => {
|
|
223
300
|
if (!videoPath) return;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
301
|
+
const work = (async () => {
|
|
302
|
+
try {
|
|
303
|
+
// Transcode the raw 4K recording down to a small evidence clip first.
|
|
304
|
+
const small = await compressVideo(videoPath);
|
|
305
|
+
const id = await withRetry(() => api.upload({ uri: small, type: 'video/mp4' }, type, MAX_VIDEO_BYTES));
|
|
306
|
+
setMediaId(mediaKey, id);
|
|
307
|
+
} catch {
|
|
308
|
+
/* supplementary — verification proceeds without the document video
|
|
309
|
+
(dropped if it failed to upload or exceeded the 5MB ceiling) */
|
|
310
|
+
}
|
|
311
|
+
})();
|
|
312
|
+
await Promise.race([
|
|
313
|
+
work,
|
|
314
|
+
new Promise<void>((resolve) => setTimeout(resolve, DOC_VIDEO_WAIT_MS)),
|
|
315
|
+
]);
|
|
233
316
|
},
|
|
234
317
|
[api, setMediaId],
|
|
235
318
|
);
|
|
@@ -303,6 +386,29 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
303
386
|
</>
|
|
304
387
|
);
|
|
305
388
|
}
|
|
389
|
+
if (!ready) {
|
|
390
|
+
// "Here's what happens next", BEFORE the OS prompt — and shown even when
|
|
391
|
+
// permission is already granted, since it is about what the step asks of
|
|
392
|
+
// the user rather than about access.
|
|
393
|
+
return (
|
|
394
|
+
<>
|
|
395
|
+
{cropper}
|
|
396
|
+
<ReadyPrimer content={READY_DOCUMENT} onReady={() => setReady(true)} />
|
|
397
|
+
</>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (showPrimer) {
|
|
401
|
+
// Primer before the OS prompt — camera not started yet.
|
|
402
|
+
return (
|
|
403
|
+
<>
|
|
404
|
+
{cropper}
|
|
405
|
+
<CameraPermissionPrimingView
|
|
406
|
+
message="When prompted, allow camera access to photograph your document."
|
|
407
|
+
onGrant={() => setPerm('requesting')}
|
|
408
|
+
/>
|
|
409
|
+
</>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
306
412
|
if (permissionDenied) {
|
|
307
413
|
// A real camera exists but the OS blocked access.
|
|
308
414
|
return (
|
|
@@ -313,57 +419,36 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
313
419
|
);
|
|
314
420
|
}
|
|
315
421
|
const isBack = phase === 'back';
|
|
422
|
+
|
|
316
423
|
return (
|
|
317
|
-
|
|
424
|
+
<>
|
|
318
425
|
{cropper}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
sideBadge={isTwoSided ? (isBack ? 'Back Side' : 'Front Side') : undefined}
|
|
323
|
-
stepLabel={isTwoSided ? (isBack ? 'Step 2 of 2' : 'Step 1 of 2') : undefined}
|
|
324
|
-
/>
|
|
325
|
-
{isBack ? (
|
|
326
|
-
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.xs }}>
|
|
327
|
-
<Icon name="credit-card" size={14} color={colors.primary} />
|
|
328
|
-
<View style={{ width: 4 }} />
|
|
329
|
-
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
|
|
330
|
-
Flip the card over and scan the other side
|
|
331
|
-
</MyazaText>
|
|
332
|
-
</View>
|
|
333
|
-
) : null}
|
|
334
|
-
<View style={{ height: spacing.md }} />
|
|
335
|
-
<CameraViewfinder
|
|
426
|
+
<CameraPhase
|
|
427
|
+
isBack={isBack}
|
|
428
|
+
cameraLive={cameraLive}
|
|
336
429
|
active={perm === 'granted'}
|
|
337
|
-
side={isBack ? 'back' : 'front'}
|
|
338
430
|
documentLabel={documentLabel}
|
|
339
431
|
guideAspect={guideAspect}
|
|
340
|
-
|
|
432
|
+
isTwoSided={isTwoSided}
|
|
341
433
|
busy={busy}
|
|
434
|
+
allowUpload={allowUpload}
|
|
435
|
+
country={country}
|
|
436
|
+
idType={selectedIdType ?? undefined}
|
|
437
|
+
mrzAlreadyCaptured={!!mrzScan}
|
|
438
|
+
onMrz={setMrzScan}
|
|
439
|
+
onCapture={(uri, videoPath, viewAr) => void captureFromCamera(uri, videoPath, viewAr)}
|
|
440
|
+
onUpload={pickFromGallery}
|
|
441
|
+
onBack={() => store.getState().previousStep()}
|
|
442
|
+
// The camera is a TRUE full-screen modal now, so it runs under the
|
|
443
|
+
// home indicator and has to clear the real inset — the smaller value
|
|
444
|
+
// that suited the inset sheet leaves the shutter under the gesture bar.
|
|
445
|
+
bottomInset={cameraBottomInset()}
|
|
342
446
|
/>
|
|
343
|
-
|
|
344
|
-
<>
|
|
345
|
-
<View style={{ height: spacing.md }} />
|
|
346
|
-
<MyazaText variant="bodySmall" style={{ textAlign: 'center' }}>
|
|
347
|
-
Tap the button to capture manually
|
|
348
|
-
</MyazaText>
|
|
349
|
-
{allowUpload ? (
|
|
350
|
-
<Pressable onPress={pickFromGallery} style={{ marginTop: spacing.sm }}>
|
|
351
|
-
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
|
|
352
|
-
<MyazaText variant="bodySmall">Having trouble? </MyazaText>
|
|
353
|
-
<Icon name="upload" size={14} color={colors.primary} />
|
|
354
|
-
<View style={{ width: 4 }} />
|
|
355
|
-
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '700' }}>
|
|
356
|
-
Upload a photo instead
|
|
357
|
-
</MyazaText>
|
|
358
|
-
</View>
|
|
359
|
-
</Pressable>
|
|
360
|
-
) : null}
|
|
361
|
-
</>
|
|
362
|
-
) : null}
|
|
363
|
-
</View>
|
|
447
|
+
</>
|
|
364
448
|
);
|
|
365
449
|
}
|
|
366
450
|
|
|
451
|
+
|
|
367
452
|
// ── Front preview (two-sided) ──────────────────────────────────────────────
|
|
368
453
|
if (phase === 'front-preview') {
|
|
369
454
|
return (
|
|
@@ -383,84 +468,44 @@ export function DocumentCaptureStep(): React.ReactElement {
|
|
|
383
468
|
|
|
384
469
|
// ── Review ─────────────────────────────────────────────────────────────────
|
|
385
470
|
return (
|
|
386
|
-
<
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
{
|
|
471
|
+
<DocumentReview
|
|
472
|
+
frontUri={frontUri!}
|
|
473
|
+
backUri={isTwoSided ? backUri : null}
|
|
474
|
+
aspect={guideAspect}
|
|
475
|
+
isBusy={uploading}
|
|
476
|
+
busyOverlay={
|
|
477
|
+
// Standard upload loader — a dark scrim + the pulse-ring loader, drawn
|
|
478
|
+
// INSIDE the preview frame, mirroring the web/Flutter SDKs (and the
|
|
479
|
+
// liveness selfie review).
|
|
480
|
+
<View
|
|
481
|
+
style={{
|
|
482
|
+
flex: 1,
|
|
483
|
+
alignItems: 'center',
|
|
484
|
+
justifyContent: 'center',
|
|
485
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
486
|
+
}}
|
|
487
|
+
>
|
|
488
|
+
<MyazaPulseLoader size={48} />
|
|
489
|
+
</View>
|
|
490
|
+
}
|
|
491
|
+
onRetakeFront={() => retake('front')}
|
|
492
|
+
onRetakeBack={() => retake('back')}
|
|
493
|
+
footer={
|
|
390
494
|
<>
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
</>
|
|
395
|
-
) : null}
|
|
396
|
-
|
|
397
|
-
{retryInfo && uploading ? (
|
|
398
|
-
<MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
|
|
399
|
-
{`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
|
|
400
|
-
</MyazaText>
|
|
401
|
-
) : null}
|
|
402
|
-
|
|
403
|
-
<View style={{ height: spacing.md }} />
|
|
404
|
-
{uploadError ? (
|
|
405
|
-
// The error message is shown as a top toast; keep a retry action here.
|
|
406
|
-
<MyazaButton label="Try Again" onPress={handleContinue} loading={uploading} />
|
|
407
|
-
) : (
|
|
408
|
-
<MyazaButton label="Continue" onPress={handleContinue} loading={uploading} />
|
|
409
|
-
)}
|
|
410
|
-
</View>
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// "Required: {label}" pill with optional side badge + step label — mirrors the
|
|
415
|
-
// Flutter SDK's _RequiredPill on the capture screen.
|
|
416
|
-
function RequiredPill({
|
|
417
|
-
documentLabel,
|
|
418
|
-
sideBadge,
|
|
419
|
-
stepLabel,
|
|
420
|
-
}: {
|
|
421
|
-
documentLabel: string;
|
|
422
|
-
sideBadge?: string;
|
|
423
|
-
stepLabel?: string;
|
|
424
|
-
}): React.ReactElement {
|
|
425
|
-
const { colors } = useTheme();
|
|
426
|
-
return (
|
|
427
|
-
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
428
|
-
<View
|
|
429
|
-
style={{
|
|
430
|
-
flex: 1,
|
|
431
|
-
flexDirection: 'row',
|
|
432
|
-
alignItems: 'center',
|
|
433
|
-
flexWrap: 'wrap',
|
|
434
|
-
borderWidth: 1,
|
|
435
|
-
borderColor: `${colors.primary}33`,
|
|
436
|
-
backgroundColor: `${colors.primary}0D`,
|
|
437
|
-
borderRadius: radius.md,
|
|
438
|
-
paddingHorizontal: spacing.sm + 4,
|
|
439
|
-
paddingVertical: spacing.sm,
|
|
440
|
-
}}
|
|
441
|
-
>
|
|
442
|
-
<Icon name="credit-card" size={16} color={colors.primary} />
|
|
443
|
-
<View style={{ width: 8 }} />
|
|
444
|
-
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
445
|
-
Required:{' '}
|
|
446
|
-
</MyazaText>
|
|
447
|
-
<MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
|
|
448
|
-
{documentLabel}
|
|
449
|
-
</MyazaText>
|
|
450
|
-
{sideBadge ? (
|
|
451
|
-
<View style={{ backgroundColor: `${colors.primary}26`, borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 2, marginLeft: 8 }}>
|
|
452
|
-
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600', fontSize: 11 }}>
|
|
453
|
-
{sideBadge}
|
|
495
|
+
{retryInfo && uploading ? (
|
|
496
|
+
<MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginBottom: spacing.sm }}>
|
|
497
|
+
{`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
|
|
454
498
|
</MyazaText>
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
499
|
+
) : null}
|
|
500
|
+
{uploadError ? (
|
|
501
|
+
// The error message is shown as a top toast; keep a retry action here.
|
|
502
|
+
<MyazaButton label="Try Again" onPress={handleContinue} loading={uploading} />
|
|
503
|
+
) : (
|
|
504
|
+
<MyazaButton label="Continue" onPress={handleContinue} loading={uploading} />
|
|
505
|
+
)}
|
|
506
|
+
</>
|
|
507
|
+
}
|
|
508
|
+
/>
|
|
464
509
|
);
|
|
465
510
|
}
|
|
466
511
|
|