@myazahq/kyc-sdk-react-native 2.5.0 → 3.0.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 (219) hide show
  1. package/README.md +277 -7
  2. package/android/build.gradle +30 -16
  3. package/android/consumer-rules.pro +22 -0
  4. package/android/src/main/AndroidManifest.xml +17 -6
  5. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -70
  6. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
  7. package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
  8. package/app.plugin.js +146 -10
  9. package/ios/HybridMyazaFaceDetector.swift +8 -3
  10. package/ios/HybridMyazaTextRecognizer.swift +8 -0
  11. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
  12. package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
  13. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
  14. package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
  15. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
  16. package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
  17. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
  18. package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
  19. package/package.json +30 -9
  20. package/src/MyazaKYC.tsx +4 -11
  21. package/src/assets/fonts/Karla_400Regular.ttf +0 -0
  22. package/src/assets/fonts/Karla_500Medium.ttf +0 -0
  23. package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
  24. package/src/assets/fonts/Karla_700Bold.ttf +0 -0
  25. package/src/assets/fonts/OFL-Karla.txt +93 -0
  26. package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
  27. package/src/assets/fonts/README.md +12 -0
  28. package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
  29. package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
  30. package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
  31. package/src/capture/useAutoCapture.ts +8 -1
  32. package/src/components/CountryField.tsx +8 -0
  33. package/src/components/CountryRegionPicker.tsx +184 -0
  34. package/src/components/DialCodePicker.tsx +47 -68
  35. package/src/components/DialCodeRow.tsx +102 -0
  36. package/src/components/DocumentCropper.tsx +30 -24
  37. package/src/components/DocumentReview.tsx +11 -1
  38. package/src/components/DocumentReviewSide.tsx +6 -2
  39. package/src/components/DocumentReviewZoom.tsx +5 -1
  40. package/src/components/FramedMapPicker.tsx +122 -0
  41. package/src/components/GeoBadge.tsx +34 -0
  42. package/src/components/Icon.tsx +10 -192
  43. package/src/components/KycFlow.tsx +69 -34
  44. package/src/components/LineSkeleton.tsx +100 -0
  45. package/src/components/MapChrome.tsx +67 -0
  46. package/src/components/MapPinMarker.tsx +54 -0
  47. package/src/components/MapPinPicker.tsx +179 -0
  48. package/src/components/MilestoneTrack.tsx +155 -0
  49. package/src/components/MyazaButton.tsx +9 -0
  50. package/src/components/MyazaInput.tsx +17 -1
  51. package/src/components/OptionRow.tsx +22 -3
  52. package/src/components/PhoneNumberInput.tsx +8 -0
  53. package/src/components/PoweredBy.tsx +5 -5
  54. package/src/components/PresenceBlocks.tsx +153 -0
  55. package/src/components/RequiredDocumentPill.tsx +114 -0
  56. package/src/components/StepHeader.tsx +23 -3
  57. package/src/components/StepView.tsx +17 -0
  58. package/src/components/StickyActions.tsx +56 -0
  59. package/src/components/dialCodeRows.ts +64 -0
  60. package/src/components/documentReviewCopy.ts +39 -0
  61. package/src/components/fonts.ts +14 -17
  62. package/src/components/icon-map.ts +176 -0
  63. package/src/components/stepHeaderMeta.tsx +48 -5
  64. package/src/config/addressCollection.ts +125 -0
  65. package/src/config/biometricOptions.ts +100 -0
  66. package/src/config/business.ts +20 -1
  67. package/src/config/businessSteps.ts +8 -2
  68. package/src/config/consentStep.ts +19 -0
  69. package/src/config/documentCaptureMethods.ts +31 -0
  70. package/src/config/proofOfAddress.ts +54 -11
  71. package/src/config/regions.ts +24 -0
  72. package/src/config/stepOrder.ts +79 -5
  73. package/src/config/uploadLimits.ts +35 -0
  74. package/src/config/workflowMerge.ts +13 -5
  75. package/src/index.ts +49 -1
  76. package/src/lib/address-current-location.ts +171 -0
  77. package/src/lib/address-field-modes.ts +119 -0
  78. package/src/lib/address-flow.ts +172 -0
  79. package/src/lib/address-helpers.ts +48 -0
  80. package/src/lib/address-line.ts +76 -0
  81. package/src/lib/address-pin-move.ts +97 -0
  82. package/src/lib/address-step-recovery.ts +63 -0
  83. package/src/lib/authed-image.ts +78 -0
  84. package/src/lib/biometric-copy.ts +51 -0
  85. package/src/lib/captureRing.ts +83 -0
  86. package/src/lib/country-adoption.ts +89 -0
  87. package/src/lib/documentCaptureCheck.ts +136 -0
  88. package/src/lib/inferred-country.ts +65 -0
  89. package/src/lib/livenessLayout.ts +49 -0
  90. package/src/lib/map-frame.ts +159 -0
  91. package/src/lib/map-tiles.ts +143 -0
  92. package/src/lib/model-ready.ts +82 -0
  93. package/src/lib/poa-country-gate.ts +27 -0
  94. package/src/lib/prime-models.ts +50 -0
  95. package/src/lib/resubmit.ts +36 -7
  96. package/src/lib/result-copy.ts +116 -0
  97. package/src/lib/result-wait.ts +53 -0
  98. package/src/lib/review-map-surface.ts +26 -0
  99. package/src/lib/scope.ts +31 -0
  100. package/src/lib/selfie-sharpness.ts +211 -0
  101. package/src/lib/selfie-upload-wait.ts +79 -0
  102. package/src/lib/street-view-fov.ts +42 -0
  103. package/src/lib/webview-available.ts +47 -0
  104. package/src/liveness/avatarSource.ts +59 -0
  105. package/src/liveness/useLiveness.ts +6 -1
  106. package/src/liveness/useModelReady.ts +9 -55
  107. package/src/mrz/extract.ts +71 -4
  108. package/src/mrz/textRecognizer.ts +36 -0
  109. package/src/mrz/useTextModelReady.ts +21 -0
  110. package/src/presence/background-math.ts +100 -0
  111. package/src/presence/background-store.ts +82 -0
  112. package/src/presence/background.ts +165 -0
  113. package/src/presence/foreground-service.ts +193 -0
  114. package/src/presence/fs.ts +51 -0
  115. package/src/presence/geofence.ts +41 -0
  116. package/src/presence/math.ts +44 -0
  117. package/src/presence/post.ts +40 -0
  118. package/src/presence/report.ts +87 -0
  119. package/src/presence/sampler.ts +110 -0
  120. package/src/presence/status.ts +92 -0
  121. package/src/presence/store.ts +94 -0
  122. package/src/presence/tier.ts +36 -0
  123. package/src/presence/watch-wait.ts +81 -0
  124. package/src/screens/AddressCountryControl.tsx +115 -0
  125. package/src/screens/BusinessDetailsFields.tsx +3 -0
  126. package/src/screens/BusinessDetailsStep.tsx +2 -0
  127. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  128. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  129. package/src/screens/CompanyInfoFields.tsx +6 -1
  130. package/src/screens/ConsentStep.tsx +6 -17
  131. package/src/screens/ContactActions.tsx +54 -0
  132. package/src/screens/ContactDestinationField.tsx +4 -0
  133. package/src/screens/ContactEntryPanel.tsx +75 -0
  134. package/src/screens/ContactVerificationStep.tsx +26 -56
  135. package/src/screens/CountrySelectStep.tsx +13 -147
  136. package/src/screens/DocumentCaptureStep.tsx +177 -93
  137. package/src/screens/LivenessAvatar.tsx +33 -16
  138. package/src/screens/LivenessStep.tsx +188 -49
  139. package/src/screens/MrzScanView.tsx +33 -0
  140. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  141. package/src/screens/ProofOfAddressParts.tsx +151 -0
  142. package/src/screens/ProofOfAddressStep.tsx +58 -99
  143. package/src/screens/SubmittedBadge.tsx +25 -0
  144. package/src/screens/SubmittedError.tsx +64 -0
  145. package/src/screens/SubmittedResult.tsx +117 -0
  146. package/src/screens/SubmittedStep.tsx +71 -164
  147. package/src/screens/SubmittedSuccess.tsx +127 -0
  148. package/src/screens/SubmittedWaiting.tsx +45 -0
  149. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  150. package/src/screens/address/AddressIntroGate.tsx +148 -0
  151. package/src/screens/address/AddressMapStub.tsx +50 -0
  152. package/src/screens/address/AddressPinStep.tsx +178 -0
  153. package/src/screens/address/AddressReviewStep.tsx +162 -0
  154. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  155. package/src/screens/address/AddressSearchField.tsx +115 -0
  156. package/src/screens/address/AddressSearchStep.tsx +94 -0
  157. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  158. package/src/screens/address/DetailsSheet.tsx +175 -0
  159. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  160. package/src/screens/address/EntranceDropzone.tsx +178 -0
  161. package/src/screens/address/EntranceFraming.tsx +66 -0
  162. package/src/screens/address/EntrancePills.tsx +60 -0
  163. package/src/screens/address/FramedStreetView.tsx +155 -0
  164. package/src/screens/address/IntroDisclosures.tsx +210 -0
  165. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  166. package/src/screens/address/PinSummaryRow.tsx +105 -0
  167. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  168. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  169. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  170. package/src/screens/address/SearchResults.tsx +105 -0
  171. package/src/screens/address/SearchScreen.tsx +176 -0
  172. package/src/screens/address/SkipForNow.tsx +43 -0
  173. package/src/screens/address/StreetViewChrome.tsx +81 -0
  174. package/src/screens/address/detail-values.ts +22 -0
  175. package/src/screens/address/fix-source.ts +27 -0
  176. package/src/screens/address/index.ts +8 -0
  177. package/src/screens/address/meta.ts +44 -0
  178. package/src/screens/address/use-address-flow.ts +200 -0
  179. package/src/screens/address/use-label-pin.ts +80 -0
  180. package/src/screens/address/use-pin-actions.ts +192 -0
  181. package/src/screens/consent/model.ts +79 -10
  182. package/src/screens/document/CaptureCheckNotice.tsx +77 -0
  183. package/src/screens/document/UploadPhase.tsx +177 -0
  184. package/src/screens/document/useDocumentCamera.ts +109 -0
  185. package/src/screens/liveness/CaptureRing.tsx +91 -0
  186. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  187. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  188. package/src/screens/liveness/LivenessOutcome.tsx +8 -4
  189. package/src/screens/liveness/SelfiePreview.tsx +31 -7
  190. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  191. package/src/screens/liveness/index.ts +4 -0
  192. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  193. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  194. package/src/screens/useAddressPhotoAttach.ts +77 -0
  195. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  196. package/src/screens/usePoaAttach.ts +12 -15
  197. package/src/services/api-types.ts +119 -1
  198. package/src/services/api-verify-types.ts +37 -0
  199. package/src/services/api.ts +149 -3
  200. package/src/services/deviceMetadata.ts +1 -1
  201. package/src/services/location.ts +234 -0
  202. package/src/services/mediaCompress.ts +23 -5
  203. package/src/services/uploadErrors.ts +30 -0
  204. package/src/services/workflowGate.ts +7 -0
  205. package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
  206. package/src/store/address.ts +97 -0
  207. package/src/store/derive.ts +29 -4
  208. package/src/store/kycStore.ts +120 -5
  209. package/src/store/serverConfig.ts +18 -0
  210. package/src/store/session.ts +23 -4
  211. package/src/store/state.ts +134 -0
  212. package/src/store/submit.ts +18 -1
  213. package/src/types/config.ts +63 -1
  214. package/src/types/verification.ts +12 -0
  215. package/src/types/workflow.ts +97 -1
  216. package/src/assets/liveness/Blink.gif +0 -0
  217. package/src/assets/liveness/Nod.gif +0 -0
  218. package/src/assets/liveness/Smile.gif +0 -0
  219. package/src/assets/liveness/Turn.gif +0 -0
