@myazahq/kyc-sdk-react-native 2.0.2 → 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/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 +96 -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 +156 -132
- package/src/screens/IdInputStep.tsx +13 -5
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/LivenessStep.tsx +155 -247
- 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 +2 -2
- 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 -124
- 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,186 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
FlatList,
|
|
4
|
+
Keyboard,
|
|
5
|
+
Modal,
|
|
6
|
+
Platform,
|
|
7
|
+
Pressable,
|
|
8
|
+
useWindowDimensions,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
|
|
12
|
+
import { radius, spacing } from '../config/theme';
|
|
13
|
+
import { useTheme } from './runtime';
|
|
14
|
+
import { MyazaText } from './Typography';
|
|
15
|
+
import { MyazaInput } from './MyazaInput';
|
|
16
|
+
import { Icon } from './Icon';
|
|
17
|
+
import { CountryFlag } from './CountryFlag';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// THE country sheet — the phone field's dial-code picker, generalised.
|
|
21
|
+
//
|
|
22
|
+
// A dedicated searchable sheet rather than MyazaSelect: that control renders
|
|
23
|
+
// every option in a ScrollView, which is fine for four document kinds and
|
|
24
|
+
// unusable for ~240 countries. A FlatList keeps the list smooth on a low-end
|
|
25
|
+
// Android device. `dialCode` is optional: with it this is the phone picker,
|
|
26
|
+
// without it a plain country picker (the key-people "where their ID was
|
|
27
|
+
// issued" field) — SAME sheet, SAME search, so the two feel identical.
|
|
28
|
+
//
|
|
29
|
+
// Laid out to match Flutter's showDialCodePicker row for row: a MyazaInput with
|
|
30
|
+
// a search prefix on top, then flag / full country name / dial code, with the
|
|
31
|
+
// selected row tinted rather than ticked.
|
|
32
|
+
//
|
|
33
|
+
// The sheet is sized against the space left ABOVE the keyboard, like Flutter's.
|
|
34
|
+
// The search field autofocuses, so measuring against the full screen would put
|
|
35
|
+
// every result underneath the keys the moment the user started typing.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export interface DialCodeOption {
|
|
39
|
+
code: string;
|
|
40
|
+
name: string;
|
|
41
|
+
/** Absent ⇒ a plain country picker: no trailing code, name/ISO search only. */
|
|
42
|
+
dialCode?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function DialCodePicker({
|
|
46
|
+
visible,
|
|
47
|
+
options,
|
|
48
|
+
selected,
|
|
49
|
+
onPick,
|
|
50
|
+
onClose,
|
|
51
|
+
searchPlaceholder = 'Search country or code',
|
|
52
|
+
}: {
|
|
53
|
+
visible: boolean;
|
|
54
|
+
options: DialCodeOption[];
|
|
55
|
+
selected: string;
|
|
56
|
+
onPick: (code: string) => void;
|
|
57
|
+
onClose: () => void;
|
|
58
|
+
searchPlaceholder?: string;
|
|
59
|
+
}): React.ReactElement {
|
|
60
|
+
const { colors } = useTheme();
|
|
61
|
+
const { height: screenHeight } = useWindowDimensions();
|
|
62
|
+
const [query, setQuery] = useState('');
|
|
63
|
+
const [keyboard, setKeyboard] = useState(0);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
// iOS reports 'will' events early enough to resize before the keys land;
|
|
67
|
+
// Android only emits 'did'.
|
|
68
|
+
const show = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
69
|
+
const hide = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
70
|
+
const shown = Keyboard.addListener(show, (e) => setKeyboard(e.endCoordinates.height));
|
|
71
|
+
const hidden = Keyboard.addListener(hide, () => setKeyboard(0));
|
|
72
|
+
return () => {
|
|
73
|
+
shown.remove();
|
|
74
|
+
hidden.remove();
|
|
75
|
+
};
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const available = screenHeight - keyboard;
|
|
79
|
+
const maxHeight = Math.min(Math.max(available * 0.85, 240), screenHeight * 0.6);
|
|
80
|
+
|
|
81
|
+
const filtered = useMemo(() => {
|
|
82
|
+
const q = query.trim().toLowerCase();
|
|
83
|
+
if (!q) return options;
|
|
84
|
+
// Match the name, the ISO code, or the dial code with or without its '+',
|
|
85
|
+
// because people search for "+234", "234" and "Nigeria" in equal measure.
|
|
86
|
+
const bare = q.replace(/^\+/, '');
|
|
87
|
+
return options.filter(
|
|
88
|
+
(o) =>
|
|
89
|
+
o.name.toLowerCase().includes(q) ||
|
|
90
|
+
o.code.toLowerCase() === q ||
|
|
91
|
+
(o.dialCode != null && o.dialCode.replace(/^\+/, '').startsWith(bare)),
|
|
92
|
+
);
|
|
93
|
+
}, [options, query]);
|
|
94
|
+
|
|
95
|
+
const close = (): void => {
|
|
96
|
+
setQuery('');
|
|
97
|
+
onClose();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Modal visible={visible} animationType="slide" transparent onRequestClose={close}>
|
|
102
|
+
<Pressable style={{ flex: 1, backgroundColor: '#00000066' }} onPress={close} />
|
|
103
|
+
<View
|
|
104
|
+
style={{
|
|
105
|
+
maxHeight,
|
|
106
|
+
marginBottom: keyboard,
|
|
107
|
+
backgroundColor: colors.background,
|
|
108
|
+
borderTopLeftRadius: radius.lg,
|
|
109
|
+
borderTopRightRadius: radius.lg,
|
|
110
|
+
paddingTop: spacing.md,
|
|
111
|
+
paddingBottom: spacing.sm,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}>
|
|
115
|
+
<MyazaInput
|
|
116
|
+
value={query}
|
|
117
|
+
onChangeText={setQuery}
|
|
118
|
+
placeholder={searchPlaceholder}
|
|
119
|
+
autoFocus
|
|
120
|
+
prefix={<Icon name="search" size={18} color={colors.textSecondary} />}
|
|
121
|
+
/>
|
|
122
|
+
</View>
|
|
123
|
+
|
|
124
|
+
<FlatList
|
|
125
|
+
data={filtered}
|
|
126
|
+
keyExtractor={(o) => o.code}
|
|
127
|
+
keyboardShouldPersistTaps="handled"
|
|
128
|
+
ListEmptyComponent={
|
|
129
|
+
<MyazaText variant="bodyMedium" style={{ padding: spacing.lg, textAlign: 'center' }}>
|
|
130
|
+
No countries match your search.
|
|
131
|
+
</MyazaText>
|
|
132
|
+
}
|
|
133
|
+
renderItem={({ item }) => (
|
|
134
|
+
<CountryRow
|
|
135
|
+
option={item}
|
|
136
|
+
isSelected={item.code === selected}
|
|
137
|
+
onPress={() => {
|
|
138
|
+
setQuery('');
|
|
139
|
+
onPick(item.code);
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
/>
|
|
144
|
+
</View>
|
|
145
|
+
</Modal>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function CountryRow({
|
|
150
|
+
option,
|
|
151
|
+
isSelected,
|
|
152
|
+
onPress,
|
|
153
|
+
}: {
|
|
154
|
+
option: DialCodeOption;
|
|
155
|
+
isSelected: boolean;
|
|
156
|
+
onPress: () => void;
|
|
157
|
+
}): React.ReactElement {
|
|
158
|
+
const { colors } = useTheme();
|
|
159
|
+
return (
|
|
160
|
+
<Pressable
|
|
161
|
+
onPress={onPress}
|
|
162
|
+
accessibilityRole="radio"
|
|
163
|
+
accessibilityState={{ checked: isSelected }}
|
|
164
|
+
style={{
|
|
165
|
+
flexDirection: 'row',
|
|
166
|
+
alignItems: 'center',
|
|
167
|
+
paddingHorizontal: spacing.md,
|
|
168
|
+
paddingVertical: spacing.sm,
|
|
169
|
+
backgroundColor: isSelected ? colors.primary50 : 'transparent',
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<CountryFlag country={option.code} size={24} />
|
|
173
|
+
<View style={{ width: spacing.md }} />
|
|
174
|
+
{/* No line clamp: "Bosnia & Herzegovina" and the like must read in full,
|
|
175
|
+
which is what Flutter's Expanded(Text) gives. */}
|
|
176
|
+
<MyazaText variant="bodyMedium" style={{ flex: 1 }}>
|
|
177
|
+
{option.name}
|
|
178
|
+
</MyazaText>
|
|
179
|
+
{option.dialCode != null ? (
|
|
180
|
+
<MyazaText variant="label" color={colors.textSecondary}>
|
|
181
|
+
{option.dialCode}
|
|
182
|
+
</MyazaText>
|
|
183
|
+
) : null}
|
|
184
|
+
</Pressable>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -279,7 +279,7 @@ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperPro
|
|
|
279
279
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', height: 48 }}>
|
|
280
280
|
<ActivityIndicator color="#FFFFFF" />
|
|
281
281
|
<View style={{ width: 10 }} />
|
|
282
|
-
<MyazaText variant="bodyMedium" color="rgba(255,255,255,0.8)">
|
|
282
|
+
<MyazaText variant="bodyMedium" color="rgba(255,255,255,0.8)" style={{ flexShrink: 1 }}>
|
|
283
283
|
Processing…
|
|
284
284
|
</MyazaText>
|
|
285
285
|
</View>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from './runtime';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
import { MyazaText } from './Typography';
|
|
7
|
+
import { DocumentReviewThumb, type ReviewSide } from './DocumentReviewSide';
|
|
8
|
+
import { DocumentReviewZoom } from './DocumentReviewZoom';
|
|
9
|
+
import { radius, spacing } from '../config/theme';
|
|
10
|
+
|
|
11
|
+
// ─── Document review ──────────────────────────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// What the user sees once their document is captured.
|
|
14
|
+
//
|
|
15
|
+
// The two-sided version used to stack the sides full-width with a retake button
|
|
16
|
+
// under each, which on a phone pushed the back image AND the Continue button
|
|
17
|
+
// below the fold — people reported not realising there was anything left to do.
|
|
18
|
+
// A review screen whose primary action you have to discover by scrolling is a
|
|
19
|
+
// dead end, so the layout is:
|
|
20
|
+
//
|
|
21
|
+
// • both sides SIDE BY SIDE at every size, so "both captured" reads at a
|
|
22
|
+
// glance instead of by scrolling;
|
|
23
|
+
// • retake as a labelled button under each thumbnail, which removes the two
|
|
24
|
+
// full-width buttons that cost the most vertical space;
|
|
25
|
+
// • the action bar at the BOTTOM of the viewport, so Continue is visible
|
|
26
|
+
// however tall the content gets;
|
|
27
|
+
// • tap a thumbnail to enlarge it — side by side means smaller, and checking
|
|
28
|
+
// the capture is legible is the entire point of this screen.
|
|
29
|
+
//
|
|
30
|
+
// Mirrors the web SDK's DocumentReview and Flutter's document_review.dart so
|
|
31
|
+
// all three behave the same.
|
|
32
|
+
//
|
|
33
|
+
// Unlike Flutter, the step here sits inside the sheet's own ScrollView (which
|
|
34
|
+
// already carries the bottom safe-area inset), so the footer is pushed down by
|
|
35
|
+
// a flexible spacer rather than pinned with a hand-computed inset.
|
|
36
|
+
|
|
37
|
+
export function DocumentReview({
|
|
38
|
+
frontUri,
|
|
39
|
+
backUri,
|
|
40
|
+
aspect,
|
|
41
|
+
isBusy,
|
|
42
|
+
busyOverlay,
|
|
43
|
+
footer,
|
|
44
|
+
onRetakeFront,
|
|
45
|
+
onRetakeBack,
|
|
46
|
+
}: {
|
|
47
|
+
frontUri: string;
|
|
48
|
+
backUri?: string | null;
|
|
49
|
+
/** Document aspect, so a half-width thumbnail still shows the whole card. */
|
|
50
|
+
aspect: number;
|
|
51
|
+
/** Uploading — dims the thumbnails and disables retake. */
|
|
52
|
+
isBusy: boolean;
|
|
53
|
+
/** Painted over each thumbnail while busy (the upload loader). */
|
|
54
|
+
busyOverlay?: React.ReactNode;
|
|
55
|
+
/** Errors, retry notices and the Continue button — below the images. */
|
|
56
|
+
footer: React.ReactNode;
|
|
57
|
+
onRetakeFront?: () => void;
|
|
58
|
+
onRetakeBack?: () => void;
|
|
59
|
+
}): React.ReactElement {
|
|
60
|
+
const { colors } = useTheme();
|
|
61
|
+
const [zoomed, setZoomed] = useState<ReviewSide | null>(null);
|
|
62
|
+
|
|
63
|
+
const sides: ReviewSide[] = [
|
|
64
|
+
{ id: 'front', label: 'Front', uri: frontUri, onRetake: onRetakeFront },
|
|
65
|
+
...(backUri ? [{ id: 'back' as const, label: 'Back', uri: backUri, onRetake: onRetakeBack }] : []),
|
|
66
|
+
];
|
|
67
|
+
const twoSided = sides.length > 1;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<View style={{ flex: 1 }}>
|
|
71
|
+
{/* Says the capture is COMPLETE — the thing users were scrolling to find
|
|
72
|
+
out. */}
|
|
73
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
|
|
74
|
+
<View
|
|
75
|
+
style={{
|
|
76
|
+
width: 20,
|
|
77
|
+
height: 20,
|
|
78
|
+
borderRadius: radius.full,
|
|
79
|
+
backgroundColor: `${colors.success}26`,
|
|
80
|
+
alignItems: 'center',
|
|
81
|
+
justifyContent: 'center',
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<Icon name="check" size={13} color={colors.success} />
|
|
85
|
+
</View>
|
|
86
|
+
<View style={{ width: spacing.xs }} />
|
|
87
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
|
|
88
|
+
{twoSided ? 'Both sides captured' : 'Photo captured'}
|
|
89
|
+
</MyazaText>
|
|
90
|
+
</View>
|
|
91
|
+
|
|
92
|
+
<View style={{ height: spacing.md }} />
|
|
93
|
+
|
|
94
|
+
{/* Top-aligned: the images are what the user checks first, so they sit
|
|
95
|
+
where the eye lands rather than floating in the middle of the screen. */}
|
|
96
|
+
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
|
|
97
|
+
{sides.map((side, i) => (
|
|
98
|
+
<React.Fragment key={side.id}>
|
|
99
|
+
{i > 0 ? <View style={{ width: spacing.sm }} /> : null}
|
|
100
|
+
<View style={{ flex: 1 }}>
|
|
101
|
+
<DocumentReviewThumb
|
|
102
|
+
side={side}
|
|
103
|
+
aspect={aspect}
|
|
104
|
+
isBusy={isBusy}
|
|
105
|
+
// Gated on isBusy HERE, not on the prop's existence. The tile
|
|
106
|
+
// rendered it whenever it was provided — and the caller always
|
|
107
|
+
// provides it — so the loader sat over the preview forever and
|
|
108
|
+
// the screen read as permanently stuck.
|
|
109
|
+
busyOverlay={isBusy ? busyOverlay : null}
|
|
110
|
+
onZoom={() => setZoomed(side)}
|
|
111
|
+
/>
|
|
112
|
+
</View>
|
|
113
|
+
</React.Fragment>
|
|
114
|
+
))}
|
|
115
|
+
</View>
|
|
116
|
+
|
|
117
|
+
<View style={{ height: spacing.xs }} />
|
|
118
|
+
<MyazaText variant="bodySmall" color={colors.textMuted} style={{ textAlign: 'center' }}>
|
|
119
|
+
Tap a photo to see it larger.
|
|
120
|
+
</MyazaText>
|
|
121
|
+
|
|
122
|
+
{/* Pushes the action bar to the bottom of the viewport so Continue can
|
|
123
|
+
never end up below the fold. */}
|
|
124
|
+
<View style={{ flex: 1, minHeight: spacing.md }} />
|
|
125
|
+
|
|
126
|
+
<View testID="kyc.review.footer">{footer}</View>
|
|
127
|
+
|
|
128
|
+
{zoomed ? (
|
|
129
|
+
<DocumentReviewZoom
|
|
130
|
+
side={zoomed}
|
|
131
|
+
isBusy={isBusy}
|
|
132
|
+
onClose={() => setZoomed(null)}
|
|
133
|
+
onRetake={() => {
|
|
134
|
+
const retake = zoomed.onRetake;
|
|
135
|
+
setZoomed(null);
|
|
136
|
+
retake?.();
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
) : null}
|
|
140
|
+
</View>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Image, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from './runtime';
|
|
5
|
+
import { Icon } from './Icon';
|
|
6
|
+
import { MyazaText } from './Typography';
|
|
7
|
+
import { radius, spacing } from '../config/theme';
|
|
8
|
+
|
|
9
|
+
// ─── One captured side, and its enlarged view ─────────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// Split from DocumentReview.tsx so each file stays readable. Mirrors the
|
|
12
|
+
// Flutter SDK's document_review_side.dart.
|
|
13
|
+
//
|
|
14
|
+
// The photo carries only LABELS — which side it is, and that tapping enlarges
|
|
15
|
+
// it. The actions are named buttons underneath. An icon-only control floating
|
|
16
|
+
// on an image reads as decoration on a phone, where there is no hover to
|
|
17
|
+
// reveal what it does.
|
|
18
|
+
|
|
19
|
+
export interface ReviewSide {
|
|
20
|
+
/** 'front' | 'back' — also the testID suffix. */
|
|
21
|
+
id: 'front' | 'back';
|
|
22
|
+
label: string;
|
|
23
|
+
uri: string;
|
|
24
|
+
onRetake?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DocumentReviewThumb({
|
|
28
|
+
side,
|
|
29
|
+
aspect,
|
|
30
|
+
isBusy,
|
|
31
|
+
busyOverlay,
|
|
32
|
+
onZoom,
|
|
33
|
+
}: {
|
|
34
|
+
side: ReviewSide;
|
|
35
|
+
aspect: number;
|
|
36
|
+
isBusy: boolean;
|
|
37
|
+
busyOverlay?: React.ReactNode;
|
|
38
|
+
onZoom: () => void;
|
|
39
|
+
}): React.ReactElement {
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
return (
|
|
42
|
+
<View>
|
|
43
|
+
<View
|
|
44
|
+
testID={`kyc.review.${side.id}`}
|
|
45
|
+
style={{ borderRadius: radius.md, overflow: 'hidden' }}
|
|
46
|
+
>
|
|
47
|
+
<Pressable onPress={onZoom} accessibilityRole="imagebutton" accessibilityLabel={`${side.label} photo — tap to enlarge`}>
|
|
48
|
+
{/* The whole card stays visible at half width — a cropped thumbnail
|
|
49
|
+
cannot be checked for legibility, which is the only reason this
|
|
50
|
+
screen exists. */}
|
|
51
|
+
<Image
|
|
52
|
+
source={{ uri: side.uri }}
|
|
53
|
+
style={{ width: '100%', aspectRatio: aspect, backgroundColor: colors.backgroundSecondary }}
|
|
54
|
+
resizeMode="contain"
|
|
55
|
+
/>
|
|
56
|
+
</Pressable>
|
|
57
|
+
{/* Inset past the corner CURVE, not just past the edge: at this radius
|
|
58
|
+
a 6px offset puts the badge on the round, where it reads as
|
|
59
|
+
clipped. */}
|
|
60
|
+
<View style={{ position: 'absolute', left: spacing.md, top: spacing.md }}>
|
|
61
|
+
<Chip>
|
|
62
|
+
<MyazaText variant="bodySmall" color="#FFFFFF" style={CHIP_TEXT}>
|
|
63
|
+
{side.label}
|
|
64
|
+
</MyazaText>
|
|
65
|
+
</Chip>
|
|
66
|
+
</View>
|
|
67
|
+
{/* Enlarging is a tap on the image, which nothing announces — so the
|
|
68
|
+
image says so itself. */}
|
|
69
|
+
<View style={{ position: 'absolute', right: spacing.md, bottom: spacing.md }} pointerEvents="none">
|
|
70
|
+
<Chip icon>
|
|
71
|
+
<Icon name="maximize" size={18} color="#FFFFFF" />
|
|
72
|
+
</Chip>
|
|
73
|
+
</View>
|
|
74
|
+
{busyOverlay ? (
|
|
75
|
+
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>{busyOverlay}</View>
|
|
76
|
+
) : null}
|
|
77
|
+
</View>
|
|
78
|
+
|
|
79
|
+
{/* Retake as a LABELLED control, not an icon on the photo.
|
|
80
|
+
The web version hides it under the image and reveals it on hover; a
|
|
81
|
+
phone has no hover, so that became a small unlabelled circle people
|
|
82
|
+
did not read as a button. Naming it costs a few pixels and is the
|
|
83
|
+
difference between a discoverable action and a decoration. */}
|
|
84
|
+
<Pressable
|
|
85
|
+
testID={`kyc.review.retake.${side.id}`}
|
|
86
|
+
onPress={isBusy ? undefined : side.onRetake}
|
|
87
|
+
disabled={isBusy}
|
|
88
|
+
accessibilityRole="button"
|
|
89
|
+
accessibilityLabel={`Retake ${side.label.toLowerCase()}`}
|
|
90
|
+
style={{
|
|
91
|
+
flexDirection: 'row',
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
justifyContent: 'center',
|
|
94
|
+
minHeight: 36, // stays a comfortable tap target
|
|
95
|
+
paddingVertical: 4,
|
|
96
|
+
marginTop: spacing.xs,
|
|
97
|
+
opacity: isBusy ? 0.4 : 1,
|
|
98
|
+
}}>
|
|
99
|
+
<Icon name="refresh" size={14} color={colors.primary} />
|
|
100
|
+
<View style={{ width: 6 }} />
|
|
101
|
+
<MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
|
|
102
|
+
Retake
|
|
103
|
+
</MyazaText>
|
|
104
|
+
</Pressable>
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const CHIP_TEXT = { fontSize: 11, fontWeight: '600' as const };
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A small dark label over the photo. Non-interactive by design — the actions on
|
|
113
|
+
* this screen are named buttons, not icons floating on an image.
|
|
114
|
+
*/
|
|
115
|
+
export function Chip({
|
|
116
|
+
children,
|
|
117
|
+
/** Icon chips are square and need room around the glyph, not text padding. */
|
|
118
|
+
icon = false,
|
|
119
|
+
}: {
|
|
120
|
+
children: React.ReactNode;
|
|
121
|
+
icon?: boolean;
|
|
122
|
+
}): React.ReactElement {
|
|
123
|
+
return (
|
|
124
|
+
<View
|
|
125
|
+
style={{
|
|
126
|
+
paddingHorizontal: icon ? 8 : 10,
|
|
127
|
+
paddingVertical: icon ? 8 : 5,
|
|
128
|
+
borderRadius: radius.full,
|
|
129
|
+
backgroundColor: 'rgba(0,0,0,0.55)',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</View>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Image, Modal, Platform, Pressable, StatusBar, View } from 'react-native';
|
|
3
|
+
import { initialWindowMetrics } from 'react-native-safe-area-context';
|
|
4
|
+
|
|
5
|
+
import { radius, spacing } from '../config/theme';
|
|
6
|
+
import { Icon } from './Icon';
|
|
7
|
+
import { MyazaText } from './Typography';
|
|
8
|
+
import { MyazaButton } from './MyazaButton';
|
|
9
|
+
import { Chip, CHIP_TEXT, type ReviewSide } from './DocumentReviewSide';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Full-bleed view of one side, with retake to hand — checking a capture and
|
|
13
|
+
* deciding to redo it are the same moment.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately opaque and dark. The first version tinted the theme background
|
|
16
|
+
* at 97% opacity, which on the dark theme is very nearly the colour it sits on:
|
|
17
|
+
* tapping a photo appeared to do nothing. An overlay has to announce itself as
|
|
18
|
+
* a new layer, so this one is near-black regardless of theme, with a close
|
|
19
|
+
* control big enough to find.
|
|
20
|
+
*/
|
|
21
|
+
/** Clearance for the status bar / notch above the zoom's own controls. */
|
|
22
|
+
function topInset(): number {
|
|
23
|
+
const sa = initialWindowMetrics?.insets;
|
|
24
|
+
if (Platform.OS === 'android') return sa?.top || StatusBar.currentHeight || 28;
|
|
25
|
+
// iOS presents this modal full-screen under the bar; 44 clears the island on
|
|
26
|
+
// the notched devices and is harmless on the ones without.
|
|
27
|
+
return sa?.top || 44;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Clearance for the home indicator / nav bar BELOW the zoom's controls.
|
|
32
|
+
*
|
|
33
|
+
* The modal runs edge to edge, so without this the Retake button sits flush
|
|
34
|
+
* against the bottom of the screen — clipped by the indicator and awkward to
|
|
35
|
+
* hit, since that strip belongs to the system gesture.
|
|
36
|
+
*/
|
|
37
|
+
function bottomInset(): number {
|
|
38
|
+
const sa = initialWindowMetrics?.insets;
|
|
39
|
+
if (Platform.OS === 'android') return sa?.bottom ?? 0;
|
|
40
|
+
return sa?.bottom || 34;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function DocumentReviewZoom({
|
|
44
|
+
side,
|
|
45
|
+
isBusy,
|
|
46
|
+
onClose,
|
|
47
|
+
onRetake,
|
|
48
|
+
}: {
|
|
49
|
+
side: ReviewSide;
|
|
50
|
+
isBusy: boolean;
|
|
51
|
+
onClose: () => void;
|
|
52
|
+
onRetake: () => void;
|
|
53
|
+
}): React.ReactElement {
|
|
54
|
+
return (
|
|
55
|
+
<Modal visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
|
|
56
|
+
{/* The modal is statusBarTranslucent, so its content starts at y=0 —
|
|
57
|
+
under the notch / Dynamic Island. Without the inset the close button
|
|
58
|
+
renders BEHIND the island and the only way out of a full-screen image
|
|
59
|
+
is invisible. Same static safe-area snapshot the sheet uses. */}
|
|
60
|
+
<View
|
|
61
|
+
testID="kyc.review.zoom"
|
|
62
|
+
style={{
|
|
63
|
+
flex: 1,
|
|
64
|
+
backgroundColor: '#0A0A12F2',
|
|
65
|
+
padding: spacing.sm,
|
|
66
|
+
paddingTop: spacing.sm + topInset(),
|
|
67
|
+
paddingBottom: spacing.sm + bottomInset(),
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
71
|
+
<Chip>
|
|
72
|
+
<MyazaText variant="bodySmall" color="#FFFFFF" style={[CHIP_TEXT, { flexShrink: 1 }]}>
|
|
73
|
+
{side.label}
|
|
74
|
+
</MyazaText>
|
|
75
|
+
</Chip>
|
|
76
|
+
<View style={{ flex: 1 }} />
|
|
77
|
+
{/* A filled circle, not a bare glyph on a dark field: the way out of
|
|
78
|
+
a full-screen view has to be obvious. */}
|
|
79
|
+
<Pressable
|
|
80
|
+
testID="kyc.review.zoom.close"
|
|
81
|
+
onPress={onClose}
|
|
82
|
+
accessibilityRole="button"
|
|
83
|
+
accessibilityLabel="Close"
|
|
84
|
+
style={{
|
|
85
|
+
width: 40,
|
|
86
|
+
height: 40,
|
|
87
|
+
borderRadius: radius.full,
|
|
88
|
+
backgroundColor: 'rgba(255,255,255,0.14)',
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
justifyContent: 'center',
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<Icon name="close" size={20} color="#FFFFFF" />
|
|
94
|
+
</Pressable>
|
|
95
|
+
</View>
|
|
96
|
+
|
|
97
|
+
<View style={{ height: spacing.sm }} />
|
|
98
|
+
{/* Tapping the image again closes it — the same gesture that opened it,
|
|
99
|
+
which is what people try first. */}
|
|
100
|
+
<Pressable onPress={onClose} style={{ flex: 1 }} accessibilityRole="button" accessibilityLabel="Close enlarged photo">
|
|
101
|
+
<Image
|
|
102
|
+
source={{ uri: side.uri }}
|
|
103
|
+
style={{ flex: 1, width: '100%', borderRadius: radius.md }}
|
|
104
|
+
resizeMode="contain"
|
|
105
|
+
/>
|
|
106
|
+
</Pressable>
|
|
107
|
+
|
|
108
|
+
<View style={{ height: spacing.sm }} />
|
|
109
|
+
<MyazaButton
|
|
110
|
+
label={`Retake ${side.label.toLowerCase()}`}
|
|
111
|
+
leadingIcon="refresh"
|
|
112
|
+
variant="outline"
|
|
113
|
+
disabled={isBusy}
|
|
114
|
+
onPress={onRetake}
|
|
115
|
+
/>
|
|
116
|
+
</View>
|
|
117
|
+
</Modal>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useTheme } from './runtime';
|
|
4
|
+
import { MyazaText } from './Typography';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Live "expires in m:ss" ticker for a one-time code.
|
|
8
|
+
//
|
|
9
|
+
// Ticks once a second and stops at zero, so a stale challenge reads as expired
|
|
10
|
+
// instead of claiming minutes that already elapsed. Mirrors the web and Flutter
|
|
11
|
+
// SDKs — without it the user has no idea whether to keep waiting for a code or
|
|
12
|
+
// ask for a new one.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export function ExpiryCountdown({
|
|
16
|
+
expiresAt,
|
|
17
|
+
expiredLabel = 'The code has expired. Request a new one.',
|
|
18
|
+
}: {
|
|
19
|
+
/** ISO timestamp from the send response. */
|
|
20
|
+
expiresAt: string;
|
|
21
|
+
expiredLabel?: string;
|
|
22
|
+
}): React.ReactElement | null {
|
|
23
|
+
const { colors } = useTheme();
|
|
24
|
+
const target = new Date(expiresAt).getTime();
|
|
25
|
+
const [remaining, setRemaining] = useState(() => Math.max(0, target - Date.now()));
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// Re-seed on a resend: the new challenge carries a later expiry.
|
|
29
|
+
setRemaining(Math.max(0, target - Date.now()));
|
|
30
|
+
const id = setInterval(() => {
|
|
31
|
+
const left = Math.max(0, target - Date.now());
|
|
32
|
+
setRemaining(left);
|
|
33
|
+
if (left === 0) clearInterval(id);
|
|
34
|
+
}, 1000);
|
|
35
|
+
return () => clearInterval(id);
|
|
36
|
+
}, [target]);
|
|
37
|
+
|
|
38
|
+
// An unparseable timestamp would render "NaN:aN" — show nothing instead.
|
|
39
|
+
if (!Number.isFinite(target)) return null;
|
|
40
|
+
|
|
41
|
+
const totalSeconds = Math.ceil(remaining / 1000);
|
|
42
|
+
const label =
|
|
43
|
+
remaining === 0
|
|
44
|
+
? expiredLabel
|
|
45
|
+
: `The code expires in ${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
49
|
+
{label}
|
|
50
|
+
</MyazaText>
|
|
51
|
+
);
|
|
52
|
+
}
|