@myazahq/kyc-sdk-react-native 2.4.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 (117) 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 -1
  16. package/src/MyazaKYC.tsx +12 -0
  17. package/src/components/DashedBorder.tsx +55 -0
  18. package/src/components/DialCodePicker.tsx +14 -7
  19. package/src/components/Icon.tsx +9 -0
  20. package/src/components/MultiIdProgress.tsx +107 -0
  21. package/src/components/MyazaInput.tsx +23 -4
  22. package/src/components/MyazaSelect.tsx +2 -1
  23. package/src/components/OwnershipSlider.tsx +102 -0
  24. package/src/components/PhoneNumberInput.tsx +37 -4
  25. package/src/components/StaggerIn.tsx +63 -0
  26. package/src/components/StepView.tsx +26 -0
  27. package/src/components/glass/FloatingSheet.tsx +73 -21
  28. package/src/components/glass/GlassSheet.tsx +14 -2
  29. package/src/components/stepHeaderMeta.tsx +3 -1
  30. package/src/config/business.ts +6 -6
  31. package/src/config/businessDetailsValidity.ts +52 -0
  32. package/src/config/businessPrefill.ts +72 -0
  33. package/src/config/businessSteps.ts +14 -3
  34. package/src/config/keyPeople.ts +111 -7
  35. package/src/config/keyPeopleCorporate.ts +19 -0
  36. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  37. package/src/config/keyPeopleOwners.ts +26 -0
  38. package/src/config/keyPeoplePrefill.ts +138 -0
  39. package/src/config/keyPeopleSectionDefs.ts +65 -0
  40. package/src/config/keyPeopleSections.ts +167 -0
  41. package/src/config/registrationHint.ts +1 -1
  42. package/src/config/stepOrder.ts +24 -4
  43. package/src/config/theme.ts +8 -2
  44. package/src/config/website.ts +36 -0
  45. package/src/config/workflowMerge.ts +5 -0
  46. package/src/emrtd/activeAuth.ts +100 -0
  47. package/src/emrtd/extras.ts +76 -0
  48. package/src/emrtd/files.ts +5 -0
  49. package/src/emrtd/index.ts +1 -0
  50. package/src/emrtd/read.ts +8 -1
  51. package/src/emrtd/session.ts +41 -0
  52. package/src/emrtd/stages.ts +11 -0
  53. package/src/lib/contact-recovery.ts +44 -0
  54. package/src/lib/multi-id.ts +210 -0
  55. package/src/lib/resubmit.ts +130 -0
  56. package/src/lib/screen-corners.ts +50 -0
  57. package/src/lib/step-log.ts +24 -3
  58. package/src/lib/use-multi-id-plan.ts +29 -0
  59. package/src/liveness/useModelReady.ts +65 -0
  60. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  61. package/src/screens/ApplicantRoleStep.tsx +9 -5
  62. package/src/screens/BusinessCheckPanel.tsx +89 -0
  63. package/src/screens/BusinessDetailsFields.tsx +109 -0
  64. package/src/screens/BusinessDetailsStep.tsx +131 -145
  65. package/src/screens/BusinessDocumentSlot.tsx +2 -3
  66. package/src/screens/BusinessDocumentsStep.tsx +1 -1
  67. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  68. package/src/screens/BusinessPickedCard.tsx +86 -0
  69. package/src/screens/BusinessPickedSection.tsx +51 -0
  70. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  71. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  72. package/src/screens/BusinessSearch.tsx +199 -0
  73. package/src/screens/BusinessSearchResults.tsx +115 -0
  74. package/src/screens/CompanyInfoFields.tsx +86 -51
  75. package/src/screens/ConsentStep.tsx +31 -0
  76. package/src/screens/ContactDestinationField.tsx +1 -0
  77. package/src/screens/ContactVerificationStep.tsx +34 -3
  78. package/src/screens/IdTypeStep.tsx +12 -5
  79. package/src/screens/KeepLinksSheet.tsx +88 -0
  80. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  81. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  82. package/src/screens/KeyPeoplePending.tsx +154 -0
  83. package/src/screens/KeyPeopleSection.tsx +162 -0
  84. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  85. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  86. package/src/screens/KeyPersonCard.tsx +36 -4
  87. package/src/screens/KeyPersonForm.tsx +195 -36
  88. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  89. package/src/screens/KeyPersonOwners.tsx +109 -0
  90. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  91. package/src/screens/KeyPersonSheet.tsx +92 -108
  92. package/src/screens/LivenessStep.tsx +18 -0
  93. package/src/screens/NfcStep.tsx +14 -0
  94. package/src/screens/ProofOfAddressStep.tsx +6 -3
  95. package/src/screens/SubmittedStep.tsx +66 -10
  96. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  97. package/src/screens/useAwaitingPeople.ts +90 -0
  98. package/src/services/api-types.ts +158 -2
  99. package/src/services/api-verify-types.ts +53 -1
  100. package/src/services/api.ts +104 -0
  101. package/src/services/deviceMetadata.ts +1 -1
  102. package/src/services/errors.ts +10 -2
  103. package/src/services/workflowGate.ts +1 -0
  104. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  105. package/src/store/businessCheck.ts +113 -0
  106. package/src/store/derive.ts +15 -0
  107. package/src/store/kycStore.ts +241 -14
  108. package/src/store/serverConfig.ts +2 -0
  109. package/src/store/session.ts +208 -0
  110. package/src/store/state.ts +155 -2
  111. package/src/store/submit.ts +63 -13
  112. package/src/store/submitApplicant.ts +5 -11
  113. package/src/types/business.ts +23 -2
  114. package/src/types/config.ts +22 -1
  115. package/src/types/verification.ts +1 -1
  116. package/src/types/workflow.ts +15 -0
  117. 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
