smartcomply-web-sdk 1.0.45 → 1.0.48
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/dist/camera/CameraManager.d.ts +7 -0
- package/dist/camera/CameraManager.d.ts.map +1 -1
- package/dist/camera/CameraManager.js +9 -0
- package/dist/camera/CameraManager.js.map +1 -1
- package/dist/esm/camera/CameraManager.d.ts +7 -0
- package/dist/esm/camera/CameraManager.d.ts.map +1 -1
- package/dist/esm/camera/CameraManager.js +9 -0
- package/dist/esm/camera/CameraManager.js.map +1 -1
- package/dist/esm/flow/SmartComplyFlow.d.ts +23 -5
- package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/esm/flow/SmartComplyFlow.js +698 -507
- package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
- package/dist/esm/flow/theme.d.ts +7 -1
- package/dist/esm/flow/theme.d.ts.map +1 -1
- package/dist/esm/flow/theme.js +27 -2
- package/dist/esm/flow/theme.js.map +1 -1
- package/dist/esm/modules/liveness/LivenessUI.d.ts +1 -1
- package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
- package/dist/esm/modules/liveness/LivenessUI.js +5 -5
- package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
- package/dist/esm/modules/liveness/liveness.d.ts +1 -0
- package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/esm/modules/liveness/liveness.js +55 -33
- package/dist/esm/modules/liveness/liveness.js.map +1 -1
- package/dist/flow/SmartComplyFlow.d.ts +23 -5
- package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/flow/SmartComplyFlow.js +698 -507
- package/dist/flow/SmartComplyFlow.js.map +1 -1
- package/dist/flow/theme.d.ts +7 -1
- package/dist/flow/theme.d.ts.map +1 -1
- package/dist/flow/theme.js +27 -2
- package/dist/flow/theme.js.map +1 -1
- package/dist/modules/liveness/LivenessUI.d.ts +1 -1
- package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
- package/dist/modules/liveness/LivenessUI.js +5 -5
- package/dist/modules/liveness/LivenessUI.js.map +1 -1
- package/dist/modules/liveness/liveness.d.ts +1 -0
- package/dist/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/modules/liveness/liveness.js +55 -33
- package/dist/modules/liveness/liveness.js.map +1 -1
- package/dist/smartcomply.browser.js +625 -430
- package/dist/smartcomply.browser.js.map +3 -3
- package/package.json +1 -1
|
@@ -10,22 +10,25 @@ const STEP_ORDER = [
|
|
|
10
10
|
"welcome",
|
|
11
11
|
"country",
|
|
12
12
|
"id_type",
|
|
13
|
-
"id_input",
|
|
13
|
+
"id_input",
|
|
14
|
+
"confirm_identity",
|
|
14
15
|
"document_capture",
|
|
15
|
-
"
|
|
16
|
+
"ocr_gate",
|
|
17
|
+
"document_confirm",
|
|
16
18
|
"liveness",
|
|
19
|
+
"done",
|
|
17
20
|
"result",
|
|
18
21
|
];
|
|
19
22
|
const COUNTRY_LABELS = {
|
|
20
|
-
nigeria: "
|
|
21
|
-
global: "
|
|
22
|
-
ghana: "
|
|
23
|
-
kenya: "
|
|
24
|
-
south_africa: "
|
|
25
|
-
united_states: "
|
|
26
|
-
united_kingdom: "
|
|
23
|
+
nigeria: "🇳🇬 Nigeria",
|
|
24
|
+
global: "🌠Other Countries",
|
|
25
|
+
ghana: "🇬🇠Ghana",
|
|
26
|
+
kenya: "🇰🇪 Kenya",
|
|
27
|
+
south_africa: "🇿🇦 South Africa",
|
|
28
|
+
united_states: "🇺🇸 United States",
|
|
29
|
+
united_kingdom: "🇬🇧 United Kingdom",
|
|
27
30
|
};
|
|
28
|
-
// Backend/admin data tags document-capture fields inconsistently
|
|
31
|
+
// Backend/admin data tags document-capture fields inconsistently — the field
|
|
29
32
|
// renderer (below) already treats "image" and "file" as file inputs alongside
|
|
30
33
|
// "upload", so flow-routing decisions must recognize the same set or a channel
|
|
31
34
|
// tagged "image"/"file" silently falls back to the number-entry screen.
|
|
@@ -35,7 +38,7 @@ function isUploadFieldType(type) {
|
|
|
35
38
|
function resolveIdTypeInfo(typeName, isDocumentFlow) {
|
|
36
39
|
const u = typeName.toUpperCase();
|
|
37
40
|
const advanced = u.includes("ADVANCED");
|
|
38
|
-
// Strip UI noise from display labels
|
|
41
|
+
// Strip UI noise from display labels — users don't need "with face" or "(NIN)" suffixes
|
|
39
42
|
const cleanName = typeName
|
|
40
43
|
.replace(/\s*with\s+face\s*/gi, ' ')
|
|
41
44
|
.replace(/\s*\(NIN\)\s*/gi, ' ')
|
|
@@ -50,41 +53,41 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
|
|
|
50
53
|
t.includes("license") ||
|
|
51
54
|
t.includes("national id") ||
|
|
52
55
|
t.includes("national id card");
|
|
53
|
-
// Note: NIN document (nin_with_face, nin_slip) does NOT require back side
|
|
56
|
+
// Note: NIN document (nin_with_face, nin_slip) does NOT require back side —
|
|
54
57
|
// the NIN card is single-sided. Only the physical NIN slip has a back but
|
|
55
58
|
// the OCR pipeline only needs the front.
|
|
56
59
|
};
|
|
57
|
-
// BVN
|
|
60
|
+
// BVN — always 11 digits
|
|
58
61
|
if (u.includes("BVN") || u.includes("BANK VERIFICATION")) {
|
|
59
|
-
return { icon: "
|
|
62
|
+
return { icon: "ðŸ¦", desc: "Enter your 11-digit Bank Verification Number (BVN)", requiresBack: false };
|
|
60
63
|
}
|
|
61
|
-
// NIN
|
|
64
|
+
// NIN — always 11 digits (covers nin_with_face, NIN slip, etc.)
|
|
62
65
|
if (u.includes("NIN") || u.includes("NATIONAL IDENTITY NUMBER")) {
|
|
63
|
-
return { icon: "
|
|
66
|
+
return { icon: "🆔", desc: "Enter your 11-digit National Identification Number (NIN)", requiresBack: false };
|
|
64
67
|
}
|
|
65
68
|
// Ghana Card / Ghana National ID
|
|
66
69
|
if (u.includes("GHANA")) {
|
|
67
70
|
if (isDocumentFlow)
|
|
68
|
-
return { icon: "
|
|
69
|
-
return { icon: "
|
|
71
|
+
return { icon: "🪪", desc: "Capture or upload your Ghana Card", requiresBack: true };
|
|
72
|
+
return { icon: "🪪", desc: "Enter your Ghana Card number", requiresBack: false };
|
|
70
73
|
}
|
|
71
74
|
// SSNIT (Ghana social security)
|
|
72
75
|
if (u.includes("SSNIT"))
|
|
73
|
-
return { icon: "
|
|
76
|
+
return { icon: "ðŸ›ï¸", desc: "Enter your SSNIT number", requiresBack: false };
|
|
74
77
|
// Kenya National ID
|
|
75
78
|
if (u.includes("KENYA") || (u.includes("NATIONAL") && u.includes("KENYA")))
|
|
76
|
-
return { icon: "
|
|
79
|
+
return { icon: "🆔", desc: "Enter your Kenya National ID number", requiresBack: false };
|
|
77
80
|
// TIN
|
|
78
81
|
if (u.includes("TIN") || u.includes("TAX IDENTIFICATION"))
|
|
79
|
-
return { icon: "
|
|
82
|
+
return { icon: "🧾", desc: "Enter your Tax Identification Number (TIN)", requiresBack: false };
|
|
80
83
|
// CAC / Company registration
|
|
81
84
|
if (u.includes("CAC") || u.includes("CORPORATE AFFAIRS") || u.includes("COMPANY"))
|
|
82
|
-
return { icon: "
|
|
85
|
+
return { icon: "ðŸ¢", desc: advanced ? "Enter RC number, company name & type" : "Enter your company registration number", requiresBack: false };
|
|
83
86
|
// Voter / PVC
|
|
84
87
|
if (u.includes("VOTER") || u.includes("PVC")) {
|
|
85
88
|
const requiresBack = requiresBackSide(typeName);
|
|
86
89
|
return {
|
|
87
|
-
icon: "
|
|
90
|
+
icon: "🗳ï¸",
|
|
88
91
|
desc: isDocumentFlow
|
|
89
92
|
? `Capture or upload your voter's card${requiresBack ? " (front & back)" : ""}`
|
|
90
93
|
: "Enter your voter ID number",
|
|
@@ -93,12 +96,12 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
|
|
|
93
96
|
}
|
|
94
97
|
// Passport
|
|
95
98
|
if (u.includes("PASSPORT"))
|
|
96
|
-
return { icon: "
|
|
99
|
+
return { icon: "📘", desc: isDocumentFlow ? "Capture or upload your passport" : "Enter your passport number", requiresBack: false };
|
|
97
100
|
// Driver's license
|
|
98
101
|
if (u.includes("DRIVER") || u.includes("LICENSE") || u.includes("LICENCE")) {
|
|
99
102
|
const requiresBack = requiresBackSide(typeName);
|
|
100
103
|
return {
|
|
101
|
-
icon: "
|
|
104
|
+
icon: "🚗",
|
|
102
105
|
desc: isDocumentFlow
|
|
103
106
|
? `Capture or upload your driver's license${requiresBack ? " (front & back)" : ""}`
|
|
104
107
|
: "Enter your driver's license number",
|
|
@@ -109,7 +112,7 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
|
|
|
109
112
|
if (u.includes("NATIONAL ID") || (u.includes("NATIONAL") && u.includes("CARD"))) {
|
|
110
113
|
const requiresBack = requiresBackSide(typeName);
|
|
111
114
|
return {
|
|
112
|
-
icon: "
|
|
115
|
+
icon: "🪪",
|
|
113
116
|
desc: isDocumentFlow
|
|
114
117
|
? `Capture or upload your ID card${requiresBack ? " (front & back)" : ""}`
|
|
115
118
|
: "Enter your National ID number",
|
|
@@ -118,15 +121,15 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
|
|
|
118
121
|
}
|
|
119
122
|
// Face liveness / comparison
|
|
120
123
|
if (u.includes("LIVENESS") || u.includes("FACE COMPARISON") || u.includes("FACE MATCH"))
|
|
121
|
-
return { icon: "
|
|
124
|
+
return { icon: "🤳", desc: "We'll capture a short selfie video to verify you", requiresBack: false };
|
|
122
125
|
return {
|
|
123
|
-
icon: isDocumentFlow ? "
|
|
126
|
+
icon: isDocumentFlow ? "📄" : "🆔",
|
|
124
127
|
desc: isDocumentFlow ? "Capture or upload your document" : "Enter your ID number",
|
|
125
128
|
requiresBack: false,
|
|
126
129
|
};
|
|
127
130
|
}
|
|
128
|
-
//
|
|
129
|
-
//
|
|
131
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
132
|
+
// ── Widget-side message sanitizer ─────────────────────────────────────────────
|
|
130
133
|
// Strips anything technical before showing text to the end user.
|
|
131
134
|
// Keeps messages that are already written plainly in natural language.
|
|
132
135
|
function _sanitizeUserMessage(raw) {
|
|
@@ -155,7 +158,56 @@ function _sanitizeUserMessage(raw) {
|
|
|
155
158
|
return null;
|
|
156
159
|
return raw.trim();
|
|
157
160
|
}
|
|
158
|
-
//
|
|
161
|
+
// ── B8: OCR date string normaliser ───────────────────────────────────────────
|
|
162
|
+
// OCR providers return dates in wildly different formats. new Date(str) is
|
|
163
|
+
// locale-dependent for anything other than ISO 8601 (YYYY-MM-DD), so we
|
|
164
|
+
// attempt a set of known patterns before falling back to the native parser.
|
|
165
|
+
function _parseOcrDate(raw) {
|
|
166
|
+
if (!raw)
|
|
167
|
+
return null;
|
|
168
|
+
const s = raw.trim();
|
|
169
|
+
// ISO 8601 or near-ISO — try first, always unambiguous
|
|
170
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(s))
|
|
171
|
+
return new Date(s.slice(0, 10));
|
|
172
|
+
// DD/MM/YYYY or DD-MM-YYYY
|
|
173
|
+
const dmy = s.match(/^(\d{2})[\/\-](\d{2})[\/\-](\d{4})/);
|
|
174
|
+
if (dmy)
|
|
175
|
+
return new Date(`${dmy[3]}-${dmy[2]}-${dmy[1]}`);
|
|
176
|
+
// MM/DD/YYYY (US format — less common from OCR but possible)
|
|
177
|
+
const mdy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/);
|
|
178
|
+
if (mdy) {
|
|
179
|
+
// Heuristic: if "month" > 12 it must be day-first
|
|
180
|
+
if (parseInt(mdy[1], 10) > 12)
|
|
181
|
+
return new Date(`${mdy[3]}-${mdy[2]}-${mdy[1]}`);
|
|
182
|
+
return new Date(`${mdy[3]}-${mdy[1]}-${mdy[2]}`);
|
|
183
|
+
}
|
|
184
|
+
// DD Mon YYYY e.g. "12 May 2029" or "12 MAY 2029"
|
|
185
|
+
const dMonY = s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})/);
|
|
186
|
+
if (dMonY)
|
|
187
|
+
return new Date(`${dMonY[2]} ${dMonY[1]} ${dMonY[3]}`);
|
|
188
|
+
// Last resort: native parser
|
|
189
|
+
const d = new Date(s);
|
|
190
|
+
return isNaN(d.getTime()) ? null : d;
|
|
191
|
+
}
|
|
192
|
+
// ── W12: Image compression before document upload ────────────────────────────
|
|
193
|
+
async function _compressImage(blob, maxWidth = 1600, quality = 0.85) {
|
|
194
|
+
try {
|
|
195
|
+
const bmp = await createImageBitmap(blob);
|
|
196
|
+
const scale = Math.min(1, maxWidth / bmp.width);
|
|
197
|
+
const w = Math.round(bmp.width * scale);
|
|
198
|
+
const h = Math.round(bmp.height * scale);
|
|
199
|
+
const canvas = document.createElement("canvas");
|
|
200
|
+
canvas.width = w;
|
|
201
|
+
canvas.height = h;
|
|
202
|
+
canvas.getContext("2d").drawImage(bmp, 0, 0, w, h);
|
|
203
|
+
bmp.close();
|
|
204
|
+
return await new Promise((res, rej) => canvas.toBlob(b => b ? res(b) : rej(new Error("Compression failed")), "image/jpeg", quality));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return blob; // fallback to original on any error
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// ── Document type code → human label ─────────────────────────────────────────
|
|
159
211
|
function _formatDocType(code) {
|
|
160
212
|
const map = {
|
|
161
213
|
passport: "Passport",
|
|
@@ -176,7 +228,7 @@ function _formatDocType(code) {
|
|
|
176
228
|
return code.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
177
229
|
}
|
|
178
230
|
// SmartComplyFlow
|
|
179
|
-
//
|
|
231
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
180
232
|
// Add CSS animation keyframes for spin animations
|
|
181
233
|
const style = document.createElement('style');
|
|
182
234
|
style.textContent = `
|
|
@@ -198,6 +250,7 @@ class SmartComplyFlow {
|
|
|
198
250
|
this.contentArea = null;
|
|
199
251
|
this.progressBar = null;
|
|
200
252
|
this.stepLabel = null;
|
|
253
|
+
this.stageDots = null;
|
|
201
254
|
this.brandLabel = null;
|
|
202
255
|
// State
|
|
203
256
|
this.sdkConfig = null;
|
|
@@ -217,13 +270,31 @@ class SmartComplyFlow {
|
|
|
217
270
|
this.livenessEntryId = null;
|
|
218
271
|
this.isDestroyed = false;
|
|
219
272
|
this.headerBackBtn = null;
|
|
273
|
+
// Retry safety — max 3 confirmation rejections or liveness failures per session
|
|
274
|
+
this._confirmRetries = 0;
|
|
275
|
+
this._livenessFailures = 0;
|
|
276
|
+
// IVS result stored for confirm_identity screen
|
|
277
|
+
this._ivsName = "";
|
|
278
|
+
this._ivsGender = "";
|
|
279
|
+
// OCR result stored for document_confirm screen
|
|
280
|
+
this._ocrName = "";
|
|
281
|
+
this._ocrDocNumber = "";
|
|
282
|
+
this._ocrExpiry = "";
|
|
283
|
+
this._ocrFaceImageUrl = "";
|
|
284
|
+
this._ocrIsExpired = false;
|
|
285
|
+
this._ocrExpirySoon = false;
|
|
286
|
+
// Camera pre-warm stream (created during confirmation screen, used in liveness)
|
|
287
|
+
this._prewarmedStream = null;
|
|
288
|
+
// OCR gate poll timer — stored so it can be cancelled on navigation or close
|
|
289
|
+
this._ocrPollTimer = null;
|
|
290
|
+
// UI timeout from _startStatusPolling — stored so close() can cancel it
|
|
291
|
+
this._uiTimeoutId = null;
|
|
220
292
|
// Session expiry
|
|
221
293
|
this.sessionExpiryTimer = null;
|
|
222
294
|
// Status polling (result screen)
|
|
223
295
|
this._pollStatusTimer = null;
|
|
224
296
|
// Components
|
|
225
297
|
this.docCapture = null;
|
|
226
|
-
this._livenessSubmitResult = null;
|
|
227
298
|
this.options = options;
|
|
228
299
|
this.sdk = new Smartcomply_1.SmartComply({
|
|
229
300
|
apiKey: options.apiKey,
|
|
@@ -257,6 +328,11 @@ class SmartComplyFlow {
|
|
|
257
328
|
close() {
|
|
258
329
|
this.isDestroyed = true;
|
|
259
330
|
this.docCapture?.destroy();
|
|
331
|
+
// Release any pre-warmed camera stream
|
|
332
|
+
if (this._prewarmedStream) {
|
|
333
|
+
this._prewarmedStream.getTracks().forEach(t => t.stop());
|
|
334
|
+
this._prewarmedStream = null;
|
|
335
|
+
}
|
|
260
336
|
if (this.sessionExpiryTimer) {
|
|
261
337
|
clearTimeout(this.sessionExpiryTimer);
|
|
262
338
|
this.sessionExpiryTimer = null;
|
|
@@ -265,13 +341,23 @@ class SmartComplyFlow {
|
|
|
265
341
|
clearTimeout(this._pollStatusTimer);
|
|
266
342
|
this._pollStatusTimer = null;
|
|
267
343
|
}
|
|
344
|
+
// B6: cancel OCR gate poll timer
|
|
345
|
+
if (this._ocrPollTimer) {
|
|
346
|
+
clearTimeout(this._ocrPollTimer);
|
|
347
|
+
this._ocrPollTimer = null;
|
|
348
|
+
}
|
|
349
|
+
// P4: cancel result-screen UI timeout
|
|
350
|
+
if (this._uiTimeoutId) {
|
|
351
|
+
clearTimeout(this._uiTimeoutId);
|
|
352
|
+
this._uiTimeoutId = null;
|
|
353
|
+
}
|
|
268
354
|
if (this.overlay) {
|
|
269
355
|
this.overlay.style.opacity = "0";
|
|
270
356
|
setTimeout(() => {
|
|
271
357
|
try {
|
|
272
358
|
this.overlay?.remove();
|
|
273
359
|
}
|
|
274
|
-
catch (_) { /* extension interference
|
|
360
|
+
catch (_) { /* extension interference — safe to ignore */ }
|
|
275
361
|
this.overlay = null;
|
|
276
362
|
this.modal = null;
|
|
277
363
|
this.contentArea = null;
|
|
@@ -279,9 +365,9 @@ class SmartComplyFlow {
|
|
|
279
365
|
}
|
|
280
366
|
this.options.onClose?.();
|
|
281
367
|
}
|
|
282
|
-
//
|
|
368
|
+
// ─────────────────────────────────────────────────────────────────
|
|
283
369
|
// Initialization
|
|
284
|
-
//
|
|
370
|
+
// ─────────────────────────────────────────────────────────────────
|
|
285
371
|
mount() {
|
|
286
372
|
// Inject keyframes
|
|
287
373
|
this.injectStyles();
|
|
@@ -366,12 +452,12 @@ class SmartComplyFlow {
|
|
|
366
452
|
}
|
|
367
453
|
// Load config
|
|
368
454
|
this.sdkConfig = await this.sdk.initializeConfig();
|
|
369
|
-
// Apply theme from config
|
|
455
|
+
// Apply theme from config — use theme_hex to override primary colour if set
|
|
370
456
|
if (this.sdkConfig.theme) {
|
|
371
|
-
this.theme = (0, theme_1.getTheme)(this.sdkConfig.theme);
|
|
457
|
+
this.theme = (0, theme_1.getTheme)(this.sdkConfig.theme, this.sdkConfig.theme_hex || null);
|
|
372
458
|
this.applyTheme();
|
|
373
459
|
}
|
|
374
|
-
// Update brand name
|
|
460
|
+
// Update brand name in header
|
|
375
461
|
if (this.brandLabel && this.sdkConfig.brand_name) {
|
|
376
462
|
this.brandLabel.textContent = this.sdkConfig.brand_name;
|
|
377
463
|
}
|
|
@@ -383,13 +469,26 @@ class SmartComplyFlow {
|
|
|
383
469
|
this.showErrorStep(err.message || "Failed to initialize. Please try again.");
|
|
384
470
|
}
|
|
385
471
|
}
|
|
386
|
-
//
|
|
472
|
+
// ─────────────────────────────────────────────────────────────────
|
|
387
473
|
// Step rendering
|
|
388
|
-
//
|
|
474
|
+
// ─────────────────────────────────────────────────────────────────
|
|
389
475
|
showStep(step) {
|
|
390
476
|
this.currentStep = step;
|
|
391
477
|
if (!this.contentArea)
|
|
392
478
|
return;
|
|
479
|
+
// P1/B6: cancel any in-flight OCR gate poll when user navigates away
|
|
480
|
+
if (this._ocrPollTimer) {
|
|
481
|
+
clearTimeout(this._ocrPollTimer);
|
|
482
|
+
this._ocrPollTimer = null;
|
|
483
|
+
}
|
|
484
|
+
// Release a pre-warmed camera stream if we're navigating anywhere other
|
|
485
|
+
// than liveness (the only step that consumes it) — otherwise "No, use a
|
|
486
|
+
// different ID" / "No, retake document" / header back-button leave the
|
|
487
|
+
// camera held open indefinitely with no consumer.
|
|
488
|
+
if (step !== "liveness" && this._prewarmedStream) {
|
|
489
|
+
this._prewarmedStream.getTracks().forEach(t => t.stop());
|
|
490
|
+
this._prewarmedStream = null;
|
|
491
|
+
}
|
|
393
492
|
// Update progress
|
|
394
493
|
this.updateProgress();
|
|
395
494
|
// Animate out current content
|
|
@@ -427,20 +526,32 @@ class SmartComplyFlow {
|
|
|
427
526
|
case "id_input":
|
|
428
527
|
this.renderIdInput(wrapper);
|
|
429
528
|
break;
|
|
529
|
+
case "confirm_identity":
|
|
530
|
+
this.renderConfirmIdentity(wrapper);
|
|
531
|
+
break;
|
|
430
532
|
case "document_capture":
|
|
431
533
|
this.renderDocumentCapture(wrapper);
|
|
432
534
|
break;
|
|
535
|
+
case "ocr_gate":
|
|
536
|
+
this.renderOcrGate(wrapper);
|
|
537
|
+
break;
|
|
538
|
+
case "document_confirm":
|
|
539
|
+
this.renderDocumentConfirm(wrapper);
|
|
540
|
+
break;
|
|
433
541
|
case "processing":
|
|
434
|
-
|
|
542
|
+
// C1: processing step is dead code — flow now goes to 'done' after submit
|
|
435
543
|
break;
|
|
436
544
|
case "liveness":
|
|
437
545
|
this.renderLiveness(wrapper);
|
|
438
546
|
break;
|
|
547
|
+
case "done":
|
|
548
|
+
this.renderDone(wrapper);
|
|
549
|
+
break;
|
|
439
550
|
case "result":
|
|
440
|
-
|
|
551
|
+
// C2: result step is dead code — showResult/renderResult removed
|
|
441
552
|
break;
|
|
442
553
|
case "error":
|
|
443
|
-
break;
|
|
554
|
+
break;
|
|
444
555
|
}
|
|
445
556
|
this.contentArea.appendChild(wrapper);
|
|
446
557
|
// Animate in
|
|
@@ -449,7 +560,7 @@ class SmartComplyFlow {
|
|
|
449
560
|
wrapper.style.transform = "translateX(0)";
|
|
450
561
|
});
|
|
451
562
|
}
|
|
452
|
-
//
|
|
563
|
+
// ── Loading ─────────────────────────────────────────────────────
|
|
453
564
|
renderLoading(container) {
|
|
454
565
|
container.style.cssText += "display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:16px;";
|
|
455
566
|
const spinnerWrap = document.createElement("div");
|
|
@@ -480,7 +591,7 @@ class SmartComplyFlow {
|
|
|
480
591
|
sub.textContent = "Connecting to the verification service...";
|
|
481
592
|
container.appendChild(sub);
|
|
482
593
|
}
|
|
483
|
-
//
|
|
594
|
+
// ── Welcome ────────────────────────────────────────────────────
|
|
484
595
|
renderWelcome(container) {
|
|
485
596
|
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:12px;";
|
|
486
597
|
const isDocFlow = this.isDocumentFlow();
|
|
@@ -495,7 +606,7 @@ class SmartComplyFlow {
|
|
|
495
606
|
background:linear-gradient(135deg,${this.theme.primary}22,${this.theme.primaryGlow});
|
|
496
607
|
border:1.5px solid ${this.theme.primary}33;
|
|
497
608
|
`;
|
|
498
|
-
iconWrap.textContent = isDocFlow ? "
|
|
609
|
+
iconWrap.textContent = isDocFlow ? "🪪" : "🛡ï¸";
|
|
499
610
|
topRow.appendChild(iconWrap);
|
|
500
611
|
const badge = document.createElement("div");
|
|
501
612
|
badge.style.cssText = `
|
|
@@ -506,7 +617,7 @@ class SmartComplyFlow {
|
|
|
506
617
|
color:${this.theme.primary};
|
|
507
618
|
border:1px solid ${this.theme.primary}30;
|
|
508
619
|
`;
|
|
509
|
-
badge.textContent = isDocFlow ? "
|
|
620
|
+
badge.textContent = isDocFlow ? "📄 Document Verification" : "🔠Data Verification";
|
|
510
621
|
topRow.appendChild(badge);
|
|
511
622
|
container.appendChild(topRow);
|
|
512
623
|
// Title + subtitle (compact)
|
|
@@ -517,7 +628,11 @@ class SmartComplyFlow {
|
|
|
517
628
|
color:${this.theme.text};font-size:20px;font-weight:700;
|
|
518
629
|
margin:0;line-height:1.2;
|
|
519
630
|
`;
|
|
520
|
-
|
|
631
|
+
// W2: Use client brand name in title
|
|
632
|
+
const brandName = this.sdkConfig?.brand_name;
|
|
633
|
+
title.textContent = brandName
|
|
634
|
+
? `${brandName} Identity Verification`
|
|
635
|
+
: "Verify Your Identity";
|
|
521
636
|
titleWrap.appendChild(title);
|
|
522
637
|
const desc = document.createElement("p");
|
|
523
638
|
desc.style.cssText = `
|
|
@@ -525,21 +640,21 @@ class SmartComplyFlow {
|
|
|
525
640
|
margin:0;max-width:300px;
|
|
526
641
|
`;
|
|
527
642
|
desc.textContent = isDocFlow
|
|
528
|
-
? "
|
|
529
|
-
: "
|
|
643
|
+
? "You'll need your document and camera access. This takes about 2 minutes."
|
|
644
|
+
: "You'll need your ID number and camera access. This takes about 2 minutes.";
|
|
530
645
|
titleWrap.appendChild(desc);
|
|
531
646
|
container.appendChild(titleWrap);
|
|
532
|
-
// Steps preview
|
|
647
|
+
// Steps preview — compact rows
|
|
533
648
|
const steps = isDocFlow
|
|
534
649
|
? [
|
|
535
|
-
{ icon: "
|
|
536
|
-
{ icon: "
|
|
537
|
-
{ icon: "
|
|
650
|
+
{ icon: "🪪", text: "Choose your document type", sub: "Passport, National ID, Driver License" },
|
|
651
|
+
{ icon: "📷", text: "Scan your document", sub: "Upload or capture a photo of your ID" },
|
|
652
|
+
{ icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
|
|
538
653
|
]
|
|
539
654
|
: [
|
|
540
|
-
{ icon: "
|
|
541
|
-
{ icon: "
|
|
542
|
-
{ icon: "
|
|
655
|
+
{ icon: "🆔", text: "Choose your ID type", sub: "NIN, BVN, Voter's Card and more" },
|
|
656
|
+
{ icon: "ðŸ“", text: "Enter your ID details", sub: "Exactly as shown on your ID" },
|
|
657
|
+
{ icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
|
|
543
658
|
];
|
|
544
659
|
const stepsWrap = document.createElement("div");
|
|
545
660
|
stepsWrap.style.cssText = `
|
|
@@ -580,21 +695,21 @@ class SmartComplyFlow {
|
|
|
580
695
|
});
|
|
581
696
|
container.appendChild(stepsWrap);
|
|
582
697
|
// CTA
|
|
583
|
-
const btn = this.createPrimaryButton("Get Started
|
|
698
|
+
const btn = this.createPrimaryButton("Get Started →");
|
|
584
699
|
btn.style.cssText += "margin-top:2px;";
|
|
585
700
|
btn.addEventListener("click", () => this.showStep("country"));
|
|
586
701
|
container.appendChild(btn);
|
|
587
|
-
// Trust badge
|
|
702
|
+
// Trust badge — "Powered by Adhere" already lives in the persistent
|
|
588
703
|
// modal footer below; repeating it here just cost vertical space.
|
|
589
704
|
const trust = document.createElement("div");
|
|
590
705
|
trust.style.cssText = `
|
|
591
706
|
display:flex;align-items:center;gap:6px;
|
|
592
707
|
color:${this.theme.textMuted};font-size:10.5px;
|
|
593
708
|
`;
|
|
594
|
-
trust.textContent = "
|
|
709
|
+
trust.textContent = "🔒 End-to-end encrypted";
|
|
595
710
|
container.appendChild(trust);
|
|
596
711
|
}
|
|
597
|
-
//
|
|
712
|
+
// ── Country Select ──────────────────────────────────────────────
|
|
598
713
|
renderCountrySelect(container) {
|
|
599
714
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
600
715
|
const channels = this.sdkConfig?.channels || {};
|
|
@@ -619,7 +734,7 @@ class SmartComplyFlow {
|
|
|
619
734
|
container.appendChild(card);
|
|
620
735
|
}
|
|
621
736
|
}
|
|
622
|
-
//
|
|
737
|
+
// ── ID Type Select ──────────────────────────────────────────────
|
|
623
738
|
renderIdTypeSelect(container) {
|
|
624
739
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
625
740
|
const globalDocFlow = this.isDocumentFlow();
|
|
@@ -647,9 +762,9 @@ class SmartComplyFlow {
|
|
|
647
762
|
// Add back side indicator to subtitle if needed
|
|
648
763
|
let subtitle = info.desc;
|
|
649
764
|
if (requiresBack && isThisDocFlow) {
|
|
650
|
-
subtitle += "
|
|
765
|
+
subtitle += " âš ï¸ Both sides required";
|
|
651
766
|
}
|
|
652
|
-
// Clean display name
|
|
767
|
+
// Clean display name — strip "with face", "(NIN)", "(BVN)" from user-visible label
|
|
653
768
|
const displayName = typeName
|
|
654
769
|
.replace(/\s*with\s+face\s*/gi, ' ')
|
|
655
770
|
.replace(/\s*\(NIN\)\s*/gi, ' ')
|
|
@@ -686,7 +801,7 @@ class SmartComplyFlow {
|
|
|
686
801
|
container.appendChild(card);
|
|
687
802
|
}
|
|
688
803
|
}
|
|
689
|
-
//
|
|
804
|
+
// ── ID Input (data verification — dynamic fields) ─────────────
|
|
690
805
|
renderIdInput(container) {
|
|
691
806
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
692
807
|
const dataFields = this.selectedChannelFields.filter((f) => f.type !== "upload");
|
|
@@ -705,7 +820,7 @@ class SmartComplyFlow {
|
|
|
705
820
|
? `Enter your ${this.selectedIdTypeName || this.selectedIdType} details exactly as they appear on your ID.`
|
|
706
821
|
: "Fill in the fields below to continue.";
|
|
707
822
|
container.appendChild(subtitle);
|
|
708
|
-
// Edge case: no data fields configured
|
|
823
|
+
// Edge case: no data fields configured — show fallback and allow submission
|
|
709
824
|
if (dataFields.length === 0) {
|
|
710
825
|
const empty = document.createElement("div");
|
|
711
826
|
empty.style.cssText = `
|
|
@@ -724,13 +839,13 @@ class SmartComplyFlow {
|
|
|
724
839
|
{ match: "BVN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "BVN must be exactly 11 digits" } },
|
|
725
840
|
{ match: "NIN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
|
|
726
841
|
{ match: "NATIONAL IDENTITY", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
|
|
727
|
-
{ match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6
|
|
728
|
-
{ match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3
|
|
729
|
-
{ match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6
|
|
730
|
-
{ match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8
|
|
731
|
-
{ match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7
|
|
732
|
-
{ match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8
|
|
733
|
-
{ match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10
|
|
842
|
+
{ match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6–20 alphanumeric characters" } },
|
|
843
|
+
{ match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3–30 alphanumeric characters" } },
|
|
844
|
+
{ match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6–30 alphanumeric characters" } },
|
|
845
|
+
{ match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8–20 alphanumeric characters" } },
|
|
846
|
+
{ match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7–15 alphanumeric characters" } },
|
|
847
|
+
{ match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8–20 characters" } },
|
|
848
|
+
{ match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10–12 characters" } },
|
|
734
849
|
];
|
|
735
850
|
function getFieldRule(label) {
|
|
736
851
|
const upper = label.toUpperCase();
|
|
@@ -758,7 +873,7 @@ class SmartComplyFlow {
|
|
|
758
873
|
// Track input elements for validation
|
|
759
874
|
const inputEls = [];
|
|
760
875
|
for (const field of dataFields) {
|
|
761
|
-
// Send the raw label as the payload key
|
|
876
|
+
// Send the raw label as the payload key — the backend's
|
|
762
877
|
// _SDK_FIELD_KEY_MAP looks up the exact label text (e.g. "National ID",
|
|
763
878
|
// "CAC Number") to remap it to the canonical IVS key. Pre-normalizing
|
|
764
879
|
// it here (as this used to do) produced keys like "national_id" that
|
|
@@ -808,7 +923,7 @@ class SmartComplyFlow {
|
|
|
808
923
|
inputEls.push({ field, el: select });
|
|
809
924
|
}
|
|
810
925
|
else if (field.type === "image" || field.type === "file") {
|
|
811
|
-
// File upload field
|
|
926
|
+
// File upload field — converts to base64 for API submission
|
|
812
927
|
const fileInput = document.createElement("input");
|
|
813
928
|
fileInput.type = "file";
|
|
814
929
|
fileInput.accept = "image/*";
|
|
@@ -948,12 +1063,25 @@ class SmartComplyFlow {
|
|
|
948
1063
|
fields: { ...this.collectedFields },
|
|
949
1064
|
});
|
|
950
1065
|
if (this.verificationResult.status === "success") {
|
|
951
|
-
// Save
|
|
1066
|
+
// Save identifier and identity_check_id for liveness step
|
|
952
1067
|
const fieldValues = Object.values(this.collectedFields);
|
|
953
1068
|
this.idNumber = fieldValues[0] || "";
|
|
954
|
-
// A4: Save identity_check_id from verify response to pass to liveness
|
|
955
1069
|
this.identityCheckId = this.verificationResult.data?.identity_check_id || null;
|
|
956
|
-
|
|
1070
|
+
// W3: Store name + gender from IVS result for confirmation screen
|
|
1071
|
+
const d = this.verificationResult.data || {};
|
|
1072
|
+
const nd = d.normalized_data || d.data || d;
|
|
1073
|
+
this._ivsName = [nd.first_name || nd.firstname || "", nd.last_name || nd.lastname || nd.surname || ""]
|
|
1074
|
+
.map(s => String(s || "").trim()).filter(Boolean).join(" ");
|
|
1075
|
+
this._ivsGender = nd.gender || "";
|
|
1076
|
+
// W4: If IVS returned no name, there is nothing for the user to confirm —
|
|
1077
|
+
// auto-proceed straight to Stage 2 (liveness) instead of showing a
|
|
1078
|
+
// confirmation screen with no identity details to check.
|
|
1079
|
+
if (!this._ivsName) {
|
|
1080
|
+
this.showStep("liveness");
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
// Route to confirmation screen — user must confirm before liveness
|
|
1084
|
+
this.showStep("confirm_identity");
|
|
957
1085
|
}
|
|
958
1086
|
else {
|
|
959
1087
|
errorEl.textContent = this.verificationResult.message || "We could not verify your details. Please check and try again.";
|
|
@@ -964,10 +1092,10 @@ class SmartComplyFlow {
|
|
|
964
1092
|
}
|
|
965
1093
|
}
|
|
966
1094
|
catch (err) {
|
|
967
|
-
// Show a single plain-language error
|
|
1095
|
+
// Show a single plain-language error — never raw field:message format
|
|
968
1096
|
const fieldErrors = err.data?.errors;
|
|
969
1097
|
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
|
|
970
|
-
// Pick the first meaningful error message
|
|
1098
|
+
// Pick the first meaningful error message — strip field prefix
|
|
971
1099
|
const firstMsg = fieldErrors[0]?.message || fieldErrors[0]?.detail || "";
|
|
972
1100
|
errorEl.textContent = _sanitizeUserMessage(firstMsg)
|
|
973
1101
|
|| "Please check your details and try again.";
|
|
@@ -998,7 +1126,7 @@ class SmartComplyFlow {
|
|
|
998
1126
|
setTimeout(() => firstInput.focus(), 350);
|
|
999
1127
|
}
|
|
1000
1128
|
}
|
|
1001
|
-
//
|
|
1129
|
+
// ── Document Capture ────────────────────────────────────────────
|
|
1002
1130
|
renderDocumentCapture(container) {
|
|
1003
1131
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
1004
1132
|
// Strip "with face", "(NIN)", "(BVN)" noise from the user-visible title
|
|
@@ -1039,105 +1167,201 @@ class SmartComplyFlow {
|
|
|
1039
1167
|
this.showErrorStep(err.message || "Document capture failed. Please try again.");
|
|
1040
1168
|
});
|
|
1041
1169
|
}
|
|
1042
|
-
//
|
|
1043
|
-
|
|
1044
|
-
container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:16px;padding:
|
|
1045
|
-
//
|
|
1170
|
+
// ── W3: Identity Confirmation (data verification) ───────────────
|
|
1171
|
+
renderConfirmIdentity(container) {
|
|
1172
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:8px 0;";
|
|
1173
|
+
// W14: Check retry limit before showing screen
|
|
1174
|
+
if (this._confirmRetries >= SmartComplyFlow.MAX_RETRIES) {
|
|
1175
|
+
this._renderRetryLimitReached(container);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
// W13: Pre-warm camera silently while user reads this screen
|
|
1179
|
+
this._prewarmCamera();
|
|
1180
|
+
const iconWrap = document.createElement("div");
|
|
1181
|
+
iconWrap.style.cssText = `width:64px;height:64px;border-radius:50%;background:${this.theme.primary}15;border:2px solid ${this.theme.primary}30;display:flex;align-items:center;justify-content:center;font-size:28px;`;
|
|
1182
|
+
iconWrap.textContent = "🛡ï¸";
|
|
1183
|
+
container.appendChild(iconWrap);
|
|
1184
|
+
const title = document.createElement("h2");
|
|
1185
|
+
title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1186
|
+
title.textContent = this._ivsName ? `Is this you?` : "Identity Confirmed";
|
|
1187
|
+
container.appendChild(title);
|
|
1188
|
+
if (this._ivsName) {
|
|
1189
|
+
// Profile card
|
|
1190
|
+
const card = document.createElement("div");
|
|
1191
|
+
card.style.cssText = `width:100%;padding:16px;border-radius:14px;background:${this.theme.cardBg};border:1.5px solid ${this.theme.border};text-align:left;display:flex;flex-direction:column;gap:10px;`;
|
|
1192
|
+
const rows = [
|
|
1193
|
+
{ label: "Name", value: this._ivsName },
|
|
1194
|
+
];
|
|
1195
|
+
if (this._ivsGender) {
|
|
1196
|
+
const gLabel = this._ivsGender.toLowerCase();
|
|
1197
|
+
rows.push({ label: "Gender", value: gLabel === "m" || gLabel === "male" || gLabel === "1" ? "Male" : gLabel === "f" || gLabel === "female" || gLabel === "2" ? "Female" : this._ivsGender });
|
|
1198
|
+
}
|
|
1199
|
+
rows.push({ label: "ID Type", value: _formatDocType(this.selectedIdType) || this.selectedIdTypeName || this.selectedIdType });
|
|
1200
|
+
rows.forEach(({ label, value }) => {
|
|
1201
|
+
const row = document.createElement("div");
|
|
1202
|
+
row.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
|
|
1203
|
+
const lEl = document.createElement("span");
|
|
1204
|
+
lEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;font-weight:500;`;
|
|
1205
|
+
lEl.textContent = label;
|
|
1206
|
+
const vEl = document.createElement("span");
|
|
1207
|
+
vEl.style.cssText = `color:${this.theme.text};font-size:13px;font-weight:600;max-width:60%;text-align:right;`;
|
|
1208
|
+
vEl.textContent = value;
|
|
1209
|
+
row.appendChild(lEl);
|
|
1210
|
+
row.appendChild(vEl);
|
|
1211
|
+
card.appendChild(row);
|
|
1212
|
+
});
|
|
1213
|
+
container.appendChild(card);
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
const sub = document.createElement("p");
|
|
1217
|
+
sub.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
|
|
1218
|
+
sub.textContent = "Your details have been confirmed. Please continue to the face check.";
|
|
1219
|
+
container.appendChild(sub);
|
|
1220
|
+
}
|
|
1221
|
+
const yesBtn = this.createPrimaryButton("Yes, that's me — continue");
|
|
1222
|
+
yesBtn.addEventListener("click", () => {
|
|
1223
|
+
// B5: Reset retry counter on successful confirmation
|
|
1224
|
+
this._confirmRetries = 0;
|
|
1225
|
+
this.showStep("liveness");
|
|
1226
|
+
});
|
|
1227
|
+
container.appendChild(yesBtn);
|
|
1228
|
+
const noBtn = this.createSecondaryButton("No, use a different ID");
|
|
1229
|
+
noBtn.addEventListener("click", () => {
|
|
1230
|
+
this._confirmRetries++;
|
|
1231
|
+
this._ivsName = "";
|
|
1232
|
+
this._ivsGender = "";
|
|
1233
|
+
this.identityCheckId = null;
|
|
1234
|
+
this.verificationResult = null;
|
|
1235
|
+
this.showStep("id_input");
|
|
1236
|
+
});
|
|
1237
|
+
container.appendChild(noBtn);
|
|
1238
|
+
}
|
|
1239
|
+
// ── W4: Document Confirmation (document verification) ────────────
|
|
1240
|
+
renderDocumentConfirm(container) {
|
|
1241
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:8px 0;";
|
|
1242
|
+
// W14: Check retry limit
|
|
1243
|
+
if (this._confirmRetries >= SmartComplyFlow.MAX_RETRIES) {
|
|
1244
|
+
this._renderRetryLimitReached(container);
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
// W13: Pre-warm camera while user reads
|
|
1248
|
+
this._prewarmCamera();
|
|
1249
|
+
const iconWrap = document.createElement("div");
|
|
1250
|
+
iconWrap.style.cssText = `width:64px;height:64px;border-radius:50%;background:${this.theme.primary}15;border:2px solid ${this.theme.primary}30;display:flex;align-items:center;justify-content:center;font-size:28px;`;
|
|
1251
|
+
iconWrap.textContent = "📄";
|
|
1252
|
+
container.appendChild(iconWrap);
|
|
1253
|
+
const title = document.createElement("h2");
|
|
1254
|
+
title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1255
|
+
title.textContent = "Are these your details?";
|
|
1256
|
+
container.appendChild(title);
|
|
1257
|
+
const sub = document.createElement("p");
|
|
1258
|
+
sub.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.5;`;
|
|
1259
|
+
sub.textContent = "We read the following from your document. Please confirm they are correct before continuing.";
|
|
1260
|
+
container.appendChild(sub);
|
|
1261
|
+
// Document face thumbnail if available
|
|
1262
|
+
if (this._ocrFaceImageUrl) {
|
|
1263
|
+
const faceWrap = document.createElement("div");
|
|
1264
|
+
faceWrap.style.cssText = `width:80px;height:80px;border-radius:50%;overflow:hidden;border:3px solid ${this.theme.primary};flex-shrink:0;margin:0 auto;`;
|
|
1265
|
+
const faceImg = document.createElement("img");
|
|
1266
|
+
faceImg.src = this._ocrFaceImageUrl;
|
|
1267
|
+
faceImg.alt = "Photo from your document";
|
|
1268
|
+
faceImg.style.cssText = "width:100%;height:100%;object-fit:cover;";
|
|
1269
|
+
faceWrap.appendChild(faceImg);
|
|
1270
|
+
container.appendChild(faceWrap);
|
|
1271
|
+
}
|
|
1272
|
+
// Expiry soon warning
|
|
1273
|
+
if (this._ocrExpirySoon && !this._ocrIsExpired) {
|
|
1274
|
+
const warn = document.createElement("div");
|
|
1275
|
+
warn.style.cssText = `width:100%;padding:10px 14px;border-radius:10px;background:${this.theme.warning}18;border:1px solid ${this.theme.warning}40;color:${this.theme.warning};font-size:12px;text-align:left;line-height:1.5;`;
|
|
1276
|
+
warn.textContent = "âš ï¸ Your document expires soon. Some services may not accept it, but you can still continue.";
|
|
1277
|
+
container.appendChild(warn);
|
|
1278
|
+
}
|
|
1279
|
+
// Details card — show whatever OCR returned, never block on missing fields
|
|
1280
|
+
const card = document.createElement("div");
|
|
1281
|
+
card.style.cssText = `width:100%;padding:14px 16px;border-radius:14px;background:${this.theme.cardBg};border:1.5px solid ${this.theme.border};text-align:left;display:flex;flex-direction:column;gap:10px;`;
|
|
1282
|
+
const docRows = [];
|
|
1283
|
+
if (this._ocrName)
|
|
1284
|
+
docRows.push({ label: "Name", value: this._ocrName });
|
|
1285
|
+
if (this._ocrDocNumber)
|
|
1286
|
+
docRows.push({ label: "Document No", value: this._ocrDocNumber });
|
|
1287
|
+
if (this._ocrExpiry)
|
|
1288
|
+
docRows.push({ label: "Expires", value: this._ocrExpiry });
|
|
1289
|
+
docRows.push({ label: "Document Type", value: _formatDocType(this.selectedIdType) || this.selectedIdTypeName });
|
|
1290
|
+
if (docRows.length === 0) {
|
|
1291
|
+
const fallback = document.createElement("div");
|
|
1292
|
+
fallback.style.cssText = `color:${this.theme.textSecondary};font-size:12px;`;
|
|
1293
|
+
fallback.textContent = "Document scanned successfully.";
|
|
1294
|
+
card.appendChild(fallback);
|
|
1295
|
+
}
|
|
1296
|
+
else {
|
|
1297
|
+
docRows.forEach(({ label, value }) => {
|
|
1298
|
+
if (!value)
|
|
1299
|
+
return;
|
|
1300
|
+
const row = document.createElement("div");
|
|
1301
|
+
row.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
|
|
1302
|
+
const lEl = document.createElement("span");
|
|
1303
|
+
lEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;font-weight:500;`;
|
|
1304
|
+
lEl.textContent = label;
|
|
1305
|
+
const vEl = document.createElement("span");
|
|
1306
|
+
vEl.style.cssText = `color:${this.theme.text};font-size:13px;font-weight:600;max-width:60%;text-align:right;`;
|
|
1307
|
+
vEl.textContent = value;
|
|
1308
|
+
row.appendChild(lEl);
|
|
1309
|
+
row.appendChild(vEl);
|
|
1310
|
+
card.appendChild(row);
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
container.appendChild(card);
|
|
1314
|
+
const yesBtn = this.createPrimaryButton("Yes, continue");
|
|
1315
|
+
yesBtn.addEventListener("click", () => {
|
|
1316
|
+
// B5: Reset retry counter on successful confirmation
|
|
1317
|
+
this._confirmRetries = 0;
|
|
1318
|
+
this.showStep("liveness");
|
|
1319
|
+
});
|
|
1320
|
+
container.appendChild(yesBtn);
|
|
1321
|
+
const noBtn = this.createSecondaryButton("No, retake document");
|
|
1322
|
+
noBtn.addEventListener("click", () => {
|
|
1323
|
+
this._confirmRetries++;
|
|
1324
|
+
this._ocrName = "";
|
|
1325
|
+
this._ocrDocNumber = "";
|
|
1326
|
+
this._ocrExpiry = "";
|
|
1327
|
+
this._ocrFaceImageUrl = "";
|
|
1328
|
+
this._ocrIsExpired = false;
|
|
1329
|
+
this._ocrExpirySoon = false;
|
|
1330
|
+
this.livenessEntryId = null;
|
|
1331
|
+
this.documentBlob = null;
|
|
1332
|
+
this.documentBackBlob = null;
|
|
1333
|
+
this.showStep("document_capture");
|
|
1334
|
+
});
|
|
1335
|
+
container.appendChild(noBtn);
|
|
1336
|
+
}
|
|
1337
|
+
// ── W6+W7+W8: OCR Gate (document verification) ───────────────────
|
|
1338
|
+
renderOcrGate(container) {
|
|
1339
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:14px;padding:20px 0;text-align:center;";
|
|
1046
1340
|
const spinnerWrap = document.createElement("div");
|
|
1047
|
-
spinnerWrap.style.cssText = "position:relative;width:
|
|
1341
|
+
spinnerWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
|
|
1048
1342
|
const ring = document.createElement("div");
|
|
1049
|
-
ring.id = "sc-proc-ring";
|
|
1050
1343
|
ring.style.cssText = `position:absolute;inset:0;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.9s linear infinite;`;
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1344
|
+
const centre = document.createElement("div");
|
|
1345
|
+
centre.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;";
|
|
1346
|
+
centre.textContent = "📄";
|
|
1054
1347
|
spinnerWrap.appendChild(ring);
|
|
1055
|
-
spinnerWrap.appendChild(
|
|
1348
|
+
spinnerWrap.appendChild(centre);
|
|
1056
1349
|
container.appendChild(spinnerWrap);
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
const
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
// Step checklist — reflects real pipeline stages
|
|
1070
|
-
const stages = [
|
|
1071
|
-
{ id: "ocr", label: "Reading document", icon: "📄" },
|
|
1072
|
-
{ id: "expiry", label: "Checking expiry", icon: "📅" },
|
|
1073
|
-
{ id: "face", label: "Face match", icon: "🤳" },
|
|
1074
|
-
{ id: "result", label: "Finalising result", icon: "✓" },
|
|
1075
|
-
];
|
|
1076
|
-
const grid = document.createElement("div");
|
|
1077
|
-
grid.style.cssText = "width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;";
|
|
1078
|
-
stages.forEach((s) => {
|
|
1079
|
-
const el = document.createElement("div");
|
|
1080
|
-
el.id = `sc-stage-${s.id}`;
|
|
1081
|
-
el.style.cssText = `display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;background:${this.theme.cardBg};border:1px solid ${this.theme.border};transition:all 0.3s ease;`;
|
|
1082
|
-
el.innerHTML = `
|
|
1083
|
-
<div style="width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;background:${this.theme.shimmer};">${s.icon}</div>
|
|
1084
|
-
<div style="flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;">${s.label}</div>
|
|
1085
|
-
<div id="sc-stage-status-${s.id}" style="width:14px;height:14px;border-radius:50%;border:2px solid ${this.theme.border};flex-shrink:0;"></div>
|
|
1086
|
-
`;
|
|
1087
|
-
grid.appendChild(el);
|
|
1088
|
-
});
|
|
1089
|
-
container.appendChild(grid);
|
|
1090
|
-
// Start polling immediately — update stages as result comes in
|
|
1091
|
-
if (entryId) {
|
|
1092
|
-
this._pollProcessingStages(entryId, statusLine, grid, stages);
|
|
1350
|
+
const statusEl = document.createElement("div");
|
|
1351
|
+
statusEl.setAttribute("aria-live", "polite");
|
|
1352
|
+
statusEl.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:600;`;
|
|
1353
|
+
statusEl.textContent = "Reading your document…";
|
|
1354
|
+
container.appendChild(statusEl);
|
|
1355
|
+
const subEl = document.createElement("div");
|
|
1356
|
+
subEl.style.cssText = `color:${this.theme.textSecondary};font-size:12px;max-width:260px;line-height:1.5;`;
|
|
1357
|
+
subEl.textContent = "This usually takes 5–15 seconds. Please wait.";
|
|
1358
|
+
container.appendChild(subEl);
|
|
1359
|
+
if (!this.livenessEntryId) {
|
|
1360
|
+
this.showErrorStep("Document upload failed. Please try again.");
|
|
1361
|
+
return;
|
|
1093
1362
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
// Animate stages sequentially on a timer — OCR takes ~5–15s,
|
|
1097
|
-
// face match another ~5–15s. We can't observe sub-steps from the
|
|
1098
|
-
// public sdk-result endpoint, so we advance stages on elapsed time
|
|
1099
|
-
// AND on the final status transition.
|
|
1100
|
-
const stageTimings = [0, 4000, 10000, 18000]; // ms after poll start
|
|
1101
|
-
const startTime = Date.now();
|
|
1102
|
-
const setStageActive = (stageId) => {
|
|
1103
|
-
const el = document.getElementById(`sc-stage-${stageId}`);
|
|
1104
|
-
const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
|
|
1105
|
-
if (!el || !statusEl)
|
|
1106
|
-
return;
|
|
1107
|
-
el.style.borderColor = this.theme.primary;
|
|
1108
|
-
el.style.background = `${this.theme.primary}10`;
|
|
1109
|
-
statusEl.innerHTML = `<div style="position:absolute;inset:1px;border-radius:50%;border:2px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>`;
|
|
1110
|
-
statusEl.style.position = "relative";
|
|
1111
|
-
};
|
|
1112
|
-
const setStageComplete = (stageId) => {
|
|
1113
|
-
const el = document.getElementById(`sc-stage-${stageId}`);
|
|
1114
|
-
const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
|
|
1115
|
-
if (!el || !statusEl)
|
|
1116
|
-
return;
|
|
1117
|
-
el.style.borderColor = this.theme.success || "#22c55e";
|
|
1118
|
-
el.style.background = `${this.theme.success || "#22c55e"}10`;
|
|
1119
|
-
statusEl.innerHTML = `<div style="position:absolute;inset:2px;border-radius:50%;background:${this.theme.success || "#22c55e"};display:flex;align-items:center;justify-content:center;color:#fff;font-size:8px;font-weight:bold;">✓</div>`;
|
|
1120
|
-
statusEl.style.position = "relative";
|
|
1121
|
-
};
|
|
1122
|
-
// Advance stage indicators on timer
|
|
1123
|
-
stages.forEach((s, i) => {
|
|
1124
|
-
setTimeout(() => {
|
|
1125
|
-
if (this.isDestroyed)
|
|
1126
|
-
return;
|
|
1127
|
-
setStageActive(s.id);
|
|
1128
|
-
if (i > 0)
|
|
1129
|
-
setStageComplete(stages[i - 1].id);
|
|
1130
|
-
statusLine.textContent = s.id === "ocr"
|
|
1131
|
-
? "Scanning document…"
|
|
1132
|
-
: s.id === "expiry"
|
|
1133
|
-
? "Checking expiry date…"
|
|
1134
|
-
: s.id === "face"
|
|
1135
|
-
? "Running face match…"
|
|
1136
|
-
: "Finalising result…";
|
|
1137
|
-
}, stageTimings[i]);
|
|
1138
|
-
});
|
|
1139
|
-
// Poll for real result
|
|
1140
|
-
const MAX_POLLS = 30; // 30 × 3s = 90s max
|
|
1363
|
+
const entryId = this.livenessEntryId;
|
|
1364
|
+
const MAX_POLLS = 10; // 10 × 2s = 20s timeout
|
|
1141
1365
|
let polls = 0;
|
|
1142
1366
|
const poll = async () => {
|
|
1143
1367
|
if (this.isDestroyed)
|
|
@@ -1145,48 +1369,206 @@ class SmartComplyFlow {
|
|
|
1145
1369
|
polls++;
|
|
1146
1370
|
try {
|
|
1147
1371
|
const result = await this.sdk.liveness.pollResult(entryId);
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1372
|
+
// W7: Expired document — stop, do not proceed to liveness
|
|
1373
|
+
if (result.is_expired === true) {
|
|
1374
|
+
container.innerHTML = "";
|
|
1375
|
+
this._renderExpiredDocument(container);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
// W8: OCR quality fail — offer retake
|
|
1379
|
+
if (result.ocr_quality_failed === true) {
|
|
1380
|
+
container.innerHTML = "";
|
|
1381
|
+
this._renderOcrQualityFail(container, result.ocr_rejection_reason);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
// OCR complete — store data and show confirmation screen
|
|
1385
|
+
const ocrDone = result.ocr_status === "done" || result.ocr_status === "ocr_complete"
|
|
1386
|
+
|| result.ocr_name || result.ocr_document_number || result.ocr_expiry_date;
|
|
1387
|
+
if (ocrDone) {
|
|
1388
|
+
this._ocrName = result.ocr_name || "";
|
|
1389
|
+
this._ocrDocNumber = result.ocr_document_number || "";
|
|
1390
|
+
this._ocrExpiry = result.ocr_expiry_date || "";
|
|
1391
|
+
this._ocrFaceImageUrl = result.ocr_face_image_url || "";
|
|
1392
|
+
this._ocrIsExpired = result.is_expired === true;
|
|
1393
|
+
// B8: Normalise expiry date string before parsing — OCR may return
|
|
1394
|
+
// DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD, or "12 May 2029" etc.
|
|
1395
|
+
// new Date() alone is locale-dependent on non-ISO strings.
|
|
1396
|
+
if (this._ocrExpiry) {
|
|
1397
|
+
const expDate = _parseOcrDate(this._ocrExpiry);
|
|
1398
|
+
if (expDate) {
|
|
1399
|
+
const sixMonths = new Date();
|
|
1400
|
+
sixMonths.setMonth(sixMonths.getMonth() + 6);
|
|
1401
|
+
this._ocrExpirySoon = expDate <= sixMonths && expDate > new Date();
|
|
1159
1402
|
}
|
|
1160
|
-
}
|
|
1403
|
+
}
|
|
1404
|
+
this.showStep("document_confirm");
|
|
1161
1405
|
return;
|
|
1162
1406
|
}
|
|
1407
|
+
// Still pending — keep polling
|
|
1408
|
+
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1409
|
+
this._ocrPollTimer = setTimeout(poll, 2000);
|
|
1410
|
+
}
|
|
1411
|
+
else if (!this.isDestroyed) {
|
|
1412
|
+
// Timeout — proceed anyway with whatever we have
|
|
1413
|
+
statusEl.textContent = "Taking longer than usual…";
|
|
1414
|
+
subEl.textContent = "We'll continue — you can confirm your details on the next screen.";
|
|
1415
|
+
this._ocrPollTimer = setTimeout(() => {
|
|
1416
|
+
if (!this.isDestroyed)
|
|
1417
|
+
this.showStep("document_confirm");
|
|
1418
|
+
}, 2000);
|
|
1419
|
+
}
|
|
1163
1420
|
}
|
|
1164
|
-
catch {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
statusLine.textContent = "Still processing — check back soon";
|
|
1421
|
+
catch {
|
|
1422
|
+
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1423
|
+
this._ocrPollTimer = setTimeout(poll, 3000);
|
|
1424
|
+
}
|
|
1425
|
+
else if (!this.isDestroyed) {
|
|
1426
|
+
this.showErrorStep("We could not read your document. Please check your connection and try again.");
|
|
1427
|
+
}
|
|
1172
1428
|
}
|
|
1173
1429
|
};
|
|
1174
|
-
// First poll after
|
|
1175
|
-
this.
|
|
1430
|
+
// First poll after 3s — give OCR submission time to start
|
|
1431
|
+
this._ocrPollTimer = setTimeout(poll, 3000);
|
|
1432
|
+
}
|
|
1433
|
+
_renderExpiredDocument(container) {
|
|
1434
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
|
|
1435
|
+
const icon = document.createElement("div");
|
|
1436
|
+
icon.style.cssText = `width:72px;height:72px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:28px;`;
|
|
1437
|
+
icon.textContent = "📅";
|
|
1438
|
+
container.appendChild(icon);
|
|
1439
|
+
const t = document.createElement("h2");
|
|
1440
|
+
t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1441
|
+
t.textContent = "Document Expired";
|
|
1442
|
+
container.appendChild(t);
|
|
1443
|
+
const d = document.createElement("p");
|
|
1444
|
+
d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
|
|
1445
|
+
d.textContent = "The document you submitted has expired. Please use a valid, current ID.";
|
|
1446
|
+
container.appendChild(d);
|
|
1447
|
+
const retryBtn = this.createPrimaryButton("Try a different document");
|
|
1448
|
+
retryBtn.addEventListener("click", () => {
|
|
1449
|
+
this.livenessEntryId = null;
|
|
1450
|
+
this.documentBlob = null;
|
|
1451
|
+
this.documentBackBlob = null;
|
|
1452
|
+
this.showStep("document_capture");
|
|
1453
|
+
});
|
|
1454
|
+
container.appendChild(retryBtn);
|
|
1455
|
+
const endBtn = this.createSecondaryButton("End session");
|
|
1456
|
+
endBtn.addEventListener("click", () => { this.options.onError?.(new Error("Document expired")); this.close(); });
|
|
1457
|
+
container.appendChild(endBtn);
|
|
1458
|
+
}
|
|
1459
|
+
_renderOcrQualityFail(container, reason) {
|
|
1460
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
|
|
1461
|
+
const icon = document.createElement("div");
|
|
1462
|
+
icon.style.cssText = `width:72px;height:72px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:28px;`;
|
|
1463
|
+
icon.textContent = "ðŸ”";
|
|
1464
|
+
container.appendChild(icon);
|
|
1465
|
+
const t = document.createElement("h2");
|
|
1466
|
+
t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1467
|
+
t.textContent = "Document Photo Unclear";
|
|
1468
|
+
container.appendChild(t);
|
|
1469
|
+
const msg = reason === "image_too_blurry" ? "Your document photo is too blurry. Please retake it in good lighting with a steady hand."
|
|
1470
|
+
: reason === "image_too_dark" ? "Your document photo is too dark. Please retake it in better lighting."
|
|
1471
|
+
: reason === "image_too_bright" ? "Your document photo has too much glare. Please reduce reflections and retake."
|
|
1472
|
+
: reason === "document_not_detected" ? "We could not detect a document in the photo. Please ensure the full ID is visible."
|
|
1473
|
+
: "We could not read your document clearly. Please retake the photo in good lighting with no glare.";
|
|
1474
|
+
const d = document.createElement("p");
|
|
1475
|
+
d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
|
|
1476
|
+
d.textContent = msg;
|
|
1477
|
+
container.appendChild(d);
|
|
1478
|
+
const retryBtn = this.createPrimaryButton("Retake photo");
|
|
1479
|
+
retryBtn.addEventListener("click", () => {
|
|
1480
|
+
this.livenessEntryId = null;
|
|
1481
|
+
this.documentBlob = null;
|
|
1482
|
+
this.documentBackBlob = null;
|
|
1483
|
+
this.showStep("document_capture");
|
|
1484
|
+
});
|
|
1485
|
+
container.appendChild(retryBtn);
|
|
1486
|
+
}
|
|
1487
|
+
// ── W11: You're all done (end-user final screen) ─────────────────
|
|
1488
|
+
renderDone(container) {
|
|
1489
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:24px 0;";
|
|
1490
|
+
container.setAttribute("role", "status");
|
|
1491
|
+
// B7: Fire onComplete immediately when the done screen renders — the end
|
|
1492
|
+
// user has completed their part regardless of whether they click Close.
|
|
1493
|
+
// Firing here means the integrator always gets the event even if the user
|
|
1494
|
+
// closes the modal via the ✕ button instead of the Close button.
|
|
1495
|
+
this.options.onComplete?.({
|
|
1496
|
+
entryId: this.livenessEntryId || 0,
|
|
1497
|
+
sessionId: this.sdk.sessionToken,
|
|
1498
|
+
status: "processing",
|
|
1499
|
+
submittedAt: new Date().toISOString(),
|
|
1500
|
+
verificationResult: this.verificationResult || undefined,
|
|
1501
|
+
});
|
|
1502
|
+
const iconWrap = document.createElement("div");
|
|
1503
|
+
iconWrap.style.cssText = `width:80px;height:80px;border-radius:50%;background:${this.theme.successBg};border:3px solid ${this.theme.success};display:flex;align-items:center;justify-content:center;font-size:36px;font-weight:700;color:${this.theme.success};animation:sc-pop 0.4s ease;`;
|
|
1504
|
+
iconWrap.textContent = "✓";
|
|
1505
|
+
container.appendChild(iconWrap);
|
|
1506
|
+
const title = document.createElement("h2");
|
|
1507
|
+
title.style.cssText = `color:${this.theme.text};font-size:22px;font-weight:700;margin:0;`;
|
|
1508
|
+
title.textContent = "You're all done!";
|
|
1509
|
+
container.appendChild(title);
|
|
1510
|
+
const brandName = this.sdkConfig?.brand_name || "the service";
|
|
1511
|
+
const desc = document.createElement("p");
|
|
1512
|
+
desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
|
|
1513
|
+
desc.textContent = `Your verification is complete. ${brandName} will review and be in touch shortly.`;
|
|
1514
|
+
container.appendChild(desc);
|
|
1515
|
+
// Subtle info line
|
|
1516
|
+
const info = document.createElement("div");
|
|
1517
|
+
info.style.cssText = `color:${this.theme.textMuted};font-size:11px;display:flex;align-items:center;gap:4px;`;
|
|
1518
|
+
info.textContent = "🔒 Your data is encrypted and secure";
|
|
1519
|
+
container.appendChild(info);
|
|
1520
|
+
const doneBtn = this.createPrimaryButton("Close");
|
|
1521
|
+
doneBtn.addEventListener("click", () => {
|
|
1522
|
+
// onComplete already fired on render — just close the modal
|
|
1523
|
+
this.close();
|
|
1524
|
+
});
|
|
1525
|
+
container.appendChild(doneBtn);
|
|
1526
|
+
}
|
|
1527
|
+
// ── W14: Retry limit reached ─────────────────────────────────────
|
|
1528
|
+
_renderRetryLimitReached(container) {
|
|
1529
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:24px 0;";
|
|
1530
|
+
const icon = document.createElement("div");
|
|
1531
|
+
icon.style.cssText = `width:72px;height:72px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:28px;`;
|
|
1532
|
+
icon.textContent = "âš ï¸";
|
|
1533
|
+
container.appendChild(icon);
|
|
1534
|
+
const t = document.createElement("h2");
|
|
1535
|
+
t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1536
|
+
t.textContent = "Unable to Complete Verification";
|
|
1537
|
+
container.appendChild(t);
|
|
1538
|
+
const d = document.createElement("p");
|
|
1539
|
+
d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
|
|
1540
|
+
d.textContent = "We were unable to complete your verification at this time. Please contact support or try again later.";
|
|
1541
|
+
container.appendChild(d);
|
|
1542
|
+
const closeBtn = this.createPrimaryButton("Close");
|
|
1543
|
+
closeBtn.addEventListener("click", () => { this.options.onError?.(new Error("Retry limit reached")); this.close(); });
|
|
1544
|
+
container.appendChild(closeBtn);
|
|
1545
|
+
}
|
|
1546
|
+
// ── W13: Camera pre-warm ─────────────────────────────────────────
|
|
1547
|
+
_prewarmCamera() {
|
|
1548
|
+
if (this._prewarmedStream || this.isDestroyed)
|
|
1549
|
+
return;
|
|
1550
|
+
navigator.mediaDevices?.getUserMedia?.({ video: { facingMode: "user" } })
|
|
1551
|
+
.then(stream => {
|
|
1552
|
+
if (this.isDestroyed) {
|
|
1553
|
+
stream.getTracks().forEach(t => t.stop());
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
this._prewarmedStream = stream;
|
|
1557
|
+
})
|
|
1558
|
+
.catch(() => { });
|
|
1176
1559
|
}
|
|
1177
1560
|
/**
|
|
1178
1561
|
* Process document capture result with front/back support
|
|
1179
1562
|
*/
|
|
1180
1563
|
async processDocumentCaptureResult(result, captureContainer, container) {
|
|
1181
|
-
//
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
//
|
|
1185
|
-
await
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
}
|
|
1564
|
+
// B2+P2: Compress both images once here. Do NOT assign documentBlob twice
|
|
1565
|
+
// (old code set it to the raw blob first, then overwrote with compressed).
|
|
1566
|
+
// processDocumentBlob also ran _compressImage — that second pass is removed
|
|
1567
|
+
// below so compression only happens once.
|
|
1568
|
+
this.documentBlob = await _compressImage(result.front, 1600, 0.85);
|
|
1569
|
+
this.documentBackBlob = result.back ? await _compressImage(result.back, 1600, 0.85) : null;
|
|
1570
|
+
// Process the front image — pass the already-compressed blob
|
|
1571
|
+
await this.processDocumentBlob(this.documentBlob, captureContainer, container);
|
|
1190
1572
|
}
|
|
1191
1573
|
/**
|
|
1192
1574
|
* Process document blob - common method for both CameraView and DocumentCapture
|
|
@@ -1196,7 +1578,7 @@ class SmartComplyFlow {
|
|
|
1196
1578
|
let retryCount = 0;
|
|
1197
1579
|
const attemptProcessing = async () => {
|
|
1198
1580
|
try {
|
|
1199
|
-
// Validate blob
|
|
1581
|
+
// Validate blob — compression already done in processDocumentCaptureResult (P2)
|
|
1200
1582
|
if (!blob || blob.size === 0) {
|
|
1201
1583
|
throw new Error("Failed to capture document. Please try again.");
|
|
1202
1584
|
}
|
|
@@ -1209,7 +1591,7 @@ class SmartComplyFlow {
|
|
|
1209
1591
|
<div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:28px 20px;">
|
|
1210
1592
|
<div style="position:relative;width:52px;height:52px;">
|
|
1211
1593
|
<div style="position:absolute;inset:0;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>
|
|
1212
|
-
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;"
|
|
1594
|
+
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">ðŸ”</div>
|
|
1213
1595
|
</div>
|
|
1214
1596
|
<div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
|
|
1215
1597
|
<div style="color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:200px;line-height:1.5;">Running OCR and verifying your identity</div>
|
|
@@ -1217,7 +1599,7 @@ class SmartComplyFlow {
|
|
|
1217
1599
|
// Create liveness entry with document.
|
|
1218
1600
|
// Use the first collected field value as identifier when available
|
|
1219
1601
|
// (document flow has no data-verification step, so collectedFields is
|
|
1220
|
-
// empty
|
|
1602
|
+
// empty — fall back to the selected ID type code so the webhook
|
|
1221
1603
|
// subject.identifier is at least human-meaningful, not a raw timestamp).
|
|
1222
1604
|
const identifier = Object.values(this.collectedFields)[0]
|
|
1223
1605
|
|| this.idNumber
|
|
@@ -1266,10 +1648,9 @@ class SmartComplyFlow {
|
|
|
1266
1648
|
}
|
|
1267
1649
|
}
|
|
1268
1650
|
this.livenessEntryId = createResult.id;
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
|
|
1272
|
-
this.showStep("liveness");
|
|
1651
|
+
// W6: Route to OCR gate — poll until OCR result is ready, validate
|
|
1652
|
+
// expiry/quality, then show document_confirm before liveness starts.
|
|
1653
|
+
this.showStep("ocr_gate");
|
|
1273
1654
|
}
|
|
1274
1655
|
catch (err) {
|
|
1275
1656
|
console.error("Document processing failed:", err);
|
|
@@ -1282,7 +1663,7 @@ class SmartComplyFlow {
|
|
|
1282
1663
|
<div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
|
|
1283
1664
|
<div style="position:relative;width:52px;height:52px;">
|
|
1284
1665
|
<div style="position:absolute;inset:0;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>
|
|
1285
|
-
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;"
|
|
1666
|
+
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔄</div>
|
|
1286
1667
|
</div>
|
|
1287
1668
|
<div style="color:${this.theme.text};font-size:14px;font-weight:600;">Retrying...</div>
|
|
1288
1669
|
<div style="color:${this.theme.textSecondary};font-size:12px;max-width:240px;line-height:1.5;">Attempt ${retryCount} of ${maxRetries}</div>
|
|
@@ -1301,7 +1682,7 @@ class SmartComplyFlow {
|
|
|
1301
1682
|
await attemptProcessing();
|
|
1302
1683
|
}
|
|
1303
1684
|
handleDocumentProcessingError(error, captureContainer) {
|
|
1304
|
-
// Map technical errors to user-friendly messages
|
|
1685
|
+
// Map technical errors to user-friendly messages — never pass raw error strings
|
|
1305
1686
|
let userMessage = "We could not verify your document. Please try again.";
|
|
1306
1687
|
let canRetry = true;
|
|
1307
1688
|
const raw = (error?.message || "").toLowerCase();
|
|
@@ -1328,13 +1709,13 @@ class SmartComplyFlow {
|
|
|
1328
1709
|
userMessage = "The document does not match the selected ID type. Please choose the correct ID type.";
|
|
1329
1710
|
}
|
|
1330
1711
|
if (captureContainer) {
|
|
1331
|
-
// Build with DOM
|
|
1712
|
+
// Build with DOM — never interpolate user-derived content into innerHTML
|
|
1332
1713
|
const wrap = document.createElement("div");
|
|
1333
1714
|
wrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;";
|
|
1334
1715
|
wrap.setAttribute("role", "alert");
|
|
1335
1716
|
const icon = document.createElement("div");
|
|
1336
1717
|
icon.style.cssText = `width:56px;height:56px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:26px;`;
|
|
1337
|
-
icon.textContent = "
|
|
1718
|
+
icon.textContent = "ðŸ”";
|
|
1338
1719
|
wrap.appendChild(icon);
|
|
1339
1720
|
const titleEl = document.createElement("div");
|
|
1340
1721
|
titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:700;`;
|
|
@@ -1359,9 +1740,14 @@ class SmartComplyFlow {
|
|
|
1359
1740
|
this.showErrorStep(userMessage);
|
|
1360
1741
|
}
|
|
1361
1742
|
}
|
|
1362
|
-
//
|
|
1743
|
+
// ── Liveness ────────────────────────────────────────────────────
|
|
1363
1744
|
renderLiveness(container) {
|
|
1364
1745
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
1746
|
+
// W13: Check retry limit before starting another liveness attempt
|
|
1747
|
+
if (this._livenessFailures >= SmartComplyFlow.MAX_RETRIES) {
|
|
1748
|
+
this._renderRetryLimitReached(container);
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1365
1751
|
const title = this.createStepTitle("Face verification");
|
|
1366
1752
|
container.appendChild(title);
|
|
1367
1753
|
const desc = document.createElement("div");
|
|
@@ -1372,18 +1758,21 @@ class SmartComplyFlow {
|
|
|
1372
1758
|
const livenessContainer = document.createElement("div");
|
|
1373
1759
|
livenessContainer.style.cssText = "margin:8px 0;width:100%;";
|
|
1374
1760
|
container.appendChild(livenessContainer);
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1761
|
+
// W9: For data verification, identity is already held by the backend via
|
|
1762
|
+
// identity_check_id — the real ID number/type must not be re-sent. The
|
|
1763
|
+
// backend's LivenessEntrySerializer still requires non-blank identifier /
|
|
1764
|
+
// identifier_type fields, so a non-identifying stub is sent to satisfy that
|
|
1765
|
+
// constraint without leaking the user's actual ID data a second time.
|
|
1766
|
+
// "SES-" + Date.now() (13 digits) = 17 chars, safely under the backend's
|
|
1767
|
+
// 20-char max on identifier.
|
|
1768
|
+
const isDataVerification = !!this.identityCheckId;
|
|
1769
|
+
const identifier = isDataVerification
|
|
1770
|
+
? `SES-${Date.now()}`
|
|
1771
|
+
: (this.idNumber || Object.values(this.collectedFields)[0] || this.selectedIdType || `DOC-${Date.now()}`);
|
|
1772
|
+
const identifierType = isDataVerification ? "session" : this.selectedIdType;
|
|
1379
1773
|
const COUNTRY_CODES = {
|
|
1380
|
-
nigeria: "NG",
|
|
1381
|
-
|
|
1382
|
-
kenya: "KE",
|
|
1383
|
-
south_africa: "ZA",
|
|
1384
|
-
united_states: "US",
|
|
1385
|
-
united_kingdom: "GB",
|
|
1386
|
-
global: "US",
|
|
1774
|
+
nigeria: "NG", ghana: "GH", kenya: "KE",
|
|
1775
|
+
south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
|
|
1387
1776
|
};
|
|
1388
1777
|
const country = COUNTRY_CODES[this.selectedCountry] || this.selectedCountry.toUpperCase().slice(0, 2);
|
|
1389
1778
|
// Start liveness check (this handles camera, detection, recording, submission)
|
|
@@ -1391,277 +1780,40 @@ class SmartComplyFlow {
|
|
|
1391
1780
|
this.sdk.liveness
|
|
1392
1781
|
.startCheck(livenessContainer, {
|
|
1393
1782
|
identifier,
|
|
1394
|
-
identifier_type:
|
|
1783
|
+
identifier_type: identifierType,
|
|
1395
1784
|
country,
|
|
1396
1785
|
document: this.documentBlob || undefined,
|
|
1397
1786
|
document_back: this.documentBackBlob || undefined,
|
|
1398
1787
|
identity_check: this.identityCheckId || undefined,
|
|
1399
|
-
// Tell the module which flow we're in so it can skip video for data verification
|
|
1400
1788
|
verification_type: this.identityCheckId
|
|
1401
1789
|
? "data_verification"
|
|
1402
1790
|
: this.documentBlob
|
|
1403
1791
|
? "document_verification"
|
|
1404
1792
|
: "liveness_only",
|
|
1793
|
+
// B4: pass pre-warmed stream so camera.open() is skipped
|
|
1794
|
+
prewarmedStream: this._prewarmedStream || undefined,
|
|
1405
1795
|
}, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
|
|
1406
1796
|
.then((result) => {
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
//
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
this.livenessEntryId = result.id;
|
|
1413
|
-
this.showStep("processing");
|
|
1797
|
+
// W11: After submit, end user is done — show "You're all done" screen.
|
|
1798
|
+
// Backend processing continues in background; client receives webhook.
|
|
1799
|
+
// Track liveness failures for retry limit (W14).
|
|
1800
|
+
if (result.status === "failed") {
|
|
1801
|
+
this._livenessFailures++;
|
|
1414
1802
|
}
|
|
1415
|
-
|
|
1416
|
-
this.
|
|
1803
|
+
if (result.id) {
|
|
1804
|
+
this.livenessEntryId = result.id;
|
|
1417
1805
|
}
|
|
1806
|
+
this.showStep("done");
|
|
1418
1807
|
})
|
|
1419
1808
|
.catch((err) => {
|
|
1809
|
+
this._livenessFailures++;
|
|
1420
1810
|
this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
|
|
1421
1811
|
});
|
|
1422
1812
|
}
|
|
1423
|
-
// ── Result ──────────────────────────────────────────────────────
|
|
1424
|
-
showResult(submitResult) {
|
|
1425
|
-
this.currentStep = "result";
|
|
1426
|
-
this.updateProgress();
|
|
1427
|
-
if (!this.contentArea)
|
|
1428
|
-
return;
|
|
1429
|
-
this.contentArea.innerHTML = "";
|
|
1430
|
-
const wrapper = document.createElement("div");
|
|
1431
|
-
wrapper.setAttribute("role", "region");
|
|
1432
|
-
wrapper.setAttribute("aria-label", "Verification result");
|
|
1433
|
-
wrapper.style.cssText = `
|
|
1434
|
-
display:flex;flex-direction:column;align-items:center;
|
|
1435
|
-
text-align:center;gap:16px;padding:20px 0;
|
|
1436
|
-
opacity:0;transform:scale(0.95);transition:all 0.4s ease;
|
|
1437
|
-
`;
|
|
1438
|
-
// ── Icon — spinner until status resolves ────────────────────────
|
|
1439
|
-
const iconWrap = document.createElement("div");
|
|
1440
|
-
iconWrap.style.cssText = `
|
|
1441
|
-
width:80px;height:80px;border-radius:50%;
|
|
1442
|
-
display:flex;align-items:center;justify-content:center;
|
|
1443
|
-
font-size:36px;
|
|
1444
|
-
background:${this.theme.cardBg};
|
|
1445
|
-
border:3px solid ${this.theme.border};
|
|
1446
|
-
transition:all 0.4s ease;
|
|
1447
|
-
`;
|
|
1448
|
-
iconWrap.innerHTML = `<div style="width:32px;height:32px;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.9s linear infinite;"></div>`;
|
|
1449
|
-
wrapper.appendChild(iconWrap);
|
|
1450
|
-
// ── Title and description — updated by polling ───────────────────
|
|
1451
|
-
const title = document.createElement("h2");
|
|
1452
|
-
title.setAttribute("aria-live", "polite");
|
|
1453
|
-
title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
|
|
1454
|
-
title.textContent = "Checking your identity…";
|
|
1455
|
-
wrapper.appendChild(title);
|
|
1456
|
-
const desc = document.createElement("p");
|
|
1457
|
-
desc.setAttribute("aria-live", "polite");
|
|
1458
|
-
desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
|
|
1459
|
-
desc.textContent = "This usually takes a few seconds. Please wait.";
|
|
1460
|
-
wrapper.appendChild(desc);
|
|
1461
|
-
// ── Result card — initially shows status only ────────────────────
|
|
1462
|
-
// Populated with name/doc type when verification completes.
|
|
1463
|
-
const resultCard = document.createElement("div");
|
|
1464
|
-
resultCard.style.cssText = `
|
|
1465
|
-
width:100%;padding:16px;border-radius:12px;
|
|
1466
|
-
background:${this.theme.cardBg};border:1px solid ${this.theme.border};
|
|
1467
|
-
text-align:left;display:flex;flex-direction:column;gap:10px;
|
|
1468
|
-
transition:border-color 0.3s ease;
|
|
1469
|
-
`;
|
|
1470
|
-
// Status row — always shown
|
|
1471
|
-
const statusBadge = document.createElement("span");
|
|
1472
|
-
statusBadge.setAttribute("role", "status");
|
|
1473
|
-
statusBadge.setAttribute("aria-live", "polite");
|
|
1474
|
-
statusBadge.setAttribute("aria-atomic", "true");
|
|
1475
|
-
statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
|
|
1476
|
-
statusBadge.textContent = "Processing…";
|
|
1477
|
-
const statusRow = document.createElement("div");
|
|
1478
|
-
statusRow.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
|
|
1479
|
-
statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Status</span>`;
|
|
1480
|
-
statusRow.appendChild(statusBadge);
|
|
1481
|
-
resultCard.appendChild(statusRow);
|
|
1482
|
-
// Verified details — shown only when passed.
|
|
1483
|
-
// nameRow: shows full name from IVS result or OCR
|
|
1484
|
-
// docTypeRow: shows document type verified
|
|
1485
|
-
const nameRow = document.createElement("div");
|
|
1486
|
-
nameRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
|
|
1487
|
-
nameRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Name</span><span id="sc-result-name" style="color:${this.theme.text};font-size:12px;font-weight:600;text-align:right;max-width:60%;"></span>`;
|
|
1488
|
-
resultCard.appendChild(nameRow);
|
|
1489
|
-
const docTypeRow = document.createElement("div");
|
|
1490
|
-
docTypeRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
|
|
1491
|
-
docTypeRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">ID Type</span><span id="sc-result-doctype" style="color:${this.theme.text};font-size:12px;font-weight:600;"></span>`;
|
|
1492
|
-
resultCard.appendChild(docTypeRow);
|
|
1493
|
-
wrapper.appendChild(resultCard);
|
|
1494
|
-
// ── Failure reason — shown only when failed ──────────────────────
|
|
1495
|
-
const failureMsg = document.createElement("div");
|
|
1496
|
-
failureMsg.setAttribute("role", "alert");
|
|
1497
|
-
failureMsg.style.cssText = `
|
|
1498
|
-
display:none;width:100%;padding:12px 14px;border-radius:10px;
|
|
1499
|
-
background:${this.theme.errorBg};border:1px solid ${this.theme.error};
|
|
1500
|
-
color:${this.theme.error};font-size:13px;text-align:left;line-height:1.6;
|
|
1501
|
-
`;
|
|
1502
|
-
wrapper.appendChild(failureMsg);
|
|
1503
|
-
// ── Action buttons ───────────────────────────────────────────────
|
|
1504
|
-
const btnRow = document.createElement("div");
|
|
1505
|
-
btnRow.style.cssText = "display:flex;flex-direction:column;gap:8px;width:100%;";
|
|
1506
|
-
const doneBtn = this.createPrimaryButton("Done");
|
|
1507
|
-
doneBtn.setAttribute("aria-label", "Close verification and continue");
|
|
1508
|
-
let finalStatus = submitResult.status;
|
|
1509
|
-
doneBtn.addEventListener("click", () => {
|
|
1510
|
-
if (this._pollStatusTimer) {
|
|
1511
|
-
clearTimeout(this._pollStatusTimer);
|
|
1512
|
-
this._pollStatusTimer = null;
|
|
1513
|
-
}
|
|
1514
|
-
this.options.onComplete?.({
|
|
1515
|
-
entryId: submitResult.id,
|
|
1516
|
-
sessionId: this.sdk.sessionToken,
|
|
1517
|
-
status: finalStatus,
|
|
1518
|
-
submittedAt: submitResult.submitted_at,
|
|
1519
|
-
verificationResult: this.verificationResult || undefined,
|
|
1520
|
-
});
|
|
1521
|
-
this.close();
|
|
1522
|
-
});
|
|
1523
|
-
btnRow.appendChild(doneBtn);
|
|
1524
|
-
// "Try again" button — shown only on failure, hidden initially
|
|
1525
|
-
const retryBtn = this.createSecondaryButton("Try Again");
|
|
1526
|
-
retryBtn.setAttribute("aria-label", "Start verification again");
|
|
1527
|
-
retryBtn.style.display = "none";
|
|
1528
|
-
retryBtn.addEventListener("click", () => {
|
|
1529
|
-
if (this._pollStatusTimer) {
|
|
1530
|
-
clearTimeout(this._pollStatusTimer);
|
|
1531
|
-
this._pollStatusTimer = null;
|
|
1532
|
-
}
|
|
1533
|
-
this.initialize();
|
|
1534
|
-
});
|
|
1535
|
-
btnRow.appendChild(retryBtn);
|
|
1536
|
-
wrapper.appendChild(btnRow);
|
|
1537
|
-
this.contentArea.appendChild(wrapper);
|
|
1538
|
-
requestAnimationFrame(() => {
|
|
1539
|
-
wrapper.style.opacity = "1";
|
|
1540
|
-
wrapper.style.transform = "scale(1)";
|
|
1541
|
-
});
|
|
1542
|
-
// ── Start polling ────────────────────────────────────────────────
|
|
1543
|
-
this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, (resolved) => { finalStatus = resolved; });
|
|
1544
|
-
}
|
|
1545
|
-
_startStatusPolling(entryId, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, onResolved) {
|
|
1546
|
-
const MAX_POLLS = 25; // 25 × 3s = 75s + 4s initial ≈ 79s max
|
|
1547
|
-
let polls = 0;
|
|
1548
|
-
// Hard 30s UI timer — stop spinner and tell user to wait even if backend is slower
|
|
1549
|
-
let uiTimeoutId = setTimeout(() => {
|
|
1550
|
-
if (!this.isDestroyed)
|
|
1551
|
-
applyTimeout();
|
|
1552
|
-
}, 30000);
|
|
1553
|
-
const clearUiTimeout = () => {
|
|
1554
|
-
if (uiTimeoutId) {
|
|
1555
|
-
clearTimeout(uiTimeoutId);
|
|
1556
|
-
uiTimeoutId = null;
|
|
1557
|
-
}
|
|
1558
|
-
};
|
|
1559
|
-
const applyTimeout = () => {
|
|
1560
|
-
if (this._pollStatusTimer) {
|
|
1561
|
-
clearTimeout(this._pollStatusTimer);
|
|
1562
|
-
this._pollStatusTimer = null;
|
|
1563
|
-
}
|
|
1564
|
-
iconWrap.innerHTML = "⏳";
|
|
1565
|
-
iconWrap.style.border = `3px solid ${this.theme.warning}`;
|
|
1566
|
-
statusBadge.style.color = this.theme.textMuted;
|
|
1567
|
-
statusBadge.textContent = "Still processing…";
|
|
1568
|
-
title.textContent = "Still verifying";
|
|
1569
|
-
desc.textContent = "This is taking a little longer than usual. You can safely close this — your result will be available in your account.";
|
|
1570
|
-
};
|
|
1571
|
-
const applyPassed = (result) => {
|
|
1572
|
-
clearUiTimeout();
|
|
1573
|
-
iconWrap.innerHTML = "✓";
|
|
1574
|
-
iconWrap.style.cssText += `background:${this.theme.successBg};border:3px solid ${this.theme.success};color:${this.theme.success};font-size:36px;font-weight:700;`;
|
|
1575
|
-
resultCard.style.borderColor = this.theme.success;
|
|
1576
|
-
statusBadge.style.color = this.theme.success;
|
|
1577
|
-
statusBadge.textContent = "Verified ✓";
|
|
1578
|
-
title.textContent = "Identity Verified";
|
|
1579
|
-
desc.textContent = "Your identity has been confirmed successfully.";
|
|
1580
|
-
// ── Show name if available ───────────────────────────────────────
|
|
1581
|
-
// Pull from IVS customer_profile (data verification) or
|
|
1582
|
-
// document OCR result (document verification) — whichever has data.
|
|
1583
|
-
const profile = result?.customer_profile || result?.data || {};
|
|
1584
|
-
const docResult = result?.document || {};
|
|
1585
|
-
const ocr = docResult?.ocr_result || {};
|
|
1586
|
-
const firstName = (profile?.first_name || ocr?.first_name || docResult?.first_name || "").trim();
|
|
1587
|
-
const lastName = (profile?.last_name || profile?.surname ||
|
|
1588
|
-
ocr?.last_name || ocr?.surname || docResult?.last_name || "").trim();
|
|
1589
|
-
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
|
1590
|
-
const nameEl = document.getElementById("sc-result-name");
|
|
1591
|
-
if (fullName && nameEl) {
|
|
1592
|
-
nameEl.textContent = fullName;
|
|
1593
|
-
nameRow.style.display = "flex";
|
|
1594
|
-
}
|
|
1595
|
-
// ── Show ID type ─────────────────────────────────────────────────
|
|
1596
|
-
const rawDocType = (docResult?.document_type ||
|
|
1597
|
-
this.selectedIdType ||
|
|
1598
|
-
result?.identifier_type ||
|
|
1599
|
-
"");
|
|
1600
|
-
const docTypeLabel = _formatDocType(rawDocType);
|
|
1601
|
-
const docTypeEl = document.getElementById("sc-result-doctype");
|
|
1602
|
-
if (docTypeLabel && docTypeEl) {
|
|
1603
|
-
docTypeEl.textContent = docTypeLabel;
|
|
1604
|
-
docTypeRow.style.display = "flex";
|
|
1605
|
-
}
|
|
1606
|
-
};
|
|
1607
|
-
const applyFailed = (reason) => {
|
|
1608
|
-
clearUiTimeout();
|
|
1609
|
-
iconWrap.innerHTML = "✕";
|
|
1610
|
-
iconWrap.style.cssText += `background:${this.theme.errorBg};border:3px solid ${this.theme.error};color:${this.theme.error};font-size:32px;`;
|
|
1611
|
-
resultCard.style.borderColor = this.theme.error;
|
|
1612
|
-
statusBadge.style.color = this.theme.error;
|
|
1613
|
-
statusBadge.textContent = "Not verified";
|
|
1614
|
-
title.textContent = "Verification Unsuccessful";
|
|
1615
|
-
desc.textContent = "We were unable to confirm your identity this time.";
|
|
1616
|
-
// Show a clean, actionable reason — never raw technical strings
|
|
1617
|
-
const safeReason = _sanitizeUserMessage(reason);
|
|
1618
|
-
if (safeReason) {
|
|
1619
|
-
failureMsg.textContent = safeReason;
|
|
1620
|
-
failureMsg.style.display = "block";
|
|
1621
|
-
}
|
|
1622
|
-
// Show retry button on failure
|
|
1623
|
-
retryBtn.style.display = "block";
|
|
1624
|
-
};
|
|
1625
|
-
const poll = async () => {
|
|
1626
|
-
if (this.isDestroyed)
|
|
1627
|
-
return;
|
|
1628
|
-
polls++;
|
|
1629
|
-
try {
|
|
1630
|
-
const result = await this.sdk.liveness.pollResult(entryId);
|
|
1631
|
-
if (result.status === "passed") {
|
|
1632
|
-
onResolved("passed");
|
|
1633
|
-
applyPassed(result);
|
|
1634
|
-
return;
|
|
1635
|
-
}
|
|
1636
|
-
if (result.status === "failed") {
|
|
1637
|
-
onResolved("failed");
|
|
1638
|
-
applyFailed(result.failure_reason || null);
|
|
1639
|
-
return;
|
|
1640
|
-
}
|
|
1641
|
-
// Still processing — schedule next poll
|
|
1642
|
-
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1643
|
-
this._pollStatusTimer = setTimeout(poll, 3000);
|
|
1644
|
-
}
|
|
1645
|
-
else if (!this.isDestroyed) {
|
|
1646
|
-
applyTimeout();
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
catch {
|
|
1650
|
-
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1651
|
-
this._pollStatusTimer = setTimeout(poll, 5000);
|
|
1652
|
-
}
|
|
1653
|
-
else if (!this.isDestroyed) {
|
|
1654
|
-
applyTimeout();
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
};
|
|
1658
|
-
// First poll after 4s — gives Celery task time to start
|
|
1659
|
-
this._pollStatusTimer = setTimeout(poll, 4000);
|
|
1660
|
-
}
|
|
1661
1813
|
renderResult(_container) {
|
|
1662
|
-
//
|
|
1814
|
+
// C2: Dead code — flow routes to 'done' step, not 'result'. Kept as stub.
|
|
1663
1815
|
}
|
|
1664
|
-
//
|
|
1816
|
+
// ── Error ───────────────────────────────────────────────────────
|
|
1665
1817
|
showErrorStep(message) {
|
|
1666
1818
|
this.currentStep = "error";
|
|
1667
1819
|
if (!this.contentArea)
|
|
@@ -1680,13 +1832,13 @@ class SmartComplyFlow {
|
|
|
1680
1832
|
const isDocQuality = /blurry|blur|quality|glare|dark|bright|lighting/i.test(message);
|
|
1681
1833
|
const isExpiredDoc = /document.*expired|expired.*document/i.test(message);
|
|
1682
1834
|
const isFaceMatch = /face.*match|selfie.*match|does not match/i.test(message);
|
|
1683
|
-
const errorIcon = isCameraErr ? "
|
|
1684
|
-
: isNetworkErr ? "
|
|
1685
|
-
: isAuthErr ? "
|
|
1686
|
-
: isDocQuality ? "
|
|
1687
|
-
: isExpiredDoc ? "
|
|
1688
|
-
: isFaceMatch ? "
|
|
1689
|
-
: "
|
|
1835
|
+
const errorIcon = isCameraErr ? "📷"
|
|
1836
|
+
: isNetworkErr ? "📡"
|
|
1837
|
+
: isAuthErr ? "â±"
|
|
1838
|
+
: isDocQuality ? "ðŸ”"
|
|
1839
|
+
: isExpiredDoc ? "📅"
|
|
1840
|
+
: isFaceMatch ? "🤳"
|
|
1841
|
+
: "✕";
|
|
1690
1842
|
const errorTitle = isCameraErr ? "Camera Access Required"
|
|
1691
1843
|
: isNetworkErr ? "Connection Problem"
|
|
1692
1844
|
: isAuthErr ? "Session Expired"
|
|
@@ -1694,7 +1846,7 @@ class SmartComplyFlow {
|
|
|
1694
1846
|
: isExpiredDoc ? "Document Expired"
|
|
1695
1847
|
: isFaceMatch ? "Face Match Failed"
|
|
1696
1848
|
: "Verification Unsuccessful";
|
|
1697
|
-
// Map to user-facing guidance
|
|
1849
|
+
// Map to user-facing guidance — never show raw internal message
|
|
1698
1850
|
const userMessage = isCameraErr
|
|
1699
1851
|
? "Please allow camera access in your browser settings and try again."
|
|
1700
1852
|
: isNetworkErr
|
|
@@ -1741,9 +1893,9 @@ class SmartComplyFlow {
|
|
|
1741
1893
|
wrapper.appendChild(closeBtn);
|
|
1742
1894
|
this.contentArea.appendChild(wrapper);
|
|
1743
1895
|
}
|
|
1744
|
-
//
|
|
1896
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1745
1897
|
// UI Helpers
|
|
1746
|
-
//
|
|
1898
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1747
1899
|
createHeader() {
|
|
1748
1900
|
const header = document.createElement("div");
|
|
1749
1901
|
header.setAttribute("role", "banner");
|
|
@@ -1786,7 +1938,22 @@ class SmartComplyFlow {
|
|
|
1786
1938
|
this.brandLabel.textContent = this.sdkConfig?.brand_name || "SmartComply";
|
|
1787
1939
|
leftArea.appendChild(this.brandLabel);
|
|
1788
1940
|
header.appendChild(leftArea);
|
|
1789
|
-
// Step indicator
|
|
1941
|
+
// Step indicator: stage dots + label
|
|
1942
|
+
const stepIndicator = document.createElement("div");
|
|
1943
|
+
stepIndicator.style.cssText = `
|
|
1944
|
+
display:flex;align-items:center;gap:8px;flex-shrink:0;
|
|
1945
|
+
`;
|
|
1946
|
+
this.stageDots = document.createElement("div");
|
|
1947
|
+
this.stageDots.style.cssText = `display:flex;align-items:center;gap:4px;`;
|
|
1948
|
+
for (let i = 0; i < 2; i++) {
|
|
1949
|
+
const dot = document.createElement("span");
|
|
1950
|
+
dot.style.cssText = `
|
|
1951
|
+
width:6px;height:6px;border-radius:50%;
|
|
1952
|
+
background:${this.theme.border};transition:background 0.2s ease;
|
|
1953
|
+
`;
|
|
1954
|
+
this.stageDots.appendChild(dot);
|
|
1955
|
+
}
|
|
1956
|
+
stepIndicator.appendChild(this.stageDots);
|
|
1790
1957
|
this.stepLabel = document.createElement("div");
|
|
1791
1958
|
this.stepLabel.setAttribute("aria-live", "polite");
|
|
1792
1959
|
this.stepLabel.setAttribute("aria-atomic", "true");
|
|
@@ -1794,10 +1961,11 @@ class SmartComplyFlow {
|
|
|
1794
1961
|
font-size:12px;color:${this.theme.textMuted};
|
|
1795
1962
|
font-variant-numeric:tabular-nums;flex-shrink:0;
|
|
1796
1963
|
`;
|
|
1797
|
-
|
|
1964
|
+
stepIndicator.appendChild(this.stepLabel);
|
|
1965
|
+
header.appendChild(stepIndicator);
|
|
1798
1966
|
// Close button
|
|
1799
1967
|
const closeBtn = document.createElement("button");
|
|
1800
|
-
closeBtn.innerHTML = "
|
|
1968
|
+
closeBtn.innerHTML = "✕";
|
|
1801
1969
|
closeBtn.setAttribute("aria-label", "Close identity verification");
|
|
1802
1970
|
closeBtn.style.cssText = `
|
|
1803
1971
|
width:32px;height:32px;border-radius:8px;flex-shrink:0;
|
|
@@ -1878,7 +2046,7 @@ class SmartComplyFlow {
|
|
|
1878
2046
|
}
|
|
1879
2047
|
const arrow = document.createElement("span");
|
|
1880
2048
|
arrow.style.cssText = `color:${this.theme.textMuted};font-size:18px;flex-shrink:0;`;
|
|
1881
|
-
arrow.textContent = "
|
|
2049
|
+
arrow.textContent = "→";
|
|
1882
2050
|
card.appendChild(textWrap);
|
|
1883
2051
|
card.appendChild(arrow);
|
|
1884
2052
|
return card;
|
|
@@ -1923,7 +2091,7 @@ class SmartComplyFlow {
|
|
|
1923
2091
|
return btn;
|
|
1924
2092
|
}
|
|
1925
2093
|
isDocumentFlow() {
|
|
1926
|
-
// 1. Check verification_type
|
|
2094
|
+
// 1. Check verification_type — handle both string and array from backend
|
|
1927
2095
|
const vType = this.sdkConfig?.verification_type;
|
|
1928
2096
|
if (vType) {
|
|
1929
2097
|
const types = Array.isArray(vType) ? vType : [vType];
|
|
@@ -1932,7 +2100,7 @@ class SmartComplyFlow {
|
|
|
1932
2100
|
if (types.includes("data_verification"))
|
|
1933
2101
|
return false;
|
|
1934
2102
|
}
|
|
1935
|
-
// 2. Fallback: inspect channel fields
|
|
2103
|
+
// 2. Fallback: inspect channel fields — any upload field = document flow
|
|
1936
2104
|
const channels = this.sdkConfig?.channels || {};
|
|
1937
2105
|
const allFields = Object.values(channels)
|
|
1938
2106
|
.flat()
|
|
@@ -1943,38 +2111,60 @@ class SmartComplyFlow {
|
|
|
1943
2111
|
const isDocFlow = this.isDocumentFlow();
|
|
1944
2112
|
const countries = Object.keys(this.sdkConfig?.channels || {});
|
|
1945
2113
|
const singleCountry = countries.length <= 1;
|
|
1946
|
-
//
|
|
1947
|
-
const
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
const
|
|
2114
|
+
// Two-stage action steps — Stage 1: validate identity, Stage 2: liveness
|
|
2115
|
+
const stage1Steps = isDocFlow
|
|
2116
|
+
? ["country", "id_type", "document_capture", "ocr_gate", "document_confirm"].filter(s => !(s === "country" && singleCountry))
|
|
2117
|
+
: ["country", "id_type", "id_input", "confirm_identity"].filter(s => !(s === "country" && singleCountry));
|
|
2118
|
+
const stage2Steps = ["liveness"];
|
|
2119
|
+
const allActionSteps = [...stage1Steps, ...stage2Steps];
|
|
2120
|
+
const totalSteps = allActionSteps.length;
|
|
2121
|
+
const actionIndex = allActionSteps.indexOf(this.currentStep);
|
|
2122
|
+
const pct = this.currentStep === "done" || this.currentStep === "result" ? 100
|
|
1951
2123
|
: actionIndex >= 0 ? ((actionIndex + 1) / totalSteps) * 100
|
|
1952
2124
|
: 0;
|
|
1953
2125
|
if (this.progressBar) {
|
|
1954
2126
|
this.progressBar.style.width = `${pct}%`;
|
|
1955
2127
|
}
|
|
2128
|
+
// Stage label: "Stage 1 of 2: Verify Identity" / "Stage 2 of 2: Face Check"
|
|
1956
2129
|
if (this.stepLabel) {
|
|
1957
2130
|
if (this.currentStep === "loading" || this.currentStep === "welcome") {
|
|
1958
2131
|
this.stepLabel.textContent = "";
|
|
1959
2132
|
}
|
|
1960
|
-
else if (this.currentStep === "result") {
|
|
1961
|
-
this.stepLabel.textContent = "Complete
|
|
2133
|
+
else if (this.currentStep === "done" || this.currentStep === "result") {
|
|
2134
|
+
this.stepLabel.textContent = "Complete ✓";
|
|
1962
2135
|
}
|
|
1963
|
-
else if (
|
|
1964
|
-
this.stepLabel.textContent =
|
|
2136
|
+
else if (stage2Steps.includes(this.currentStep)) {
|
|
2137
|
+
this.stepLabel.textContent = "Stage 2 of 2: Face Check";
|
|
2138
|
+
}
|
|
2139
|
+
else if (stage1Steps.includes(this.currentStep)) {
|
|
2140
|
+
this.stepLabel.textContent = isDocFlow ? "Stage 1 of 2: Scan Document" : "Stage 1 of 2: Verify Identity";
|
|
1965
2141
|
}
|
|
1966
2142
|
else {
|
|
1967
2143
|
this.stepLabel.textContent = "";
|
|
1968
2144
|
}
|
|
1969
2145
|
}
|
|
1970
|
-
//
|
|
2146
|
+
// W3: Stage dots — dot 1 lights once Stage 1 is reached, dot 2 once Stage 2 is reached
|
|
2147
|
+
if (this.stageDots) {
|
|
2148
|
+
const dots = Array.from(this.stageDots.children);
|
|
2149
|
+
const inStage1 = stage1Steps.includes(this.currentStep);
|
|
2150
|
+
const inStage2 = stage2Steps.includes(this.currentStep);
|
|
2151
|
+
const isComplete = this.currentStep === "done" || this.currentStep === "result";
|
|
2152
|
+
const stage1Active = inStage1 || inStage2 || isComplete;
|
|
2153
|
+
const stage2Active = inStage2 || isComplete;
|
|
2154
|
+
if (dots[0])
|
|
2155
|
+
dots[0].style.background = stage1Active ? this.theme.primary : this.theme.border;
|
|
2156
|
+
if (dots[1])
|
|
2157
|
+
dots[1].style.background = stage2Active ? this.theme.primary : this.theme.border;
|
|
2158
|
+
}
|
|
2159
|
+
// Back button — show for navigable steps only
|
|
1971
2160
|
if (this.headerBackBtn) {
|
|
1972
|
-
const countries = Object.keys(this.sdkConfig?.channels || {});
|
|
1973
2161
|
const backTargets = {
|
|
1974
2162
|
id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
|
|
1975
2163
|
country: () => this.showStep("welcome"),
|
|
1976
2164
|
id_input: () => this.showStep("id_type"),
|
|
2165
|
+
confirm_identity: () => this.showStep("id_input"),
|
|
1977
2166
|
document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
|
|
2167
|
+
document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
|
|
1978
2168
|
};
|
|
1979
2169
|
const backAction = backTargets[this.currentStep];
|
|
1980
2170
|
if (backAction) {
|
|
@@ -2045,4 +2235,5 @@ class SmartComplyFlow {
|
|
|
2045
2235
|
}
|
|
2046
2236
|
}
|
|
2047
2237
|
exports.SmartComplyFlow = SmartComplyFlow;
|
|
2238
|
+
SmartComplyFlow.MAX_RETRIES = 3;
|
|
2048
2239
|
//# sourceMappingURL=SmartComplyFlow.js.map
|