@myazahq/kyc-sdk-react-native 2.3.0 → 2.5.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 (129) hide show
  1. package/android/build.gradle +35 -3
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
  3. package/ios/HybridMyazaFaceDetector.swift +8 -0
  4. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  5. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  6. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  7. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  8. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  9. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  10. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  11. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  12. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  13. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  14. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  15. package/package.json +1 -2
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/BrandBar.tsx +137 -0
  18. package/src/components/DashedBorder.tsx +55 -0
  19. package/src/components/DialCodePicker.tsx +25 -24
  20. package/src/components/DocumentReviewSide.tsx +34 -14
  21. package/src/components/Icon.tsx +14 -0
  22. package/src/components/KycSheet.tsx +39 -140
  23. package/src/components/MediaSourceSheet.tsx +7 -37
  24. package/src/components/MultiIdProgress.tsx +107 -0
  25. package/src/components/MyazaDateField.tsx +6 -20
  26. package/src/components/MyazaInput.tsx +23 -4
  27. package/src/components/MyazaSelect.tsx +17 -40
  28. package/src/components/OwnershipSlider.tsx +102 -0
  29. package/src/components/PhoneNumberInput.tsx +37 -4
  30. package/src/components/SandboxBanner.tsx +92 -0
  31. package/src/components/StaggerIn.tsx +63 -0
  32. package/src/components/StepHeader.tsx +15 -2
  33. package/src/components/StepView.tsx +26 -0
  34. package/src/components/glass/ChromeGlass.tsx +65 -0
  35. package/src/components/glass/FloatingSheet.tsx +242 -0
  36. package/src/components/glass/GlassSheet.tsx +50 -0
  37. package/src/components/glass/GlassSurface.tsx +31 -3
  38. package/src/components/stepHeaderMeta.tsx +3 -1
  39. package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
  40. package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
  41. package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
  42. package/src/config/business.ts +6 -6
  43. package/src/config/businessDetailsValidity.ts +52 -0
  44. package/src/config/businessPrefill.ts +72 -0
  45. package/src/config/businessSteps.ts +14 -3
  46. package/src/config/keyPeople.ts +111 -7
  47. package/src/config/keyPeopleCorporate.ts +19 -0
  48. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  49. package/src/config/keyPeopleOwners.ts +26 -0
  50. package/src/config/keyPeoplePrefill.ts +138 -0
  51. package/src/config/keyPeopleSectionDefs.ts +65 -0
  52. package/src/config/keyPeopleSections.ts +167 -0
  53. package/src/config/registrationHint.ts +1 -1
  54. package/src/config/stepOrder.ts +24 -4
  55. package/src/config/theme.ts +8 -2
  56. package/src/config/website.ts +36 -0
  57. package/src/config/workflowMerge.ts +5 -0
  58. package/src/emrtd/activeAuth.ts +100 -0
  59. package/src/emrtd/extras.ts +76 -0
  60. package/src/emrtd/files.ts +5 -0
  61. package/src/emrtd/index.ts +1 -0
  62. package/src/emrtd/read.ts +8 -1
  63. package/src/emrtd/session.ts +41 -0
  64. package/src/emrtd/stages.ts +11 -0
  65. package/src/lib/contact-recovery.ts +44 -0
  66. package/src/lib/multi-id.ts +210 -0
  67. package/src/lib/resubmit.ts +130 -0
  68. package/src/lib/screen-corners.ts +50 -0
  69. package/src/lib/step-log.ts +24 -3
  70. package/src/lib/use-multi-id-plan.ts +29 -0
  71. package/src/liveness/useModelReady.ts +65 -0
  72. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  73. package/src/screens/ApplicantRoleStep.tsx +9 -5
  74. package/src/screens/BusinessCheckPanel.tsx +89 -0
  75. package/src/screens/BusinessDetailsFields.tsx +109 -0
  76. package/src/screens/BusinessDetailsStep.tsx +131 -145
  77. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  78. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  79. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  80. package/src/screens/BusinessPickedCard.tsx +86 -0
  81. package/src/screens/BusinessPickedSection.tsx +51 -0
  82. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  83. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  84. package/src/screens/BusinessSearch.tsx +199 -0
  85. package/src/screens/BusinessSearchResults.tsx +115 -0
  86. package/src/screens/CompanyInfoFields.tsx +86 -51
  87. package/src/screens/ConsentStep.tsx +31 -0
  88. package/src/screens/ContactDestinationField.tsx +1 -0
  89. package/src/screens/ContactVerificationStep.tsx +34 -3
  90. package/src/screens/IdTypeStep.tsx +12 -5
  91. package/src/screens/KeepLinksSheet.tsx +88 -0
  92. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  93. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  94. package/src/screens/KeyPeoplePending.tsx +154 -0
  95. package/src/screens/KeyPeopleSection.tsx +162 -0
  96. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  97. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  98. package/src/screens/KeyPersonCard.tsx +36 -4
  99. package/src/screens/KeyPersonForm.tsx +195 -36
  100. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  101. package/src/screens/KeyPersonOwners.tsx +109 -0
  102. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  103. package/src/screens/KeyPersonSheet.tsx +92 -108
  104. package/src/screens/LivenessStep.tsx +18 -0
  105. package/src/screens/NfcStep.tsx +14 -0
  106. package/src/screens/ProofOfAddressStep.tsx +6 -3
  107. package/src/screens/SubmittedStep.tsx +66 -10
  108. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  109. package/src/screens/useAwaitingPeople.ts +90 -0
  110. package/src/services/api-types.ts +158 -2
  111. package/src/services/api-verify-types.ts +53 -1
  112. package/src/services/api.ts +104 -0
  113. package/src/services/deviceMetadata.ts +1 -1
  114. package/src/services/errors.ts +10 -2
  115. package/src/services/workflowGate.ts +1 -0
  116. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  117. package/src/store/businessCheck.ts +113 -0
  118. package/src/store/derive.ts +15 -0
  119. package/src/store/kycStore.ts +241 -14
  120. package/src/store/serverConfig.ts +2 -0
  121. package/src/store/session.ts +208 -0
  122. package/src/store/state.ts +155 -2
  123. package/src/store/submit.ts +63 -13
  124. package/src/store/submitApplicant.ts +5 -11
  125. package/src/types/business.ts +23 -2
  126. package/src/types/config.ts +29 -3
  127. package/src/types/verification.ts +1 -1
  128. package/src/types/workflow.ts +15 -0
  129. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -0,0 +1,107 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { resolveIdTypeDefinition } from '../config/idTypes';
