@myazahq/kyc-sdk-react-native 2.6.0 → 3.1.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 +128 -4
- package/android/build.gradle +30 -16
- package/android/consumer-rules.pro +22 -0
- package/android/src/main/AndroidManifest.xml +17 -6
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +27 -72
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +14 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/MlKitModelReadiness.kt +154 -0
- package/app.plugin.js +57 -2
- package/ios/HybridMyazaTextRecognizer.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +21 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +8 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +2 -0
- package/package.json +24 -14
- package/src/MyazaKYC.tsx +4 -11
- package/src/assets/fonts/Karla_400Regular.ttf +0 -0
- package/src/assets/fonts/Karla_500Medium.ttf +0 -0
- package/src/assets/fonts/Karla_600SemiBold.ttf +0 -0
- package/src/assets/fonts/Karla_700Bold.ttf +0 -0
- package/src/assets/fonts/OFL-Karla.txt +93 -0
- package/src/assets/fonts/OFL-SpaceGrotesk.txt +93 -0
- package/src/assets/fonts/README.md +12 -0
- package/src/assets/fonts/SpaceGrotesk_500Medium.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_600SemiBold.ttf +0 -0
- package/src/assets/fonts/SpaceGrotesk_700Bold.ttf +0 -0
- package/src/capture/useAutoCapture.ts +8 -1
- package/src/components/DocumentCropper.tsx +30 -24
- package/src/components/DocumentReview.tsx +11 -1
- package/src/components/DocumentReviewSide.tsx +6 -2
- package/src/components/DocumentReviewZoom.tsx +5 -1
- package/src/components/GlassIconButton.tsx +2 -5
- package/src/components/Icon.tsx +17 -13
- package/src/components/KycFlow.tsx +10 -2
- package/src/components/KycSheet.tsx +0 -1
- package/src/components/MyazaButton.tsx +9 -0
- package/src/components/RequiredDocumentPill.tsx +114 -0
- package/src/components/StepHeader.tsx +23 -3
- package/src/components/StepView.tsx +3 -0
- package/src/components/documentReviewCopy.ts +39 -0
- package/src/components/fonts.ts +14 -17
- package/src/components/icons/glyphs.ts +35 -0
- package/src/components/icons/index.ts +3 -0
- package/src/components/icons/map.ts +165 -0
- package/src/components/icons/names.ts +86 -0
- package/src/components/stepHeaderMeta.tsx +21 -1
- package/src/config/documentCaptureMethods.ts +31 -0
- package/src/config/stepOrder.ts +20 -5
- package/src/config/supportingDocuments.ts +131 -0
- package/src/config/theme.ts +29 -3
- package/src/config/workflowMerge.ts +15 -0
- package/src/index.ts +5 -10
- package/src/lib/documentCaptureCheck.ts +136 -0
- package/src/lib/model-ready.ts +82 -0
- package/src/lib/prime-models.ts +50 -0
- package/src/lib/resubmit.ts +36 -7
- package/src/lib/selfie-sharpness.ts +211 -0
- package/src/lib/supportingDocumentsIntro.ts +35 -0
- package/src/liveness/avatarSource.ts +59 -0
- package/src/liveness/useModelReady.ts +9 -55
- package/src/mrz/extract.ts +71 -4
- package/src/mrz/textRecognizer.ts +36 -0
- package/src/mrz/useTextModelReady.ts +21 -0
- package/src/screens/BusinessDocumentSlot.tsx +37 -8
- package/src/screens/DocumentCaptureStep.tsx +177 -93
- package/src/screens/LivenessAvatar.tsx +18 -13
- package/src/screens/LivenessStep.tsx +37 -0
- package/src/screens/MrzScanView.tsx +33 -0
- package/src/screens/SupportingDocumentCard.tsx +134 -0
- package/src/screens/SupportingDocumentsStep.tsx +203 -0
- package/src/screens/consent/model.ts +22 -5
- package/src/screens/document/CaptureCheckNotice.tsx +77 -0
- package/src/screens/document/UploadPhase.tsx +177 -0
- package/src/screens/document/useDocumentCamera.ts +109 -0
- package/src/screens/liveness/CaptureRing.tsx +31 -17
- package/src/screens/liveness/LivenessOutcome.tsx +4 -1
- package/src/screens/liveness/SelfiePreview.tsx +19 -4
- package/src/screens/supportingDocumentParts.tsx +126 -0
- package/src/screens/useBusinessDocumentAttach.ts +20 -9
- package/src/services/api-types.ts +31 -2
- package/src/services/api.ts +19 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/mediaCompress.ts +23 -5
- package/src/specs/MyazaTextRecognizer.nitro.ts +29 -0
- package/src/store/derive.ts +11 -0
- package/src/store/kycStore.ts +23 -2
- package/src/store/session.ts +18 -0
- package/src/store/state.ts +26 -0
- package/src/store/submit.ts +11 -0
- package/src/types/config.ts +25 -0
- package/src/types/workflow.ts +39 -0
- package/src/MyazaBiometricAuth.tsx +0 -119
- package/src/assets/liveness/Blink.gif +0 -0
- package/src/assets/liveness/Nod.gif +0 -0
- package/src/assets/liveness/Smile.gif +0 -0
- package/src/assets/liveness/Turn.gif +0 -0
- package/src/components/icon-map.ts +0 -176
- package/src/lib/biometric-auth.ts +0 -52
- package/src/screens/biometric/BiometricAuthFlow.tsx +0 -170
- package/src/services/api-biometric.ts +0 -37
- package/src/services/api-types-biometric.ts +0 -41
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Image } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { resolveBaseUrl } from '../services/resolveUrl';
|
|
4
|
+
import type { LivenessChallenge } from './types';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Where the gesture animations come from.
|
|
8
|
+
//
|
|
9
|
+
// They used to be four GIFs bundled in this package — 5.5 MB, shipped to every
|
|
10
|
+
// device whatever the workflow asked for, for a badge that is on screen for a
|
|
11
|
+
// few seconds of the liveness step. They are served by the API instead
|
|
12
|
+
// (`/api/kyc/assets/liveness/<gesture>.gif`), prefetched when the flow opens,
|
|
13
|
+
// and cached by the platform image loader from then on.
|
|
14
|
+
//
|
|
15
|
+
// GIF on BOTH platforms deliberately, even though the server also has WebP at a
|
|
16
|
+
// tenth of the size: React Native decodes only GIF animation on iOS, and
|
|
17
|
+
// animated WebP on Android needs Fresco's `animated-webp` module, which the
|
|
18
|
+
// host app enables or does not — invisible from in here. One URL, no platform
|
|
19
|
+
// branch, no dependence on the integrator's Fresco configuration.
|
|
20
|
+
//
|
|
21
|
+
// Nothing here throws and nothing here is awaited by the flow. A device that
|
|
22
|
+
// never gets the file shows the gesture icon the avatar has always fallen back
|
|
23
|
+
// to, and the instruction text above it says what to do regardless.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export function livenessAvatarUrl(
|
|
27
|
+
challenge: LivenessChallenge,
|
|
28
|
+
apiKey: string,
|
|
29
|
+
devUrl?: string,
|
|
30
|
+
): string | null {
|
|
31
|
+
try {
|
|
32
|
+
// Throws on a malformed key, which is a real error everywhere else in the
|
|
33
|
+
// SDK and merely a missing cartoon here.
|
|
34
|
+
return `${resolveBaseUrl(apiKey, devUrl)}/api/kyc/assets/liveness/${challenge}.gif`;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const GESTURES: readonly LivenessChallenge[] = ['nod', 'turn', 'blink', 'smile'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Warm the image cache at flow open, so the avatar is already on the device by
|
|
44
|
+
* the time liveness renders.
|
|
45
|
+
*
|
|
46
|
+
* Called beside `primeFaceModel()` and for the same reason: the work overlaps
|
|
47
|
+
* consent and ID-type selection instead of stalling in front of the camera.
|
|
48
|
+
* All four are fetched because which gestures a session asks for is randomised
|
|
49
|
+
* per session, and together they are under 700 KB.
|
|
50
|
+
*/
|
|
51
|
+
export function primeLivenessAvatars(apiKey: string, devUrl?: string): void {
|
|
52
|
+
for (const gesture of GESTURES) {
|
|
53
|
+
const url = livenessAvatarUrl(gesture, apiKey, devUrl);
|
|
54
|
+
if (url == null) return;
|
|
55
|
+
Image.prefetch(url).catch(() => {
|
|
56
|
+
/* best-effort: the avatar falls back to its icon */
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -1,65 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { useNativeModelReady, type ModelReadyState } from '../lib/model-ready';
|
|
3
2
|
import { isFaceModelReady, primeFaceModel } from './visionCameraFaceDetector';
|
|
4
3
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// it, which keeps ~8 MB per device out of the APK but leaves a window where
|
|
9
|
-
// detection cannot run: first launch before the download lands, or a device with
|
|
10
|
-
// no Google Play Services at all.
|
|
11
|
-
//
|
|
12
|
-
// This has to be answered BEFORE the camera opens. `detectFace` reports through
|
|
13
|
-
// `FaceResult`, where a missing model and an empty frame are both
|
|
14
|
-
// `faceCount: 0` — so gating on the per-frame result would strand the user on
|
|
15
|
-
// "position your face" indefinitely, with the SDK unable to say why. That is the
|
|
16
|
-
// single failure mode this whole gate exists to prevent.
|
|
17
|
-
//
|
|
18
|
-
// iOS is always ready (Apple Vision is a system framework), so this resolves on
|
|
19
|
-
// the first tick there and costs nothing.
|
|
20
|
-
|
|
21
|
-
export type ModelReadyState = 'ready' | 'preparing' | 'unavailable';
|
|
4
|
+
// The face half of the shared readiness gate. The rule, the polling and the
|
|
5
|
+
// reasoning live in ../lib/model-ready.ts, because the text recogniser needs
|
|
6
|
+
// exactly the same thing and one decision should not exist in two places.
|
|
22
7
|
|
|
23
|
-
|
|
24
|
-
const MODEL_WAIT_MS = 20_000;
|
|
25
|
-
/** Gap between readiness polls while the download is in flight. */
|
|
26
|
-
const POLL_INTERVAL_MS = 500;
|
|
8
|
+
export type { ModelReadyState };
|
|
27
9
|
|
|
28
10
|
/**
|
|
29
11
|
* Tracks whether on-device face detection can run.
|
|
30
12
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* The flow primes the download at open (see `MyazaKYC.tsx`), so by the time the
|
|
36
|
-
* user reaches liveness this is normally already `'ready'` and no waiting screen
|
|
37
|
-
* is ever shown.
|
|
13
|
+
* `'preparing'` while Play Services fetches the model, `'ready'` once it can
|
|
14
|
+
* run, `'unavailable'` when it could not be obtained — no Play Services, no
|
|
15
|
+
* network, or a declined install.
|
|
38
16
|
*/
|
|
39
17
|
export function useFaceModelReady(): ModelReadyState {
|
|
40
|
-
|
|
41
|
-
isFaceModelReady() ? 'ready' : 'preparing',
|
|
42
|
-
);
|
|
43
|
-
const startedAt = useRef(Date.now());
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
if (state !== 'preparing') return;
|
|
47
|
-
|
|
48
|
-
// Re-prime rather than assume the open-time call ran: the step can be
|
|
49
|
-
// reached directly in a resumed flow, and prepareModel() is a no-op once
|
|
50
|
-
// the model is present.
|
|
51
|
-
primeFaceModel();
|
|
52
|
-
|
|
53
|
-
const id = setInterval(() => {
|
|
54
|
-
if (isFaceModelReady()) {
|
|
55
|
-
setState('ready');
|
|
56
|
-
} else if (Date.now() - startedAt.current > MODEL_WAIT_MS) {
|
|
57
|
-
setState('unavailable');
|
|
58
|
-
}
|
|
59
|
-
}, POLL_INTERVAL_MS);
|
|
60
|
-
|
|
61
|
-
return () => clearInterval(id);
|
|
62
|
-
}, [state]);
|
|
63
|
-
|
|
64
|
-
return state;
|
|
18
|
+
return useNativeModelReady(isFaceModelReady, primeFaceModel);
|
|
65
19
|
}
|
package/src/mrz/extract.ts
CHANGED
|
@@ -57,6 +57,15 @@ function fit(line: string, width: number): string | null {
|
|
|
57
57
|
if (line.length >= width - 2 && line.length < width) return line.padEnd(width, '<');
|
|
58
58
|
// Long by a stray glyph or three: drop the trailing noise.
|
|
59
59
|
if (line.length > width && line.length <= width + 3) return line.slice(0, width);
|
|
60
|
+
// A filler run the recogniser miscounted. Android returns runs of '<' as
|
|
61
|
+
// guillemets whose number does not match the fillers printed, so the mapped
|
|
62
|
+
// run overshoots or falls short by many characters (a passport's first line
|
|
63
|
+
// came back 53 wide on the Flutter SDK, 2026-09-15). Trailing fillers are
|
|
64
|
+
// padding and carry no data, so only they are removed or added.
|
|
65
|
+
if (line.length > width && !/[^<]/.test(line.slice(width))) return line.slice(0, width);
|
|
66
|
+
if (line.length < width && line.length >= Math.floor(width / 2) && line.endsWith('<')) {
|
|
67
|
+
return line.padEnd(width, '<');
|
|
68
|
+
}
|
|
60
69
|
return null;
|
|
61
70
|
}
|
|
62
71
|
|
|
@@ -65,10 +74,9 @@ function fit(line: string, width: number): string | null {
|
|
|
65
74
|
* not carry a complete, valid one.
|
|
66
75
|
*/
|
|
67
76
|
export function extractMrz(recognizedLines: string[], now: Date = new Date()): MrzScan | null {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (candidates.length === 0) return null;
|
|
77
|
+
const pieces = recognizedLines.map(sanitizeMrzLine).filter((line) => line.length > 0);
|
|
78
|
+
const candidates = pieces.filter(looksLikeMrzLine);
|
|
79
|
+
if (candidates.length === 0) return fromFragments(pieces, now);
|
|
72
80
|
|
|
73
81
|
// 1) A single line already holding the whole MRZ (some recognisers merge).
|
|
74
82
|
for (const line of candidates) {
|
|
@@ -97,5 +105,64 @@ export function extractMrz(recognizedLines: string[], now: Date = new Date()): M
|
|
|
97
105
|
if (scan) return scan;
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
return fromFragments(pieces, now);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Split lines.
|
|
113
|
+
//
|
|
114
|
+
// On a high-resolution still, Android's recogniser can return ONE printed MRZ
|
|
115
|
+
// line as two text lines (seen on the Flutter SDK, 2026-09-15: a passport's
|
|
116
|
+
// two-line band came back as three lines). Joining runs of adjacent pieces back
|
|
117
|
+
// to line width recovers it; a wrong join is harmless, as above. Mirrored in
|
|
118
|
+
// kyc-sdk-flutter's mrz_extract.dart.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** The longest run of pieces one printed line is ever split into. */
|
|
122
|
+
const MAX_PIECES_PER_LINE = 4;
|
|
123
|
+
|
|
124
|
+
interface JoinedRow {
|
|
125
|
+
end: number;
|
|
126
|
+
text: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Joined rows of `width`, keyed by the index of the piece each one starts at. */
|
|
130
|
+
function rowsByStart(pieces: string[], width: number): Map<number, JoinedRow[]> {
|
|
131
|
+
const rows = new Map<number, JoinedRow[]>();
|
|
132
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
133
|
+
let text = '';
|
|
134
|
+
for (let j = i; j < pieces.length && j < i + MAX_PIECES_PER_LINE; j++) {
|
|
135
|
+
text += pieces[j]!;
|
|
136
|
+
// Generous: a joined row may carry an over-counted filler run `fit` trims.
|
|
137
|
+
if (text.length > width * 2) break;
|
|
138
|
+
const fitted = fit(text, width);
|
|
139
|
+
if (fitted) rows.set(i, [...(rows.get(i) ?? []), { end: j, text: fitted }]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return rows;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Consecutive joined rows of line width: TD3 two of 44, TD1 three of 30. */
|
|
146
|
+
function fromFragments(pieces: string[], now: Date): MrzScan | null {
|
|
147
|
+
const td3 = rowsByStart(pieces, 44);
|
|
148
|
+
for (const list of td3.values()) {
|
|
149
|
+
for (const a of list) {
|
|
150
|
+
for (const b of td3.get(a.end + 1) ?? []) {
|
|
151
|
+
const scan = parseMrz(a.text + b.text, now);
|
|
152
|
+
if (scan) return scan;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const td1 = rowsByStart(pieces, 30);
|
|
157
|
+
for (const list of td1.values()) {
|
|
158
|
+
for (const a of list) {
|
|
159
|
+
for (const b of td1.get(a.end + 1) ?? []) {
|
|
160
|
+
for (const c of td1.get(b.end + 1) ?? []) {
|
|
161
|
+
const scan = parseMrz(a.text + b.text + c.text, now);
|
|
162
|
+
if (scan) return scan;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
100
167
|
return null;
|
|
101
168
|
}
|
|
@@ -71,6 +71,42 @@ export function hasTextRecognizer(): boolean {
|
|
|
71
71
|
return resolve() !== null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Start fetching the text model, so it is warm by the time the document step
|
|
76
|
+
* runs.
|
|
77
|
+
*
|
|
78
|
+
* Android fetches ML Kit's models through Play Services rather than bundling
|
|
79
|
+
* them, which keeps ~18.5 MB per device out of the APK but leaves a window on
|
|
80
|
+
* first launch where recognition cannot work. Priming at flow start puts that
|
|
81
|
+
* download behind the consent and ID-type screens instead of in front of the
|
|
82
|
+
* camera.
|
|
83
|
+
*
|
|
84
|
+
* Best-effort and non-blocking, exactly like `primeFaceModel()`: a failure here
|
|
85
|
+
* is not fatal, because {@link isTextModelReady} is what the document step
|
|
86
|
+
* actually reads. iOS resolves immediately (Apple Vision is a system
|
|
87
|
+
* framework).
|
|
88
|
+
*/
|
|
89
|
+
export function primeTextModel(): void {
|
|
90
|
+
resolve()?.prepareModel().catch(() => {
|
|
91
|
+
/* best-effort: isTextModelReady() is the real signal */
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether text recognition can run right now.
|
|
97
|
+
*
|
|
98
|
+
* `recognizeText` cannot answer this — a missing model and a frame with no text
|
|
99
|
+
* are both an empty `lines` array — so auto-capture would silently never fire
|
|
100
|
+
* and the MRZ would never produce a chip key, with nothing able to say why.
|
|
101
|
+
*
|
|
102
|
+
* `false` when the native module is absent entirely (Expo Go), because in that
|
|
103
|
+
* build there is no recogniser to become ready. Callers treat both the same
|
|
104
|
+
* way: fall back to the manual shutter, which was always the primary route.
|
|
105
|
+
*/
|
|
106
|
+
export function isTextModelReady(): boolean {
|
|
107
|
+
return resolve()?.isModelReady() ?? false;
|
|
108
|
+
}
|
|
109
|
+
|
|
74
110
|
/**
|
|
75
111
|
* The bottom slice of the frame the MRZ occupies.
|
|
76
112
|
*
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useNativeModelReady, type ModelReadyState } from '../lib/model-ready';
|
|
2
|
+
import { isTextModelReady, primeTextModel } from './textRecognizer';
|
|
3
|
+
|
|
4
|
+
// The text half of the shared readiness gate. The rule, the polling and the
|
|
5
|
+
// reasoning live in ../lib/model-ready.ts — the same gate the face detector
|
|
6
|
+
// uses, for the same reason.
|
|
7
|
+
|
|
8
|
+
export type { ModelReadyState };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tracks whether on-device text recognition can run.
|
|
12
|
+
*
|
|
13
|
+
* The live MRZ scanner is what gates on this: the printed strip IS the chip's
|
|
14
|
+
* access key, so a recogniser that can never run leaves the user aiming at a
|
|
15
|
+
* passport that is silently never read. Auto-capture deliberately does NOT gate
|
|
16
|
+
* on it — there the recogniser is an accelerator and the manual shutter is
|
|
17
|
+
* always live, so an absent model costs convenience rather than a dead end.
|
|
18
|
+
*/
|
|
19
|
+
export function useTextModelReady(): ModelReadyState {
|
|
20
|
+
return useNativeModelReady(isTextModelReady, primeTextModel);
|
|
21
|
+
}
|
|
@@ -64,6 +64,7 @@ function UploadedThumb({ previewUri, isPdf }: { previewUri: string | null; isPdf
|
|
|
64
64
|
|
|
65
65
|
export function BusinessDocumentSlot({
|
|
66
66
|
label,
|
|
67
|
+
description = null,
|
|
67
68
|
required,
|
|
68
69
|
fileName,
|
|
69
70
|
uploading,
|
|
@@ -71,8 +72,16 @@ export function BusinessDocumentSlot({
|
|
|
71
72
|
isPdf = false,
|
|
72
73
|
onPick,
|
|
73
74
|
onRemove,
|
|
75
|
+
compact = false,
|
|
74
76
|
}: {
|
|
75
77
|
label: string;
|
|
78
|
+
/**
|
|
79
|
+
* Guidance the organisation wrote: which document, and what it has to show.
|
|
80
|
+
* Optional because the business-documents step names a fixed catalogue the
|
|
81
|
+
* applicant already recognises; a supporting document is whatever the org
|
|
82
|
+
* called it, so this is often the only thing that makes it findable.
|
|
83
|
+
*/
|
|
84
|
+
description?: string | null;
|
|
76
85
|
required: boolean;
|
|
77
86
|
/** Uploaded file name, when this slot already has a mediaId. */
|
|
78
87
|
fileName: string | null;
|
|
@@ -83,6 +92,15 @@ export function BusinessDocumentSlot({
|
|
|
83
92
|
/** Open the photo/file source sheet (also serves Replace). */
|
|
84
93
|
onPick: () => void;
|
|
85
94
|
onRemove: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* The slot is the FOOTER of a card that already names the document.
|
|
97
|
+
*
|
|
98
|
+
* So it drops everything the card above it has already said — the label
|
|
99
|
+
* echo, the required star, the guidance — and its own bottom margin, and
|
|
100
|
+
* the empty state reads "Upload <document>" rather than repeating the title.
|
|
101
|
+
* Mirrors the Flutter slot's `compact`.
|
|
102
|
+
*/
|
|
103
|
+
compact?: boolean;
|
|
86
104
|
}): React.ReactElement {
|
|
87
105
|
const { colors } = useTheme();
|
|
88
106
|
|
|
@@ -97,7 +115,7 @@ export function BusinessDocumentSlot({
|
|
|
97
115
|
borderRadius: radius.sm,
|
|
98
116
|
backgroundColor: colors.backgroundSecondary,
|
|
99
117
|
padding: spacing.md,
|
|
100
|
-
marginBottom: spacing.sm + 4,
|
|
118
|
+
marginBottom: compact ? 0 : spacing.sm + 4,
|
|
101
119
|
}}
|
|
102
120
|
>
|
|
103
121
|
<UploadedThumb previewUri={previewUri} isPdf={isPdf} />
|
|
@@ -106,9 +124,11 @@ export function BusinessDocumentSlot({
|
|
|
106
124
|
<MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={1}>
|
|
107
125
|
{fileName}
|
|
108
126
|
</MyazaText>
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
|
|
127
|
+
{compact ? null : (
|
|
128
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
129
|
+
{label}
|
|
130
|
+
</MyazaText>
|
|
131
|
+
)}
|
|
112
132
|
</View>
|
|
113
133
|
<Pressable onPress={onPick} hitSlop={8} accessibilityRole="button">
|
|
114
134
|
<MyazaText variant="bodySmall" color={colors.primary} style={{ fontWeight: '600' }}>
|
|
@@ -138,7 +158,7 @@ export function BusinessDocumentSlot({
|
|
|
138
158
|
alignItems: 'center',
|
|
139
159
|
borderRadius: radius.sm,
|
|
140
160
|
padding: spacing.md,
|
|
141
|
-
marginBottom: spacing.sm + 4,
|
|
161
|
+
marginBottom: compact ? 0 : spacing.sm + 4,
|
|
142
162
|
opacity: uploading ? 0.7 : 1,
|
|
143
163
|
}}
|
|
144
164
|
>
|
|
@@ -150,14 +170,23 @@ export function BusinessDocumentSlot({
|
|
|
150
170
|
)}
|
|
151
171
|
<View style={{ width: spacing.sm + 4 }} />
|
|
152
172
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
153
|
-
<MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={
|
|
154
|
-
{label}
|
|
155
|
-
{required ? (
|
|
173
|
+
<MyazaText variant="label" style={{ fontWeight: '600' }} numberOfLines={2}>
|
|
174
|
+
{compact ? `Upload ${label.toLowerCase()}` : label}
|
|
175
|
+
{required && !compact ? (
|
|
156
176
|
<MyazaText variant="label" color={colors.error} style={{ fontWeight: '600' }}>
|
|
157
177
|
{' *'}
|
|
158
178
|
</MyazaText>
|
|
159
179
|
) : null}
|
|
160
180
|
</MyazaText>
|
|
181
|
+
{description && !uploading && !compact ? (
|
|
182
|
+
<MyazaText
|
|
183
|
+
variant="bodySmall"
|
|
184
|
+
color={colors.textDark}
|
|
185
|
+
style={{ marginTop: 2, opacity: 0.75 }}
|
|
186
|
+
>
|
|
187
|
+
{description}
|
|
188
|
+
</MyazaText>
|
|
189
|
+
) : null}
|
|
161
190
|
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
162
191
|
{uploading ? 'Uploading…' : UPLOAD_HINT}
|
|
163
192
|
</MyazaText>
|