@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -5
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -185
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +42 -4
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +11 -5
- package/src/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +154 -52
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +93 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +133 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +112 -3
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +48 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
|
@@ -17,9 +17,21 @@ const GIFS: Record<LivenessChallenge, number> = {
|
|
|
17
17
|
smile: require('../assets/liveness/Smile.gif'),
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** The badge on a tall phone; the step hands a smaller size down on a short one
|
|
21
|
+
* (lib/livenessLayout), so the gesture stays on screen beside the circle. */
|
|
22
|
+
const DEFAULT_SIZE = 96;
|
|
23
|
+
const DEFAULT_ICON = 40;
|
|
21
24
|
|
|
22
|
-
export function LivenessAvatar({
|
|
25
|
+
export function LivenessAvatar({
|
|
26
|
+
challenge,
|
|
27
|
+
size = DEFAULT_SIZE,
|
|
28
|
+
iconSize = DEFAULT_ICON,
|
|
29
|
+
}: {
|
|
30
|
+
challenge: LivenessChallenge;
|
|
31
|
+
size?: number;
|
|
32
|
+
iconSize?: number;
|
|
33
|
+
}): React.ReactElement {
|
|
34
|
+
const SIZE = size;
|
|
23
35
|
const { colors } = useTheme();
|
|
24
36
|
const [displayed, setDisplayed] = useState<LivenessChallenge>(challenge);
|
|
25
37
|
const [broken, setBroken] = useState(false);
|
|
@@ -55,7 +67,7 @@ export function LivenessAvatar({ challenge }: { challenge: LivenessChallenge }):
|
|
|
55
67
|
}}
|
|
56
68
|
>
|
|
57
69
|
{broken ? (
|
|
58
|
-
<Icon name="scan-face" size={
|
|
70
|
+
<Icon name="scan-face" size={iconSize} color={colors.primary} />
|
|
59
71
|
) : (
|
|
60
72
|
<Image
|
|
61
73
|
source={GIFS[displayed]}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Animated, Easing, Image, useWindowDimensions, View } from 'react-native';
|
|
3
3
|
import Svg, { Ellipse, Path } from 'react-native-svg';
|
|
4
4
|
import {
|
|
5
|
-
Camera,
|
|
6
5
|
useCameraDevice,
|
|
7
6
|
useCameraPermission,
|
|
8
7
|
useFrameOutput,
|
|
@@ -35,9 +34,11 @@ import { useFaceModelReady } from '../liveness/useModelReady';
|
|
|
35
34
|
import { ReadyPrimer } from '../components/ReadyPrimer';
|
|
36
35
|
import { READY_LIVENESS } from '../components/readyPrimerContent';
|
|
37
36
|
import { LivenessAvatar } from './LivenessAvatar';
|
|
37
|
+
import { livenessLayout } from '../lib/livenessLayout';
|
|
38
38
|
import { detectFaceOnFrame } from '../liveness/visionCameraFaceDetector';
|
|
39
39
|
import { buildLivenessIntegrity } from '../liveness/integritySignals';
|
|
40
40
|
import { DEFAULT_LIVENESS_CONFIG } from '../liveness/types';
|
|
41
|
+
import { livenessProgress } from '../lib/captureRing';
|
|
41
42
|
import { useFlashSequence } from './useFlashSequence';
|
|
42
43
|
import { useFlashHole } from './useFlashHole';
|
|
43
44
|
import {
|
|
@@ -45,13 +46,19 @@ import {
|
|
|
45
46
|
INSTRUCTION_HEIGHT,
|
|
46
47
|
LightingBanner,
|
|
47
48
|
ProgressDots,
|
|
49
|
+
CaptureRing,
|
|
50
|
+
ShutterFlash,
|
|
48
51
|
SelfiePreview,
|
|
49
52
|
StyleAbsFill,
|
|
50
53
|
resolveGuidance,
|
|
51
54
|
useSelfieUpload,
|
|
55
|
+
LivenessCamera,
|
|
52
56
|
LivenessComplete,
|
|
53
57
|
LivenessFailed,
|
|
58
|
+
LivenessHandover,
|
|
59
|
+
useSelfieAutoAdvance,
|
|
54
60
|
} from './liveness';
|
|
61
|
+
import { showsSelfieReview } from '../config/biometricOptions';
|
|
55
62
|
import {
|
|
56
63
|
lightingGuidanceText,
|
|
57
64
|
positionGuidanceText,
|
|
@@ -85,6 +92,7 @@ export function LivenessStep(): React.ReactElement {
|
|
|
85
92
|
const setMediaId = useKyc((s) => s.setMediaId);
|
|
86
93
|
const setCaptureIntegrity = useKyc((s) => s.setCaptureIntegrity);
|
|
87
94
|
const nextStep = useKyc((s) => s.nextStep);
|
|
95
|
+
const clearSelfie = useKyc((s) => s.clearSelfie);
|
|
88
96
|
|
|
89
97
|
const device = useCameraDevice('front');
|
|
90
98
|
const { hasPermission, requestPermission } = useCameraPermission();
|
|
@@ -176,6 +184,16 @@ export function LivenessStep(): React.ReactElement {
|
|
|
176
184
|
uploadSelfieAndVideo,
|
|
177
185
|
} = upload;
|
|
178
186
|
|
|
187
|
+
// A stored selfie with no uploaded mediaId is an interrupted upload (failed,
|
|
188
|
+
// or the user left mid-flight): resume it once on mount. Idempotent — with a
|
|
189
|
+
// mediaId there is nothing to do.
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (selfieUri && !selfieIdRef.current && !uploading && !uploadError) {
|
|
192
|
+
void uploadSelfieAndVideo(selfieUri, videoPathRef.current);
|
|
193
|
+
}
|
|
194
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
179
197
|
// ── Liveness state machine ─────────────────────────────────────────────────
|
|
180
198
|
// `handleCapture` is created after `liveness` (it calls liveness.markComplete),
|
|
181
199
|
// so the machine gets it via a ref to avoid a definition cycle.
|
|
@@ -274,6 +292,66 @@ export function LivenessStep(): React.ReactElement {
|
|
|
274
292
|
// always reads the latest state-machine callbacks.
|
|
275
293
|
const livenessRef = useRef(liveness);
|
|
276
294
|
livenessRef.current = liveness;
|
|
295
|
+
|
|
296
|
+
// The circle and the gesture avatar are sized from the WINDOW, not the width
|
|
297
|
+
// alone, so a short phone keeps the avatar on screen (lib/livenessLayout).
|
|
298
|
+
// A hook, so it lives up here with the ring refs, above the gates below.
|
|
299
|
+
const windowSize = useWindowDimensions();
|
|
300
|
+
const layout = livenessLayout(windowSize);
|
|
301
|
+
|
|
302
|
+
// Progress for the ring, read every frame from a ref — never from render.
|
|
303
|
+
// Declared HERE, above the early returns below: a hook after a conditional
|
|
304
|
+
// return renders "more hooks than during the previous render" the moment the
|
|
305
|
+
// gate dismisses, which is exactly what the ready primer does on its way out.
|
|
306
|
+
const phaseStartedRef = useRef(Date.now());
|
|
307
|
+
const phaseKeyRef = useRef(`${liveness.phase}:${liveness.completedCount}`);
|
|
308
|
+
const phaseKey = `${liveness.phase}:${liveness.completedCount}`;
|
|
309
|
+
if (phaseKeyRef.current !== phaseKey) {
|
|
310
|
+
phaseKeyRef.current = phaseKey;
|
|
311
|
+
phaseStartedRef.current = Date.now();
|
|
312
|
+
}
|
|
313
|
+
const timeoutRef = useRef(
|
|
314
|
+
liveness.timeoutRemaining || DEFAULT_LIVENESS_CONFIG.timeoutPerChallenge,
|
|
315
|
+
);
|
|
316
|
+
if (liveness.phase === 'challenge' && liveness.timeoutRemaining > timeoutRef.current) {
|
|
317
|
+
timeoutRef.current = liveness.timeoutRemaining;
|
|
318
|
+
}
|
|
319
|
+
const getRingTarget = useCallback(() => {
|
|
320
|
+
const l = livenessRef.current;
|
|
321
|
+
return livenessProgress({
|
|
322
|
+
phase: l.phase,
|
|
323
|
+
completedCount: l.completedCount,
|
|
324
|
+
totalCount: l.totalCount,
|
|
325
|
+
elapsedInPhase: (Date.now() - phaseStartedRef.current) / 1000,
|
|
326
|
+
challengeTimeout: timeoutRef.current,
|
|
327
|
+
});
|
|
328
|
+
}, []);
|
|
329
|
+
|
|
330
|
+
// The selfie URI lands BEFORE the machine reaches `complete` (capture →
|
|
331
|
+
// compress → setSelfieUri → markComplete), and the review is gated on that
|
|
332
|
+
// URI — so the camera circle used to unmount on the very frame the ring's
|
|
333
|
+
// target reached the end: the close, the green, the shutter, none of it
|
|
334
|
+
// was ever seen. Hold the frame for the closing beat, then hand over.
|
|
335
|
+
const [closeSeen, setCloseSeen] = useState(false);
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
if (liveness.phase !== 'complete') return;
|
|
338
|
+
const t = setTimeout(() => setCloseSeen(true), 700);
|
|
339
|
+
return () => clearTimeout(t);
|
|
340
|
+
}, [liveness.phase]);
|
|
341
|
+
const holdingForClose =
|
|
342
|
+
(liveness.phase === 'capturing' || liveness.phase === 'complete') && !closeSeen;
|
|
343
|
+
// The biometric scopes skip the review by default: hand over the moment the
|
|
344
|
+
// ring has closed, WITHOUT waiting for the upload (see LivenessHandover).
|
|
345
|
+
// The upload reports to the store and the submitted step waits on it, so
|
|
346
|
+
// the person sees one loading screen rather than one per step. A restored
|
|
347
|
+
// selfie (media id, no local file) is ready at once; an upload that already
|
|
348
|
+
// failed keeps the review, whose Try Again is the recovery.
|
|
349
|
+
const selfieReview = showsSelfieReview(config);
|
|
350
|
+
useSelfieAutoAdvance({
|
|
351
|
+
enabled: !selfieReview,
|
|
352
|
+
ready: (!!selfieUri || !!selfieIdRef.current) && !holdingForClose && !uploadError,
|
|
353
|
+
onAdvance: nextStep,
|
|
354
|
+
});
|
|
277
355
|
// A plain JS function the worklet hands the per-frame result to. It is wrapped
|
|
278
356
|
// with `runOnJS(...)` INSIDE the worklet (the canonical react-native-worklets
|
|
279
357
|
// pattern) rather than pre-wrapped — pre-wrapping produced a "non-worklet
|
|
@@ -353,6 +431,38 @@ export function LivenessStep(): React.ReactElement {
|
|
|
353
431
|
},
|
|
354
432
|
});
|
|
355
433
|
|
|
434
|
+
// ── Review (selfie already exists) ──────────────────────────────────────────
|
|
435
|
+
// Checked BEFORE every capture-flow gate (ready primer, permission, model):
|
|
436
|
+
// a selfie captured on an earlier visit — or a restored session where only
|
|
437
|
+
// the uploaded mediaId survived — means there is nothing to capture, so the
|
|
438
|
+
// step opens on review exactly as the document steps reopen on theirs.
|
|
439
|
+
// A restored selfie (earlier visit) opens straight on review; the one being
|
|
440
|
+
// captured RIGHT NOW waits for the ring to close first.
|
|
441
|
+
if (selfieIdRef.current || (selfieUri && !holdingForClose)) {
|
|
442
|
+
// Review hidden: the hook above advances on this render; the view is the
|
|
443
|
+
// submitted step's own loading screen, so the hand-over is invisible. A
|
|
444
|
+
// FAILED upload still gets the review, for its retry.
|
|
445
|
+
if (!selfieReview && !uploadError) {
|
|
446
|
+
return <LivenessHandover />;
|
|
447
|
+
}
|
|
448
|
+
return (
|
|
449
|
+
<LivenessComplete
|
|
450
|
+
selfieUri={selfieUri}
|
|
451
|
+
upload={upload}
|
|
452
|
+
onRetake={() => {
|
|
453
|
+
setSelfieUri(null);
|
|
454
|
+
setCloseSeen(false);
|
|
455
|
+
setUploadError(null);
|
|
456
|
+
selfieIdRef.current = null;
|
|
457
|
+
videoPathRef.current = null;
|
|
458
|
+
clearSelfie();
|
|
459
|
+
liveness.reset();
|
|
460
|
+
}}
|
|
461
|
+
onContinue={nextStep}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
356
466
|
// ── Error states ────────────────────────────────────────────────────────────
|
|
357
467
|
if (cameraUnavailable) {
|
|
358
468
|
return <CameraUnavailableView />;
|
|
@@ -383,24 +493,6 @@ export function LivenessStep(): React.ReactElement {
|
|
|
383
493
|
return <CameraPermissionView onRetry={() => setPerm('requesting')} />;
|
|
384
494
|
}
|
|
385
495
|
|
|
386
|
-
// ── Review (selfie captured) ─────────────────────────────────────────────────
|
|
387
|
-
if (liveness.phase === 'complete' && selfieUri) {
|
|
388
|
-
return (
|
|
389
|
-
<LivenessComplete
|
|
390
|
-
selfieUri={selfieUri}
|
|
391
|
-
upload={upload}
|
|
392
|
-
onRetake={() => {
|
|
393
|
-
setSelfieUri(null);
|
|
394
|
-
setUploadError(null);
|
|
395
|
-
selfieIdRef.current = null;
|
|
396
|
-
videoPathRef.current = null;
|
|
397
|
-
liveness.reset();
|
|
398
|
-
}}
|
|
399
|
-
onContinue={nextStep}
|
|
400
|
-
/>
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
496
|
if (liveness.phase === 'failed') {
|
|
405
497
|
return <LivenessFailed reason={liveness.failureReason} onRetry={liveness.reset} />;
|
|
406
498
|
}
|
|
@@ -416,31 +508,32 @@ export function LivenessStep(): React.ReactElement {
|
|
|
416
508
|
liveness.lightingGuidance != null;
|
|
417
509
|
const isCamLoading = phase === 'loading';
|
|
418
510
|
|
|
511
|
+
// The ring is on the frame from positioning to the shutter, so for that whole
|
|
512
|
+
// stretch the border is its TRACK. Phase colour stays on the instruction text
|
|
513
|
+
// and the passed flash; a warning still turns the track red under the arc.
|
|
514
|
+
// `failed` has already returned above, so loading is the only phase without it.
|
|
515
|
+
const ringOnFrame = !isCamLoading;
|
|
419
516
|
const ringColor = isCamLoading
|
|
420
517
|
? colors.gray300
|
|
421
518
|
: hasWarning
|
|
422
519
|
? colors.error
|
|
423
|
-
:
|
|
424
|
-
? colors.
|
|
425
|
-
:
|
|
426
|
-
? colors.warning
|
|
427
|
-
: liveness.faceDetected
|
|
428
|
-
? colors.primary
|
|
429
|
-
: colors.gray300;
|
|
520
|
+
: ringOnFrame
|
|
521
|
+
? `${colors.primary}33`
|
|
522
|
+
: colors.gray300;
|
|
430
523
|
|
|
431
524
|
const guidance = resolveGuidance(liveness);
|
|
432
525
|
const instrColor = isCamLoading
|
|
433
526
|
? colors.textMuted
|
|
434
527
|
: guidance.tone === 'error'
|
|
435
528
|
? colors.error
|
|
436
|
-
: phase === 'challenge_passed'
|
|
529
|
+
: phase === 'challenge_passed' || phase === 'complete'
|
|
437
530
|
? colors.success
|
|
438
531
|
: phase === 'challenge'
|
|
439
532
|
? colors.warning
|
|
440
533
|
: colors.textDark;
|
|
441
534
|
|
|
442
535
|
// Fixed circle, sized to fit the sheet width (caps at 300).
|
|
443
|
-
const CIRCLE =
|
|
536
|
+
const CIRCLE = layout.circle;
|
|
444
537
|
const lighting = liveness.lightingGuidance;
|
|
445
538
|
|
|
446
539
|
// Rendered INSIDE the sheet, so the SDK header and the "powered by" footer
|
|
@@ -456,7 +549,10 @@ export function LivenessStep(): React.ReactElement {
|
|
|
456
549
|
{phase === 'flash' ? '' : guidance.text}
|
|
457
550
|
</MyazaText>
|
|
458
551
|
|
|
459
|
-
{/* Circular camera
|
|
552
|
+
{/* Circular camera. Two boxes: the inner one CLIPS the preview to a circle,
|
|
553
|
+
the outer one lets the progress ring sit on that border rather than be
|
|
554
|
+
clipped by it. */}
|
|
555
|
+
<View style={{ width: CIRCLE, height: CIRCLE }}>
|
|
460
556
|
<View
|
|
461
557
|
ref={flashHoleRef}
|
|
462
558
|
collapsable={false}
|
|
@@ -470,21 +566,14 @@ export function LivenessStep(): React.ReactElement {
|
|
|
470
566
|
backgroundColor: '#111111',
|
|
471
567
|
}}
|
|
472
568
|
>
|
|
473
|
-
{
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
mirrorMode="on"
|
|
482
|
-
/>
|
|
483
|
-
) : (
|
|
484
|
-
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
485
|
-
<ActivityIndicator color="#FFFFFF" />
|
|
486
|
-
</View>
|
|
487
|
-
)}
|
|
569
|
+
{/* The Camera lives in its own borderless wrapper — see LivenessCamera
|
|
570
|
+
for why a bordered parent shifts the Android preview by one border. */}
|
|
571
|
+
<LivenessCamera
|
|
572
|
+
device={device}
|
|
573
|
+
active={perm === 'granted'}
|
|
574
|
+
outputs={[photoOutput, videoOutput, frameOutput]}
|
|
575
|
+
circle={CIRCLE}
|
|
576
|
+
/>
|
|
488
577
|
|
|
489
578
|
{/* Dashed face-guide ellipse (taller than wide) */}
|
|
490
579
|
<Svg style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }} width={CIRCLE} height={CIRCLE}>
|
|
@@ -519,12 +608,9 @@ export function LivenessStep(): React.ReactElement {
|
|
|
519
608
|
</View>
|
|
520
609
|
) : null}
|
|
521
610
|
|
|
522
|
-
{/*
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
<MyazaText variant="heading3" color="#FFFFFF">Got it!</MyazaText>
|
|
526
|
-
</View>
|
|
527
|
-
) : null}
|
|
611
|
+
{/* The shutter: a white flash as the still is taken. It used to sit on
|
|
612
|
+
`capturing` reading "Got it!", announcing a photo not yet taken. */}
|
|
613
|
+
{phase === 'complete' ? <ShutterFlash /> : null}
|
|
528
614
|
|
|
529
615
|
{/* Timeout countdown */}
|
|
530
616
|
{phase === 'challenge' && liveness.timeoutRemaining > 0 ? (
|
|
@@ -534,6 +620,20 @@ export function LivenessStep(): React.ReactElement {
|
|
|
534
620
|
) : null}
|
|
535
621
|
</View>
|
|
536
622
|
|
|
623
|
+
{/* The ring — outside the clipping box, on the frame's own edge. One line,
|
|
624
|
+
building one way for the whole test; primary while it builds, green
|
|
625
|
+
only as it closes, on the same frame as the shutter. */}
|
|
626
|
+
{ringOnFrame ? (
|
|
627
|
+
<CaptureRing
|
|
628
|
+
size={CIRCLE}
|
|
629
|
+
getTarget={getRingTarget}
|
|
630
|
+
color={colors.primary}
|
|
631
|
+
successColor={colors.success}
|
|
632
|
+
complete={phase === 'complete'}
|
|
633
|
+
/>
|
|
634
|
+
) : null}
|
|
635
|
+
</View>
|
|
636
|
+
|
|
537
637
|
{/* Lighting warning banner — mirrors Flutter (amber-50/200/800 + lightbulb) */}
|
|
538
638
|
{lighting ? <LightingBanner text={lightingGuidanceText(lighting)} /> : null}
|
|
539
639
|
|
|
@@ -541,7 +641,9 @@ export function LivenessStep(): React.ReactElement {
|
|
|
541
641
|
<ProgressDots total={liveness.totalCount} completed={liveness.completedCount} active={phase === 'challenge' || phase === 'positioning' || phase === 'flash'} />
|
|
542
642
|
|
|
543
643
|
{/* Gesture demo avatar */}
|
|
544
|
-
{liveness.activeChallenge ?
|
|
644
|
+
{liveness.activeChallenge ? (
|
|
645
|
+
<LivenessAvatar challenge={liveness.activeChallenge} size={layout.avatar} iconSize={layout.avatarIcon} />
|
|
646
|
+
) : null}
|
|
545
647
|
</View>
|
|
546
648
|
);
|
|
547
649
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { useTheme } from '../components/runtime';
|
|
5
|
+
import { Icon, type IconName } from '../components/Icon';
|
|
6
|
+
import { OptionRow } from '../components/OptionRow';
|
|
7
|
+
import type { PoaDocumentType } from '../types/workflow';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// The document kinds LISTED OUT as the questionnaire's multi-select cards (a
|
|
11
|
+
// square check, primary when picked, an icon beside the label) rather than
|
|
12
|
+
// hidden behind a select — the options are the information, and a sheet makes
|
|
13
|
+
// the person open it to find out what is on offer (user decision 2026-09-05).
|
|
14
|
+
// ONE kind is picked, because one document is uploaded: the cards are radios
|
|
15
|
+
// wearing the multi-select's square check. Locked once a file is attached, so
|
|
16
|
+
// the kind cannot drift from the document already uploaded. Mirrors the web
|
|
17
|
+
// SDK's steps/PoaDocumentTypeList and Flutter's proof_of_address_kinds.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** One glyph per kind — the SAME Lucide names the web and Flutter maps carry. */
|
|
21
|
+
export const POA_TYPE_ICONS: Record<PoaDocumentType, IconName> = {
|
|
22
|
+
utility_bill: 'zap',
|
|
23
|
+
bank_statement: 'landmark',
|
|
24
|
+
tenancy_agreement: 'house',
|
|
25
|
+
government_document: 'stamp',
|
|
26
|
+
other: 'file-text',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function PoaDocumentTypeList({
|
|
30
|
+
options,
|
|
31
|
+
value,
|
|
32
|
+
disabled,
|
|
33
|
+
onChange,
|
|
34
|
+
}: {
|
|
35
|
+
options: Array<{ value: PoaDocumentType; label: string }>;
|
|
36
|
+
value: PoaDocumentType;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
onChange: (value: PoaDocumentType) => void;
|
|
39
|
+
}): React.ReactElement {
|
|
40
|
+
const { colors } = useTheme();
|
|
41
|
+
return (
|
|
42
|
+
<View>
|
|
43
|
+
{options.map((opt) => {
|
|
44
|
+
const selected = opt.value === value;
|
|
45
|
+
return (
|
|
46
|
+
<OptionRow
|
|
47
|
+
key={opt.value}
|
|
48
|
+
label={opt.label}
|
|
49
|
+
selected={selected}
|
|
50
|
+
multi
|
|
51
|
+
role="radio"
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
icon={
|
|
54
|
+
<Icon
|
|
55
|
+
name={POA_TYPE_ICONS[opt.value]}
|
|
56
|
+
size={20}
|
|
57
|
+
color={selected ? colors.primary : colors.textSecondary}
|
|
58
|
+
/>
|
|
59
|
+
}
|
|
60
|
+
onPress={() => onChange(opt.value)}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, Image, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { UPLOAD_HINT } from '../config/uploadLimits';
|
|
6
|
+
import { useTheme } from '../components/runtime';
|
|
7
|
+
import { MyazaText } from '../components/Typography';
|
|
8
|
+
import { Icon } from '../components/Icon';
|
|
9
|
+
import { DashedBorder } from '../components/DashedBorder';
|
|
10
|
+
import { CountryFlag } from '../components/CountryFlag';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Proof of Address — the two states of the attachment area, split out of the
|
|
14
|
+
// step (200-line rule; mirrors the Flutter SDK's proof_of_address_parts).
|
|
15
|
+
// Pure presentation: the step owns the picking, uploading and removing.
|
|
16
|
+
//
|
|
17
|
+
// Both states carry the country the document is for (user decision
|
|
18
|
+
// 2026-09-05): the flag before the call to action on the drop zone, and before
|
|
19
|
+
// the document kind on the uploaded row, so a person on a multi-market flow
|
|
20
|
+
// sees which market the paper is being read against. Null when the flow does
|
|
21
|
+
// not know it yet (the address scope before a pick) — nothing is invented.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function Flag({ country, size }: { country: string | null; size: number }): React.ReactElement | null {
|
|
25
|
+
if (!country) return null;
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<CountryFlag country={country} size={size} />
|
|
29
|
+
<View style={{ width: spacing.xs }} />
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function PoaUploadedRow({
|
|
35
|
+
preview,
|
|
36
|
+
fileName,
|
|
37
|
+
typeLabel,
|
|
38
|
+
country,
|
|
39
|
+
onRemove,
|
|
40
|
+
}: {
|
|
41
|
+
preview: { uri: string; isPdf: boolean } | null;
|
|
42
|
+
fileName: string | null;
|
|
43
|
+
typeLabel: string;
|
|
44
|
+
country: string | null;
|
|
45
|
+
onRemove: () => void;
|
|
46
|
+
}): React.ReactElement {
|
|
47
|
+
const { colors } = useTheme();
|
|
48
|
+
return (
|
|
49
|
+
<View
|
|
50
|
+
style={{
|
|
51
|
+
flexDirection: 'row',
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
padding: spacing.md,
|
|
54
|
+
borderRadius: radius.md,
|
|
55
|
+
borderWidth: 1,
|
|
56
|
+
borderColor: colors.border,
|
|
57
|
+
backgroundColor: colors.backgroundSecondary,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{preview && !preview.isPdf ? (
|
|
61
|
+
<Image
|
|
62
|
+
source={{ uri: preview.uri }}
|
|
63
|
+
style={{ width: 48, height: 48, borderRadius: radius.sm }}
|
|
64
|
+
resizeMode="cover"
|
|
65
|
+
/>
|
|
66
|
+
) : (
|
|
67
|
+
<View
|
|
68
|
+
style={{
|
|
69
|
+
width: 48,
|
|
70
|
+
height: 48,
|
|
71
|
+
borderRadius: radius.sm,
|
|
72
|
+
borderWidth: 1,
|
|
73
|
+
borderColor: colors.border,
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
justifyContent: 'center',
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Icon name="file-text" size={22} color={colors.primary} />
|
|
79
|
+
</View>
|
|
80
|
+
)}
|
|
81
|
+
<View style={{ width: spacing.md, flexShrink: 0 }} />
|
|
82
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
83
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }} numberOfLines={1}>
|
|
84
|
+
{fileName ?? 'Document uploaded'}
|
|
85
|
+
</MyazaText>
|
|
86
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
87
|
+
<Flag country={country} size={14} />
|
|
88
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
89
|
+
{typeLabel}
|
|
90
|
+
</MyazaText>
|
|
91
|
+
</View>
|
|
92
|
+
</View>
|
|
93
|
+
<Pressable
|
|
94
|
+
onPress={onRemove}
|
|
95
|
+
accessibilityRole="button"
|
|
96
|
+
accessibilityLabel="Remove document"
|
|
97
|
+
hitSlop={8}
|
|
98
|
+
>
|
|
99
|
+
<Icon name="x" size={18} color={colors.textSecondary} />
|
|
100
|
+
</Pressable>
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The DASHED drop zone that NAMES the document being asked for. */
|
|
106
|
+
export function PoaDropzone({
|
|
107
|
+
busy,
|
|
108
|
+
typeLabel,
|
|
109
|
+
country,
|
|
110
|
+
onPress,
|
|
111
|
+
}: {
|
|
112
|
+
busy: boolean;
|
|
113
|
+
typeLabel: string;
|
|
114
|
+
country: string | null;
|
|
115
|
+
onPress: () => void;
|
|
116
|
+
}): React.ReactElement {
|
|
117
|
+
const { colors } = useTheme();
|
|
118
|
+
return (
|
|
119
|
+
<Pressable
|
|
120
|
+
onPress={() => {
|
|
121
|
+
if (!busy) onPress();
|
|
122
|
+
}}
|
|
123
|
+
accessibilityRole="button"
|
|
124
|
+
accessibilityLabel={`Upload your ${typeLabel.toLowerCase()}`}
|
|
125
|
+
style={{
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
justifyContent: 'center',
|
|
128
|
+
paddingVertical: spacing.xl,
|
|
129
|
+
paddingHorizontal: spacing.lg,
|
|
130
|
+
borderRadius: radius.md,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<DashedBorder color={colors.border} radius={radius.md} strokeWidth={1.5} />
|
|
134
|
+
{busy ? (
|
|
135
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
136
|
+
) : (
|
|
137
|
+
<Icon name="upload" size={30} color={colors.textSecondary} />
|
|
138
|
+
)}
|
|
139
|
+
<View style={{ height: spacing.sm }} />
|
|
140
|
+
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
141
|
+
<Flag country={country} size={18} />
|
|
142
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '600' }}>
|
|
143
|
+
{busy ? 'Uploading…' : `Upload your ${typeLabel.toLowerCase()}`}
|
|
144
|
+
</MyazaText>
|
|
145
|
+
</View>
|
|
146
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary}>
|
|
147
|
+
{UPLOAD_HINT}
|
|
148
|
+
</MyazaText>
|
|
149
|
+
</Pressable>
|
|
150
|
+
);
|
|
151
|
+
}
|