+ }
@@ -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
  />
@@ -147,7 +147,8 @@ export function MyazaSelect<T extends string | number>({
147
147
  style={{
148
148
  fontWeight: '700',
149
149
  paddingHorizontal: spacing.md,
150
- paddingTop: spacing.md,
150
+ // Tight under the handle, the way the system sheet titles sit.
151
+ paddingTop: spacing.xs,
151
152
  paddingBottom: spacing.sm,
152
153
  }}
153
154
  >
@@ -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,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
+ }
@@ -18,6 +18,8 @@ import { BusinessKeyPeopleStep } from '../screens/BusinessKeyPeopleStep';
18
18
  import { BusinessDocumentsStep } from '../screens/BusinessDocumentsStep';
19
19
  import { ApplicantRoleStep } from '../screens/ApplicantRoleStep';
20
20
  import { NfcStep } from '../screens/NfcStep';
21
+ import { MultiIdProgress } from './MultiIdProgress';
22
+ import { useMultiIdPlan } from '../lib/use-multi-id-plan';
21
23
 
22
24
  // ---------------------------------------------------------------------------
23
25
  // The step router — which screen a step renders.
@@ -26,7 +28,31 @@ import { NfcStep } from '../screens/NfcStep';
26
28
  // and the step order, and nothing else.
27
29
  // ---------------------------------------------------------------------------
28
30
 
31
+ /** The steps a multi-ID run walks once PER ID — the ones whose screen is about
32
+ * one particular check and therefore needs the position strip above it. */
33
+ const MULTI_ID_STEPS: readonly KYCStep[] = [
34
+ 'id-type',
35
+ 'id-input',
36
+ 'document-capture',
37
+ 'nfc',
38
+ 'liveness',
39
+ ];
40
+
41
+ /** Wraps a step's screen with the multi-ID position strip when a run is active. */
29
42
  export function StepView({ step, onClose }: { step: KYCStep; onClose: () => void }): React.ReactElement {
43
+ const plan = useMultiIdPlan();
44
+ if (plan && MULTI_ID_STEPS.includes(step)) {
45
+ return (
46
+ <View style={{ flex: 1 }}>
47
+ <MultiIdProgress plan={plan} />
48
+ <View style={{ flex: 1 }}>{stepScreen(step, onClose)}</View>
49
+ </View>
50
+ );
51
+ }
52
+ return stepScreen(step, onClose);
53
+ }
54
+
55
+ function stepScreen(step: KYCStep, onClose: () => void): React.ReactElement {
30
56
  switch (step) {
31
57
  case 'consent':
32
58
  return <ConsentStep />;
@@ -8,9 +8,8 @@ import {
8
8
  View,
9
9
  type ViewStyle,
10
10
  } from 'react-native';
11
- import { initialWindowMetrics } from 'react-native-safe-area-context';
12
-
13
- import { radius, spacing } from '../../config/theme';
11
+ import { spacing } from '../../config/theme';
12
+ import { displayCornerRadius } from '../../lib/screen-corners';
14
13
  import { useTheme } from '../runtime';
15
14
  import { GlassIconButton } from '../GlassIconButton';
16
15
  import { GlassSheet } from './GlassSheet';
@@ -18,10 +17,12 @@ import { GlassSheet } from './GlassSheet';
18
17
  // ---------------------------------------------------------------------------
19
18
  // FloatingSheet — the shell every bottom sheet in the SDK sits in.
20
19
  //
21
- // It floats: inset from all three edges and rounded on every corner, rather
22
- // than welded to the bottom of the screen. That is what the glass is for. A
23
- // panel flush to the edge reads as part of the screen, so seeing through it
24
- // says nothing; a detached one reads as a layer that has risen above the flow.
20
+ // A floating card: detached from the screen's edges by a TIGHT gap (8px), all
21
+ // four corners rounded, a grab handle centred under a whisper of padding. The
22
+ // gap is deliberately small — the earlier float swam in margin and spent
23
+ // screen height a phone does not have, while welding it flush read as a
24
+ // different surface from the SDK's glass language. The 8px is what says
25
+ // "card above the page" without costing content room.
25
26
  //
26
27
  // It offers three ways out, because a sheet that can only be dismissed one way
27
28
  // is a trap for whichever user does not find that way:
@@ -34,6 +35,28 @@ import { GlassSheet } from './GlassSheet';
34
35
  // apart on handle size, dismiss threshold and safe-area maths.
35
36
  // ---------------------------------------------------------------------------
36
37
 
38
+ /** Detached from the screen edges by a tight, even gap. */
39
+ const FLOAT_GAP = spacing.sm;
40
+
41
+ /**
42
+ * The float's corner radius is PRESENTATION GRAMMAR, not branding — and it is
43
+ * computed the way Apple computes nested corners: CONCENTRIC with the display,
44
+ * i.e. the device's own corner radius minus the gap, so the sheet's curve runs
45
+ * parallel to the phone's. Square-cornered screens have no curve to be
46
+ * concentric with, so they keep the share-sheet look instead. Never the
47
+ * brand-scaled `radius` tokens: an org whose buttons are 4px square should not
48
+ * get a squared-off bottom sheet.
49
+ */
50
+ const screenRadius = displayCornerRadius();
51
+ const SHEET_RADIUS = screenRadius > 0 ? Math.max(screenRadius - FLOAT_GAP, 16) : 38;
52
+
53
+ /**
54
+ * The corner curve eats into the header, so the close button's inset SCALES
55
+ * with the radius — ~0.42·R is where the system sheet sits its own X. A fixed
56
+ * inset that cleared 38pt corners sat ON the curve at a 16-class phone's 47.
57
+ */
58
+ const CLOSE_INSET = Math.max(14, Math.round(SHEET_RADIUS * 0.42));
59
+
37
60
  /** How far down a drag must travel before release dismisses instead of springing back. */
38
61
  const DISMISS_DISTANCE = 90;
39
62
  /** A flick dismisses at any distance — velocity is intent, distance is only evidence of it. */
@@ -121,14 +144,16 @@ export function FloatingSheet({
121
144
  }),
122
145
  ).current;
123
146
 
124
- // Read once at module init like the rest of the SDK: these sheets are modal,
125
- // so there is no provider above them to read live insets from.
126
- const safeBottom = initialWindowMetrics?.insets?.bottom ?? 0;
127
-
147
+ // A TIGHT float: detached from the edges by a whisper (8px), not welded to
148
+ // them and not swimming in margin. The gap is what says "card above the
149
+ // page" — any more of it and the sheet loses height it needs for content.
128
150
  const panel: ViewStyle = {
129
- marginHorizontal: spacing.md,
130
- // Clear the home indicator, and never sit flush even where there is none.
131
- marginBottom: bottomOffset + Math.max(safeBottom, spacing.md),
151
+ marginHorizontal: FLOAT_GAP,
152
+ // The CARD sits low, like the system share sheet: a tight 8px off the
153
+ // screen edge, with the home indicator riding ON the card. Reserving the
154
+ // whole safe-area under it left the sheet hovering mid-air. The CONTENT
155
+ // still clears the indicator — that inset moves INSIDE the card (below).
156
+ marginBottom: bottomOffset > 0 ? bottomOffset + FLOAT_GAP : FLOAT_GAP,
132
157
  };
133
158
 
134
159
  return (
@@ -147,7 +172,18 @@ export function FloatingSheet({
147
172
  <Animated.View style={[panel, { transform: [{ translateY }] }]}>
148
173
  {/* Swallows taps so only the backdrop dismisses. */}
149
174
  <Pressable onPress={() => undefined}>
150
- <GlassSheet style={{ borderRadius: radius.xl, maxHeight, overflow: 'hidden' }}>
175
+ <GlassSheet
176
+ style={{
177
+ // Floating card: all four corners round, not just the top
178
+ // pair — at the SYSTEM sheet's radius, never the brand scale.
179
+ borderRadius: SHEET_RADIUS,
180
+ maxHeight,
181
+ // No bottom padding, deliberately: a scrolling body runs all
182
+ // the way to the lip and is CLIPPED by the curve — content
183
+ // sliding under the rounded edge, the way the system does it.
184
+ overflow: 'hidden',
185
+ }}
186
+ >
151
187
  {/* The drag lives on the header, NOT the whole panel: bodies here
152
188
  scroll, and a panel-wide responder fights the list for every
153
189
  downward swipe. */}
@@ -155,9 +191,15 @@ export function FloatingSheet({
155
191
  {...pan.panHandlers}
156
192
  style={{
157
193
  flexDirection: 'row',
158
- alignItems: 'center',
159
- paddingTop: spacing.sm,
160
- paddingHorizontal: spacing.sm,
194
+ // TOP, not centre. Centred, the handle sits against the
195
+ // tallest child, so the close button's height decided how far
196
+ // down the panel the grabber sat. Each now hangs off the top
197
+ // with its own offset, which is what puts the handle at the
198
+ // lip where a grabber belongs. Matches the Flutter sheet.
199
+ alignItems: 'flex-start',
200
+ // Radius-proportional (CLOSE_INSET): deep concentric corners
201
+ // need the button further from the edges than shallow ones.
202
+ paddingHorizontal: CLOSE_INSET,
161
203
  }}
162
204
  >
163
205
  <View style={{ flex: 1 }} />
@@ -165,12 +207,22 @@ export function FloatingSheet({
165
207
  <View
166
208
  style={{
167
209
  width: 36,
168
- height: 4,
169
- borderRadius: 2,
210
+ height: 5,
211
+ borderRadius: 2.5,
212
+ marginTop: 8,
170
213
  backgroundColor: colors.border,
171
214
  }}
172
215
  />
173
- <View style={{ flex: 1, alignItems: 'flex-end' }}>
216
+ {/* Pushed down as well as in: the button's own box already
217
+ holds the icon off its edges, so set flush to the top it
218
+ hung off the corner instead of tucking into it. */}
219
+ <View
220
+ style={{
221
+ flex: 1,
222
+ alignItems: 'flex-end',
223
+ marginTop: Math.min(16, Math.max(6, CLOSE_INSET - 6)),
224
+ }}
225
+ >
174
226
  <GlassIconButton
175
227
  icon="close"
176
228
  onPress={onClose}
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { type StyleProp, type ViewStyle } from 'react-native';
2
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
 
4
4
  import { useTheme } from '../runtime';
5
5
  import { GlassSurface } from './GlassSurface';
@@ -29,9 +29,21 @@ export interface GlassSheetProps {
29
29
  }
30
30
 
31
31
  export function GlassSheet({ children, style }: GlassSheetProps): React.ReactElement {
32
- const { colors } = useTheme();
32
+ const { colors, mode } = useTheme();
33
33
  return (
34
34
  <GlassSurface glassStyle="regular" fallbackColor={colors.background} style={style}>
35
+ {/* A WHISPER of scrim between the glass and the content — the sheet
36
+ must stay glass (the flow showing through is the point). This only
37
+ takes the edge off a strongly tinted flow so body text keeps
38
+ contrast; anything critical sits on its own alert panel besides.
39
+ Slightly heavier in light mode, where the wash was worst. */}
40
+ <View
41
+ pointerEvents="none"
42
+ style={[
43
+ StyleSheet.absoluteFill,
44
+ { backgroundColor: colors.background, opacity: mode === 'light' ? 0.35 : 0.18 },
45
+ ]}
46
+ />
35
47
  {children}
36
48
  </GlassSurface>
37
49
  );
@@ -47,7 +47,9 @@ export function stepHeaderMeta(
47
47
  case 'id-type':
48
48
  return { title: 'Select ID Type', description: "Choose the type of identification document you'd like to use." };
49
49
  case 'id-input':
50
- return { title: 'Enter Your Details', description: `Provide your ${label} for verification.` };
50
+ // The number is all this step asks for. The name comes from the
51
+ // integrator (mount props / the session), never the applicant.
52
+ return { title: `Enter your ${label}`, description: 'We’ll check this against the official record.' };
51
53
  case 'document-capture':
52
54
  // Phase-aware title/description live in the header (synced from the
53
55
  // capture screen via `documentCapturePhase`) — mirrors Flutter.