@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
@@ -1,7 +1,11 @@
1
+ import { configScope, isFaceScope } from '../../lib/scope';
1
2
  import type { MyazaKYCConfig } from '../../types/config';
2
3
  import type { IconName } from '../../components/Icon';
3
4
  import { fillTokens } from '../../utils/tokens';
4
5
  import { hasEmailVerificationStep, hasPhoneVerificationStep } from '../../config/contact';
6
+ import { hasActiveQuestionnaire } from '../../config/questionnaire';
7
+ import { hasProofOfAddressStep } from '../../config/proofOfAddress';
8
+ import { hasAddressCollectionStep } from '../../config/addressCollection';
5
9
  import {
6
10
  hasApplicantVerification,
7
11
  hasBusinessDocumentsStep,
@@ -45,15 +49,55 @@ const DEFAULT_DESCRIPTION =
45
49
  const DEFAULT_BUSINESS_DESCRIPTION =
46
50
  'We need to verify your business to comply with regulatory requirements. This process is quick and secure.';
47
51
 
52
+ const SCOPE_TITLES: Record<string, string> = {
53
+ address: 'Address Verification',
54
+ 'biometric-authentication': 'Face Check',
55
+ 'biometric-enrollment': 'Face Enrolment',
56
+ questionnaire: 'A Few Questions',
57
+ contact: 'Confirm Your Contact Details',
58
+ };
59
+
60
+ const SCOPE_DESCRIPTIONS: Record<string, string> = {
61
+ address:
62
+ 'We need to confirm your home address to comply with regulatory requirements. This process is quick and secure.',
63
+ 'biometric-authentication':
64
+ 'A quick face check confirms it is really you. This takes a few seconds and is secure.',
65
+ 'biometric-enrollment':
66
+ 'A quick selfie sets up face checks for next time, so you will not have to prove your identity again. This takes a few seconds and is secure.',
67
+ questionnaire:
68
+ 'A few questions keep your account details up to date and help us comply with regulatory requirements.',
69
+ contact:
70
+ 'We need to re-confirm the email address and phone number on your account. This takes a minute and is secure.',
71
+ };
72
+
73
+ const SCOPE_BULLETS: Record<string, ConsentProcessStep[]> = {
74
+ address: [
75
+ { icon: 'map-pin-house', label: 'Pin your home address on a map' },
76
+ { icon: 'badge-check', label: 'Confirm the details only you can know' },
77
+ ],
78
+ 'biometric-authentication': [
79
+ { icon: 'scan-face', label: 'Take a quick selfie with liveness checks' },
80
+ { icon: 'badge-check', label: 'We match it against your enrolled face' },
81
+ ],
82
+ 'biometric-enrollment': [
83
+ { icon: 'scan-face', label: 'Take a quick selfie with liveness checks' },
84
+ { icon: 'badge-check', label: 'It becomes your face check for next time' },
85
+ ],
86
+ questionnaire: [{ icon: 'badge-check', label: 'Answer a few short questions' }],
87
+ contact: [{ icon: 'lock', label: 'Confirm your contact details with a one-time code' }],
88
+ };
89
+
48
90
  export function buildConsentModel(config: MyazaKYCConfig): ConsentModel {
49
91
  const isBusiness = config.subjectType === 'business';
92
+ const scope = isBusiness ? null : configScope(config);
93
+ const faceScope = isFaceScope(scope);
50
94
  const firstName = config.userData?.firstName ?? '';
51
95
 
52
96
  const defaultTitle = firstName
53
97
  ? `Welcome, ${firstName}`
54
98
  : isBusiness
55
99
  ? 'Business Verification'
56
- : 'Identity Verification';
100
+ : SCOPE_TITLES[scope ?? ''] ?? 'Identity Verification';
57
101
  const title = config.consent?.title
58
102
  ? fillTokens(config.consent.title, config.userData)
59
103
  : defaultTitle;
@@ -61,40 +105,65 @@ export function buildConsentModel(config: MyazaKYCConfig): ConsentModel {
61
105
  ? fillTokens(config.consent.description, config.userData)
62
106
  : isBusiness
63
107
  ? DEFAULT_BUSINESS_DESCRIPTION
64
- : DEFAULT_DESCRIPTION;
108
+ : SCOPE_DESCRIPTIONS[scope ?? ''] ?? DEFAULT_DESCRIPTION;
65
109
 
66
110
  // A business flow captures a face only when the applicant verifies their own
67
111
  // identity in-flow; an individual flow whenever the selfie step is on.
68
112
  const capturesFace = isBusiness
69
113
  ? hasApplicantVerification(config.business)
70
- : config.enableSelfie !== false;
71
- const recordsVideo = capturesFace || (!isBusiness && config.enableDocumentCapture !== false);
114
+ : faceScope || (!scope && config.enableSelfie !== false);
115
+ const recordsVideo =
116
+ capturesFace || (!isBusiness && !scope && config.enableDocumentCapture !== false);
72
117
 
73
118
  const steps: ConsentProcessStep[] = isBusiness
74
119
  ? [
75
120
  { icon: 'building-2', label: 'Collect your business registration details' },
76
121
  { icon: 'badge-check', label: 'Verify your business against the official registry' },
77
122
  ]
78
- : [
123
+ : scope
124
+ ? // COPY the catalogue entry: pushing below would otherwise mutate the
125
+ // shared constant, appending one more bullet per re-render.
126
+ [...(SCOPE_BULLETS[scope] ?? [])]
127
+ : [
79
128
  { icon: 'badge-check', label: 'Verify your government-issued ID' },
80
129
  { icon: 'user', label: 'Collect basic personal information' },
81
130
  ];
82
- if (hasEmailVerificationStep(config.emailVerification) || hasPhoneVerificationStep(config.phoneVerification)) {
83
- steps.push({ icon: 'lock', label: 'Confirm your contact details with a one-time code' });
131
+ // On the CONTACT scope the catalogue bullet already says this — appending
132
+ // the generic line showed "confirm your contact details" twice the moment
133
+ // both channels were on.
134
+ const hasEmail = hasEmailVerificationStep(config.emailVerification);
135
+ const hasPhone = hasPhoneVerificationStep(config.phoneVerification);
136
+ if (scope !== 'contact' && (hasEmail || hasPhone)) {
137
+ const what = hasEmail && hasPhone ? 'email and phone number' : hasEmail ? 'email' : 'phone number';
138
+ steps.push({ icon: 'lock', label: `Confirm your ${what} with a one-time code` });
84
139
  }
85
- if (!isBusiness && config.enableDocumentCapture !== false) {
140
+ if (!isBusiness && !scope && config.enableDocumentCapture !== false) {
86
141
  steps.push({ icon: 'scan-line', label: 'Capture a photo of your ID document' });
87
142
  }
88
143
  // Chip-capable IDs additionally read the document's NFC chip — listed when
89
144
  // the flow enables NFC so the user knows to have the physical document to
90
145
  // hand (a non-chip ID simply skips the step). Individual flows only; a KYB
91
146
  // config can't carry `nfc`.
92
- if (!isBusiness && config.nfc?.enabled) {
147
+ if (!isBusiness && !scope && config.nfc?.enabled) {
93
148
  steps.push({ icon: 'nfc', label: 'Scan your document’s security chip (NFC)' });
94
149
  }
95
- if (!isBusiness && config.enableSelfie !== false) {
150
+ if (!isBusiness && !scope && config.enableSelfie !== false) {
96
151
  steps.push({ icon: 'scan-face', label: 'Take a selfie for facial verification' });
97
152
  }
153
+ // Post-capture features, in the order the flow runs them. Each is gated on
154
+ // the flow that actually asks for it, and skipped where a scope's own
155
+ // catalogue bullet already covers the same step.
156
+ if (!isBusiness && hasProofOfAddressStep(config.proofOfAddress)) {
157
+ steps.push({ icon: 'file-text', label: 'Upload a proof of address document' });
158
+ }
159
+ if (scope !== 'address' && hasAddressCollectionStep(config.addressCollection)) {
160
+ steps.push({ icon: 'map-pin-house', label: 'Pin your address on a map' });
161
+ }
162
+ // The step-order predicate, not a raw fields check: a questionnaire with
163
+ // questions but enabled: false never runs, so it must not be promised.
164
+ if (scope !== 'questionnaire' && hasActiveQuestionnaire(config.questionnaire)) {
165
+ steps.push({ icon: 'badge-check', label: 'Answer a few short questions' });
166
+ }
98
167
  if (isBusiness && hasKeyPeopleCollection(config.business)) {
99
168
  steps.push({ icon: 'users', label: "List the company's directors and owners" });
100
169
  }
@@ -0,0 +1,91 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import Svg, { Circle } from 'react-native-svg';
3
+ import { advanceTarget, easeToward, mixHex } from '../../lib/captureRing';
4
+
5
+ // A single line traced around the camera circle's edge for the length of the
6
+ // liveness test: empty when it starts, closed at the shutter, one way only, no
7
+ // rotation. The frame's own border filling in, not a spinner on top of it.
8
+ //
9
+ // Driven OUTSIDE React. `getTarget` is read every frame in a requestAnimationFrame
10
+ // loop and the arc is written with setNativeProps — no state, no re-render.
11
+ // react-native-reanimated is deliberately not a dependency of this SDK, and a
12
+ // native module is the wrong price for one ring; setNativeProps per frame is
13
+ // the no-render path core React Native offers, and the write is a single prop
14
+ // on a single leaf node. Judge it on a release build on the slowest Android
15
+ // you support.
16
+
17
+ const STROKE = 4; // the frame's own border width
18
+ const GREEN_MS = 300;
19
+
20
+ export function CaptureRing({
21
+ size,
22
+ getTarget,
23
+ color,
24
+ successColor,
25
+ complete,
26
+ }: {
27
+ size: number;
28
+ /** Latest 0..1 progress. Read every frame; must be a stable function. */
29
+ getTarget: () => number;
30
+ color: string;
31
+ successColor: string;
32
+ /** Turns the closed ring green — on the same frame as the shutter. */
33
+ complete: boolean;
34
+ }) {
35
+ const ref = useRef<React.ElementRef<typeof Circle>>(null);
36
+ const targetRef = useRef(0);
37
+ const shownRef = useRef(0);
38
+ const completeRef = useRef(complete);
39
+ const greenSinceRef = useRef<number | null>(null);
40
+ completeRef.current = complete;
41
+
42
+ const r = size / 2 - STROKE / 2;
43
+ const circumference = 2 * Math.PI * r;
44
+
45
+ useEffect(() => {
46
+ let frame = 0;
47
+ let last = Date.now();
48
+ const tick = () => {
49
+ const now = Date.now();
50
+ const dt = Math.min((now - last) / 1000, 0.1); // a backgrounded app must not jump
51
+ last = now;
52
+ targetRef.current = advanceTarget(targetRef.current, getTarget());
53
+ shownRef.current = easeToward(shownRef.current, targetRef.current, dt);
54
+ if (completeRef.current && greenSinceRef.current == null) greenSinceRef.current = now;
55
+ const green = greenSinceRef.current == null ? 0 : (now - greenSinceRef.current) / GREEN_MS;
56
+ ref.current?.setNativeProps({
57
+ strokeDashoffset: circumference * (1 - shownRef.current),
58
+ stroke: green > 0 ? mixHex(color, successColor, green) : color,
59
+ });
60
+ frame = requestAnimationFrame(tick);
61
+ };
62
+ frame = requestAnimationFrame(tick);
63
+ return () => cancelAnimationFrame(frame);
64
+ }, [getTarget, circumference, color, successColor]);
65
+
66
+ return (
67
+ <Svg
68
+ pointerEvents="none"
69
+ style={{ position: 'absolute', top: 0, left: 0 }}
70
+ width={size}
71
+ height={size}
72
+ // Static origin, not motion: the arc starts at twelve o'clock.
73
+ rotation={-90}
74
+ originX={size / 2}
75
+ originY={size / 2}
76
+ >
77
+ <Circle
78
+ ref={ref}
79
+ cx={size / 2}
80
+ cy={size / 2}
81
+ r={r}
82
+ fill="none"
83
+ stroke={color}
84
+ strokeWidth={STROKE}
85
+ strokeLinecap="butt"
86
+ strokeDasharray={[circumference]}
87
+ strokeDashoffset={circumference}
88
+ />
89
+ </Svg>
90
+ );
91
+ }
@@ -0,0 +1,63 @@
1
+ import React from 'react';
2
+ import { ActivityIndicator, View } from 'react-native';
3
+ import { Camera, type CameraDevice, type CameraViewProps } from 'react-native-vision-camera';
4
+
5
+ // ─── The front camera inside the liveness circle ────────────────────────────
6
+ //
7
+ // The Camera is mounted inside a plain, BORDERLESS wrapper, and that wrapper is
8
+ // the fix for a preview that did not reach the right-hand edge of the circle
9
+ // on Android (Galaxy S24, 2026-09-07: a vertical line one border-width in from
10
+ // the ring, the clip box's ground showing through).
11
+ //
12
+ // VisionCamera installs a "hierarchy fitter" on its Android PreviewView: the
13
+ // moment CameraX adds the SurfaceView, it re-runs `layout(0, 0, w, h)` on the
14
+ // PreviewView so the new child gets measured (React Native never lays native
15
+ // children out on its own). That call keeps the SIZE React Native gave the view
16
+ // and throws away its POSITION — so a Camera that is the direct child of a
17
+ // bordered box snaps from (border, border) back to the box's outer corner. The
18
+ // 3:4 preview buffer overflows the circle vertically and hides the shift on
19
+ // that axis; horizontally it fits exactly, and the missing border-width is the
20
+ // line. iOS has no fitter, which is why only Android showed it.
21
+ //
22
+ // A wrapper with no border sits at (border, border) itself, laid out by React
23
+ // Native, so the fitter's (0, 0) is now the right answer. The wrapper must be
24
+ // `collapsable={false}`: a View that carries nothing but layout is FLATTENED on
25
+ // Android (it never exists natively, its child is re-parented to the
26
+ // grandparent), which put the Camera straight back inside the bordered box
27
+ // and reproduced the line on the first attempt. Never mount VisionCamera's
28
+ // Camera as the direct child of a view with `borderWidth`.
29
+ // Pinned by __tests__/livenessCameraWrapper.test.ts.
30
+
31
+ interface LivenessCameraProps {
32
+ device: CameraDevice | undefined;
33
+ active: boolean;
34
+ outputs: NonNullable<CameraViewProps['outputs']>;
35
+ /** Diameter of the circle, for the Camera's own rounded clip. */
36
+ circle: number;
37
+ }
38
+
39
+ export function LivenessCamera({ device, active, outputs, circle }: LivenessCameraProps): React.ReactElement {
40
+ return (
41
+ <View style={{ flex: 1 }} collapsable={false}>
42
+ {device ? (
43
+ <Camera
44
+ // borderRadius repeated on the Camera — iOS does not reliably clip
45
+ // a native child to a rounded parent, so the preview's square
46
+ // corners showed through the circle. Same fix the selfie preview
47
+ // and the header brand bar already carry.
48
+ style={{ flex: 1, borderRadius: circle / 2, overflow: 'hidden' }}
49
+ device={device}
50
+ isActive={active}
51
+ outputs={outputs}
52
+ // Mirror the front-camera preview + capture so it reads like a
53
+ // mirror (matches the web SDK's scaleX(-1) video).
54
+ mirrorMode="on"
55
+ />
56
+ ) : (
57
+ <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
58
+ <ActivityIndicator color="#FFFFFF" />
59
+ </View>
60
+ )}
61
+ </View>
62
+ );
63
+ }
@@ -0,0 +1,43 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+
3
+ import { useKycConfig } from '../../components/runtime';
4
+ import { configScope } from '../../lib/scope';
5
+ import { describeWaiting } from '../../lib/result-copy';
6
+ import { waitsForResult } from '../../config/biometricOptions';
7
+ import { biometricCopyFor } from '../../lib/biometric-copy';
8
+ import { SubmittedWaiting } from '../SubmittedWaiting';
9
+
10
+ // ─── Handing over without the review ────────────────────────────────────────
11
+ //
12
+ // On the biometric scopes the selfie review (Retake / Continue) is OFF by
13
+ // default (config/biometricOptions.ts): a re-authentication is a few-second
14
+ // check and a review screen is a stop in the middle of it. The step hands
15
+ // over the moment the ring has closed, WITHOUT waiting for the upload: the
16
+ // upload keeps running and reports to the store, and the submitted step waits
17
+ // on that record (lib/selfie-upload-wait.ts). So the person sees one loading
18
+ // screen from the shutter to the verdict, not one per step. The view below
19
+ // exists for the single render between "ready" and the step change, and it
20
+ // is the same screen the submitted step shows, so nothing visibly changes.
21
+ // An upload that fails BEFORE the ring closes still gets the review, whose
22
+ // Try Again is the recovery.
23
+
24
+ /** Advance exactly once, the first render on which `ready` holds. */
25
+ export function useSelfieAutoAdvance(opts: { enabled: boolean; ready: boolean; onAdvance: () => void }): void {
26
+ const advanced = useRef(false);
27
+ const { enabled, ready, onAdvance } = opts;
28
+ useEffect(() => {
29
+ if (!enabled || !ready || advanced.current) return;
30
+ advanced.current = true;
31
+ onAdvance();
32
+ }, [enabled, ready, onAdvance]);
33
+ }
34
+
35
+ export function LivenessHandover(): React.ReactElement {
36
+ const config = useKycConfig();
37
+ const copy = describeWaiting({
38
+ scope: configScope(config),
39
+ waitsForResult: waitsForResult(config),
40
+ override: biometricCopyFor(config).waiting,
41
+ });
42
+ return <SubmittedWaiting title={copy.title} description={copy.description} />;
43
+ }
@@ -27,7 +27,8 @@ export function LivenessComplete({
27
27
  onRetake,
28
28
  onContinue,
29
29
  }: {
30
- selfieUri: string;
30
+ /** Null on a restored session: the mediaId survived, the local file did not. */
31
+ selfieUri: string | null;
31
32
  upload: SelfieUpload;
32
33
  onRetake: () => void;
33
34
  onContinue: () => void;
@@ -40,7 +41,7 @@ export function LivenessComplete({
40
41
  <SelfiePreview uri={selfieUri} uploading={uploading && !uploadError} />
41
42
  {retryInfo && uploading ? (
42
43
  <MyazaText variant="bodySmall" color={colors.warning} style={{ textAlign: 'center', marginTop: spacing.sm }}>
43
- {`Upload failed — retrying (${retryInfo.attempt}/${retryInfo.total})…`}
44
+ {`Upload failed, retrying (${retryInfo.attempt}/${retryInfo.total})`}
44
45
  </MyazaText>
45
46
  ) : null}
46
47
  <View style={{ height: spacing.md }} />
@@ -51,7 +52,7 @@ export function LivenessComplete({
51
52
  // Disabled while the retry is in flight, not spinning — the progress
52
53
  // indicator belongs in the selfie frame, where the work is.
53
54
  disabled={uploading}
54
- onPress={() => void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
55
+ onPress={() => selfieUri && void uploadSelfieAndVideo(selfieUri, videoPathRef.current)}
55
56
  />
56
57
  ) : (
57
58
  <View style={{ flexDirection: 'row', gap: spacing.md }}>
@@ -11,15 +11,24 @@ import { StyleAbsFill } from './constants';
11
11
  // The captured selfie, shown back while it uploads.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- export function SelfiePreview({ uri, uploading }: { uri: string; uploading?: boolean }): React.ReactElement {
14
+ export function SelfiePreview({ uri, uploading }: { uri: string | null; uploading?: boolean }): React.ReactElement {
15
15
  const { colors } = useTheme();
16
16
  const S = 200;
17
17
  return (
18
18
  <View style={{ alignItems: 'center', justifyContent: 'center', alignSelf: 'stretch' }}>
19
- <View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: '#111111', borderColor: `${colors.primary}33`, overflow: 'hidden' }}>
19
+ <View style={{ width: S, height: S, borderRadius: S / 2, borderWidth: 4, backgroundColor: uri ? '#111111' : `${colors.primary}14`, borderColor: `${colors.primary}33`, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
20
20
  {/* borderRadius repeated on the Image — iOS doesn't reliably clip an Image
21
- child to a rounded parent (same fix as the header brand bar). */}
22
- <Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
21
+ child to a rounded parent (same fix as the header brand bar). A null
22
+ uri is a restored session whose local file is gone: the uploaded
23
+ selfie is the durable record, so show a completed state rather than
24
+ a broken image. */}
25
+ {uri ? (
26
+ <Image source={{ uri }} style={{ width: '100%', height: '100%', borderRadius: S / 2 }} resizeMode="cover" />
27
+ ) : (
28
+ <MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center', paddingHorizontal: spacing.lg }}>
29
+ Selfie already captured
30
+ </MyazaText>
31
+ )}
23
32
  {/* Standard upload loader inside the preview circle (mirrors web/Flutter). */}
24
33
  {uploading ? (
25
34
  <View style={[StyleAbsFill, { backgroundColor: 'rgba(0,0,0,0.45)', alignItems: 'center', justifyContent: 'center' }]}>
@@ -0,0 +1,25 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Animated, Easing } from 'react-native';
3
+ import { StyleAbsFill } from './constants';
4
+
5
+ // The shutter: a white flash over the camera circle as the still is taken,
6
+ // fading out over the beat the ring takes to close and go green. Mirrors the
7
+ // web SDK's animate-capture-flash (600ms ease-out). Core Animated on the
8
+ // native driver — opacity only — is the same recipe LightingBanner uses.
9
+ export function ShutterFlash() {
10
+ const opacity = useRef(new Animated.Value(0.85)).current;
11
+ useEffect(() => {
12
+ Animated.timing(opacity, {
13
+ toValue: 0,
14
+ duration: 600,
15
+ easing: Easing.out(Easing.cubic),
16
+ useNativeDriver: true,
17
+ }).start();
18
+ }, [opacity]);
19
+ return (
20
+ <Animated.View
21
+ pointerEvents="none"
22
+ style={[StyleAbsFill, { backgroundColor: '#FFFFFF', opacity }]}
23
+ />
24
+ );
25
+ }
@@ -3,6 +3,10 @@ export { StyleAbsFill, FRESH_FACE_TIMEOUT_MS, INSTRUCTION_HEIGHT } from './const
3
3
  export { LightingBanner } from './LightingBanner';
4
4
  export { resolveGuidance } from './resolveGuidance';
5
5
  export { ProgressDots } from './ProgressDots';
6
+ export { CaptureRing } from './CaptureRing';
7
+ export { ShutterFlash } from './ShutterFlash';
6
8
  export { SelfiePreview } from './SelfiePreview';
7
9
  export { useSelfieUpload } from './useSelfieUpload';
8
10
  export { LivenessComplete, LivenessFailed } from './LivenessOutcome';
11
+ export { LivenessCamera } from './LivenessCamera';
12
+ export { LivenessHandover, useSelfieAutoAdvance } from './LivenessHandover';
@@ -44,12 +44,29 @@ export function useSelfieUpload(): SelfieUpload {
44
44
  const config = useKycConfig();
45
45
  const api = useKyc((s) => s.api);
46
46
  const setMediaId = useKyc((s) => s.setMediaId);
47
+ const setSelfiePreview = useKyc((s) => s.setSelfiePreview);
48
+ // The store-side progress report. The liveness step may hand over before
49
+ // the upload lands (the biometric scopes hide the review), so the submitted
50
+ // step waits on THIS rather than on this hook's local state.
51
+ const setSelfieUpload = useKyc((s) => s.setSelfieUpload);
52
+ const storedPreview = useKyc((s) => s.selfiePreviewUri);
53
+ const storedSelfieId = useKyc((s) => s.mediaIds.selfie);
47
54
 
48
- const [selfieUri, setSelfieUri] = useState<string | null>(null);
55
+ // Hydrated from the STORE so leaving the step and returning lands on the
56
+ // review screen with the same selfie, exactly as document captures do. The
57
+ // setter writes through, keeping the store the source of truth.
58
+ const [selfieUri, setSelfieUriState] = useState<string | null>(storedPreview);
59
+ const setSelfieUri = useCallback(
60
+ (uri: string | null) => {
61
+ setSelfieUriState(uri);
62
+ setSelfiePreview(uri);
63
+ },
64
+ [setSelfiePreview],
65
+ );
49
66
  const [uploading, setUploading] = useState(false);
50
67
  const [retryInfo, setRetryInfo] = useState<{ attempt: number; total: number } | null>(null);
51
68
  const [uploadError, setUploadError] = useState<string | null>(null);
52
- const selfieIdRef = useRef<string | null>(null);
69
+ const selfieIdRef = useRef<string | null>(storedSelfieId ?? null);
53
70
  const videoPathRef = useRef<string | null>(null);
54
71
 
55
72
  const uploadSelfieAndVideo = useCallback(
@@ -57,6 +74,7 @@ export function useSelfieUpload(): SelfieUpload {
57
74
  setUploading(true);
58
75
  setUploadError(null);
59
76
  setRetryInfo(null);
77
+ setSelfieUpload({ status: 'uploading', message: null });
60
78
  const onRetry = (attempt: number, total: number) => setRetryInfo({ attempt, total });
61
79
  try {
62
80
  const selfieId = await withRetry(
@@ -81,16 +99,18 @@ export function useSelfieUpload(): SelfieUpload {
81
99
  }
82
100
  setRetryInfo(null);
83
101
  setUploading(false);
102
+ setSelfieUpload({ status: 'done', message: null });
84
103
  } catch (err) {
85
104
  setRetryInfo(null);
86
105
  setUploading(false);
87
106
  const kycError = mapToKycError(err, 'upload');
88
107
  setUploadError(kycError.message);
108
+ setSelfieUpload({ status: 'failed', message: kycError.message });
89
109
  toast.show({ variant: 'error', title: 'Upload failed', message: kycError.message });
90
110
  safeReportError(config.onError, kycError);
91
111
  }
92
112
  },
93
- [api, setMediaId, config.onError, toast],
113
+ [api, setMediaId, setSelfieUpload, config.onError, toast],
94
114
  );
95
115
 
96
116
  return {
@@ -30,6 +30,7 @@ const STEPS: NfcReadStage[] = [
30
30
  'readingData',
31
31
  'readingSecurity',
32
32
  'readingPhoto',
33
+ 'readingDetails',
33
34
  ];
34
35
 
35
36
  export function NfcReadProgress({ stage }: { stage: NfcReadStage }): React.ReactElement {
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import { AccessibilityInfo, Animated, Easing, View } from 'react-native';
3
- import Svg, { Circle, Defs, G, Path, RadialGradient, Rect, Stop } from 'react-native-svg';
3
+ import Svg, { Circle, G, Path, Rect } from 'react-native-svg';
4
4
 
5
5
  import { useTheme } from '../../components/runtime';
6
6
 
@@ -112,22 +112,6 @@ export function NfcScanIllustration(): React.ReactElement {
112
112
  // Same footprint as the web: full width up to 384 (`max-w-sm`), 320:240.
113
113
  <View style={{ width: '100%', maxWidth: 384, aspectRatio: 320 / 240, alignSelf: 'center' }}>
114
114
  <Svg width="100%" height="100%" viewBox="0 0 320 240">
115
- <Defs>
116
- {/* The field falls off with distance — the reason the document has
117
- to be held CLOSE, said as a gradient instead of a caption. */}
118
- <RadialGradient
119
- id="kyc-nfc-field"
120
- cx={COUPLING.x}
121
- cy={COUPLING.y}
122
- r={90}
123
- gradientUnits="userSpaceOnUse"
124
- >
125
- <Stop offset="0" stopColor={colors.primary} stopOpacity={0.18} />
126
- <Stop offset="0.55" stopColor={colors.primary} stopOpacity={0.06} />
127
- <Stop offset="1" stopColor={colors.primary} stopOpacity={0} />
128
- </RadialGradient>
129
- </Defs>
130
-
131
115
  {/* ── The document, tucked BEHIND the phone: portrait, chip, MRZ. ── */}
132
116
  <G rotation={7} origin="212, 112">
133
117
  <Rect
@@ -224,7 +208,12 @@ export function NfcScanIllustration(): React.ReactElement {
224
208
  </G>
225
209
 
226
210
  {/* ── The field, over both objects. ── */}
227
- <Circle cx={COUPLING.x} cy={COUPLING.y} r={90} fill="url(#kyc-nfc-field)" />
211
+ {/* The field falls off with distance — the reason the document has to
212
+ be held CLOSE. Flat concentric steps (no gradients in the flow,
213
+ house rule 2026-08-29). */}
214
+ <Circle cx={COUPLING.x} cy={COUPLING.y} r={90} fill={colors.primary} fillOpacity={0.04} />
215
+ <Circle cx={COUPLING.x} cy={COUPLING.y} r={56} fill={colors.primary} fillOpacity={0.07} />
216
+ <Circle cx={COUPLING.x} cy={COUPLING.y} r={28} fill={colors.primary} fillOpacity={0.12} />
228
217
  <Circle cx={COUPLING.x} cy={COUPLING.y} r={4.5} fill={colors.primary} />
229
218
  {WAVES.map((w, i) => (
230
219
  <AnimatedPath
@@ -0,0 +1,77 @@
1
+ import { useCallback, useState } from 'react';
2
+ import * as ImagePicker from 'expo-image-picker';
3
+
4
+ import { isAcceptedAddressPhotoMimeType } from '../config/addressCollection';
5
+ import { IMAGE_MAX_BYTES } from '../config/uploadLimits';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Getting the door photo off the device — the address step's slim sibling of
9
+ // usePoaAttach. Camera or photo library only: the door photo is a PICTURE of a
10
+ // place, so there is no PDF/file case, and each pick closes the source sheet
11
+ // before the picker opens.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** What the picker handed back, so the screen can preview it. */
15
+ export interface AddressPhotoPick {
16
+ uri: string;
17
+ mimeType: string | undefined;
18
+ name: string;
19
+ }
20
+
21
+ type UploadFn = (pick: AddressPhotoPick) => Promise<void>;
22
+
23
+ export function useAddressPhotoAttach(
24
+ upload: UploadFn,
25
+ setError: (message: string) => void,
26
+ ): {
27
+ pick: () => void;
28
+ sheetOpen: boolean;
29
+ closeSheet: () => void;
30
+ choosePhoto: () => Promise<void>;
31
+ takePhoto: () => Promise<void>;
32
+ } {
33
+ const [sheetOpen, setSheetOpen] = useState(false);
34
+ const pick = useCallback(() => setSheetOpen(true), []);
35
+ const closeSheet = useCallback(() => setSheetOpen(false), []);
36
+
37
+ const accept = useCallback(
38
+ async (asset: ImagePicker.ImagePickerAsset | undefined, fallbackName: string) => {
39
+ if (!asset) return;
40
+ if (typeof asset.fileSize === 'number' && asset.fileSize > IMAGE_MAX_BYTES) {
41
+ setError('Photo is too large (max 5 MB).');
42
+ return;
43
+ }
44
+ const mimeType = asset.mimeType ?? 'image/jpeg';
45
+ // The picker's media filter is advisory on some platforms, so what came
46
+ // back is re-checked rather than trusted.
47
+ if (!isAcceptedAddressPhotoMimeType(mimeType)) {
48
+ setError('Please choose a photo (JPEG, PNG or WebP).');
49
+ return;
50
+ }
51
+ await upload({ uri: asset.uri, mimeType, name: asset.fileName ?? fallbackName });
52
+ },
53
+ [setError, upload],
54
+ );
55
+
56
+ const takePhoto = useCallback(async () => {
57
+ setSheetOpen(false);
58
+ const permission = await ImagePicker.requestCameraPermissionsAsync();
59
+ if (!permission.granted) {
60
+ setError('Camera access is needed to photograph the entrance.');
61
+ return;
62
+ }
63
+ const result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], quality: 1 });
64
+ await accept(result.canceled ? undefined : result.assets[0], 'address-photo.jpg');
65
+ }, [accept, setError]);
66
+
67
+ const choosePhoto = useCallback(async () => {
68
+ setSheetOpen(false);
69
+ const result = await ImagePicker.launchImageLibraryAsync({
70
+ mediaTypes: ['images'],
71
+ quality: 1,
72
+ });
73
+ await accept(result.canceled ? undefined : result.assets[0], 'address-photo.jpg');
74
+ }, [accept]);
75
+
76
+ return { pick, sheetOpen, closeSheet, choosePhoto, takePhoto };
77
+ }