@@ -0,0 +1,102 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, View } from 'react-native';
3
+
4
+ import { spacing } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { MyazaText } from './Typography';
7
+ import { CountryFlag } from './CountryFlag';
8
+ import { GeoBadge } from './GeoBadge';
9
+ import type { DialCodeOption } from './DialCodePicker';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // One country in the dial-code sheet. Extracted from DialCodePicker (200-line
13
+ // rule) and shared by the pinned geo row and the alphabetical ones, so the two
14
+ // cannot drift apart: they are the same control in two positions, and the
15
+ // pinned one only differs by carrying a tag and a hairline beneath it.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export function DialCodeRow({
19
+ option,
20
+ isSelected,
21
+ badge,
22
+ onPress,
23
+ }: {
24
+ option: DialCodeOption;
25
+ isSelected: boolean;
26
+ /** Tags the pinned geo row ("Your location"). */
27
+ badge?: string;
28
+ onPress: () => void;
29
+ }): React.ReactElement {
30
+ const { colors } = useTheme();
31
+ return (
32
+ <Pressable
33
+ onPress={onPress}
34
+ accessibilityRole="radio"
35
+ accessibilityState={{ checked: isSelected }}
36
+ accessibilityLabel={badge ? `${option.name}, ${badge.toLowerCase()}` : option.name}
37
+ style={{
38
+ flexDirection: 'row',
39
+ alignItems: 'center',
40
+ paddingHorizontal: spacing.md,
41
+ // Roomier rows: a country list is a long scan, and cramped rows make
42
+ // every tap a precision job. Matches the web dropdown's row height.
43
+ paddingVertical: 12,
44
+ backgroundColor: isSelected ? colors.primary50 : 'transparent',
45
+ }}
46
+ >
47
+ <CountryFlag country={option.code} size={28} />
48
+ <View style={{ width: spacing.md }} />
49
+ {/* No line clamp: "Bosnia & Herzegovina" and the like must read in full,
50
+ which is what Flutter's Expanded(Text) gives. Full body size (16),
51
+ the web dropdown's reading size — 14 read small against the flags. */}
52
+ <MyazaText variant="body" style={{ flex: 1 }}>
53
+ {option.name}
54
+ </MyazaText>
55
+ {badge ? <GeoBadge label={badge} /> : null}
56
+ {option.dialCode != null ? (
57
+ <MyazaText variant="bodyMedium" color={colors.textSecondary}>
58
+ {option.dialCode}
59
+ </MyazaText>
60
+ ) : null}
61
+ </Pressable>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * A region label between grouped rows — the country-select step's header
67
+ * (uppercase, tracked out), so the two pickers read as one control.
68
+ */
69
+ export function DialCodeRegionHeader({ region }: { region: string }): React.ReactElement {
70
+ const { colors } = useTheme();
71
+ return (
72
+ <MyazaText
73
+ variant="bodySmall"
74
+ color={colors.textSecondary}
75
+ accessibilityRole="header"
76
+ style={{
77
+ fontWeight: '700',
78
+ letterSpacing: 0.6,
79
+ paddingHorizontal: spacing.md,
80
+ paddingTop: spacing.md,
81
+ paddingBottom: spacing.xs,
82
+ }}
83
+ >
84
+ {region.toUpperCase()}
85
+ </MyazaText>
86
+ );
87
+ }
88
+
89
+ /** The hairline under the pinned row, separating it from the alphabet. */
90
+ export function DialCodeDivider(): React.ReactElement {
91
+ const { colors } = useTheme();
92
+ return (
93
+ <View
94
+ style={{
95
+ marginHorizontal: spacing.md,
96
+ marginVertical: spacing.xs,
97
+ borderBottomWidth: StyleSheet.hairlineWidth,
98
+ borderBottomColor: colors.border,
99
+ }}
100
+ />
101
+ );
102
+ }
@@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { ActivityIndicator, LayoutChangeEvent, Modal, PanResponder, Platform, Pressable, StatusBar, StyleSheet, View } from 'react-native';
3
3
  import Svg, { Line, Rect } from 'react-native-svg';
4
4
 
5
- import { spacing } from '../config/theme';
5
+ import { headerSurface, spacing } from '../config/theme';
6
6
  import { cropImage, imageSize } from '../services/mediaCompress';
7
7
  import { MyazaText } from './Typography';
8
8
  import { MyazaButton } from './MyazaButton';
9
9
  import { Icon } from './Icon';
10
+ import { useTheme } from './runtime';
10
11
  import { Image } from 'react-native';
11
12
 
12
13
  // Interactive ID-card cropper — a 1:1 RN port of the Flutter SDK's
@@ -19,7 +20,11 @@ import { Image } from 'react-native';
19
20
  const ID_AR = 85.6 / 53.98;
20
21
  const HANDLE_RADIUS = 28; // touch radius for the corner handles
21
22
  const ARM = 22; // corner-bracket arm length
22
- const APPBAR_BG = '#1A1A2E';
23
+ // The photo sits on a neutral dark stage in both themes (the web SDK's
24
+ // neutral-900): it is the backdrop a document photo reads well against, and it
25
+ // keeps the white crop strokes legible. Everything around the photo takes the
26
+ // workflow's own colours.
27
+ export const CROPPER_STAGE = '#171717';
23
28
  // Keep the title clear of the status bar / notch on the full-screen cropper Modal.
24
29
  const TOP_INSET = Platform.OS === 'ios' ? 56 : (StatusBar.currentHeight ?? 24) + 8;
25
30
 
@@ -95,6 +100,7 @@ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperPro
95
100
  const [container, setContainer] = useState<{ w: number; h: number } | null>(null);
96
101
  const [crop, setCrop] = useState<Rectangle | null>(null);
97
102
  const [processing, setProcessing] = useState(false);
103
+ const { colors, mode } = useTheme();
98
104
 
99
105
  // Pan state held in refs so the PanResponder callbacks stay stable.
100
106
  const handleRef = useRef<Handle>('none');
@@ -219,17 +225,18 @@ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperPro
219
225
 
220
226
  return (
221
227
  <Modal visible animationType="slide" presentationStyle="fullScreen" onRequestClose={onCancel}>
222
- <View style={{ flex: 1, backgroundColor: '#000' }}>
223
- {/* App bar */}
224
- <View style={styles.appBar}>
225
- <Pressable onPress={onCancel} accessibilityRole="button" accessibilityLabel="Cancel" hitSlop={8} style={{ padding: 4, marginRight: 4 }}>
226
- <Icon name="close" size={20} color="#FFFFFF" />
228
+ <StatusBar barStyle={mode === 'dark' ? 'light-content' : 'dark-content'} />
229
+ <View style={{ flex: 1, backgroundColor: CROPPER_STAGE }}>
230
+ {/* App bar: the band the flow's steps sit under */}
231
+ <View style={[styles.appBar, { backgroundColor: headerSurface(colors, mode), borderBottomColor: colors.border }]}>
232
+ <Pressable onPress={onCancel} accessibilityRole="button" accessibilityLabel="Cancel" hitSlop={8} style={styles.close}>
233
+ <Icon name="close" size={20} color={colors.textDark} />
227
234
  </Pressable>
228
- <View>
229
- <MyazaText variant="bodyMedium" color="#FFFFFF" style={{ fontWeight: '600' }}>
235
+ <View style={{ flexShrink: 1 }}>
236
+ <MyazaText variant="bodyMedium" color={colors.textDark} style={{ fontWeight: '600' }}>
230
237
  Crop to ID Card
231
238
  </MyazaText>
232
- <MyazaText variant="bodySmall" color="rgba(255,255,255,0.6)">
239
+ <MyazaText variant="bodySmall" color={colors.textMuted}>
233
240
  Drag to reposition · handles to resize
234
241
  </MyazaText>
235
242
  </View>
@@ -274,18 +281,9 @@ export function DocumentCropper({ uri, onCancel, onConfirm }: DocumentCropperPro
274
281
  </View>
275
282
 
276
283
  {/* Action bar */}
277
- <View style={styles.actionBar}>
278
- {processing ? (
279
- <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', height: 48 }}>
280
- <ActivityIndicator color="#FFFFFF" />
281
- <View style={{ width: 10 }} />
282
- <MyazaText variant="bodyMedium" color="rgba(255,255,255,0.8)" style={{ flexShrink: 1 }}>
283
- Processing…
284
- </MyazaText>
285
- </View>
286
- ) : (
287
- <MyazaButton label="Crop & Use" leadingIcon="check" onPress={confirm} disabled={!crop} />
288
- )}
284
+ <View style={[styles.actionBar, { backgroundColor: colors.background, borderTopColor: colors.border }]}>
285
+ {/* The flow's own button: primary from the workflow, its loading state while the crop runs. */}
286
+ <MyazaButton label="Crop & Use" leadingIcon="check" onPress={confirm} disabled={!crop} loading={processing} />
289
287
  </View>
290
288
  </View>
291
289
  </Modal>
@@ -311,15 +309,23 @@ const styles = StyleSheet.create({
311
309
  appBar: {
312
310
  flexDirection: 'row',
313
311
  alignItems: 'center',
314
- backgroundColor: APPBAR_BG,
315
312
  paddingTop: TOP_INSET,
316
313
  paddingBottom: spacing.sm,
317
314
  paddingHorizontal: spacing.sm,
315
+ borderBottomWidth: StyleSheet.hairlineWidth,
316
+ },
317
+ // 44pt: the tap target the platforms ask for, around a 20pt glyph.
318
+ close: {
319
+ width: 44,
320
+ height: 44,
321
+ alignItems: 'center',
322
+ justifyContent: 'center',
323
+ marginRight: 4,
318
324
  },
319
325
  actionBar: {
320
- backgroundColor: APPBAR_BG,
321
326
  paddingHorizontal: spacing.md,
322
327
  paddingTop: spacing.sm,
323
328
  paddingBottom: spacing.lg,
329
+ borderTopWidth: StyleSheet.hairlineWidth,
324
330
  },
325
331
  });
@@ -6,6 +6,7 @@ import { Icon } from './Icon';
6
6
  import { MyazaText } from './Typography';
7
7
  import { DocumentReviewThumb, type ReviewSide } from './DocumentReviewSide';
8
8
  import { DocumentReviewZoom } from './DocumentReviewZoom';
9
+ import { documentReviewCopy, type DocumentCaptureMode } from './documentReviewCopy';
9
10
  import { radius, spacing } from '../config/theme';
10
11
 
11
12
  // ─── Document review ──────────────────────────────────────────────────────────
@@ -33,6 +34,9 @@ import { radius, spacing } from '../config/theme';
33
34
  // Unlike Flutter, the step here sits inside the sheet's own ScrollView (which
34
35
  // already carries the bottom safe-area inset), so the footer is pushed down by
35
36
  // a flexible spacer rather than pinned with a hand-computed inset.
37
+ //
38
+ // The words follow `mode` (see documentReviewCopy): an upload-only workflow
39
+ // never showed a camera, so it says "Replace" and "added".
36
40
 
37
41
  export function DocumentReview({
38
42
  frontUri,
@@ -43,6 +47,7 @@ export function DocumentReview({
43
47
  footer,
44
48
  onRetakeFront,
45
49
  onRetakeBack,
50
+ mode = 'scan',
46
51
  }: {
47
52
  frontUri: string;
48
53
  backUri?: string | null;
@@ -56,6 +61,8 @@ export function DocumentReview({
56
61
  footer: React.ReactNode;
57
62
  onRetakeFront?: () => void;
58
63
  onRetakeBack?: () => void;
64
+ /** How the photos got in: the camera (default) or chosen from the device. */
65
+ mode?: DocumentCaptureMode;
59
66
  }): React.ReactElement {
60
67
  const { colors } = useTheme();
61
68
  const [zoomed, setZoomed] = useState<ReviewSide | null>(null);
@@ -65,6 +72,7 @@ export function DocumentReview({
65
72
  ...(backUri ? [{ id: 'back' as const, label: 'Back', uri: backUri, onRetake: onRetakeBack }] : []),
66
73
  ];
67
74
  const twoSided = sides.length > 1;
75
+ const copy = documentReviewCopy(mode, twoSided);
68
76
 
69
77
  return (
70
78
  <View style={{ flex: 1 }}>
@@ -85,7 +93,7 @@ export function DocumentReview({
85
93
  </View>
86
94
  <View style={{ width: spacing.xs }} />
87
95
  <MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
88
- {twoSided ? 'Both sides captured' : 'Photo captured'}
96
+ {copy.status}
89
97
  </MyazaText>
90
98
  </View>
91
99
 
@@ -108,6 +116,7 @@ export function DocumentReview({
108
116
  // the screen read as permanently stuck.
109
117
  busyOverlay={isBusy ? busyOverlay : null}
110
118
  onZoom={() => setZoomed(side)}
119
+ copy={copy}
111
120
  />
112
121
  </View>
113
122
  </React.Fragment>
@@ -129,6 +138,7 @@ export function DocumentReview({
129
138
  <DocumentReviewZoom
130
139
  side={zoomed}
131
140
  isBusy={isBusy}
141
+ copy={copy}
132
142
  onClose={() => setZoomed(null)}
133
143
  onRetake={() => {
134
144
  const retake = zoomed.onRetake;
@@ -6,6 +6,7 @@ import { Icon } from './Icon';
6
6
  import { MyazaText } from './Typography';
7
7
  import { radius, spacing } from '../config/theme';
8
8
  import { CHROME_SCRIM, ChromeGlass } from './glass/ChromeGlass';
9
+ import type { DocumentReviewCopy } from './documentReviewCopy';
9
10
 
10
11
  /** The chip's flat backing, and still the fallback off iOS 26. */
11
12
  const CHIP_SCRIM = CHROME_SCRIM;
@@ -34,12 +35,15 @@ export function DocumentReviewThumb({
34
35
  isBusy,
35
36
  busyOverlay,
36
37
  onZoom,
38
+ copy,
37
39
  }: {
38
40
  side: ReviewSide;
39
41
  aspect: number;
40
42
  isBusy: boolean;
41
43
  busyOverlay?: React.ReactNode;
42
44
  onZoom: () => void;
45
+ /** The review's words for the capture mode (documentReviewCopy). */
46
+ copy: DocumentReviewCopy;
43
47
  }): React.ReactElement {
44
48
  const { colors } = useTheme();
45
49
  return (
@@ -90,7 +94,7 @@ export function DocumentReviewThumb({
90
94
  onPress={isBusy ? undefined : side.onRetake}
91
95
  disabled={isBusy}
92
96
  accessibilityRole="button"
93
- accessibilityLabel={`Retake ${side.label.toLowerCase()}`}
97
+ accessibilityLabel={copy.redoAccessibility(side.label)}
94
98
  style={{
95
99
  flexDirection: 'row',
96
100
  alignItems: 'center',
@@ -103,7 +107,7 @@ export function DocumentReviewThumb({
103
107
  <Icon name="refresh" size={14} color={colors.primary} />
104
108
  <View style={{ width: 6 }} />
105
109
  <MyazaText variant="bodySmall" color={colors.primary} style={{ flexShrink: 1 }}>
106
- Retake
110
+ {copy.redo}
107
111
  </MyazaText>
108
112
  </Pressable>
109
113
  </View>
@@ -7,6 +7,7 @@ import { Icon } from './Icon';
7
7
  import { MyazaText } from './Typography';
8
8
  import { MyazaButton } from './MyazaButton';
9
9
  import { Chip, CHIP_TEXT, type ReviewSide } from './DocumentReviewSide';
10
+ import type { DocumentReviewCopy } from './documentReviewCopy';
10
11
 
11
12
  /**
12
13
  * Full-bleed view of one side, with retake to hand — checking a capture and
@@ -43,11 +44,14 @@ function bottomInset(): number {
43
44
  export function DocumentReviewZoom({
44
45
  side,
45
46
  isBusy,
47
+ copy,
46
48
  onClose,
47
49
  onRetake,
48
50
  }: {
49
51
  side: ReviewSide;
50
52
  isBusy: boolean;
53
+ /** The review's words for the capture mode (documentReviewCopy). */
54
+ copy: DocumentReviewCopy;
51
55
  onClose: () => void;
52
56
  onRetake: () => void;
53
57
  }): React.ReactElement {
@@ -107,7 +111,7 @@ export function DocumentReviewZoom({
107
111
 
108
112
  <View style={{ height: spacing.sm }} />
109
113
  <MyazaButton
110
- label={`Retake ${side.label.toLowerCase()}`}
114
+ label={copy.redoSide(side.label)}
111
115
  leadingIcon="refresh"
112
116
  variant="outline"
113
117
  disabled={isBusy}
@@ -0,0 +1,122 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+
4
+ import { radius } from '../config/theme';
5
+ import { loadWebView, type WebViewLike } from '../lib/webview-available';
6
+ import { useTheme } from './runtime';
7
+ import { MapPinPicker } from './MapPinPicker';
8
+ import {
9
+ MAP_FRAME_READY_TIMEOUT_MS,
10
+ buildMapFrameSrc,
11
+ centerCommandScript,
12
+ parseMapFrameMessage,
13
+ samePin,
14
+ } from '../lib/map-frame';
15
+ import type { LatLng } from '../lib/map-tiles';
16
+
17
+ // Google Maps on mobile, via OUR hosted /embed/map page in a WebView (the OkHi
18
+ // model — the map runs on the hosted origin, on Myaza's own key; the host app
19
+ // never brings one). Mirrors the web SDK's FramedMapPicker: a page that never
20
+ // says `ready` (no webview module installed, blocked script, refused grant,
21
+ // vendor outage) falls back to the dependency-free OSM picker, so the step can
22
+ // never go blank. `react-native-webview` is an OPTIONAL peer: absent, this
23
+ // component IS the OSM picker.
24
+
25
+ interface FramedMapPickerProps {
26
+ /** The server-minted page URL; null ⇒ the OSM picker outright. */
27
+ frameUrl: string | null;
28
+ value: LatLng | null;
29
+ /** Corner rounding. 0 where a parent already shapes the frame — the review
30
+ * card rounds its TOP corners only, and the picker's own radius put a curve
31
+ * where the map meets the address band. */
32
+ cornerRadius?: number;
33
+ onChange: (pin: LatLng) => void;
34
+ defaultCenter: LatLng;
35
+ defaultZoom: number;
36
+ height?: number;
37
+ }
38
+
39
+ export function FramedMapPicker({ frameUrl, value, onChange, defaultCenter, defaultZoom, height = 300, cornerRadius }: FramedMapPickerProps): React.ReactElement {
40
+ const WebView = useMemo(loadWebView, []);
41
+ const { colors, mode } = useTheme();
42
+ const [ready, setReady] = useState(false);
43
+ const [failed, setFailed] = useState(!frameUrl || !WebView);
44
+ const readyRef = useRef(false);
45
+ const webRef = useRef<WebViewLike>(null);
46
+ const onChangeRef = useRef(onChange);
47
+ onChangeRef.current = onChange;
48
+ const lastFromFrame = useRef<LatLng | null>(null);
49
+
50
+ // Built once per mount: a src that tracked the pin would reload the page per drag.
51
+ const src = useMemo(
52
+ () =>
53
+ frameUrl
54
+ ? buildMapFrameSrc(frameUrl, {
55
+ center: value ?? defaultCenter,
56
+ zoom: defaultZoom,
57
+ hasPin: value != null,
58
+ theme: mode === 'dark' ? 'dark' : 'light',
59
+ primaryColor: colors.primary,
60
+ })
61
+ : null,
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ [frameUrl],
64
+ );
65
+
66
+ useEffect(() => {
67
+ if (failed) return undefined;
68
+ const timeout = setTimeout(() => {
69
+ if (!readyRef.current) setFailed(true);
70
+ }, MAP_FRAME_READY_TIMEOUT_MS);
71
+ return () => clearTimeout(timeout);
72
+ }, [failed]);
73
+
74
+ // An external recentre (Use my location, restored progress) moves the map —
75
+ // but never echo back a pin the page itself just reported.
76
+ useEffect(() => {
77
+ if (!value || !ready || samePin(lastFromFrame.current, value)) return;
78
+ webRef.current?.injectJavaScript(centerCommandScript(value));
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [value?.lat, value?.lng, ready]);
81
+
82
+ if (failed || !WebView || !src) {
83
+ return <MapPinPicker value={value} onChange={onChange} defaultCenter={defaultCenter} defaultZoom={defaultZoom} height={height} cornerRadius={cornerRadius} />;
84
+ }
85
+
86
+ return (
87
+ <View style={{ height, borderRadius: cornerRadius ?? radius.md, overflow: 'hidden', backgroundColor: colors.backgroundSecondary }}>
88
+ <WebView
89
+ ref={webRef}
90
+ source={{ uri: src }}
91
+ style={{ flex: 1, backgroundColor: 'transparent' }}
92
+ javaScriptEnabled
93
+ // Android: this WebView sits inside StickyActions' ScrollView, and a
94
+ // ScrollView intercepts every vertical drag its children start, so the
95
+ // map moved a few pixels and stopped while the page scrolled instead.
96
+ // nestedScrollEnabled makes the WebView ask the parent not to intercept
97
+ // (requestDisallowInterceptTouchEvent) for the life of the touch. No-op
98
+ // on iOS. Reported on a Galaxy S24, 2026-09-07.
99
+ nestedScrollEnabled
100
+ onError={() => setFailed(true)}
101
+ onMessage={(event) => {
102
+ const msg = parseMapFrameMessage(event.nativeEvent.data);
103
+ if (!msg) return;
104
+ if (msg.type === 'ready') {
105
+ readyRef.current = true;
106
+ setReady(true);
107
+ } else if (msg.type === 'failed') {
108
+ setFailed(true);
109
+ } else {
110
+ lastFromFrame.current = { lat: msg.lat, lng: msg.lng };
111
+ onChangeRef.current({ lat: msg.lat, lng: msg.lng });
112
+ }
113
+ }}
114
+ />
115
+ {!ready ? (
116
+ <View pointerEvents="none" style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center' }}>
117
+ <ActivityIndicator size="small" color={colors.primary} />
118
+ </View>
119
+ ) : null}
120
+ </View>
121
+ );
122
+ }
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../config/theme';
5
+ import { useTheme } from './runtime';
6
+ import { MyazaText } from './Typography';
7
+
8
+ /**
9
+ * The "Your location" pill beside a pinned geo row: primary tint, primary
10
+ * text. It marks a GUESS made on the visitor's behalf, never a verdict, and
11
+ * the row it sits on is one tap away rather than buried in the alphabet.
12
+ * Shared by the country-select picker and the dial-code sheet so the tag
13
+ * reads identically wherever the guess is offered.
14
+ */
15
+ export function GeoBadge({ label }: { label: string }): React.ReactElement {
16
+ const { colors } = useTheme();
17
+ return (
18
+ <View
19
+ style={{
20
+ marginRight: spacing.sm,
21
+ paddingHorizontal: spacing.sm,
22
+ paddingVertical: 2,
23
+ borderRadius: radius.full,
24
+ borderWidth: 1,
25
+ borderColor: colors.primary,
26
+ backgroundColor: colors.primary50,
27
+ }}
28
+ >
29
+ <MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '500' }}>
30
+ {label}
31
+ </MyazaText>
32
+ </View>
33
+ );
34
+ }