@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.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 (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. package/src/types/workflow.ts +97 -1
@@ -1,67 +1,58 @@
1
- import React, { useEffect, useRef, useState } from 'react';
2
- import { View } from 'react-native';
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
3
2
 
4
- import { radius, spacing } from '../config/theme';
5
3
  import { mapToKycError, safeReportError } from '../services/errors';
6
4
  import { contactStepFor, expiredContactChannels } from '../lib/contact-recovery';
7
- import type { KYCError, KYCSubmission } from '../types/verification';
8
- import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
9
- import { MyazaText } from '../components/Typography';
10
- import { MyazaButton } from '../components/MyazaButton';
11
- import { MyazaAlert } from '../components/MyazaAlert';
12
- import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
13
- import { Icon } from '../components/Icon';
14
- import { fillTokens } from '../utils/tokens';
15
- import { KeepLinksSheet } from './KeepLinksSheet';
16
- import { KeyPeopleAwaitList, rowsFromServer } from './KeyPeopleAwaitList';
17
- import { KeyPeoplePending } from './KeyPeoplePending';
18
- import { useAwaitingPeople } from './useAwaitingPeople';
5
+ import { KYCError, type KYCSubmission } from '../types/verification';
6
+ import { useKyc, useKycConfig, useKycStore } from '../components/runtime';
7
+ import { configScope } from '../lib/scope';
8
+ import { describeWaiting } from '../lib/result-copy';
9
+ import { biometricCopyFor } from '../lib/biometric-copy';
10
+ import { awaitSelfieUpload, IDLE_SELFIE_UPLOAD } from '../lib/selfie-upload-wait';
11
+ import { showsDoneButton, showsSelfieReview, waitsForResult } from '../config/biometricOptions';
12
+ import { SubmittedWaiting } from './SubmittedWaiting';
13
+ import { SubmittedResult } from './SubmittedResult';
14
+ import { SubmittedSuccess } from './SubmittedSuccess';
15
+ import { SubmittedError } from './SubmittedError';
19
16
 
20
- // Terminal screen — 1:1 with the Flutter SDK's SubmittedScreen. Calls
21
- // submitAsync on mount; renders submitting / success / error views with the same
22
- // badges + copy. The header title is empty for this step.
17
+ // Terminal step — 1:1 with the Flutter SDK's SubmittedScreen. Calls submitAsync
18
+ // on mount and renders one of: the waiting screen, the success screen, the
19
+ // error screen, or (on a flow that waits for its verdict) the result screen,
20
+ // which owns the whole wait from the first render. The views live in their
21
+ // own files (200-line rule); this file is the orchestration only. The header
22
+ // title is empty for this step.
23
23
 
24
24
  type Phase = 'submitting' | 'success' | 'error';
25
25
 
26
- const DEFAULT_SUCCESS_TITLE = 'Verification Submitted!';
27
- // The default description depends on WHAT was submitted. A KYB applicant told
28
- // "your identity verification has been submitted" is being told about the wrong
29
- // thing: they submitted a company. Mirrors the web SDK's successDescription.
30
- const defaultSuccessDescription = (isBusiness: boolean): string =>
31
- isBusiness
32
- ? "Your business verification has been submitted for review. You'll be notified of the result."
33
- : "Your identity verification has been submitted for review. You'll be notified of the result.";
34
-
35
- const ERROR_TITLES: Record<string, string> = {
36
- insufficient_credits: 'Credits Exhausted',
37
- invalid_api_key: 'Authentication Failed',
38
- feature_disabled: 'Verification Unavailable',
39
- upload_failed: 'Upload Failed',
40
- network_error: 'Connection Failed',
41
- };
42
-
43
26
  export function SubmittedStep({ onClose }: { onClose: () => void }): React.ReactElement {
44
- const { colors } = useTheme();
45
27
  const config = useKycConfig();
46
28
  const store = useKycStore();
47
29
  const existingResult = useKyc((s) => s.submissionResult);
48
30
 
49
31
  const [phase, setPhase] = useState<Phase>('submitting');
50
- // The people list comes from the SERVER once registry discovery settles —
51
- // the submit-time invites are a first draft the register can contradict
52
- // (it adds people the applicant never listed, including ones they removed).
53
- const sessionId = useKyc((s) => s.sessionId);
54
- const settled = useAwaitingPeople(store.getState().api, sessionId, phase === 'success');
55
32
  const [error, setError] = useState<KYCError | null>(null);
56
- const [keepLinksOpen, setKeepLinksOpen] = useState(false);
57
33
  const [retry, setRetry] = useState<{ attempt: number; total: number } | null>(null);
58
34
  const reportedRef = useRef(false);
59
35
  const kickedRef = useRef(false);
60
36
 
61
- const submit = React.useCallback(async () => {
37
+ const submit = useCallback(async () => {
62
38
  setPhase('submitting');
63
39
  setError(null);
64
40
  setRetry(null);
41
+ // The biometric scopes hand over BEFORE the selfie upload lands (the
42
+ // review is off, so nothing on the liveness step gated on it): wait for
43
+ // the upload's own record here, under the same loading screen. A failed
44
+ // upload was already reported to onError by the hook that ran it.
45
+ if (!showsSelfieReview(config)) {
46
+ const upload = await awaitSelfieUpload({
47
+ read: () => ({ selfieUpload: store.getState().selfieUpload, selfieMediaId: store.getState().mediaIds.selfie }),
48
+ subscribe: (listener) => store.subscribe(listener),
49
+ });
50
+ if (!upload.ok) {
51
+ setError(new KYCError('upload_failed', upload.message));
52
+ setPhase('error');
53
+ return;
54
+ }
55
+ }
65
56
  try {
66
57
  const result = await store.getState().submitAsync((attempt, total) => setRetry({ attempt, total }));
67
58
  const submission: KYCSubmission = {
@@ -105,134 +96,50 @@ export function SubmittedStep({ onClose }: { onClose: () => void }): React.React
105
96
  // eslint-disable-next-line react-hooks/exhaustive-deps
106
97
  }, []);
107
98
 
108
- if (phase === 'submitting') {
109
- const retrying = retry != null;
99
+ // Try Again after a failed selfie upload re-enters the liveness step, whose
100
+ // mount resumes the interrupted upload and hands straight back here. The
101
+ // record is reset first so this step waits for the NEW attempt rather than
102
+ // reading the old failure a second time.
103
+ const retryUpload = () => {
104
+ store.getState().setSelfieUpload(IDLE_SELFIE_UPLOAD);
105
+ store.getState().goToStep('liveness');
106
+ };
107
+
108
+ if (phase === 'error' && error) {
110
109
  return (
111
- <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl }}>
112
- <MyazaPulseLoader />
113
- <View style={{ height: spacing.lg }} />
114
- <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
115
- {retrying ? 'Reconnecting…' : 'Submitting your verification…'}
116
- </MyazaText>
117
- <View style={{ height: spacing.sm }} />
118
- <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
119
- {retrying ? `Connection issue — retrying (${retry!.attempt}/${retry!.total})…` : 'Please wait a moment.'}
120
- </MyazaText>
121
- </View>
110
+ <SubmittedError
111
+ error={error}
112
+ onRetry={error.code === 'upload_failed' ? retryUpload : error.code === 'network_error' ? () => void submit() : null}
113
+ onClose={onClose}
114
+ />
122
115
  );
123
116
  }
124
117
 
125
- if (phase === 'error' && error) {
126
- const title = ERROR_TITLES[error.code] ?? 'Submission Failed';
127
- const canRetry = error.code === 'network_error';
118
+ // A flow that waits for its verdict (a biometric re-authentication, by
119
+ // default) renders the result screen from the FIRST render: it shows the
120
+ // one loading screen through the upload, the submission and the poll, then
121
+ // the verdict. onSubmit has already fired by the time the id lands: the
122
+ // submission is a fact whichever screen follows it.
123
+ if (waitsForResult(config)) {
128
124
  return (
129
- <View style={{ flex: 1, justifyContent: 'space-between' }}>
130
- <View style={{ alignItems: 'center' }}>
131
- <View style={{ height: spacing.xl }} />
132
- <Badge bg={colors.errorBg} border={`${colors.error}4D`}>
133
- <Icon name="alert" size={44} color={colors.error} />
134
- </Badge>
135
- <View style={{ height: spacing.lg }} />
136
- <MyazaText variant="heading1" style={{ textAlign: 'center' }}>
137
- {title}
138
- </MyazaText>
139
- <View style={{ height: spacing.md }} />
140
- <View style={{ width: '100%', paddingHorizontal: spacing.md }}>
141
- <MyazaAlert variant="error" title="What happened" message={error.message} />
142
- </View>
143
- </View>
144
- <View style={{ flexDirection: 'row', gap: spacing.md }}>
145
- {canRetry ? (
146
- <View style={{ flex: 1 }}>
147
- <MyazaButton label="Try Again" variant="outline" leadingIcon="refresh" onPress={() => void submit()} />
148
- </View>
149
- ) : null}
150
- <View style={{ flex: 1 }}>
151
- <MyazaButton label="Close" onPress={onClose} />
152
- </View>
153
- </View>
154
- </View>
125
+ <SubmittedResult
126
+ verificationId={phase === 'success' ? (existingResult?.verificationId ?? null) : null}
127
+ retry={retry}
128
+ showDone={showsDoneButton(config)}
129
+ onClose={onClose}
130
+ />
155
131
  );
156
132
  }
157
133
 
158
- // success
159
- const title = config.success?.title ? fillTokens(config.success.title, config.userData) : DEFAULT_SUCCESS_TITLE;
160
- const description = config.success?.description
161
- ? fillTokens(config.success.description, config.userData)
162
- : defaultSuccessDescription(config.subjectType === 'business');
163
- // KYB: whether a people list is COMING (the submit minted invites). The list
164
- // itself renders from the server's reconciled view, never from this draft.
165
- const invitesExpected = store.getState().keyPeopleInvites.length > 0;
166
-
167
- // People still owing a check when the applicant leaves. The settled list is
168
- // authoritative once it arrives; before that, minted invites are the signal.
169
- const outstanding = settled
170
- ? settled.some((r) => r.status === 'pending' || r.status === 'failed')
171
- : invitesExpected;
172
- const sessionUrl = store.getState().sessionUrl;
173
- // Tapping Done with links still live: offer the web page those links live
174
- // on, because this screen dies with the app and the links die with it.
175
- // Workflow opt-out: `keyPeopleLinkRecovery: false` (on by default).
176
- const offerRecovery =
177
- outstanding && !!sessionUrl && config.keyPeopleLinkRecovery !== false;
178
-
179
- return (
180
- <View style={{ flex: 1, justifyContent: 'space-between' }}>
181
- <View style={{ alignItems: 'center' }}>
182
- <View style={{ height: spacing.xl }} />
183
- <Badge bg={colors.successBg} border={`${colors.success}4D`}>
184
- <Icon name="check" size={44} color={colors.success} />
185
- </Badge>
186
- <View style={{ height: spacing.lg }} />
187
- <MyazaText variant="heading1" style={{ textAlign: 'center' }}>
188
- {title}
189
- </MyazaText>
190
- <View style={{ height: spacing.sm }} />
191
- <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
192
- {description}
193
- </MyazaText>
194
- {settled
195
- ? settled.length > 0
196
- ? <KeyPeopleAwaitList rows={rowsFromServer(settled)} />
197
- : null
198
- : invitesExpected
199
- ? <KeyPeoplePending />
200
- : null}
201
- </View>
202
- <MyazaButton
203
- label="Done"
204
- onPress={() => (offerRecovery ? setKeepLinksOpen(true) : onClose())}
205
- />
206
- {offerRecovery ? (
207
- <KeepLinksSheet
208
- open={keepLinksOpen}
209
- url={sessionUrl!}
210
- onClose={() => setKeepLinksOpen(false)}
211
- onDone={() => {
212
- setKeepLinksOpen(false);
213
- onClose();
214
- }}
215
- />
216
- ) : null}
217
- </View>
218
- );
219
- }
134
+ if (phase === 'submitting') {
135
+ const copy = describeWaiting({
136
+ scope: configScope(config),
137
+ waitsForResult: false,
138
+ retry,
139
+ override: biometricCopyFor(config).waiting,
140
+ });
141
+ return <SubmittedWaiting title={copy.title} description={copy.description} retrying={retry != null} />;
142
+ }
220
143
 
221
- function Badge({ bg, border, children }: { bg: string; border: string; children: React.ReactNode }): React.ReactElement {
222
- return (
223
- <View
224
- style={{
225
- width: 88,
226
- height: 88,
227
- borderRadius: radius.full,
228
- backgroundColor: bg,
229
- borderWidth: 2,
230
- borderColor: border,
231
- alignItems: 'center',
232
- justifyContent: 'center',
233
- }}
234
- >
235
- {children}
236
- </View>
237
- );
144
+ return <SubmittedSuccess showDone={showsDoneButton(config)} onClose={onClose} />;
238
145
  }
@@ -0,0 +1,127 @@
1
+ import React, { useState } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { MyazaButton } from '../components/MyazaButton';
8
+ import { Icon } from '../components/Icon';
9
+ import { fillTokens } from '../utils/tokens';
10
+ import { configScope } from '../lib/scope';
11
+ import { KeepLinksSheet } from './KeepLinksSheet';
12
+ import { KeyPeopleAwaitList, rowsFromServer } from './KeyPeopleAwaitList';
13
+ import { PresenceExpectations } from '../components/PresenceBlocks';
14
+ import { StaggerIn } from '../components/StaggerIn';
15
+ import { KeyPeoplePending } from './KeyPeoplePending';
16
+ import { useAwaitingPeople } from './useAwaitingPeople';
17
+ import { Badge } from './SubmittedBadge';
18
+
19
+ // The submitted step's success screen (split from SubmittedStep, 200-line
20
+ // rule): the org's success copy, the presence expectations, the KYB key
21
+ // people still owing a check, and Done. `showDone` is the biometric scopes'
22
+ // `doneButton` option: off when the host app closes the flow itself.
23
+
24
+ const DEFAULT_SUCCESS_TITLE = 'Verification Submitted!';
25
+ // The default description depends on WHAT was submitted. A KYB applicant told
26
+ // "your identity verification has been submitted" is being told about the wrong
27
+ // thing: they submitted a company, and an address-only applicant submitted a
28
+ // pin. Mirrors the web SDK's successDescription (scope map included).
29
+ const SCOPE_DESCRIPTIONS: Record<string, string> = {
30
+ address: "Your address verification has been submitted. You'll be notified of the result.",
31
+ 'biometric-authentication': "Your face check has been submitted. You'll be notified of the result.",
32
+ 'biometric-enrollment': "Your selfie is saved as the reference for your future face checks.",
33
+ questionnaire: "Your answers have been submitted. You'll be notified of the result.",
34
+ contact: "Your contact verification has been submitted. You'll be notified of the result.",
35
+ };
36
+ const defaultSuccessDescription = (isBusiness: boolean, scope: string | null): string => {
37
+ if (scope && SCOPE_DESCRIPTIONS[scope]) return SCOPE_DESCRIPTIONS[scope]!;
38
+ return isBusiness
39
+ ? "Your business verification has been submitted for review. You'll be notified of the result."
40
+ : "Your identity verification has been submitted for review. You'll be notified of the result.";
41
+ };
42
+
43
+ export function SubmittedSuccess({ showDone, onClose }: { showDone: boolean; onClose: () => void }): React.ReactElement {
44
+ const { colors } = useTheme();
45
+ const config = useKycConfig();
46
+ const store = useKycStore();
47
+ // The people list comes from the SERVER once registry discovery settles —
48
+ // the submit-time invites are a first draft the register can contradict
49
+ // (it adds people the applicant never listed, including ones they removed).
50
+ const sessionId = useKyc((s) => s.sessionId);
51
+ const settled = useAwaitingPeople(store.getState().api, sessionId, true);
52
+ const [keepLinksOpen, setKeepLinksOpen] = useState(false);
53
+
54
+ const title = config.success?.title ? fillTokens(config.success.title, config.userData) : DEFAULT_SUCCESS_TITLE;
55
+ const description = config.success?.description
56
+ ? fillTokens(config.success.description, config.userData)
57
+ : defaultSuccessDescription(config.subjectType === 'business', configScope(config));
58
+ // KYB: whether a people list is COMING (the submit minted invites). The list
59
+ // itself renders from the server's reconciled view, never from this draft.
60
+ const invitesExpected = store.getState().keyPeopleInvites.length > 0;
61
+
62
+ // People still owing a check when the applicant leaves. The settled list is
63
+ // authoritative once it arrives; before that, minted invites are the signal.
64
+ const outstanding = settled
65
+ ? settled.some((r) => r.status === 'pending' || r.status === 'failed')
66
+ : invitesExpected;
67
+ const sessionUrl = store.getState().sessionUrl;
68
+ // Tapping Done with links still live: offer the web page those links live
69
+ // on, because this screen dies with the app and the links die with it.
70
+ // Workflow opt-out: `keyPeopleLinkRecovery: false` (on by default).
71
+ const offerRecovery = outstanding && !!sessionUrl && config.keyPeopleLinkRecovery !== false;
72
+
73
+ return (
74
+ <View style={{ flex: 1, justifyContent: 'space-between' }}>
75
+ <View style={{ alignItems: 'center' }}>
76
+ <View style={{ height: spacing.xl }} />
77
+ {/* Lands in the order Flutter's success view does: badge, title,
78
+ description, then Done (delays 0 / 200 / 320 / 600 ms). */}
79
+ <StaggerIn>
80
+ <Badge bg={colors.successBg} border={`${colors.success}4D`}>
81
+ <Icon name="check" size={44} color={colors.success} />
82
+ </Badge>
83
+ </StaggerIn>
84
+ <View style={{ height: spacing.lg }} />
85
+ <StaggerIn delayMs={200}>
86
+ <MyazaText variant="heading1" style={{ textAlign: 'center' }}>
87
+ {title}
88
+ </MyazaText>
89
+ </StaggerIn>
90
+ <View style={{ height: spacing.sm }} />
91
+ <StaggerIn delayMs={320}>
92
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center' }}>
93
+ {description}
94
+ </MyazaText>
95
+ </StaggerIn>
96
+ {config.addressCollection?.presence?.enabled === true && store.getState().address ? (
97
+ <PresenceExpectations />
98
+ ) : null}
99
+ {settled
100
+ ? settled.length > 0
101
+ ? <KeyPeopleAwaitList rows={rowsFromServer(settled)} />
102
+ : null
103
+ : invitesExpected
104
+ ? <KeyPeoplePending />
105
+ : null}
106
+ </View>
107
+ {showDone ? (
108
+ <StaggerIn delayMs={600}>
109
+ <MyazaButton label="Done" onPress={() => (offerRecovery ? setKeepLinksOpen(true) : onClose())} />
110
+ </StaggerIn>
111
+ ) : (
112
+ <View />
113
+ )}
114
+ {offerRecovery ? (
115
+ <KeepLinksSheet
116
+ open={keepLinksOpen}
117
+ url={sessionUrl!}
118
+ onClose={() => setKeepLinksOpen(false)}
119
+ onDone={() => {
120
+ setKeepLinksOpen(false);
121
+ onClose();
122
+ }}
123
+ />
124
+ ) : null}
125
+ </View>
126
+ );
127
+ }
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { useTheme } from '../components/runtime';
6
+ import { MyazaText } from '../components/Typography';
7
+ import { MyazaPulseLoader } from '../components/MyazaPulseLoader';
8
+
9
+ // ─── The one loading screen ─────────────────────────────────────────────────
10
+ //
11
+ // Every wait after the capture renders THIS, with copy from
12
+ // lib/result-copy.ts: the selfie upload, the submission and (on a flow that
13
+ // waits for its verdict) the status poll all show one loader under one title.
14
+ // Three screens with three sentences read as three things going wrong; one
15
+ // screen reads as the check running. A retry in flight swaps only the
16
+ // description, in the warning colour.
17
+
18
+ export function SubmittedWaiting({
19
+ title,
20
+ description,
21
+ retrying = false,
22
+ }: {
23
+ title: string;
24
+ description: string;
25
+ retrying?: boolean;
26
+ }): React.ReactElement {
27
+ const { colors } = useTheme();
28
+ return (
29
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: spacing.xl }}>
30
+ <MyazaPulseLoader />
31
+ <View style={{ height: spacing.lg }} />
32
+ <MyazaText variant="heading3" style={{ textAlign: 'center' }}>
33
+ {title}
34
+ </MyazaText>
35
+ <View style={{ height: spacing.sm }} />
36
+ <MyazaText
37
+ variant="bodyMedium"
38
+ color={retrying ? colors.warning : undefined}
39
+ style={{ textAlign: 'center', paddingHorizontal: spacing.lg }}
40
+ >
41
+ {description}
42
+ </MyazaText>
43
+ </View>
44
+ );
45
+ }
@@ -0,0 +1,161 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../../config/theme';
5
+ import { useKyc, useKycStore, useTheme } from '../../components/runtime';
6
+ import { MyazaText } from '../../components/Typography';
7
+ import { MyazaButton } from '../../components/MyazaButton';
8
+ import { MediaSourceSheet } from '../../components/MediaSourceSheet';
9
+ import { StickyActions } from '../../components/StickyActions';
10
+ import { withRetry } from '../../services/retry';
11
+ import { compressDocumentImage } from '../../services/mediaCompress';
12
+ import { addressVendorsStubbed } from '../../lib/address-flow';
13
+ import { streetViewFrameUrlOf } from '../../lib/map-frame';
14
+ import { useAddressPhotoAttach, type AddressPhotoPick } from '../useAddressPhotoAttach';
15
+ import { useAddressFlow } from './use-address-flow';
16
+ import { EntranceDropzone } from './EntranceDropzone';
17
+ import { EntrancePlaceholder } from './EntranceFraming';
18
+ import { FramedStreetView } from './FramedStreetView';
19
+
20
+ /**
21
+ * The entrance step: Street View FIRST — it opens automatically wherever
22
+ * Google has photographed the street, because framing beats fumbling for a
23
+ * camera — and the applicant's own photo is the fallback (no coverage, or
24
+ * they skip). Capturing a frame advances straight to review. The panorama
25
+ * reaches a phone the way the map does: the hosted /embed/street-view page in
26
+ * a WebView on the app grant (FramedStreetView). Mirrors the web and Flutter
27
+ * AddressEntranceStep.
28
+ */
29
+ export function AddressEntranceStep(): React.ReactElement | null {
30
+ const store = useKycStore();
31
+ const flow = useAddressFlow();
32
+ const mapsFrameUrl = useKyc((s) => s.serverConfig.mapsFrameUrl ?? null);
33
+ const vendorsStubbed = addressVendorsStubbed({
34
+ environment: useKyc((s) => s.serverConfig.environment ?? null),
35
+ });
36
+ const svFrameUrl = mapsFrameUrl ? streetViewFrameUrlOf(mapsFrameUrl) : null;
37
+ // Street View can be shown: offered by the flow, a pin to look from, and
38
+ // either the sandbox stand-in or a framed page to load.
39
+ const streetView = flow.streetViewOffered && Boolean(flow.pin) && (vendorsStubbed || Boolean(svFrameUrl));
40
+ const [mode, setMode] = useState<'framing' | 'photo'>(() => (streetView ? 'framing' : 'photo'));
41
+ const [skipped, setSkipped] = useState(false);
42
+ const framing = mode === 'framing' && streetView && Boolean(flow.pin);
43
+
44
+ // The sheet header describes the framing while it is up, the photo after.
45
+ useEffect(() => {
46
+ store.getState().setAddressEntranceFraming(framing);
47
+ return () => store.getState().setAddressEntranceFraming(false);
48
+ }, [framing, store]);
49
+
50
+ // Fell back to the photo with the photo input off: nothing left to capture
51
+ // here, carry on. (The never-offered case is `recoverAddressStep`'s.)
52
+ const { goNext } = flow;
53
+ const emptyAfterFallback = !framing && flow.streetViewOffered && flow.photoMode === 'off';
54
+ useEffect(() => {
55
+ if (emptyAfterFallback) goNext();
56
+ }, [emptyAfterFallback, goNext]);
57
+
58
+ // Nothing to capture at all: this step is absent from the flow, and the
59
+ // address flow's own recovery moves the applicant, not this screen.
60
+ if (flow.photoMode === 'off' && !flow.streetViewOffered) return null;
61
+
62
+ if (framing && flow.pin) {
63
+ const svRequired = flow.cfg?.streetView === 'required';
64
+ const toPhoto = (wasSkip: boolean) => {
65
+ setSkipped(wasSkip);
66
+ setMode('photo');
67
+ };
68
+ if (vendorsStubbed) {
69
+ return <EntrancePlaceholder hideSkip={svRequired} onSkip={() => toPhoto(true)} onUse={() => flow.goNext()} />;
70
+ }
71
+ return (
72
+ <FramedStreetView
73
+ frameUrl={svFrameUrl!}
74
+ pin={flow.pin}
75
+ hideSkip={svRequired}
76
+ onCaptured={(frame) => {
77
+ const current = store.getState().address;
78
+ if (current) store.getState().setAddress({ ...current, streetView: frame });
79
+ flow.goNext();
80
+ }}
81
+ onSkip={() => toPhoto(true)}
82
+ onUnavailable={() => toPhoto(false)}
83
+ />
84
+ );
85
+ }
86
+ if (emptyAfterFallback) return null;
87
+ return <EntrancePhoto flow={flow} skipped={skipped} />;
88
+ }
89
+
90
+ /** The photo half, split so the framing branch above stays hook-free. */
91
+ function EntrancePhoto({
92
+ flow,
93
+ skipped,
94
+ }: {
95
+ flow: ReturnType<typeof useAddressFlow>;
96
+ skipped: boolean;
97
+ }): React.ReactElement {
98
+ const store = useKycStore();
99
+ const { colors } = useTheme();
100
+ const photoId = useKyc((s) => s.mediaIds.addressPhoto);
101
+ const previewUri = useKyc((s) => s.addressPhotoPreview);
102
+ const { pickPhoto, setError } = flow;
103
+
104
+ const upload = useCallback(
105
+ async ({ uri, mimeType, name }: AddressPhotoPick) => {
106
+ await pickPhoto(async () => {
107
+ const finalUri = await compressDocumentImage(uri).catch(() => uri);
108
+ return withRetry(() => store.getState().api.upload({ uri: finalUri, type: mimeType, name }, 'address_photo'));
109
+ }, uri);
110
+ },
111
+ [pickPhoto, store],
112
+ );
113
+
114
+ const { pick, sheetOpen, closeSheet, choosePhoto, takePhoto } = useAddressPhotoAttach(upload, setError);
115
+
116
+ const uploaded = Boolean(photoId);
117
+ const required = flow.photoMode === 'required';
118
+
119
+ return (
120
+ <StickyActions
121
+ actions={
122
+ <MyazaButton
123
+ label={uploaded || required ? 'Continue' : 'Continue without a photo'}
124
+ disabled={flow.uploading || (required && !uploaded)}
125
+ onPress={() => flow.goNext()}
126
+ />
127
+ }
128
+ >
129
+ <MediaSourceSheet
130
+ open={sheetOpen}
131
+ onClose={closeSheet}
132
+ title="Add a photo of the entrance"
133
+ options={[
134
+ { icon: 'camera', label: 'Take a photo', caption: 'Photograph the entrance now', onPress: () => void takePhoto() },
135
+ { icon: 'image', label: 'Photo library', caption: 'Pick a photo you already have', onPress: () => void choosePhoto() },
136
+ ]}
137
+ />
138
+
139
+ {skipped ? (
140
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginBottom: spacing.sm }}>
141
+ No problem. A quick photo of the entrance works just as well.
142
+ </MyazaText>
143
+ ) : null}
144
+
145
+ <EntranceDropzone
146
+ uploaded={uploaded}
147
+ uploading={flow.uploading}
148
+ previewUri={previewUri}
149
+ required={required}
150
+ onPick={() => pick()}
151
+ onRemove={flow.removePhoto}
152
+ />
153
+
154
+ {flow.error ? (
155
+ <MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.sm }}>
156
+ {flow.error}
157
+ </MyazaText>
158
+ ) : null}
159
+ </StickyActions>
160
+ );
161
+ }