@myazahq/kyc-sdk-react-native 2.6.0 → 3.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.
Files changed (104) hide show
  1. package/README.md +128 -4
  2. package/android/build.gradle +30 -16
  3. package/android/consumer-rules.pro +22 -0
  4. package/android/src/main/AndroidManifest.xml +17 -6
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
  8. package/app.plugin.js +57 -2
  9. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  10. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  12. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  13. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  18. package/package.json +24 -14
  19. package/src/MyazaKYC.tsx +4 -11
  20. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  21. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  22. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  23. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  24. package/src/assets/fonts/OFL-Karla.txt +93 -0
  25. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  26. package/src/assets/fonts/README.md +12 -0
  27. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  28. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  30. package/src/capture/useAutoCapture.ts +8 -1
  31. package/src/components/DocumentCropper.tsx +30 -24
  32. package/src/components/DocumentReview.tsx +11 -1
  33. package/src/components/DocumentReviewSide.tsx +6 -2
  34. package/src/components/DocumentReviewZoom.tsx +5 -1
  35. package/src/components/GlassIconButton.tsx +2 -5
  36. package/src/components/Icon.tsx +17 -13
  37. package/src/components/KycFlow.tsx +10 -2
  38. package/src/components/KycSheet.tsx +0 -1
  39. package/src/components/MyazaButton.tsx +9 -0
  40. package/src/components/RequiredDocumentPill.tsx +114 -0
  41. package/src/components/StepHeader.tsx +23 -3
  42. package/src/components/StepView.tsx +3 -0
  43. package/src/components/documentReviewCopy.ts +39 -0
  44. package/src/components/fonts.ts +14 -17
  45. package/src/components/icons/glyphs.ts +35 -0
  46. package/src/components/icons/index.ts +3 -0
  47. package/src/components/icons/map.ts +165 -0
  48. package/src/components/icons/names.ts +86 -0
  49. package/src/components/stepHeaderMeta.tsx +21 -1
  50. package/src/config/documentCaptureMethods.ts +31 -0
  51. package/src/config/stepOrder.ts +20 -5
  52. package/src/config/supportingDocuments.ts +131 -0
  53. package/src/config/theme.ts +29 -3
  54. package/src/config/workflowMerge.ts +15 -0
  55. package/src/index.ts +5 -10
  56. package/src/lib/documentCaptureCheck.ts +136 -0
  57. package/src/lib/model-ready.ts +82 -0
  58. package/src/lib/prime-models.ts +50 -0
  59. package/src/lib/resubmit.ts +36 -7
  60. package/src/lib/selfie-sharpness.ts +211 -0
  61. package/src/lib/supportingDocumentsIntro.ts +35 -0
  62. package/src/liveness/avatarSource.ts +59 -0
  63. package/src/liveness/useModelReady.ts +9 -55
  64. package/src/mrz/extract.ts +71 -4
  65. package/src/mrz/textRecognizer.ts +36 -0
  66. package/src/mrz/useTextModelReady.ts +21 -0
  67. package/src/screens/BusinessDocumentSlot.tsx +37 -8
  68. package/src/screens/DocumentCaptureStep.tsx +177 -93
  69. package/src/screens/LivenessAvatar.tsx +18 -13
  70. package/src/screens/LivenessStep.tsx +37 -0
  71. package/src/screens/MrzScanView.tsx +33 -0
  72. package/src/screens/SupportingDocumentCard.tsx +134 -0
  73. package/src/screens/SupportingDocumentsStep.tsx +203 -0
  74. package/src/screens/consent/model.ts +22 -5
  75. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  76. package/src/screens/document/UploadPhase.tsx +177 -0
  77. package/src/screens/document/useDocumentCamera.ts +109 -0
  78. package/src/screens/liveness/CaptureRing.tsx +31 -17
  79. package/src/screens/liveness/LivenessOutcome.tsx +4 -1
  80. package/src/screens/liveness/SelfiePreview.tsx +19 -4
  81. package/src/screens/supportingDocumentParts.tsx +126 -0
  82. package/src/screens/useBusinessDocumentAttach.ts +20 -9
  83. package/src/services/api-types.ts +31 -2
  84. package/src/services/api.ts +19 -3
  85. package/src/services/deviceMetadata.ts +1 -1
  86. package/src/services/mediaCompress.ts +23 -5
  87. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  88. package/src/store/derive.ts +11 -0
  89. package/src/store/kycStore.ts +23 -2
  90. package/src/store/session.ts +18 -0
  91. package/src/store/state.ts +26 -0
  92. package/src/store/submit.ts +11 -0
  93. package/src/types/config.ts +25 -0
  94. package/src/types/workflow.ts +39 -0
  95. package/src/MyazaBiometricAuth.tsx +0 -119
  96. package/src/assets/liveness/Blink.gif +0 -0
  97. package/src/assets/liveness/Nod.gif +0 -0
  98. package/src/assets/liveness/Smile.gif +0 -0
  99. package/src/assets/liveness/Turn.gif +0 -0
  100. package/src/components/icon-map.ts +0 -176
  101. package/src/lib/biometric-auth.ts +0 -52
  102. package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
  103. package/src/services/api-biometric.ts +0 -37
  104. package/src/services/api-types-biometric.ts +0 -41
