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