@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,18 +1,21 @@
1
- import React, { useCallback, useState } from 'react';
2
- import { ActivityIndicator, Image, Pressable, View } from 'react-native';
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import { View } from 'react-native';
3
3
 
4
- import { radius, spacing } from '../config/theme';
5
- import { useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
4
+ import { spacing } from '../config/theme';
5
+ import { useEffectiveCountry, useKyc, useKycConfig, useKycStore, useTheme } from '../components/runtime';
6
6
  import { MyazaText } from '../components/Typography';
7
7
  import { MyazaButton } from '../components/MyazaButton';
8
- import { Icon } from '../components/Icon';
9
8
  import { MediaSourceSheet } from '../components/MediaSourceSheet';
10
- import { MyazaSelect } from '../components/MyazaSelect';
11
- import { DashedBorder } from '../components/DashedBorder';
9
+ import { PoaDocumentTypeList } from './PoaDocumentTypeList';
10
+ import { configScope } from '../lib/scope';
12
11
  import { withRetry } from '../services/retry';
13
12
  import { compressDocumentImage } from '../services/mediaCompress';
13
+ import { uploadFailureMessage } from '../services/uploadErrors';
14
14
  import { poaDocumentTypes, poaMaxAgeDays, poaTypeLabel } from '../config/proofOfAddress';
15
15
  import { usePoaAttach, type PoaPick } from './usePoaAttach';
16
+ import { AddressCountryControl, poaOfferedCountries } from './AddressCountryControl';
17
+ import { poaCountryDeclared } from '../lib/poa-country-gate';
18
+ import { PoaDropzone, PoaUploadedRow } from './ProofOfAddressParts';
16
19
  import type { PoaDocumentType } from '../types/workflow';
17
20
 
18
21
  // ---------------------------------------------------------------------------
@@ -28,10 +31,17 @@ import type { PoaDocumentType } from '../types/workflow';
28
31
  // beyond making sure a file was actually attached.
29
32
  // ---------------------------------------------------------------------------
30
33
 
31
- export function proofOfAddressMeta(maxAgeDays: number): { title: string; description: string } {
34
+ export function proofOfAddressMeta(
35
+ maxAgeDays: number,
36
+ /** Whether the workflow's name rule wants the applicant's name on THIS
37
+ * document. False for e.g. a Nigerian utility bill that names the meter,
38
+ * not the tenant — asking for "your name" there sends people hunting for a
39
+ * document they do not have. */
40
+ nameNeeded = true,
41
+ ): { title: string; description: string } {
32
42
  return {
33
43
  title: 'Proof of address',
34
- description: `Upload a document that shows your name and home address, issued within the last ${maxAgeDays} days.`,
44
+ description: `Upload a document that shows your ${nameNeeded ? 'name and home address' : 'home address'}, issued within the last ${maxAgeDays} days.`,
35
45
  };
36
46
  }
37
47
 
@@ -43,8 +53,23 @@ export function ProofOfAddressStep(): React.ReactElement {
43
53
  const fileName = useKyc((s) => s.poaFileName);
44
54
  const storedType = useKyc((s) => s.poaDocumentType);
45
55
 
46
- const types = poaDocumentTypes(config.proofOfAddress);
56
+ // The kinds on offer follow the country — on the address scope the picker
57
+ // below changes it, and an org may accept different documents per market.
58
+ const country = useEffectiveCountry();
59
+ const types = poaDocumentTypes(config.proofOfAddress, country);
60
+ // The flag on the attachment area: on the address scope only a country the
61
+ // applicant picked (the scope has no seeded country to show), else the
62
+ // flow's effective country.
63
+ const selectedCountry = useKyc((s) => s.selectedCountry);
64
+ const flagCountry = configScope(config) === 'address' ? (selectedCountry ?? null) : (country ?? null);
47
65
  const [selectedType, setSelectedType] = useState<PoaDocumentType>(storedType ?? types[0]!);
66
+ const typesKey = types.join(',');
67
+ useEffect(() => {
68
+ // A country change can withdraw the picked kind; fall back to the first
69
+ // offered rather than submitting a label that country does not accept.
70
+ if (!types.includes(selectedType)) setSelectedType(types[0]!);
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, [typesKey]);
48
73
  const [busy, setBusy] = useState(false);
49
74
  const [error, setError] = useState<string | null>(null);
50
75
  // The picked file, kept for the thumbnail — the only way a user catches
@@ -67,9 +92,9 @@ export function ProofOfAddressStep(): React.ReactElement {
67
92
  store.getState().api.upload({ uri: finalUri, type: mimeType, name }, 'proof_of_address'),
68
93
  );
69
94
  store.getState().setProofOfAddress(id, selectedType, name);
70
- } catch {
95
+ } catch (e) {
71
96
  setPreview(null);
72
- setError('We could not upload that document. Please try again.');
97
+ setError(uploadFailureMessage(e));
73
98
  } finally {
74
99
  setBusy(false);
75
100
  }
@@ -89,6 +114,14 @@ export function ProofOfAddressStep(): React.ReactElement {
89
114
  }, [store]);
90
115
 
91
116
  const uploaded = Boolean(mediaId) && !busy;
117
+ // The address scope's country is the applicant's declaration and drives the
118
+ // document's market; Continue holds until it is made (the control above
119
+ // asks for it). Never bites elsewhere. See lib/poa-country-gate.ts.
120
+ const countryDeclared = poaCountryDeclared({
121
+ scope: configScope(config),
122
+ selectedCountry,
123
+ offered: poaOfferedCountries(config.proofOfAddress?.countries),
124
+ });
92
125
 
93
126
  return (
94
127
  <View>
@@ -117,111 +150,37 @@ export function ProofOfAddressStep(): React.ReactElement {
117
150
  },
118
151
  ]}
119
152
  />
153
+ <AddressCountryControl />
120
154
  {types.length > 1 ? (
121
155
  <>
122
156
  <MyazaText variant="bodySmall" style={{ fontWeight: '600', marginBottom: spacing.xs }}>
123
157
  Document type
124
158
  </MyazaText>
125
- <MyazaSelect<PoaDocumentType>
159
+ <PoaDocumentTypeList
126
160
  value={selectedType}
127
- sheetTitle="Document type"
128
161
  // Locked once a file is attached: switching the kind afterwards
129
162
  // would mislabel the document already uploaded.
130
- enabled={!uploaded && !busy}
163
+ disabled={uploaded || busy}
131
164
  options={types.map((type) => ({
132
165
  value: type,
133
166
  label: poaTypeLabel(type, config.proofOfAddress),
134
167
  }))}
135
168
  onChange={setSelectedType}
136
169
  />
137
- <View style={{ height: spacing.lg }} />
170
+ <View style={{ height: spacing.sm }} />
138
171
  </>
139
172
  ) : null}
140
173
 
141
174
  {uploaded ? (
142
- <View
143
- style={{
144
- flexDirection: 'row',
145
- alignItems: 'center',
146
- padding: spacing.md,
147
- borderRadius: radius.md,
148
- borderWidth: 1,
149
- borderColor: colors.border,
150
- backgroundColor: colors.backgroundSecondary,
151
- }}
152
- >
153
- {preview && !preview.isPdf ? (
154
- <Image
155
- source={{ uri: preview.uri }}
156
- style={{ width: 48, height: 48, borderRadius: radius.sm }}
157
- resizeMode="cover"
158
- />
159
- ) : (
160
- <View
161
- style={{
162
- width: 48,
163
- height: 48,
164
- borderRadius: radius.sm,
165
- borderWidth: 1,
166
- borderColor: colors.border,
167
- alignItems: 'center',
168
- justifyContent: 'center',
169
- }}
170
- >
171
- <Icon name="file-text" size={22} color={colors.primary} />
172
- </View>
173
- )}
174
- <View style={{ width: spacing.md, flexShrink: 0 }} />
175
- <View style={{ flex: 1, minWidth: 0 }}>
176
- <MyazaText variant="bodySmall" style={{ fontWeight: '600' }} numberOfLines={1}>
177
- {fileName ?? 'Document uploaded'}
178
- </MyazaText>
179
- <MyazaText variant="bodySmall" color={colors.textSecondary} numberOfLines={1}>
180
- {typeLabel}
181
- </MyazaText>
182
- </View>
183
- <Pressable
184
- onPress={remove}
185
- accessibilityRole="button"
186
- accessibilityLabel="Remove document"
187
- hitSlop={8}
188
- >
189
- <Icon name="x" size={18} color={colors.textSecondary} />
190
- </Pressable>
191
- </View>
175
+ <PoaUploadedRow
176
+ preview={preview}
177
+ fileName={fileName}
178
+ typeLabel={typeLabel}
179
+ country={flagCountry}
180
+ onRemove={remove}
181
+ />
192
182
  ) : (
193
- <Pressable
194
- onPress={() => {
195
- if (!busy) void pick();
196
- }}
197
- accessibilityRole="button"
198
- accessibilityLabel={`Upload your ${typeLabel.toLowerCase()}`}
199
- style={{
200
- alignItems: 'center',
201
- justifyContent: 'center',
202
- paddingVertical: spacing.xl,
203
- paddingHorizontal: spacing.lg,
204
- borderRadius: radius.md,
205
- }}
206
- >
207
- <DashedBorder
208
- color={colors.border}
209
- radius={radius.md}
210
- strokeWidth={1.5}
211
- />
212
- {busy ? (
213
- <ActivityIndicator size="small" color={colors.primary} />
214
- ) : (
215
- <Icon name="upload" size={30} color={colors.textSecondary} />
216
- )}
217
- <View style={{ height: spacing.sm }} />
218
- <MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
219
- {busy ? 'Uploading…' : `Upload your ${typeLabel.toLowerCase()}`}
220
- </MyazaText>
221
- <MyazaText variant="bodySmall" color={colors.textSecondary}>
222
- Photo or PDF, up to 20MB
223
- </MyazaText>
224
- </Pressable>
183
+ <PoaDropzone busy={busy} typeLabel={typeLabel} country={flagCountry} onPress={() => void pick()} />
225
184
  )}
226
185
 
227
186
  {error ? (
@@ -233,7 +192,7 @@ export function ProofOfAddressStep(): React.ReactElement {
233
192
  <View style={{ height: spacing.lg }} />
234
193
  <MyazaButton
235
194
  label="Continue"
236
- disabled={!mediaId || busy}
195
+ disabled={!mediaId || busy || !countryDeclared}
237
196
  onPress={() => store.getState().nextStep()}
238
197
  />
239
198
  </View>
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { radius } from '../config/theme';
5
+
6
+ /** The terminal screens' 88px circular badge: a tinted disc with a 2px ring,
7
+ * holding the outcome icon. Shared by the submitted and result screens. */
8
+ export function Badge({ bg, border, children }: { bg: string; border: string; children: React.ReactNode }): React.ReactElement {
9
+ return (
10
+ <View
11
+ style={{
12
+ width: 88,
13
+ height: 88,
14
+ borderRadius: radius.full,
15
+ backgroundColor: bg,
16
+ borderWidth: 2,
17
+ borderColor: border,
18
+ alignItems: 'center',
19
+ justifyContent: 'center',
20
+ }}
21
+ >
22
+ {children}
23
+ </View>
24
+ );
25
+ }
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import type { KYCError } from '../types/verification';
6
+ import { useTheme } from '../components/runtime';
7
+ import { MyazaText } from '../components/Typography';
8
+ import { MyazaButton } from '../components/MyazaButton';
9
+ import { MyazaAlert } from '../components/MyazaAlert';
10
+ import { Icon } from '../components/Icon';
11
+ import { Badge } from './SubmittedBadge';
12
+
13
+ // The submitted step's error screen (split from SubmittedStep, 200-line
14
+ // rule). `onRetry` is null when the failure is not recoverable in-flow (an
15
+ // exhausted balance, a refused key); the step decides, this only renders.
16
+
17
+ const ERROR_TITLES: Record<string, string> = {
18
+ insufficient_credits: 'Credits Exhausted',
19
+ invalid_api_key: 'Authentication Failed',
20
+ feature_disabled: 'Verification Unavailable',
21
+ upload_failed: 'Upload Failed',
22
+ network_error: 'Connection Failed',
23
+ };
24
+
25
+ export function SubmittedError({
26
+ error,
27
+ onRetry,
28
+ onClose,
29
+ }: {
30
+ error: KYCError;
31
+ onRetry: (() => void) | null;
32
+ onClose: () => void;
33
+ }): React.ReactElement {
34
+ const { colors } = useTheme();
35
+ const title = ERROR_TITLES[error.code] ?? 'Submission Failed';
36
+ return (
37
+ <View style={{ flex: 1, justifyContent: 'space-between' }}>
38
+ <View style={{ alignItems: 'center' }}>
39
+ <View style={{ height: spacing.xl }} />
40
+ <Badge bg={colors.errorBg} border={`${colors.error}4D`}>
41
+ <Icon name="alert" size={44} color={colors.error} />
42
+ </Badge>
43
+ <View style={{ height: spacing.lg }} />
44
+ <MyazaText variant="heading1" style={{ textAlign: 'center' }}>
45
+ {title}
46
+ </MyazaText>
47
+ <View style={{ height: spacing.md }} />
48
+ <View style={{ width: '100%', paddingHorizontal: spacing.md }}>
49
+ <MyazaAlert variant="error" title="What happened" message={error.message} />
50
+ </View>
51
+ </View>
52
+ <View style={{ flexDirection: 'row', gap: spacing.md }}>
53
+ {onRetry ? (
54
+ <View style={{ flex: 1 }}>
55
+ <MyazaButton label="Try Again" variant="outline" leadingIcon="refresh" onPress={onRetry} />
56
+ </View>
57
+ ) : null}
58
+ <View style={{ flex: 1 }}>
59
+ <MyazaButton label="Close" onPress={onClose} />
60
+ </View>
61
+ </View>
62
+ </View>
63
+ );
64
+ }
@@ -0,0 +1,117 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { 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 { awaitVerificationOutcome, type VerificationOutcome } from '../lib/result-wait';
10
+ import { describeOutcome, describeWaiting } from '../lib/result-copy';
11
+ import { biometricCopyFor } from '../lib/biometric-copy';
12
+ import { configScope } from '../lib/scope';
13
+ import { Badge } from './SubmittedBadge';
14
+ import { SubmittedWaiting } from './SubmittedWaiting';
15
+ import { StaggerIn } from '../components/StaggerIn';
16
+
17
+ // ─── The result screen (a flow that waits for its verdict) ──────────────────
18
+ //
19
+ // A biometric re-authentication delivered in the flow ('both', the default, or 'app')
20
+ // is answered NOW or it is useless. This screen is mounted from the submitted
21
+ // step's FIRST render and shows one loading screen through the selfie upload,
22
+ // the submission (`verificationId` is null until it lands) and the status
23
+ // poll, then the verdict, firing `onResult` once. The wait has a budget: past
24
+ // it the screen says so and the webhook remains the record, exactly as on
25
+ // every other flow. `showDone` is the `doneButton` option: off when the host
26
+ // app dismisses the flow itself from `onResult`.
27
+
28
+ export function SubmittedResult({
29
+ verificationId,
30
+ retry,
31
+ showDone,
32
+ onClose,
33
+ }: {
34
+ verificationId: string | null;
35
+ retry: { attempt: number; total: number } | null;
36
+ showDone: boolean;
37
+ onClose: () => void;
38
+ }): React.ReactElement {
39
+ const { colors } = useTheme();
40
+ const config = useKycConfig();
41
+ const store = useKycStore();
42
+ const [outcome, setOutcome] = useState<VerificationOutcome | null>(null);
43
+
44
+ useEffect(() => {
45
+ if (!verificationId) return undefined;
46
+ let alive = true;
47
+ const api = store.getState().api;
48
+ void awaitVerificationOutcome({
49
+ fetchStatus: () => api.status(verificationId).catch(() => null),
50
+ }).then((settled) => {
51
+ if (!alive) return;
52
+ setOutcome(settled);
53
+ if (settled.kind === 'settled') {
54
+ config.onResult?.({
55
+ verificationId,
56
+ status: settled.status,
57
+ reason: settled.reason,
58
+ reasonCode: settled.reasonCode,
59
+ });
60
+ }
61
+ });
62
+ return () => {
63
+ alive = false;
64
+ };
65
+ // The wait is tied to the verification, not to the callback identity.
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [verificationId]);
68
+
69
+ // The org's own words for these screens, tokens filled (null = the defaults).
70
+ const words = biometricCopyFor(config);
71
+ if (!outcome) {
72
+ const copy = describeWaiting({ scope: configScope(config), waitsForResult: true, retry, override: words.waiting });
73
+ return <SubmittedWaiting title={copy.title} description={copy.description} retrying={retry != null} />;
74
+ }
75
+
76
+ const copy = describeOutcome(outcome, words);
77
+ const palette =
78
+ copy.tone === 'success'
79
+ ? { bg: colors.successBg, fg: colors.success, icon: 'check' as const }
80
+ : copy.tone === 'error'
81
+ ? { bg: colors.errorBg, fg: colors.error, icon: 'alert' as const }
82
+ : { bg: `${colors.primary}14`, fg: colors.primary, icon: 'info' as const };
83
+
84
+ return (
85
+ <View style={{ flex: 1, justifyContent: 'space-between' }}>
86
+ <View style={{ alignItems: 'center' }}>
87
+ <View style={{ height: spacing.xl }} />
88
+ {/* The verdict lands in the order Flutter's does: badge, title,
89
+ description, then Done (delays 0 / 200 / 320 / 600 ms). */}
90
+ <StaggerIn>
91
+ <Badge bg={palette.bg} border={`${palette.fg}4D`}>
92
+ <Icon name={palette.icon} size={44} color={palette.fg} />
93
+ </Badge>
94
+ </StaggerIn>
95
+ <View style={{ height: spacing.lg }} />
96
+ <StaggerIn delayMs={200}>
97
+ <MyazaText variant="heading1" style={{ textAlign: 'center' }}>
98
+ {copy.title}
99
+ </MyazaText>
100
+ </StaggerIn>
101
+ <View style={{ height: spacing.sm }} />
102
+ <StaggerIn delayMs={320}>
103
+ <MyazaText variant="bodyMedium" style={{ textAlign: 'center', paddingHorizontal: spacing.lg }}>
104
+ {copy.description}
105
+ </MyazaText>
106
+ </StaggerIn>
107
+ </View>
108
+ {showDone ? (
109
+ <StaggerIn delayMs={600}>
110
+ <MyazaButton label="Done" onPress={onClose} />
111
+ </StaggerIn>
112
+ ) : (
113
+ <View />
114
+ )}
115
+ </View>
116
+ );
117
+ }