@@ -1,26 +1,29 @@
1
1
  import type { TextStyle } from 'react-native';
2
2
  import { useFonts } from 'expo-font';
3
- import { SpaceGrotesk_500Medium, SpaceGrotesk_600SemiBold, SpaceGrotesk_700Bold } from '@expo-google-fonts/space-grotesk';
4
- import { Karla_400Regular, Karla_500Medium, Karla_600SemiBold, Karla_700Bold } from '@expo-google-fonts/karla';
5
3
  import { fontFamilyFor } from '../config/font-resolve';
6
4
  import { useTheme } from './theme-provider';
7
5
 
8
6
  export { fontFamilyFor, markFamilyName, brandFamilyName, BRAND_WEIGHTS } from '../config/font-resolve';
9
7
 
10
-
11
8
  // Typography fonts — the SAME families the Flutter SDK uses via google_fonts:
12
9
  // Space Grotesk for headings, Karla for body. Loaded at runtime through
13
10
  // expo-font (already natively linked), so no native rebuild is needed. Each
14
11
  // weight is its own RN font family, so we resolve family-by-weight below.
15
-
12
+ //
13
+ // The files are VENDORED under src/assets/fonts and required one by one (see
14
+ // the README there). They used to be named imports from the
15
+ // @expo-google-fonts packages, whose index requires EVERY weight the family
16
+ // ships — and Metro bundles every `require` it can see, used or not — so 19
17
+ // font files reached every integrator's app for the seven faces the SDK draws
18
+ // with. Measured at 1.85 MB on a real integrator's release APK.
16
19
  export const MYAZA_FONTS = {
17
- SpaceGrotesk_500Medium,
18
- SpaceGrotesk_600SemiBold,
19
- SpaceGrotesk_700Bold,
20
- Karla_400Regular,
21
- Karla_500Medium,
22
- Karla_600SemiBold,
23
- Karla_700Bold,
20
+ SpaceGrotesk_500Medium: require('../assets/fonts/SpaceGrotesk_500Medium.ttf'),
21
+ SpaceGrotesk_600SemiBold: require('../assets/fonts/SpaceGrotesk_600SemiBold.ttf'),
22
+ SpaceGrotesk_700Bold: require('../assets/fonts/SpaceGrotesk_700Bold.ttf'),
23
+ Karla_400Regular: require('../assets/fonts/Karla_400Regular.ttf'),
24
+ Karla_500Medium: require('../assets/fonts/Karla_500Medium.ttf'),
25
+ Karla_600SemiBold: require('../assets/fonts/Karla_600SemiBold.ttf'),
26
+ Karla_700Bold: require('../assets/fonts/Karla_700Bold.ttf'),
24
27
  };
25
28
 
26
29
  /** Loads the Myaza fonts. Returns true once they're ready (system font until then). */
@@ -29,12 +32,6 @@ export function useMyazaFonts(): boolean {
29
32
  return loaded;
30
33
  }
31
34
 