6
+ import type { MultiIdPlan } from '../lib/multi-id';
7
+ import type { MultiIdSlot } from '../store/state';
8
+ import { useEffectiveCountry, useKyc, useTheme } from './runtime';
9
+ import { MyazaText } from './Typography';
10
+ import { Icon } from './Icon';
11
+
12
+ // The multi-ID run's position strip: one chip per check, filling in with the
13
+ // picked ID's name as each check commits, the current one highlighted.
14
+ //
15
+ // A PORT of the web SDK's MultiIdProgress. Rendered above the picker, the
16
+ // evidence steps and liveness so a reader always knows which of the run's IDs
17
+ // the screen is about and how many remain — without it a three-ID run is three
18
+ // visits to the same-looking screen with nothing saying which is which.
19
+
20
+ export function MultiIdProgress({ plan }: { plan: MultiIdPlan }): React.ReactElement {
21
+ const { colors } = useTheme();
22
+ const country = useEffectiveCountry();
23
+ const serverConfig = useKyc((s) => s.serverConfig);
24
+ const slots: MultiIdSlot[] = useKyc((s) => s.multiIdSlots);
25
+
26
+ const labelFor = (idType: string): string => {
27
+ const row = serverConfig.idTypes.find(
28
+ (r) => r.country === country && r.idType === idType,
29
+ );
30
+ return resolveIdTypeDefinition(country, idType, {
31
+ label: row?.label,
32
+ requiresDocumentCapture: row?.requiresDocumentCapture,
33
+ scanSides: row?.scanSides,
34
+ supportsNfc: row?.supportsNfc,
35
+ }).label;
36
+ };
37
+
38
+ return (
39
+ <View
40
+ accessibilityLabel={`ID ${Math.min(plan.index + 1, plan.count)} of ${plan.count}`}
41
+ style={{
42
+ flexDirection: 'row',
43
+ alignItems: 'center',
44
+ gap: spacing.sm,
45
+ marginBottom: spacing.md,
46
+ paddingHorizontal: spacing.md,
47
+ paddingVertical: spacing.sm,
48
+ borderRadius: radius.md,
49
+ borderWidth: 1,
50
+ borderColor: colors.border,
51
+ backgroundColor: colors.backgroundSecondary,
52
+ }}
53
+ >
54
+ {Array.from({ length: plan.count }, (_, i) => {
55
+ const committed = slots[i];
56
+ const active = i === plan.index;
57
+ return (
58
+ <View
59
+ key={i}
60
+ style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, minWidth: 0 }}
61
+ >
62
+ {i > 0 && (
63
+ <View
64
+ style={{
65
+ height: 1,
66
+ width: 16,
67
+ backgroundColor: committed || active ? colors.primary : colors.border,
68
+ opacity: committed || active ? 0.5 : 1,
69
+ }}
70
+ />
71
+ )}
72
+ <View
73
+ style={{
74
+ height: 20,
75
+ width: 20,
76
+ borderRadius: 10,
77
+ alignItems: 'center',
78
+ justifyContent: 'center',
79
+ backgroundColor: committed ? colors.primary : 'transparent',
80
+ borderWidth: committed ? 0 : active ? 2 : 1,
81
+ borderColor: active ? colors.primary : colors.border,
82
+ }}
83
+ >
84
+ {committed ? (
85
+ <Icon name="check" size={12} color={colors.onPrimary} />
86
+ ) : (
87
+ <MyazaText
88
+ variant="bodySmall"
89
+ color={active ? colors.primary : colors.textMuted}
90
+ >
91
+ {String(i + 1)}
92
+ </MyazaText>
93
+ )}
94
+ </View>
95
+ <MyazaText
96
+ variant="bodySmall"
97
+ color={active ? colors.textDark : colors.textMuted}
98
+ numberOfLines={1}
99
+ >
100
+ {committed ? labelFor(committed.idType) : `ID ${i + 1}`}
101
+ </MyazaText>
102
+ </View>
103
+ );
104
+ })}
105
+ </View>
106
+ );
107
+ }
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from 'react';
2
- import { Modal, Pressable, ScrollView, View } from 'react-native';
2
+ import { Pressable, ScrollView, View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import {
@@ -13,6 +13,7 @@ import {
13
13
  import { Icon } from './Icon';
14
14
  import { useTheme } from './runtime';
15
15
  import { MyazaText } from './Typography';
16
+ import { FloatingSheet } from './glass/FloatingSheet';
16
17
 
17
18
  /**
18
19
  * A date field that opens a REAL calendar picker, replacing the "type
@@ -117,22 +118,8 @@ function DatePickerSheet({
117
118
  !!initial && initial.year === cursor.year && initial.month0 === cursor.month0 && initial.day === d;
118
119
 
119
120
  return (
120
- <Modal transparent animationType="fade" onRequestClose={onClose}>
121
- <Pressable
122
- onPress={onClose}
123
- style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
124
- >
125
- {/* Stop the backdrop press from closing when the card itself is tapped. */}
126
- <Pressable
127
- onPress={() => undefined}
128
- style={{
129
- backgroundColor: colors.background,
130
- borderTopLeftRadius: radius.xl,
131
- borderTopRightRadius: radius.xl,
132
- padding: spacing.md,
133
- paddingBottom: spacing.xl,
134
- }}
135
- >
121
+ <FloatingSheet visible onClose={onClose} closeLabel="Close date picker">
122
+ <View style={{ padding: spacing.md, paddingTop: spacing.sm }}>
136
123
  {/* Month header: step, or tap the title to jump years. */}
137
124
  <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }}>
