@myazahq/kyc-sdk-react-native 2.4.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 (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -0,0 +1,184 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../../config/theme';
5
+ import { useTheme } from '../../components/runtime';
6
+ import { CountryFlag } from '../../components/CountryFlag';
7
+ import { MyazaText } from '../../components/Typography';
8
+ import { MyazaInput } from '../../components/MyazaInput';
9
+ import type { AddressFieldMode } from '../../lib/address-field-modes';
10
+
11
+ // The edit-details form primitives (200-line split from DetailsSheet).
12
+ // Mirrors the web SDK's DetailsSheetFields; keep the two in lockstep.
13
+
14
+ /** undefined = never touched (the map's prefill shows); '' = cleared. The
15
+ * distinction is what lets an applicant DELETE a wrong prefill without the
16
+ * field refilling itself under their cursor. */
17
+ export interface DetailValues {
18
+ propertyNumber: string;
19
+ street?: string;
20
+ unit?: string;
21
+ propertyName: string;
22
+ directions: string;
23
+ neighbourhood?: string;
24
+ city?: string;
25
+ state?: string;
26
+ postcode?: string;
27
+ }
28
+
29
+ export type DetailPatch = Partial<DetailValues>;
30
+
31
+ export function EditField({
32
+ label,
33
+ value,
34
+ placeholder,
35
+ maxLength,
36
+ disabled,
37
+ multiline,
38
+ required,
39
+ onChange,
40
+ }: {
41
+ label: string;
42
+ value: string;
43
+ placeholder?: string;
44
+ maxLength: number;
45
+ disabled?: boolean;
46
+ multiline?: boolean;
47
+ /** Workflow-required (addressCollection.fields): marked, and the pin
48
+ * step's Continue holds until it is filled. */
49
+ required?: boolean;
50
+ onChange: (value: string) => void;
51
+ }): React.ReactElement {
52
+ return (
53
+ <MyazaInput
54
+ value={value}
55
+ onChangeText={onChange}
56
+ label={label}
57
+ required={required}
58
+ placeholder={placeholder}
59
+ maxLength={maxLength}
60
+ multiline={multiline}
61
+ editable={!disabled}
62
+ />
63
+ );
64
+ }
65
+
66
+ export function SectionHeading({ children }: { children: React.ReactNode }): React.ReactElement {
67
+ const { colors } = useTheme();
68
+ return (
69
+ <MyazaText
70
+ variant="bodySmall"
71
+ color={colors.textSecondary}
72
+ style={{ fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: 11, marginBottom: spacing.sm }}
73
+ >
74
+ {children}
75
+ </MyazaText>
76
+ );
77
+ }
78
+
79
+ /** Read-only country row: the flow's country is a fact of the verification,
80
+ * not an address field — visually distinct from a disabled input. */
81
+ export function CountryRow({ country }: { country: string | null }): React.ReactElement | null {
82
+ const { colors } = useTheme();
83
+ if (!country) return null;
84
+ let name = country;
85
+ try {
86
+ name = new Intl.DisplayNames(['en'], { type: 'region' }).of(country) ?? country;
87
+ } catch {
88
+ // The ISO code stands in where DisplayNames is unavailable.
89
+ }
90
+ return (
91
+ <View>
92
+ {/* The SAME label treatment MyazaInput gives its own fields (web: one
93
+ `text-sm font-medium` for both), so the tile's box lines up with the
94
+ input beside it and the two labels read as one row. */}
95
+ <MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
96
+ Country
97
+ </MyazaText>
98
+ <View
99
+ style={{
100
+ flexDirection: 'row',
101
+ alignItems: 'center',
102
+ gap: spacing.xs + 2,
103
+ height: 48,
104
+ borderWidth: 1,
105
+ borderColor: colors.border,
106
+ borderRadius: radius.md,
107
+ backgroundColor: colors.backgroundSecondary,
108
+ paddingHorizontal: spacing.sm + 4,
109
+ }}
110
+ >
111
+ <CountryFlag country={country} size={20} />
112
+ <MyazaText variant="bodyMedium" style={{ fontWeight: '600', flexShrink: 1 }} numberOfLines={1}>
113
+ {name}
114
+ </MyazaText>
115
+ </View>
116
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 4 }}>
117
+ From your verification
118
+ </MyazaText>
119
+ </View>
120
+ );
121
+ }
122
+
123
+ // TOP-aligned, so the two columns' labels and boxes sit on one line. Bottom
124
+ // alignment pushed the country tile up by the height of its own caption, since
125
+ // it is the taller of the pair (web has no such problem: its grid stretches).
126
+ const twoCol = { flexDirection: 'row' as const, gap: spacing.sm, alignItems: 'flex-start' as const };
127
+ const col = { flex: 1, minWidth: 0 };
128
+
129
+ /** The "Area and region" section. Each field honours its workflow mode:
130
+ * 'off' hides it, 'required' marks it. Returns null when all four are off. */
131
+ export function AreaFields({
132
+ modes,
133
+ values,
134
+ parts,
135
+ shown,
136
+ country,
137
+ disabled,
138
+ onChange,
139
+ }: {
140
+ modes: Record<'neighbourhood' | 'city' | 'state' | 'postcode', AddressFieldMode>;
141
+ values: DetailValues;
142
+ parts: { area?: string | null; city?: string | null; state?: string | null; postcode?: string | null } | null;
143
+ /** Typed wins (a cleared field stays cleared); the map's answer fills the gap. */
144
+ shown: (typed: string | undefined, part: string | null | undefined) => string;
145
+ country: string | null;
146
+ disabled?: boolean;
147
+ onChange: (patch: DetailPatch) => void;
148
+ }): React.ReactElement | null {
149
+ const on = (k: keyof typeof modes) => modes[k] !== 'off';
150
+ const req = (k: keyof typeof modes) => modes[k] === 'required';
151
+ if (!on('neighbourhood') && !on('city') && !on('state') && !on('postcode')) return null;
152
+ return (
153
+ <View style={{ marginTop: spacing.md }}>
154
+ <SectionHeading>Area and region</SectionHeading>
155
+ {on('neighbourhood') ? (
156
+ <EditField label="Neighbourhood" value={shown(values.neighbourhood, parts?.area)} placeholder="e.g. Idim Ita" maxLength={80} disabled={disabled} required={req('neighbourhood')} onChange={(neighbourhood) => onChange({ neighbourhood })} />
157
+ ) : null}
158
+ {on('city') || on('state') ? (
159
+ <View style={[twoCol, { marginTop: spacing.sm }]}>
160
+ {on('city') ? (
161
+ <View style={col}>
162
+ <EditField label="City" value={shown(values.city, parts?.city)} placeholder="e.g. Calabar" maxLength={80} disabled={disabled} required={req('city')} onChange={(city) => onChange({ city })} />
163
+ </View>
164
+ ) : null}
165
+ {on('state') ? (
166
+ <View style={col}>
167
+ <EditField label="State" value={shown(values.state, parts?.state)} placeholder="e.g. Cross River" maxLength={80} disabled={disabled} required={req('state')} onChange={(state) => onChange({ state })} />
168
+ </View>
169
+ ) : null}
170
+ </View>
171
+ ) : null}
172
+ <View style={[twoCol, { marginTop: spacing.sm }]}>
173
+ {on('postcode') ? (
174
+ <View style={col}>
175
+ <EditField label="Area code" value={shown(values.postcode, parts?.postcode)} placeholder="e.g. 540281" maxLength={12} disabled={disabled} required={req('postcode')} onChange={(postcode) => onChange({ postcode })} />
176
+ </View>
177
+ ) : null}
178
+ <View style={col}>
179
+ <CountryRow country={country} />
180
+ </View>
181
+ </View>
182
+ </View>
183
+ );
184
+ }
@@ -0,0 +1,178 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, Image, Pressable, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../../config/theme';
5
+ import { useTheme } from '../../components/runtime';
6
+ import { DashedBorder } from '../../components/DashedBorder';
7
+ import { Icon } from '../../components/Icon';
8
+ import { OverlayPill, pillSurface } from './EntrancePills';
9
+ import { MyazaText } from '../../components/Typography';
10
+
11
+ const FRAME_HEIGHT = 300;
12
+
13
+ /**
14
+ * The entrance photo: the hero of its step, so it is a full-width dropzone
15
+ * rather than a button in a list.
16
+ *
17
+ * Filled, it shows the photo itself with Replace and Remove over it. A RESTORED
18
+ * session holds the uploaded mediaId but not the bytes, so that case gets an
19
+ * honest "photo added" placeholder instead of a broken image.
20
+ */
21
+ export function EntranceDropzone({
22
+ uploaded,
23
+ uploading,
24
+ previewUri,
25
+ required,
26
+ onPick,
27
+ onRemove,
28
+ }: {
29
+ uploaded: boolean;
30
+ uploading: boolean;
31
+ /** Local preview of the picked file; absent on a restored session. */
32
+ previewUri: string | null;
33
+ required: boolean;
34
+ onPick: () => void;
35
+ onRemove: () => void;
36
+ }): React.ReactElement {
37
+ const { colors } = useTheme();
38
+
39
+ if (uploaded && !uploading) {
40
+ return (
41
+ <View
42
+ style={{
43
+ height: FRAME_HEIGHT,
44
+ borderRadius: radius.md,
45
+ overflow: 'hidden',
46
+ borderWidth: 1,
47
+ borderColor: colors.border,
48
+ backgroundColor: colors.backgroundSecondary,
49
+ alignItems: 'center',
50
+ justifyContent: 'center',
51
+ }}
52
+ >
53
+ {previewUri ? (
54
+ <Image
55
+ source={{ uri: previewUri }}
56
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
57
+ resizeMode="cover"
58
+ />
59
+ ) : (
60
+ <>
61
+ <Icon name="check" size={30} color={colors.success} />
62
+ <View style={{ height: spacing.sm }} />
63
+ <MyazaText variant="bodyMedium" style={{ fontWeight: '600' }}>
64
+ Entrance photo added
65
+ </MyazaText>
66
+ </>
67
+ )}
68
+
69
+ {/* Web's tag: `text-[11px] font-semibold uppercase tracking-wider
70
+ text-primary` with a 12px check, on a 90% background. */}
71
+ <View
72
+ style={{
73
+ position: 'absolute',
74
+ top: 12,
75
+ left: 12,
76
+ flexDirection: 'row',
77
+ alignItems: 'center',
78
+ gap: 6,
79
+ paddingHorizontal: spacing.sm + 2,
80
+ paddingVertical: 4,
81
+ borderRadius: radius.full,
82
+ ...pillSurface(colors.background, colors.textDark),
83
+ }}
84
+ >
85
+ <Icon name="circle-check" size={12} color={colors.primary} />
86
+ <MyazaText
87
+ variant="bodySmall"
88
+ color={colors.primary}
89
+ style={{ fontSize: 11, fontWeight: '600', letterSpacing: 0.6, textTransform: 'uppercase' }}
90
+ >
91
+ Entrance photo
92
+ </MyazaText>
93
+ </View>
94
+
95
+ <View
96
+ style={{
97
+ position: 'absolute',
98
+ right: 12,
99
+ bottom: 12,
100
+ flexDirection: 'row',
101
+ gap: spacing.sm,
102
+ }}
103
+ >
104
+ <OverlayPill icon="refresh-ccw" label="Replace" onPress={onPick} />
105
+ <OverlayPill icon="x" label="Remove" accessibilityLabel="Remove photo" onPress={onRemove} />
106
+ </View>
107
+ </View>
108
+ );
109
+ }
110
+
111
+ return (
112
+ <Pressable
113
+ onPress={uploading ? undefined : onPick}
114
+ disabled={uploading}
115
+ accessibilityRole="button"
116
+ accessibilityLabel="Take or upload a photo"
117
+ style={{
118
+ height: FRAME_HEIGHT,
119
+ alignItems: 'center',
120
+ justifyContent: 'center',
121
+ paddingHorizontal: spacing.lg,
122
+ borderRadius: radius.md,
123
+ }}
124
+ >
125
+ <DashedBorder color={colors.border} radius={radius.md} strokeWidth={2} />
126
+ {/* Web's `h-14 w-14 rounded-2xl bg-primary/10 ring-1 ring-primary/20`,
127
+ the box Flutter draws too. */}
128
+ <View
129
+ style={{
130
+ width: 56,
131
+ height: 56,
132
+ borderRadius: radius.md,
133
+ alignItems: 'center',
134
+ justifyContent: 'center',
135
+ backgroundColor: `${colors.primary}1A`,
136
+ borderWidth: 1,
137
+ borderColor: `${colors.primary}33`,
138
+ }}
139
+ >
140
+ {uploading ? (
141
+ <ActivityIndicator size="small" color={colors.primary} />
142
+ ) : (
143
+ <Icon name="camera" size={24} color={colors.primary} />
144
+ )}
145
+ </View>
146
+ <View style={{ height: spacing.md }} />
147
+ <MyazaText variant="body" style={{ fontWeight: '600' }}>
148
+ {uploading ? 'Uploading photo…' : 'Take or upload a photo'}
149
+ </MyazaText>
150
+ {uploading ? null : (
151
+ <>
152
+ <MyazaText
153
+ variant="bodyMedium"
154
+ color={colors.textSecondary}
155
+ style={{ marginTop: spacing.xs, textAlign: 'center' }}
156
+ >
157
+ {required
158
+ ? 'The gate, front door or the building itself.'
159
+ : 'The gate, front door or the building itself. Optional.'}
160
+ </MyazaText>
161
+ <View
162
+ style={{
163
+ marginTop: spacing.md,
164
+ paddingHorizontal: spacing.sm + 2,
165
+ paddingVertical: 4,
166
+ borderRadius: radius.full,
167
+ backgroundColor: colors.backgroundSecondary,
168
+ }}
169
+ >
170
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ fontSize: 11, fontWeight: '500' }}>
171
+ JPEG · PNG · WebP
172
+ </MyazaText>
173
+ </View>
174
+ </>
175
+ )}
176
+ </Pressable>
177
+ );
178
+ }
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import { useWindowDimensions, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../../config/theme';
5
+ import { useTheme } from '../../components/runtime';
6
+ import { Icon } from '../../components/Icon';
7
+ import { MyazaText } from '../../components/Typography';
8
+ import { MyazaButton } from '../../components/MyazaButton';
9
+ import { StickyActions } from '../../components/StickyActions';
10
+ import { mapSurfaceHeight } from '../../lib/map-tiles';
11
+
12
+ // The entrance step's SANDBOX stand-in. A SANDBOX mount keeps the step real
13
+ // users get, on a static placeholder (the camera steps' rule): no Google
14
+ // loads from a test key, the server cans the verdict regardless, and "Use
15
+ // this view" simply advances. Mirrors the web SDK's preview branch; the
16
+ // Flutter twin is address_entrance_framing.dart.
17
+
18
+ export function EntrancePlaceholder({
19
+ hideSkip,
20
+ onSkip,
21
+ onUse,
22
+ }: {
23
+ hideSkip: boolean;
24
+ onSkip: () => void;
25
+ onUse: () => void;
26
+ }): React.ReactElement {
27
+ const { colors } = useTheme();
28
+ const height = mapSurfaceHeight(useWindowDimensions());
29
+ return (
30
+ <StickyActions
31
+ actions={
32
+ <View style={{ flexDirection: 'row', gap: spacing.sm }}>
33
+ {!hideSkip ? (
34
+ <View style={{ flex: 1 }}>
35
+ <MyazaButton label="Skip" variant="outline" onPress={onSkip} />
36
+ </View>
37
+ ) : null}
38
+ <View style={{ flex: 1 }}>
39
+ <MyazaButton label="Use this view" onPress={onUse} />
40
+ </View>
41
+ </View>
42
+ }
43
+ >
44
+ <View
45
+ style={{
46
+ height,
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
49
+ gap: spacing.sm,
50
+ // Web's `rounded-xl border-2 border-dashed bg-muted/40`.
51
+ borderRadius: radius.sm,
52
+ borderWidth: 2,
53
+ borderStyle: 'dashed',
54
+ borderColor: colors.border,
55
+ backgroundColor: `${colors.backgroundSecondary}66`,
56
+ paddingHorizontal: spacing.lg,
57
+ }}
58
+ >
59
+ <Icon name="landmark" size={32} color={`${colors.textSecondary}99`} />
60
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center', maxWidth: 288 }}>
61
+ Applicants pan real street imagery to frame their entrance here. It loads only for real users.
62
+ </MyazaText>
63
+ </View>
64
+ </StickyActions>
65
+ );
66
+ }
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { Pressable } from 'react-native';
3
+
4
+ import { radius, spacing } from '../../config/theme';
5
+ import { useTheme } from '../../components/runtime';
6
+ import { Icon, type IconName } from '../../components/Icon';
7
+ import { MyazaText } from '../../components/Typography';
8
+
9
+ // The pills that sit over a picked entrance photo (200-line split from
10
+ // EntranceDropzone). Web's `bg-background/90 shadow` surface, drawn the same
11
+ // on Flutter.
12
+
13
+ /** Web's overlay pill surface: `bg-background/90 shadow`. */
14
+ export function pillSurface(background: string, ink: string) {
15
+ return {
16
+ backgroundColor: `${background}E6`,
17
+ shadowColor: ink,
18
+ shadowOpacity: 0.12,
19
+ shadowRadius: 6,
20
+ shadowOffset: { width: 0, height: 2 },
21
+ elevation: 2,
22
+ } as const;
23
+ }
24
+
25
+ /** Web's `px-3 py-1.5 text-xs font-medium` pill with a 14px icon. */
26
+ export function OverlayPill({
27
+ icon,
28
+ label,
29
+ accessibilityLabel,
30
+ onPress,
31
+ }: {
32
+ icon: IconName;
33
+ label: string;
34
+ accessibilityLabel?: string;
35
+ onPress: () => void;
36
+ }): React.ReactElement {
37
+ const { colors } = useTheme();
38
+ return (
39
+ <Pressable
40
+ onPress={onPress}
41
+ accessibilityRole="button"
42
+ accessibilityLabel={accessibilityLabel ?? label}
43
+ style={({ pressed }) => ({
44
+ flexDirection: 'row',
45
+ alignItems: 'center',
46
+ gap: 6,
47
+ paddingHorizontal: spacing.sm + 4,
48
+ paddingVertical: 6,
49
+ borderRadius: radius.full,
50
+ ...pillSurface(colors.background, colors.textDark),
51
+ opacity: pressed ? 0.8 : 1,
52
+ })}
53
+ >
54
+ <Icon name={icon} size={14} color={colors.textDark} />
55
+ <MyazaText variant="bodySmall" style={{ fontWeight: '500' }}>
56
+ {label}
57
+ </MyazaText>
58
+ </Pressable>
59
+ );
60
+ }
@@ -0,0 +1,155 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ActivityIndicator, useWindowDimensions, View } from 'react-native';
3
+
4
+ import { radius, spacing } from '../../config/theme';
5
+ import { useTheme } from '../../components/runtime';
6
+ import { MyazaText } from '../../components/Typography';
7
+ import { MyazaButton } from '../../components/MyazaButton';
8
+ import { StickyActions } from '../../components/StickyActions';
9
+ import { loadWebView } from '../../lib/webview-available';
10
+ import {
11
+ MAP_FRAME_READY_TIMEOUT_MS,
12
+ buildStreetViewFrameSrc,
13
+ parseStreetViewFrameMessage,
14
+ } from '../../lib/map-frame';
15
+ import { captureStreetViewFrame, type StreetViewFrame } from '../../lib/street-view-fov';
16
+ import { mapSurfaceHeight, type LatLng } from '../../lib/map-tiles';
17
+ import { StreetViewChrome, streetViewFrameRect } from './StreetViewChrome';
18
+
19
+ // Street View on mobile, via OUR hosted /embed/street-view page in a WebView —
20
+ // the same model (and the same app grant) as FramedMapPicker. The page renders
21
+ // the panorama and streams the current view; THIS component owns the framing
22
+ // chrome, the frame-subtended fov maths and the capture decision, so hosted,
23
+ // embedded and native applicants meet the identical instrument. Mirrors the
24
+ // web SDK's FramedStreetView and the Flutter twin; keep the three in lockstep.
25
+ // A page that never says sv-ready (no webview module, blocked script, refused
26
+ // grant, no coverage) hands the step to the photo fallback via onUnavailable.
27
+
28
+ type Pov = { panoId: string; heading: number; pitch: number; viewFov: number };
29
+
30
+ export function FramedStreetView({
31
+ frameUrl,
32
+ pin,
33
+ onCaptured,
34
+ onSkip,
35
+ hideSkip,
36
+ onUnavailable,
37
+ }: {
38
+ frameUrl: string;
39
+ pin: LatLng;
40
+ onCaptured: (frame: StreetViewFrame) => void;
41
+ /** The applicant would rather add their own photo. */
42
+ onSkip: () => void;
43
+ /** streetView 'required': the skip affordance is removed while coverage
44
+ * exists (no-coverage still falls back — the client-UX gate only). */
45
+ hideSkip?: boolean;
46
+ /** No coverage, no grant, or no page — fall back to the photo. */
47
+ onUnavailable: () => void;
48
+ }): React.ReactElement {
49
+ const WebView = useMemo(loadWebView, []);
50
+ const { colors, mode } = useTheme();
51
+ const height = mapSurfaceHeight(useWindowDimensions());
52
+ const [ready, setReady] = useState(false);
53
+ const [viewWidth, setViewWidth] = useState(0);
54
+ const readyRef = useRef(false);
55
+ const latestPov = useRef<Pov | null>(null);
56
+ const gone = useRef(false);
57
+ const unavailableRef = useRef(onUnavailable);
58
+ unavailableRef.current = onUnavailable;
59
+ const unavailable = () => {
60
+ if (gone.current) return;
61
+ gone.current = true;
62
+ unavailableRef.current();
63
+ };
64
+
65
+ // Built once per mount: a src that tracked the theme would reload the page.
66
+ const src = useMemo(
67
+ () => buildStreetViewFrameSrc(frameUrl, { pin, theme: mode === 'dark' ? 'dark' : 'light' }),
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ [frameUrl],
70
+ );
71
+
72
+ useEffect(() => {
73
+ if (!WebView) {
74
+ unavailable();
75
+ return undefined;
76
+ }
77
+ const timeout = setTimeout(() => {
78
+ if (!readyRef.current) unavailable();
79
+ }, MAP_FRAME_READY_TIMEOUT_MS);
80
+ return () => clearTimeout(timeout);
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ }, [WebView]);
83
+
84
+ const capture = () => {
85
+ const pov = latestPov.current;
86
+ if (!pov) return;
87
+ // The promise on screen is the FRAME, so the captured fov is the slice the
88
+ // frame subtends of the reported viewport fov — same maths, same frame
89
+ // geometry as the hosted framer.
90
+ const frame = streetViewFrameRect(viewWidth, height);
91
+ onCaptured(captureStreetViewFrame(pov, frame.width, viewWidth));
92
+ };
93
+
94
+ return (
95
+ <StickyActions
96
+ actions={
97
+ <View style={{ flexDirection: 'row', gap: spacing.sm }}>
98
+ {!hideSkip ? (
99
+ <View style={{ flex: 1 }}>
100
+ <MyazaButton label="Skip" variant="outline" onPress={onSkip} />
101
+ </View>
102
+ ) : null}
103
+ <View style={{ flex: 1 }}>
104
+ <MyazaButton label="Use this view" disabled={!ready} onPress={capture} />
105
+ </View>
106
+ </View>
107
+ }
108
+ >
109
+ <View
110
+ onLayout={(e) => setViewWidth(e.nativeEvent.layout.width)}
111
+ style={{ height, borderRadius: radius.md, overflow: 'hidden', backgroundColor: colors.backgroundSecondary }}
112
+ >
113
+ {WebView ? (
114
+ <WebView
115
+ source={{ uri: src }}
116
+ style={{ flex: 1, backgroundColor: 'transparent' }}
117
+ javaScriptEnabled
118
+ // Android: this WebView sits inside StickyActions' ScrollView, and a
119
+ // ScrollView intercepts every vertical drag its children start, so the
120
+ // map moved a few pixels and stopped while the page scrolled instead.
121
+ // nestedScrollEnabled makes the WebView ask the parent not to intercept
122
+ // (requestDisallowInterceptTouchEvent) for the life of the touch. No-op
123
+ // on iOS. Reported on a Galaxy S24, 2026-09-07.
124
+ nestedScrollEnabled
125
+ onError={unavailable}
126
+ onMessage={(event) => {
127
+ const msg = parseStreetViewFrameMessage(event.nativeEvent.data);
128
+ if (!msg) return;
129
+ if (msg.type === 'sv-ready') {
130
+ readyRef.current = true;
131
+ setReady(true);
132
+ } else if (msg.type === 'sv-unavailable') {
133
+ unavailable();
134
+ } else {
135
+ latestPov.current = { panoId: msg.panoId, heading: msg.heading, pitch: msg.pitch, viewFov: msg.viewFov };
136
+ }
137
+ }}
138
+ />
139
+ ) : null}
140
+ {!ready ? (
141
+ <View pointerEvents="none" style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center' }}>
142
+ <ActivityIndicator size="small" color={colors.primary} />
143
+ </View>
144
+ ) : (
145
+ <StreetViewChrome viewportWidth={viewWidth} viewportHeight={height} />
146
+ )}
147
+ </View>
148
+ {ready ? (
149
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center', marginTop: spacing.sm }}>
150
+ Drag to look around until your gate or front door sits inside the frame.
151
+ </MyazaText>
152
+ ) : null}
153
+ </StickyActions>
154
+ );
155
+ }