32
- /**
33
- * Resolves the concrete RN font family for a (heading vs body, weight) pair.
34
- * Returns `undefined` until fonts are loaded so text falls back to the system
35
- * font + `fontWeight` without triggering an "unrecognized font" warning.
36
- */
37
-
38
35
  /**
39
36
  * The body font family for a TEXT INPUT, at the given weight.
40
37
  *
@@ -0,0 +1,35 @@
1
+ import type { IconSvgElement } from '@hugeicons/react-native';
2
+
3
+ /**
4
+ * Glyphs Hugeicons does not carry, drawn in the set's own stroke language.
5
+ *
6
+ * Kept to the strict minimum: every other icon comes from the set, and this
7
+ * file exists only where the set has no equivalent at all. It MIRRORS the web
8
+ * SDK's `components/icons/glyphs.ts` - same geometry, same stroke attributes -
9
+ * so the two platforms draw one arrow rather than two near-misses.
10
+ */
11
+
12
+ const STROKE = {
13
+ stroke: 'currentColor',
14
+ strokeLinecap: 'round',
15
+ strokeLinejoin: 'round',
16
+ strokeWidth: '1.5',
17
+ } as const;
18
+
19
+ /**
20
+ * A LONG left arrow - a full-width shaft with a small head.
21
+ *
22
+ * Hugeicons has no such glyph. Its longest one-way left arrow is
23
+ * `ArrowLeft02Icon`, whose shaft spans 13.5 of the 24-unit viewBox; the rest
24
+ * are shorter, bare chevrons, or arrows into a vertical bar. This SDK's back
25
+ * control drew Lucide's `MoveLeft` before the migration, whose shaft is 20
26
+ * units, so moving to the set would visibly shorten it.
27
+ *
28
+ * The geometry is Lucide's, inset to sit on the same optical margins as its
29
+ * Hugeicons siblings (they start at ~3.5 rather than Lucide's 2), and the
30
+ * stroke attributes are copied from the set so weight and joins match exactly.
31
+ */
32
+ export const LONG_ARROW_LEFT: IconSvgElement = [
33
+ ['path', { d: 'M20.5 12H3.5', ...STROKE, key: '0' }],
34
+ ['path', { d: 'M8 7.5L3.5 12L8 16.5', ...STROKE, key: '1' }],
35
+ ];
@@ -0,0 +1,3 @@
1
+ export type { IconName } from './names';
2
+ export { ICONS } from './map';
3
+ export { LONG_ARROW_LEFT } from './glyphs';
@@ -0,0 +1,165 @@
1
+ import type { IconSvgElement } from '@hugeicons/react-native';
2
+
3
+ import Add01Icon from '@hugeicons/core-free-icons/Add01Icon';
4
+ import AlertCircleIcon from '@hugeicons/core-free-icons/AlertCircleIcon';
5
+ import BadgeCheckIcon from '@hugeicons/core-free-icons/BadgeCheckIcon';
6
+ import BellRingIcon from '@hugeicons/core-free-icons/BellRingIcon';
7
+ import BookUserIcon from '@hugeicons/core-free-icons/BookUserIcon';
8
+ import BubbleChatIcon from '@hugeicons/core-free-icons/BubbleChatIcon';
9
+ import Building02Icon from '@hugeicons/core-free-icons/Building02Icon';
10
+ import BulbIcon from '@hugeicons/core-free-icons/BulbIcon';
11
+ import Calendar01Icon from '@hugeicons/core-free-icons/Calendar01Icon';
12
+ import Camera01Icon from '@hugeicons/core-free-icons/Camera01Icon';
13
+ import CameraOff01Icon from '@hugeicons/core-free-icons/CameraOff01Icon';
14
+ import Cancel01Icon from '@hugeicons/core-free-icons/Cancel01Icon';
15
+ import CancelCircleIcon from '@hugeicons/core-free-icons/CancelCircleIcon';
16
+ import Car01Icon from '@hugeicons/core-free-icons/Car01Icon';
17
+ import CheckIcon from '@hugeicons/core-free-icons/CheckIcon';
18
+ import CheckmarkCircle02Icon from '@hugeicons/core-free-icons/CheckmarkCircle02Icon';
19
+ import ChevronDownIcon from '@hugeicons/core-free-icons/ChevronDownIcon';
20
+ import ChevronLeftIcon from '@hugeicons/core-free-icons/ChevronLeftIcon';
21
+ import ChevronRightIcon from '@hugeicons/core-free-icons/ChevronRightIcon';
22
+ import CircleDashedIcon from '@hugeicons/core-free-icons/CircleDashedIcon';
23
+ import ContactIcon from '@hugeicons/core-free-icons/ContactIcon';
24
+ import CopyIcon from '@hugeicons/core-free-icons/CopyIcon';
25
+ import CreditCardIcon from '@hugeicons/core-free-icons/CreditCardIcon';
26
+ import ExpandIcon from '@hugeicons/core-free-icons/ExpandIcon';
27
+ import FaceIdIcon from '@hugeicons/core-free-icons/FaceIdIcon';
28
+ import File02Icon from '@hugeicons/core-free-icons/File02Icon';
29
+ import FilterIcon from '@hugeicons/core-free-icons/FilterIcon';
30
+ import FingerPrintIcon from '@hugeicons/core-free-icons/FingerPrintIcon';
31
+ import FlashOffIcon from '@hugeicons/core-free-icons/FlashOffIcon';
32
+ import FlaskConicalIcon from '@hugeicons/core-free-icons/FlaskConicalIcon';
33
+ import GlobeIcon from '@hugeicons/core-free-icons/GlobeIcon';
34
+ import Gps02Icon from '@hugeicons/core-free-icons/Gps02Icon';
35
+ import HelpCircleIcon from '@hugeicons/core-free-icons/HelpCircleIcon';
36
+ import Home01Icon from '@hugeicons/core-free-icons/Home01Icon';
37
+ import IdentityCardIcon from '@hugeicons/core-free-icons/IdentityCardIcon';
38
+ import Image01Icon from '@hugeicons/core-free-icons/Image01Icon';
39
+ import InformationCircleIcon from '@hugeicons/core-free-icons/InformationCircleIcon';
40
+ import LandmarkIcon from '@hugeicons/core-free-icons/LandmarkIcon';
41
+ import Link02Icon from '@hugeicons/core-free-icons/Link02Icon';
42
+ import Location02Icon from '@hugeicons/core-free-icons/Location02Icon';
43
+ import LocationCheck01Icon from '@hugeicons/core-free-icons/LocationCheck01Icon';
44
+ import LockIcon from '@hugeicons/core-free-icons/LockIcon';
45
+ import Mail01Icon from '@hugeicons/core-free-icons/Mail01Icon';
46
+ import MapPinHouseIcon from '@hugeicons/core-free-icons/MapPinHouseIcon';
47
+ import MapPinIcon from '@hugeicons/core-free-icons/MapPinIcon';
48
+ import Message01Icon from '@hugeicons/core-free-icons/Message01Icon';
49
+ import MinusSignIcon from '@hugeicons/core-free-icons/MinusSignIcon';
50
+ import MoonIcon from '@hugeicons/core-free-icons/MoonIcon';
51
+ import NfcIcon from '@hugeicons/core-free-icons/NfcIcon';
52
+ import PencilEdit01Icon from '@hugeicons/core-free-icons/PencilEdit01Icon';
53
+ import PencilIcon from '@hugeicons/core-free-icons/PencilIcon';
54
+ import Radar01Icon from '@hugeicons/core-free-icons/Radar01Icon';
55
+ import RefreshCwIcon from '@hugeicons/core-free-icons/RefreshCwIcon';
56
+ import RefreshIcon from '@hugeicons/core-free-icons/RefreshIcon';
57
+ import ScanIcon from '@hugeicons/core-free-icons/ScanIcon';
58
+ import Search01Icon from '@hugeicons/core-free-icons/Search01Icon';
59
+ import SecurityCheckIcon from '@hugeicons/core-free-icons/SecurityCheckIcon';
60
+ import Share01Icon from '@hugeicons/core-free-icons/Share01Icon';
61
+ import SlidersHorizontalIcon from '@hugeicons/core-free-icons/SlidersHorizontalIcon';
62
+ import SmartPhone01Icon from '@hugeicons/core-free-icons/SmartPhone01Icon';
63
+ import StampIcon from '@hugeicons/core-free-icons/StampIcon';
64
+ import Sun01Icon from '@hugeicons/core-free-icons/Sun01Icon';
65
+ import Timer01Icon from '@hugeicons/core-free-icons/Timer01Icon';
66
+ import Upload01Icon from '@hugeicons/core-free-icons/Upload01Icon';
67
+ import UserAdd01Icon from '@hugeicons/core-free-icons/UserAdd01Icon';
68
+ import UserCircleIcon from '@hugeicons/core-free-icons/UserCircleIcon';
69
+ import UserGroupIcon from '@hugeicons/core-free-icons/UserGroupIcon';
70
+ import VideoOffIcon from '@hugeicons/core-free-icons/VideoOffIcon';
71
+ import VoteIcon from '@hugeicons/core-free-icons/VoteIcon';
72
+ import ZapIcon from '@hugeicons/core-free-icons/ZapIcon';
73
+
74
+ import { LONG_ARROW_LEFT } from './glyphs';
75
+ import type { IconName } from './names';
76
+
77
+ /**
78
+ * Each role, and the glyph that draws it.
79
+ *
80
+ * Imports are DEEP - one module per icon - and must stay that way. Importing
81
+ * from the package index instead pulls the whole 6,025-icon pack into the
82
+ * bundle: measured at +8.33 MB for five icons against +7 KB for the same five
83
+ * imported this way. The pack declares a `./*` export for exactly this, and
84
+ * iconImports.test.ts fails the build on a barrel import.
85
+ *
86
+ * The glyphs are the ones the web SDK and Flutter already chose for these
87
+ * names, so the three SDKs draw one set. Where neither had a choice to copy,
88
+ * it was made against the catalogue by meaning and is noted below.
89
+ */
90
+ export const ICONS: Record<IconName, IconSvgElement> = {
91
+ close: Cancel01Icon,
92
+ back: LONG_ARROW_LEFT,
93
+ moon: MoonIcon,
94
+ sun: Sun01Icon,
95
+ check: CheckIcon,
96
+ lock: LockIcon,
97
+ alert: AlertCircleIcon,
98
+ // The environment banner's mark, shared with the web SDK (same lucide glyph)
99
+ // and mirrored by Flutter's Icons.science_outlined.
100
+ flask: FlaskConicalIcon,
101
+ refresh: RefreshCwIcon,
102
+ 'refresh-ccw': RefreshIcon,
103
+ maximize: ExpandIcon,
104
+ calendar: Calendar01Icon,
105
+ image: Image01Icon,
106
+ 'chevron-left': ChevronLeftIcon,
107
+ 'chevron-right': ChevronRightIcon,
108
+ 'chevron-down': ChevronDownIcon,
109
+ shield: SecurityCheckIcon,
110
+ 'badge-check': BadgeCheckIcon,
111
+ 'circle-dashed': CircleDashedIcon,
112
+ 'circle-check': CheckmarkCircle02Icon,
113
+ 'circle-help': HelpCircleIcon,
114
+ 'circle-x': CancelCircleIcon,
115
+ 'building-2': Building02Icon,
116
+ stamp: StampIcon,
117
+ user: UserCircleIcon,
118
+ info: InformationCircleIcon,
119
+ 'user-plus': UserAdd01Icon,
120
+ plus: Add01Icon,
121
+ link: Link02Icon,
122
+ copy: CopyIcon,
123
+ share: Share01Icon,
124
+ users: UserGroupIcon,
125
+ 'scan-line': ScanIcon,
126
+ 'scan-face': FaceIdIcon,
127
+ nfc: NfcIcon,
128
+ filter: FilterIcon,
129
+ pencil: PencilIcon,
130
+ 'pencil-line': PencilEdit01Icon,
131
+ search: Search01Icon,
132
+ zap: ZapIcon,
133
+ 'zap-off': FlashOffIcon,
134
+ timer: Timer01Icon,
135
+ fingerprint: FingerPrintIcon,
136
+ 'credit-card': CreditCardIcon,
137
+ globe: GlobeIcon,
138
+ car: Car01Icon,
139
+ vote: VoteIcon,
140
+ 'file-text': File02Icon,
141
+ house: Home01Icon,
142
+ landmark: LandmarkIcon,
143
+ 'map-pin': MapPinIcon,
144
+ 'map-pin-house': MapPinHouseIcon,
145
+ 'map-pin-check': LocationCheck01Icon,
146
+ 'map-pinned': Location02Icon,
147
+ radar: Radar01Icon,
148
+ 'bell-ring': BellRingIcon,
149
+ sliders: SlidersHorizontalIcon,
150
+ locate: Gps02Icon,
151
+ minus: MinusSignIcon,
152
+ 'id-card': IdentityCardIcon,
153
+ contact: ContactIcon,
154
+ passport: BookUserIcon,
155
+ camera: Camera01Icon,
156
+ 'camera-off': CameraOff01Icon,
157
+ 'video-off': VideoOffIcon,
158
+ upload: Upload01Icon,
159
+ x: Cancel01Icon,
160
+ lightbulb: BulbIcon,
161
+ mail: Mail01Icon,
162
+ smartphone: SmartPhone01Icon,
163
+ 'message-square': Message01Icon,
164
+ 'message-circle': BubbleChatIcon,
165
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The icon roles this SDK draws, by MEANING rather than by glyph name.
3
+ *
4
+ * This is the boundary that lets the set underneath change without touching a
5
+ * call site: a screen asks for 'close', and `icons/map.ts` decides what that
6
+ * looks like. Adding a role here means adding its glyph there.
7
+ */
8
+
9
+ export type IconName =
10
+ // chrome
11
+ | 'close'
12
+ | 'back'
13
+ | 'moon'
14
+ | 'sun'
15
+ | 'check'
16
+ | 'lock'
17
+ | 'alert'
18
+ | 'flask'
19
+ | 'refresh'
20
+ | 'refresh-ccw'
21
+ | 'maximize'
22
+ | 'calendar'
23
+ | 'image'
24
+ | 'chevron-left'
25
+ | 'chevron-right'
26
+ | 'chevron-down'
27
+ // consent hero + process steps
28
+ | 'shield'
29
+ | 'badge-check'
30
+ | 'circle-dashed'
31
+ | 'circle-check'
32
+ | 'circle-help'
33
+ | 'circle-x'
34
+ | 'building-2'
35
+ | 'stamp'
36
+ | 'user'
37
+ | 'user-plus'
38
+ | 'plus'
39
+ | 'info'
40
+ | 'users'
41
+ | 'scan-line'
42
+ | 'scan-face'
43
+ | 'nfc'
44
+ | 'filter'
45
+ | 'pencil'
46
+ | 'pencil-line'
47
+ | 'search'
48
+ | 'zap'
49
+ | 'link'
50
+ | 'copy'
51
+ | 'share'
52
+ | 'zap-off'
53
+ | 'timer'
54
+ // id-type glyphs
55
+ | 'fingerprint'
56
+ | 'credit-card'
57
+ | 'globe'
58
+ | 'car'
59
+ | 'vote'
60
+ | 'file-text'
61
+ | 'house'
62
+ | 'landmark'
63
+ | 'id-card'
64
+ | 'contact'
65
+ | 'passport'
66
+ | 'camera'
67
+ | 'camera-off'
68
+ | 'video-off'
69
+ | 'upload'
70
+ | 'x'
71
+ // address collection (map picker + the presence milestone track)
72
+ | 'map-pin'
73
+ | 'map-pin-house'
74
+ | 'map-pin-check'
75
+ | 'map-pinned'
76
+ | 'radar'
77
+ | 'bell-ring'
78
+ | 'sliders'
79
+ | 'locate'
80
+ | 'minus'
81
+ | 'lightbulb'
82
+ // contact verification (OTP channels + step footer)
83
+ | 'mail'
84
+ | 'smartphone'
85
+ | 'message-square'
86
+ | 'message-circle';
@@ -1,6 +1,7 @@
1
1
  import { ID_TYPES } from '../config/idTypes';
2
2
  import { poaDocumentTypes, poaMaxAgeDays, poaNamePolicy } from '../config/proofOfAddress';
3
3
  import { documentCaptureMeta } from '../screens/DocumentCaptureStep';
4
+ import { documentCaptureMethods } from '../config/documentCaptureMethods';
4
5
  import { questionnaireMeta } from '../screens/QuestionnaireStep';
5
6
  import { contactCodeLength, contactMeta, type ContactChallenge } from '../config/contact';
6
7
 
@@ -9,6 +10,8 @@ import { addressStepMeta } from '../screens/address';
9
10
  import { countrySelectMeta } from '../screens/CountrySelectStep';
10
11
  import { businessDetailsMeta } from '../screens/BusinessDetailsStep';
11
12
  import { businessKeyPeopleMeta } from '../screens/BusinessKeyPeopleStep';
13
+ import { supportingDocumentsMeta } from '../screens/SupportingDocumentsStep';
14
+ import { resolveSupportingDocuments, verifiedIdsFor } from '../config/supportingDocuments';
12
15
  import { businessDocumentsMeta } from '../screens/BusinessDocumentsStep';
13
16
  import { applicantRoleMeta } from '../screens/ApplicantRoleStep';
14
17
  import { nfcMeta } from '../screens/NfcStep';
@@ -48,6 +51,9 @@ export interface StepHeaderContext {
48
51
  /** The proof-of-address kind the applicant has picked, so the header can ask
49
52
  * for what the workflow's name rule wants on THAT document. */
50
53
  poaDocumentType?: string | null;
54
+ /** The committed multi-ID slots, so the supporting-documents header resolves
55
+ * the SAME list the screen shows. */
56
+ multiIdSlots?: Array<{ idType: string }>;
51
57
  }
52
58
 
53
59
  export function stepHeaderMeta(
@@ -61,6 +67,7 @@ export function stepHeaderMeta(
61
67
  addressIntroPending,
62
68
  addressEntranceFraming,
63
69
  poaDocumentType,
70
+ multiIdSlots,
64
71
  }: StepHeaderContext,
65
72
  ): { title: string; description: string | null } {
66
73
  const label = labelFor(country, selectedIdType);
@@ -77,7 +84,11 @@ export function stepHeaderMeta(
77
84
  case 'document-capture':
78
85
  // Phase-aware title/description live in the header (synced from the
79
86
  // capture screen via `documentCapturePhase`) — mirrors Flutter.
80
- return documentCaptureMeta(documentCapturePhase, label);
87
+ return documentCaptureMeta(
88
+ documentCapturePhase,
89
+ label,
90
+ documentCaptureMethods(config).scan ? 'scan' : 'upload',
91
+ );
81
92
  case 'liveness':
82
93
  return { title: 'Face Verification', description: 'Follow the on-screen instructions' };
83
94
  case 'questionnaire':
@@ -116,6 +127,15 @@ export function stepHeaderMeta(
116
127
  return businessKeyPeopleMeta;
117
128
  case 'business-documents':
118
129
  return businessDocumentsMeta;
130
+ case 'supporting-documents':
131
+ // Resolved exactly as the screen resolves its own slots, or the header
132
+ // could name a number the body does not show.
133
+ return supportingDocumentsMeta(
134
+ resolveSupportingDocuments(
135
+ config.supportingDocuments,
136
+ verifiedIdsFor({ country, idType: selectedIdType, multiIdSlots }),
137
+ ),
138
+ );
119
139
  case 'applicant-role':
120
140
  return applicantRoleMeta;
121
141
  case 'nfc':
@@ -0,0 +1,31 @@
1
+ // ---------------------------------------------------------------------------
2
+ // How the applicant may put a document in front of us.
3
+ //
4
+ // Two workflow switches, each on unless set to `false`: `allowDocumentScan`
5
+ // (photograph it with the live camera) and `allowDocumentUpload` (choose a
6
+ // photo of it from the device). The server refuses to publish a workflow with
7
+ // both off, but a stored or prop-supplied config can still carry that, and a
8
+ // document step with no way in is a dead end. So the camera comes back on in
9
+ // that case: it is the method the step was built around.
10
+ //
11
+ // Screens read the pair through here and never the raw keys, so the fallback
12
+ // is one rule rather than a check every screen has to remember. Mirrors the
13
+ // web and Flutter SDKs.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface DocumentCaptureMethods {
17
+ /** Photograph the document with the live camera. */
18
+ scan: boolean;
19
+ /** Choose a photo of the document from the device. */
20
+ upload: boolean;
21
+ }
22
+
23
+ export function documentCaptureMethods(config: {
24
+ allowDocumentScan?: boolean;
25
+ allowDocumentUpload?: boolean;
26
+ }): DocumentCaptureMethods {
27
+ const upload = config.allowDocumentUpload !== false;
28
+ // With upload off the camera is the only way left, whatever its flag says.
29
+ const scan = upload ? config.allowDocumentScan !== false : true;
30
+ return { scan, upload };
31
+ }
@@ -44,6 +44,14 @@ export interface StepOrderOptions {
44
44
  hasEmailVerification: boolean;
45
45
  hasPhoneVerification: boolean;
46
46
  hasPoa: boolean;
47
+ /**
48
+ * Whether the supporting-documents step has anything to ask for on THIS
49
+ * attempt. Resolved by the caller (config/supportingDocuments.ts) because
50
+ * the list depends on the ID the person actually picked — a document scoped
51
+ * to one ID is not asked of somebody who used another, and a step with an
52
+ * empty list must not appear at all.
53
+ */
54
+ hasSupportingDocuments: boolean;
47
55
  hasAddressCollection: boolean;
48
56
  /**
49
57
  * Which address screens the flow has, from the ONE `addressFlowFor` call.
@@ -120,11 +128,15 @@ function fullStepOrder(o: StepOrderOptions): KYCStep[] {
120
128
  if (o.scope === 'address') {
121
129
  const steps: KYCStep[] = [...openingSteps(o), ...contactSteps(o)];
122
130
  if (o.hasPoa) steps.push('proof-of-address');
123
- steps.push(
124
- ...addressFlowSteps(
125
- o.addressFlow ?? { searchAvailable: false, photoMode: 'optional', streetViewOffered: false },
126
- ),
127
- );
131
+ // Gated like the full flow, and like Flutter has always done it: an
132
+ // address flow that asks only for proof of address never opens a map.
133
+ if (o.hasAddressCollection) {
134
+ steps.push(
135
+ ...addressFlowSteps(
136
+ o.addressFlow ?? { searchAvailable: false, photoMode: 'optional', streetViewOffered: false },
137
+ ),
138
+ );
139
+ }
128
140
  if (o.hasQuestionnaire) steps.push('questionnaire');
129
141
  steps.push('submitted');
130
142
  return steps;
@@ -143,6 +155,9 @@ function fullStepOrder(o: StepOrderOptions): KYCStep[] {
143
155
  }
144
156
 
145
157
  const middle: KYCStep[] = [...captureLeg(o)];
158
+ // Paperwork the org files comes BEFORE the address evidence the
159
+ // verification is judged on (user decision 2026-09-22).
160
+ if (o.hasSupportingDocuments) middle.push('supporting-documents');
146
161
  if (o.hasPoa) middle.push('proof-of-address');
147
162
  // The address flow is FOUR real steps on an individual flow (find it, confirm
148
163
  // it, show it, commit it), so the progress bar advances through them and back
@@ -0,0 +1,131 @@
1
+ import type { SupportingDocumentsConfig } from '../types/workflow';
2
+
3
+ // Which supporting documents this flow asks for, given the ID the person
4
+ // actually verified with.
5
+ //
6
+ // MIRROR of the server's `requestedSupportingDocuments`
7
+ // (kyc-core src/lib/workflows/supporting-documents-config.ts) and of the web
8
+ // SDK's lib/supporting-documents.ts. Three copies of one rule: the mobile SDKs
9
+ // cannot import the web package, so a change here changes all three in the
10
+ // same commit. The server VALIDATES what the client produced, so a client that
11
+ // resolved differently just builds submissions the server refuses.
12
+ //
13
+ // There is no catalogue to mirror: a document is whatever the ORG named it, so
14
+ // the SDK renders the title and guidance the workflow sent rather than
15
+ // captioning a key it recognises.
16
+
17
+ export interface RequestedSupportingDocument {
18
+ key: string;
19
+ label: string;
20
+ /** Guidance under the slot, when the author wrote some. */
21
+ description: string | null;
22
+ required: boolean;
23
+ /**
24
+ * The names of the values the server will read off it, for the card to show.
25
+ *
26
+ * DISPLAY ONLY, and deliberately not a mirror of the server's own field
27
+ * resolution: it drops blanks and repeats and stops there. The server decides
28
+ * what is actually read, and an extra name on a chip costs an applicant
29
+ * nothing.
30
+ */
31
+ reads: string[];
32
+ }
33
+
34
+ /** The names on one document's fields: what the applicant is told we read. */
35
+ function documentReads(fields: Array<{ label?: string }> | undefined): string[] {
36
+ const seen = new Set<string>();
37
+ const out: string[] = [];
38
+ for (const field of fields ?? []) {
39
+ const label = field.label?.trim();
40
+ if (!label || seen.has(label.toLowerCase())) continue;
41
+ seen.add(label.toLowerCase());
42
+ out.push(label);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** `${country}/${idType}` — the composite a document's `idTypes` is written in. */
48
+ export function idComposite(country: string, idType: string): string {
49
+ return `${country.trim().toUpperCase()}/${idType.trim()}`;
50
+ }
51
+
52
+ /**
53
+ * The documents to ask for. An empty result means the step does not appear:
54
+ * a document that exists only because the person used a particular ID must not
55
+ * be put in front of somebody who used another.
56
+ */
57
+ export function resolveSupportingDocuments(
58
+ config: SupportingDocumentsConfig | undefined | null,
59
+ verifiedIds: string[],
60
+ ): RequestedSupportingDocument[] {
61
+ if (!config?.enabled) return [];
62
+ const wanted = new Set(verifiedIds.map((id) => id.toUpperCase()));
63
+ const seen = new Set<string>();
64
+ const out: RequestedSupportingDocument[] = [];
65
+ for (const entry of config.types ?? []) {
66
+ const label = entry.label?.trim();
67
+ // A nameless slot reaches nobody. Publish refuses one, so this only ever
68
+ // bites a draft mid-edit.
69
+ if (!label) continue;
70
+ const scoped = entry.idTypes && entry.idTypes.length > 0;
71
+ const inScope =
72
+ !scoped || entry.idTypes!.some((id) => wanted.has(id.trim().toUpperCase()));
73
+ // `alwaysAsk` keeps the slot on screen for everybody, so the scope decides
74
+ // only who must fill it: an out-of-scope applicant may hand the document
75
+ // over and is never blocked for not having one.
76
+ if (!inScope && entry.alwaysAsk !== true) continue;
77
+ if (seen.has(entry.key)) continue;
78
+ seen.add(entry.key);
79
+ out.push({
80
+ key: entry.key,
81
+ label,
82
+ description: entry.description?.trim() || null,
83
+ required: entry.required === true && inScope,
84
+ reads: documentReads(entry.fields),
85
+ });
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** The composites this attempt has committed — one per ID, multi-ID included. */
91
+ export function verifiedIdsFor(input: {
92
+ country?: string | null;
93
+ idType?: string | null;
94
+ multiIdSlots?: Array<{ idType: string }>;
95
+ }): string[] {
96
+ if (!input.country) return [];
97
+ const slots = input.multiIdSlots ?? [];
98
+ const ids = slots.length > 0 ? slots.map((s) => s.idType) : input.idType ? [input.idType] : [];
99
+ return [...new Set(ids.map((id) => idComposite(input.country!, id)))];
100
+ }
101
+
102
+ /**
103
+ * Whether the flow may ask for a supporting document AT ALL.
104
+ *
105
+ * The CONSENT screen's question, deliberately not the step order's.
106
+ * `hasSupportingDocumentsStep` resolves against the VERIFIED IDS, and consent
107
+ * runs before an ID is picked — so every SCOPED document answers "nothing to
108
+ * ask for" there, and a slip scoped to one ID (the commonest case there is)
109
+ * would go undisclosed.
110
+ *
111
+ * Consent is a DISCLOSURE of what MAY be collected: an applicant seeing a
112
+ * bullet for paperwork they are never asked for is the cheap error, and being
113
+ * asked for undisclosed paperwork is the real one.
114
+ *
115
+ * Still not a raw field check — a disabled step and a nameless entry each
116
+ * promise nothing, the same two gates the resolver applies.
117
+ */
118
+ export function mayAskSupportingDocuments(
119
+ config: SupportingDocumentsConfig | undefined | null,
120
+ ): boolean {
121
+ if (!config?.enabled) return false;
122
+ return (config.types ?? []).some((entry) => (entry.label ?? '').trim().length > 0);
123
+ }
124
+
125
+ /** Whether the step has anything to ask for on this attempt. */
126
+ export function hasSupportingDocumentsStep(
127
+ config: SupportingDocumentsConfig | undefined | null,
128
+ verifiedIds: string[],
129
+ ): boolean {
130
+ return resolveSupportingDocuments(config, verifiedIds).length > 0;
131
+ }
@@ -161,6 +161,22 @@ function toHex({ r, g, b }: Rgb): string {
161
161
  }
162
162
 
163
163
  /** Alpha-blends `fg` over `bg` at opacity `alpha` (0–1). */
164
+ /**
165
+ * `color` at `alpha`, as an 8-digit hex React Native composites itself.
166
+ *
167
+ * Falls back to blending against `fallbackBg` when the colour is not hex (an
168
+ * `rgb()` or a named colour), which is the old behaviour rather than a crash.
169
+ */
170
+ function withAlpha(color: string, alpha: number, fallbackBg: string): string {
171
+ const parsed = parseHex(color);
172
+ if (!parsed) return alphaBlend(color, fallbackBg, alpha);
173
+ const byte = Math.round(Math.min(Math.max(alpha, 0), 1) * 255)
174
+ .toString(16)
175
+ .padStart(2, '0');
176
+ const hex = (n: number) => Math.round(n).toString(16).padStart(2, '0');
177
+ return `#${hex(parsed.r)}${hex(parsed.g)}${hex(parsed.b)}${byte}`;
178
+ }
179
+
164
180
  function alphaBlend(fg: string, bg: string, alpha: number): string {
165
181
  const f = parseHex(fg);
166
182
  const b = parseHex(bg);
@@ -197,9 +213,19 @@ export function applyAppearance(
197
213
 
198
214
  if (appearance.primaryColor) {
199
215
  next.primary = appearance.primaryColor;
200
- next.primary50 = alphaBlend(appearance.primaryColor, background, 0.04);
201
- next.primary100 = alphaBlend(appearance.primaryColor, background, 0.1);
202
- next.primary200 = alphaBlend(appearance.primaryColor, background, 0.2);
216
+ // Real alpha, not a pre-blend. These tints are drawn on several different
217
+ // surfaces - the sheet, a card, a pill - and pre-blending has to guess ONE
218
+ // of them. It guessed `background`, so on a card (backgroundSecondary) the
219
+ // 10% tint landed within 3/255 of the card it sat on and the supporting
220
+ // documents step drew its numbered markers invisibly (user report
221
+ // 2026-09-25). Carrying the alpha lets each one composite against whatever
222
+ // it is actually on, which is what the web SDK's `bg-primary/10` does and
223
+ // why it never had this bug. On `background` the result matches the old
224
+ // pre-blend to within 1/255 - an alpha byte cannot hold 0.1 exactly - so
225
+ // nothing that was already correct moves perceptibly.
226
+ next.primary50 = withAlpha(appearance.primaryColor, 0.04, background);
227
+ next.primary100 = withAlpha(appearance.primaryColor, 0.1, background);
228
+ next.primary200 = withAlpha(appearance.primaryColor, 0.2, background);
203
229
  }
204
230
  if (appearance.accentColor) {
205
231
  next.primary100 = appearance.accentColor;