138
125
  <Pressable
@@ -233,8 +220,7 @@ function DatePickerSheet({
233
220
  ))}
234
221
  </>
235
222
  )}
236
- </Pressable>
237
- </Pressable>
238
- </Modal>
223
+ </View>
224
+ </FloatingSheet>
239
225
  );
240
226
  }
@@ -39,6 +39,18 @@ export interface MyazaInputProps {
39
39
  /** Rendered inside the field, after the text — a unit like "%". Same
40
40
  * bordered-row treatment as `prefix`. */
41
41
  suffix?: React.ReactNode;
42
+ /** Field height override — the results filter runs at 40 where every other
43
+ * input is 48, matching the web SDK's h-10 filter beside h-12 inputs. */
44
+ height?: number;
45
+ /** Text size override, for the compact filter (web: text-sm = 14). */
46
+ fontSize?: number;
47
+ /**
48
+ * Off for proper-noun fields (company names, registration numbers): iOS
49
+ * autocorrect rewrites them right before the person submits, so a search
50
+ * for the company they typed silently becomes a search for a word the
51
+ * keyboard preferred. Defaults on, like the platform.
52
+ */
53
+ autoCorrect?: boolean;
42
54
  onFocus?: FocusHandler;
43
55
  onBlur?: BlurHandler;
44
56
  }
@@ -57,6 +69,9 @@ export function MyazaInput({
57
69
  autoFocus = false,
58
70
  prefix,
59
71
  suffix,
72
+ height,
73
+ fontSize,
74
+ autoCorrect = true,
60
75
  onFocus,
61
76
  onBlur,
62
77
  }: MyazaInputProps): React.ReactElement {
@@ -95,7 +110,7 @@ export function MyazaInput({
95
110
  style={{
96
111
  flexDirection: 'row',
97
112
  alignItems: 'center',
98
- height: sizing.inputHeight,
113
+ height: height ?? sizing.inputHeight,
99
114
  borderWidth,
100
115
  borderColor,
101
116
  borderRadius: radius.sm,
@@ -112,12 +127,14 @@ export function MyazaInput({
112
127
  placeholderTextColor={colors.textMuted}
113
128
  keyboardType={keyboardType}
114
129
  autoCapitalize={autoCapitalize}
130
+ autoCorrect={autoCorrect}
131
+ spellCheck={autoCorrect}
115
132
  maxLength={maxLength}
116
133
  editable={editable}
117
134
  autoFocus={autoFocus}
118
135
  onFocus={handleFocus}
119
136
  onBlur={handleBlur}
120
- style={{ flex: 1, height: '100%', color: colors.textDark, fontSize: 16, fontFamily }}
137
+ style={{ flex: 1, height: '100%', color: colors.textDark, fontSize: fontSize ?? 16, fontFamily }}
121
138
  />
122
139
  {suffix ? <View style={{ width: spacing.sm }} /> : null}
123
140
  {suffix}
@@ -130,13 +147,15 @@ export function MyazaInput({
130
147
  placeholderTextColor={colors.textMuted}
131
148
  keyboardType={keyboardType}
132
149
  autoCapitalize={autoCapitalize}
150
+ autoCorrect={autoCorrect}
151
+ spellCheck={autoCorrect}
133
152
  maxLength={maxLength}
134
153
  editable={editable}
135
154
  autoFocus={autoFocus}
136
155
  onFocus={handleFocus}
137
156
  onBlur={handleBlur}
138
157
  style={{
139
- height: sizing.inputHeight,
158
+ height: height ?? sizing.inputHeight,
140
159
  borderWidth,
141
160
  borderColor,
142
161
  borderRadius: radius.sm,
@@ -145,7 +164,7 @@ export function MyazaInput({
145
164
  paddingHorizontal: spacing.md + (2 - borderWidth),
146
165
  color: colors.textDark,
147
166
  backgroundColor: colors.background,
148
- fontSize: 16,
167
+ fontSize: fontSize ?? 16,
149
168
  fontFamily,
150
169
  }}
151
170
  />
@@ -1,11 +1,12 @@
1
1
  import React, { useState } from 'react';
2
- import { Modal, Pressable, ScrollView, TextInput, useWindowDimensions, View } from 'react-native';
2
+ import { Pressable, ScrollView, TextInput, useWindowDimensions, View } from 'react-native';
3
3
 
4
4
  import { radius, spacing } from '../config/theme';
5
5
  import { useTheme } from './runtime';
6
6
  import { useInputFontFamily } from './fonts';
7
7
  import { MyazaText } from './Typography';
8
8
  import { Icon } from './Icon';
9
+ import { FloatingSheet } from './glass/FloatingSheet';
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // The select field — a collapsed value that opens a sheet of options.
@@ -131,51 +132,23 @@ export function MyazaSelect<T extends string | number>({
131
132
  <Icon name="chevron-down" size={18} color={colors.textSecondary} />
132
133
  </Pressable>
133
134
 
134
- <Modal
135
+ <FloatingSheet
135
136
  visible={open}
136
- transparent
137
- animationType="slide"
138
- onRequestClose={() => {
137
+ onClose={() => {
139
138
  setOpen(false);
140
139
  setQuery('');
141
140
  }}
141
+ // Long option lists stay inside the sheet and scroll.
142
+ maxHeight={windowHeight * 0.6}
143
+ closeLabel={`Close ${sheetTitle ?? hint}`}
142
144
  >
143
- {/* Tapping the backdrop dismisses, matching the platform sheet. */}
144
- <Pressable
145
- onPress={() => {
146
- setOpen(false);
147
- setQuery('');
148
- }}
149
- style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' }}
150
- >
151
- {/* Stops a tap inside the sheet from closing it. */}
152
- <Pressable
153
- onPress={() => {}}
154
- style={{
155
- backgroundColor: colors.background,
156
- borderTopLeftRadius: radius.lg,
157
- borderTopRightRadius: radius.lg,
158
- paddingBottom: spacing.lg,
159
- // Long option lists stay inside the sheet and scroll.
160
- maxHeight: windowHeight * 0.6,
161
- }}
162
- >
163
- <View
164
- style={{
165
- alignSelf: 'center',
166
- width: 36,
167
- height: 4,
168
- borderRadius: radius.full,
169
- backgroundColor: colors.border,
170
- marginTop: spacing.sm,
171
- }}
172
- />
173
145
  <MyazaText
174
146
  variant="body"
175
147
  style={{
176
148
  fontWeight: '700',
177
149
  paddingHorizontal: spacing.md,
178
- paddingTop: spacing.md,
150
+ // Tight under the handle, the way the system sheet titles sit.
151
+ paddingTop: spacing.xs,
179
152
  paddingBottom: spacing.sm,
180
153
  }}
181
154
  >
@@ -222,7 +195,13 @@ export function MyazaSelect<T extends string | number>({
222
195
  </View>
223
196
  ) : null}
224
197
 
225
- <ScrollView bounces={false} keyboardShouldPersistTaps="handled">
198
+ <ScrollView
199
+ bounces={false}
200
+ keyboardShouldPersistTaps="handled"
201
+ // The floating shell has no bottom padding of its own, so the
202
+ // last option must not sit flush against the rounded corner.
203
+ contentContainerStyle={{ paddingBottom: spacing.lg }}
204
+ >
226
205
  {visible.length === 0 ? (
227
206
  <MyazaText
228
207
  variant="bodySmall"
@@ -280,9 +259,7 @@ export function MyazaSelect<T extends string | number>({
280
259
  );
281
260
  })}
282
261
  </ScrollView>
283
- </Pressable>
284
- </Pressable>
285
- </Modal>
262
+ </FloatingSheet>
286
263
  </>
287
264
  );
288
265
  }
@@ -0,0 +1,102 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import { PanResponder, View } from 'react-native';
3
+
4
+ import { useTheme } from './runtime';
5
+
6
+ // ─── A 0–100 slider for the ownership field ──────────────────────────────────
7
+ //
8
+ // The fast coarse gesture beside the exact box: dragging writes whole numbers
9
+ // into the same field, and a register stake like 48.42 is still typed (no
10
+ // thumb lands on it). Dependency-free — a track, a fill and a thumb under one
11
+ // PanResponder — because a native slider module is a heavy import for one
12
+ // field. Mirrors the web SDK's range input under the same box.
13
+
14
+ const THUMB = 22;
15
+ const TRACK = 5;
16
+
17
+ export function OwnershipSlider({
18
+ value,
19
+ onChange,
20
+ }: {
21
+ /** The current stake 0–100; an undeclared one rests the thumb at 0. */
22
+ value: number;
23
+ onChange: (next: number) => void;
24
+ }): React.ReactElement {
25
+ const { colors } = useTheme();
26
+ const [width, setWidth] = useState(0);
27
+ const widthRef = useRef(0);
28
+ const onChangeRef = useRef(onChange);
29
+ onChangeRef.current = onChange;
30
+
31
+ const setFromX = (x: number): void => {
32
+ const usable = widthRef.current - THUMB;
33
+ if (usable <= 0) return;
34
+ const ratio = (x - THUMB / 2) / usable;
35
+ const next = Math.round(Math.min(1, Math.max(0, ratio)) * 100);
36
+ onChangeRef.current(next);
37
+ };
38
+
39
+ const pan = useRef(
40
+ PanResponder.create({
41
+ onStartShouldSetPanResponder: () => true,
42
+ onMoveShouldSetPanResponder: () => true,
43
+ onPanResponderGrant: (e) => setFromX(e.nativeEvent.locationX),
44
+ onPanResponderMove: (e) => setFromX(e.nativeEvent.locationX),
45
+ }),
46
+ ).current;
47
+
48
+ const clamped = Math.min(100, Math.max(0, value));
49
+ const left = width > 0 ? (clamped / 100) * (width - THUMB) : 0;
50
+
51
+ return (
52
+ <View
53
+ {...pan.panHandlers}
54
+ onLayout={(e) => {
55
+ widthRef.current = e.nativeEvent.layout.width;
56
+ setWidth(e.nativeEvent.layout.width);
57
+ }}
58
+ accessibilityRole="adjustable"
59
+ accessibilityLabel="Ownership percentage"
60
+ accessibilityValue={{ min: 0, max: 100, now: clamped }}
61
+ // Tall hit area; the visible track stays thin.
62
+ style={{ height: 32, justifyContent: 'center' }}
63
+ >
64
+ <View
65
+ style={{
66
+ height: TRACK,
67
+ borderRadius: TRACK / 2,
68
+ backgroundColor: colors.border,
69
+ }}
70
+ />
71
+ <View
72
+ pointerEvents="none"
73
+ style={{
74
+ position: 'absolute',
75
+ left: 0,
76
+ width: left + THUMB / 2,
77
+ height: TRACK,
78
+ borderRadius: TRACK / 2,
79
+ backgroundColor: colors.primary,
80
+ }}
81
+ />
82
+ <View
83
+ pointerEvents="none"
84
+ style={{
85
+ position: 'absolute',
86
+ left,
87
+ width: THUMB,
88
+ height: THUMB,
89
+ borderRadius: THUMB / 2,
90
+ backgroundColor: colors.primary,
91
+ borderWidth: 3,
92
+ borderColor: colors.background,
93
+ shadowColor: '#000',
94
+ shadowOpacity: 0.15,
95
+ shadowRadius: 3,
96
+ shadowOffset: { width: 0, height: 1 },
97
+ elevation: 2,
98
+ }}
99
+ />
100
+ </View>
101
+ );
102
+ }
@@ -1,4 +1,4 @@
1
- import React, { useMemo, useState } from 'react';
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { Pressable, View } from 'react-native';
3
3
 
4
4
  import {
@@ -34,13 +34,25 @@ import { COUNTRY_NAMES } from '../config/countryNames.g';
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
36
  export function PhoneNumberInput({
37
+ value,
37
38
  defaultCountry,
38
39
  disabled,
40
+ autoFocus = false,
39
41
  onChange,
40
42
  }: {
43
+ /**
44
+ * E.164 seed. The field itself stays uncontrolled (the formatter owns the
45
+ * text), but a value arriving after mount — the register's, prefilled a
46
+ * moment later — has to reach the field, or we hold a number the applicant
47
+ * can neither see nor correct and submit it as though they gave it.
48
+ */
49
+ value?: string;
41
50
  /** Seeds the picker (ISO-2). Falls back to NG. */
42
51
  defaultCountry?: string;
43
52
  disabled?: boolean;
53
+ /** Focus + keyboard on mount. Opt-in: right for a single-field OTP screen,
54
+ * wrong for a row in a company-profile form. */
55
+ autoFocus?: boolean;
44
56
  /** Fires on every edit with the E.164 value ('' until parseable) + validity. */
45
57
  onChange: (value: { e164: string; isValid: boolean; country: string }) => void;
46
58
  }): React.ReactElement {
@@ -64,11 +76,32 @@ export function PhoneNumberInput({
64
76
  [],
65
77
  );
66
78
 
67
- const seed = (defaultCountry?.toUpperCase() ?? 'NG') as CountryCode;
79
+ // A supplied number decides its OWN dial code — it is a fact, where the
80
+ // country prop is a guess. Falls through to the guess when there is no
81
+ // number or it cannot be parsed.
82
+ const supplied = value ? parsePhoneNumberFromString(value) : null;
83
+ const seed = (supplied?.country ??
84
+ defaultCountry?.toUpperCase() ??
85
+ 'NG') as CountryCode;
68
86
  const [country, setCountry] = useState<CountryCode>(
69
87
  options.some((o) => o.code === seed) ? seed : ('NG' as CountryCode),
70
88
  );
71
- const [national, setNational] = useState('');
89
+ const [national, setNational] = useState(() =>
90
+ supplied ? formatNationalNumber(supplied.nationalNumber, seed) : '',
91
+ );
92
+ // A number that arrives AFTER mount — the register's, prefilled a moment
93
+ // later — has to reach the field. Seeding is one-way and keyed on the value,
94
+ // so the applicant's own edits are never fought.
95
+ const seededRef = useRef(value ?? '');
96
+ useEffect(() => {
97
+ if (!value || value === seededRef.current) return;
98
+ seededRef.current = value;
99
+ const parsed = parsePhoneNumberFromString(value);
100
+ if (!parsed) return;
101
+ const next = (parsed.country ?? country) as CountryCode;
102
+ setCountry(next);
103
+ setNational(formatNationalNumber(parsed.nationalNumber, next));
104
+ }, [value, country]);
72
105
  const [open, setOpen] = useState(false);
73
106
 
74
107
  const selected = options.find((o) => o.code === country);
@@ -132,7 +165,7 @@ export function PhoneNumberInput({
132
165
  placeholder="803 123 4567"
133
166
  keyboardType="phone-pad"
134
167
  editable={!disabled}
135
- autoFocus
168
+ autoFocus={autoFocus}
136
169
  />
137
170
  </View>
138
171
  </View>
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import type { KycState } from '../store/state';
6
+ import { useKyc } from './runtime';
7
+ import { Icon } from './Icon';
8
+ import { MyazaText } from './Typography';
9
+
10
+ /**
11
+ * "You are not in production" strip, shown above the sheet's header.
12
+ *
13
+ * A sandbox flow is pixel-identical to a live one, which is the point — you are
14
+ * testing the real thing — and also the hazard: screenshots get mistaken for
15
+ * production incidents, testers wonder why a real passport was rejected, and a
16
+ * `pk_test_` key shipped to production looks like it works right up until
17
+ * nobody is actually verified.
18
+ *
19
+ * Environment comes from the SERVER (`/config`), not the API key prefix. Hosted
20
+ * sessions authenticate with an `hs_` handoff token that carries no environment
21
+ * slot, so key-sniffing would leave exactly the surface an end user sees
22
+ * unlabelled.
23
+ *
24
+ * DEVELOPMENT is labelled too, and differently: it runs the real pipeline
25
+ * against staging provider credentials, so "test data only" would be a lie
26
+ * there. 1:1 with the web and Flutter SDKs.
27
+ */
28
+ /**
29
+ * Whether the banner will render. The sheet needs this to decide who owns the
30
+ * safe-area top inset: the banner sits ABOVE the header, so when it shows it is
31
+ * the topmost element and must clear the status bar itself — otherwise the
32
+ * header pads for an inset the banner already crossed, and the strip is drawn
33
+ * under the clock on Android's edge-to-edge modals.
34
+ */
35
+ export function useSandboxBannerVisible(): boolean {
36
+ const environment = useKyc((s: KycState) => s.serverConfig.environment);
37
+ return environment === 'SANDBOX' || environment === 'DEVELOPMENT';
38
+ }
39
+
40
+ export function SandboxBanner({
41
+ topInset = 0,
42
+ }: {
43
+ /** Safe-area top inset to absorb, when the banner is the topmost element. */
44
+ topInset?: number;
45
+ }): React.ReactElement | null {
46
+ const environment = useKyc((s: KycState) => s.serverConfig.environment);
47
+ if (environment !== 'SANDBOX' && environment !== 'DEVELOPMENT') return null;
48
+
49
+ const sandbox = environment === 'SANDBOX';
50
+
51
+ return (
52
+ <View
53
+ accessibilityRole="alert"
54
+ style={{
55
+ flexDirection: 'row',
56
+ alignItems: 'center',
57
+ justifyContent: 'center',
58
+ gap: spacing.xs + 2,
59
+ // Amber, not the brand colour: this is an out-of-band status about the
60
+ // session, and the org's palette would read as part of their flow.
61
+ backgroundColor: AMBER_TINT,
62
+ paddingHorizontal: spacing.md,
63
+ // The tint extends up through the status bar so the strip reads as one
64
+ // band rather than floating below a bare gap.
65
+ paddingTop: topInset + spacing.xs + 2,
66
+ paddingBottom: spacing.xs + 2,
67
+ }}
68
+ >
69
+ {/* Flask, not an alert mark: nothing is wrong — this is a statement about
70
+ WHICH environment you are in ("test environment", "test data only").
71
+ An alert glyph reads as a fault and sends testers hunting for one.
72
+ Same lucide icon the web SDK uses; Flutter mirrors it with
73
+ Icons.science_outlined. */}
74
+ <Icon name="flask" size={13} color={AMBER_INK} />
75
+ <MyazaText
76
+ variant="bodySmall"
77
+ color={AMBER_INK}
78
+ style={{ fontSize: 11, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase' }}
79
+ >
80
+ {sandbox ? 'Sandbox' : 'Development'}
81
+ </MyazaText>
82
+ <MyazaText variant="bodySmall" color={AMBER_INK} style={{ fontSize: 11, flexShrink: 1 }}>
83
+ {sandbox ? 'Test data only, no real checks run' : 'Test environment, results are not live'}
84
+ </MyazaText>
85
+ </View>
86
+ );
87
+ }
88
+
89
+ // Fixed rather than themed: the strip must read identically in light and dark,
90
+ // and it deliberately does not belong to the org's palette.
91
+ const AMBER_TINT = 'rgba(245, 158, 11, 0.18)';
92
+ const AMBER_INK = '#B45309';
@@ -0,0 +1,63 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { AccessibilityInfo, Animated } from 'react-native';
3
+
4
+ /**
5
+ * Whether the OS asks for reduced motion. Shared by the entrance/skeleton
6
+ * animations so a system-level preference silences all of them at once.
7
+ */
8
+ export function useReduceMotion(): boolean {
9
+ const [reduced, setReduced] = useState(false);
10
+ useEffect(() => {
11
+ let cancelled = false;
12
+ void AccessibilityInfo.isReduceMotionEnabled().then((r) => {
13
+ if (!cancelled) setReduced(r);
14
+ });
15
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduced);
16
+ return () => {
17
+ cancelled = true;
18
+ sub.remove();
19
+ };
20
+ }, []);
21
+ return reduced;
22
+ }
23
+
24
+ /**
25
+ * Rise-and-fade entrance for list items, mirroring the web SDK's staggered
26
+ * `animate-slide-up`: each card enters in reading order, ~45ms apart, so a
27
+ * resolved list reads as the skeleton before it settling into place rather
28
+ * than a screen swap. Reduced motion renders children statically.
29
+ */
30
+ export function StaggerIn({
31
+ delayMs = 0,
32
+ children,
33
+ }: {
34
+ delayMs?: number;
35
+ children: React.ReactNode;
36
+ }): React.ReactElement {
37
+ const reduceMotion = useReduceMotion();
38
+ const progress = useRef(new Animated.Value(0)).current;
39
+
40
+ useEffect(() => {
41
+ Animated.timing(progress, {
42
+ toValue: 1,
43
+ duration: 300,
44
+ delay: delayMs,
45
+ useNativeDriver: true,
46
+ }).start();
47
+ }, [progress, delayMs]);
48
+
49
+ if (reduceMotion) return <>{children}</>;
50
+
51
+ return (
52
+ <Animated.View
53
+ style={{
54
+ opacity: progress,
55
+ transform: [
56
+ { translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }) },
57
+ ],
58
+ }}
59
+ >
60
+ {children}
61
+ </Animated.View>
62
+ );
63
+ }