smartcomply-web-sdk 1.0.43 → 1.0.47
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/esm/flow/SmartComplyFlow.d.ts +22 -4
- package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/esm/flow/SmartComplyFlow.js +904 -492
- 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 +10 -5
- package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/esm/modules/liveness/liveness.js +87 -43
- package/dist/esm/modules/liveness/liveness.js.map +1 -1
- package/dist/flow/SmartComplyFlow.d.ts +22 -4
- package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/flow/SmartComplyFlow.js +904 -492
- 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 +10 -5
- package/dist/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/modules/liveness/liveness.js +87 -43
- package/dist/modules/liveness/liveness.js.map +1 -1
- package/dist/smartcomply.browser.js +828 -461
- 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,16 +118,114 @@ 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
|
-
//
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
129
|
+
// ── Widget-side message sanitizer ─────────────────────────────────────────────
|
|
130
|
+
// Strips anything technical before showing text to the end user.
|
|
131
|
+
// Keeps messages that are already written plainly in natural language.
|
|
132
|
+
function _sanitizeUserMessage(raw) {
|
|
133
|
+
if (!raw)
|
|
134
|
+
return null;
|
|
135
|
+
const lower = raw.toLowerCase();
|
|
136
|
+
// Block anything that looks internal / technical
|
|
137
|
+
const blocklist = [
|
|
138
|
+
"smartcomply", "face comparison service", "session id", "session_id",
|
|
139
|
+
"provider", "ocr", "textract", "rekognition", "deepface",
|
|
140
|
+
"ivs", "celery", "exception", "traceback", "str(e)",
|
|
141
|
+
"error:", "failed:", "django", "python", "http ",
|
|
142
|
+
"comparison mode", "liveness mode", "callback",
|
|
143
|
+
"face_comparison", "face_match", "confidence_percentage",
|
|
144
|
+
"threshold_percentage", "polling_fallback", "resolved_by",
|
|
145
|
+
];
|
|
146
|
+
for (const blocked of blocklist) {
|
|
147
|
+
if (lower.includes(blocked))
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// Block snake_case (internal field names accidentally leaking through)
|
|
151
|
+
if (/[a-z]+_[a-z]+/.test(raw) && !/\s/.test(raw.trim()))
|
|
152
|
+
return null;
|
|
153
|
+
// Block anything starting with a lowercase letter (likely a code/key)
|
|
154
|
+
if (raw.trim()[0] === raw.trim()[0].toLowerCase() && /^[a-z]/.test(raw.trim()))
|
|
155
|
+
return null;
|
|
156
|
+
return raw.trim();
|
|
157
|
+
}
|
|
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 ─────────────────────────────────────────
|
|
208
|
+
function _formatDocType(code) {
|
|
209
|
+
const map = {
|
|
210
|
+
passport: "Passport",
|
|
211
|
+
national_id: "National ID",
|
|
212
|
+
nin_slip: "NIN Slip",
|
|
213
|
+
nin_with_face: "National Identity Number",
|
|
214
|
+
drivers_license: "Driver's Licence",
|
|
215
|
+
voters_card: "Voter's Card",
|
|
216
|
+
kenya_id: "Kenya National ID",
|
|
217
|
+
ghana_id: "Ghana Card",
|
|
218
|
+
};
|
|
219
|
+
if (!code)
|
|
220
|
+
return "";
|
|
221
|
+
const key = code.toLowerCase().replace(/[\s-]/g, "_");
|
|
222
|
+
if (map[key])
|
|
223
|
+
return map[key];
|
|
224
|
+
// Fallback: title-case the code
|
|
225
|
+
return code.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
226
|
+
}
|
|
126
227
|
// SmartComplyFlow
|
|
127
|
-
//
|
|
228
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
128
229
|
// Add CSS animation keyframes for spin animations
|
|
129
230
|
const style = document.createElement('style');
|
|
130
231
|
style.textContent = `
|
|
@@ -165,6 +266,25 @@ export class SmartComplyFlow {
|
|
|
165
266
|
this.livenessEntryId = null;
|
|
166
267
|
this.isDestroyed = false;
|
|
167
268
|
this.headerBackBtn = null;
|
|
269
|
+
// Retry safety — max 3 confirmation rejections or liveness failures per session
|
|
270
|
+
this._confirmRetries = 0;
|
|
271
|
+
this._livenessFailures = 0;
|
|
272
|
+
// IVS result stored for confirm_identity screen
|
|
273
|
+
this._ivsName = "";
|
|
274
|
+
this._ivsGender = "";
|
|
275
|
+
// OCR result stored for document_confirm screen
|
|
276
|
+
this._ocrName = "";
|
|
277
|
+
this._ocrDocNumber = "";
|
|
278
|
+
this._ocrExpiry = "";
|
|
279
|
+
this._ocrFaceImageUrl = "";
|
|
280
|
+
this._ocrIsExpired = false;
|
|
281
|
+
this._ocrExpirySoon = false;
|
|
282
|
+
// Camera pre-warm stream (created during confirmation screen, used in liveness)
|
|
283
|
+
this._prewarmedStream = null;
|
|
284
|
+
// OCR gate poll timer — stored so it can be cancelled on navigation or close
|
|
285
|
+
this._ocrPollTimer = null;
|
|
286
|
+
// UI timeout from _startStatusPolling — stored so close() can cancel it
|
|
287
|
+
this._uiTimeoutId = null;
|
|
168
288
|
// Session expiry
|
|
169
289
|
this.sessionExpiryTimer = null;
|
|
170
290
|
// Status polling (result screen)
|
|
@@ -204,6 +324,11 @@ export class SmartComplyFlow {
|
|
|
204
324
|
close() {
|
|
205
325
|
this.isDestroyed = true;
|
|
206
326
|
this.docCapture?.destroy();
|
|
327
|
+
// Release any pre-warmed camera stream
|
|
328
|
+
if (this._prewarmedStream) {
|
|
329
|
+
this._prewarmedStream.getTracks().forEach(t => t.stop());
|
|
330
|
+
this._prewarmedStream = null;
|
|
331
|
+
}
|
|
207
332
|
if (this.sessionExpiryTimer) {
|
|
208
333
|
clearTimeout(this.sessionExpiryTimer);
|
|
209
334
|
this.sessionExpiryTimer = null;
|
|
@@ -212,13 +337,23 @@ export class SmartComplyFlow {
|
|
|
212
337
|
clearTimeout(this._pollStatusTimer);
|
|
213
338
|
this._pollStatusTimer = null;
|
|
214
339
|
}
|
|
340
|
+
// B6: cancel OCR gate poll timer
|
|
341
|
+
if (this._ocrPollTimer) {
|
|
342
|
+
clearTimeout(this._ocrPollTimer);
|
|
343
|
+
this._ocrPollTimer = null;
|
|
344
|
+
}
|
|
345
|
+
// P4: cancel result-screen UI timeout
|
|
346
|
+
if (this._uiTimeoutId) {
|
|
347
|
+
clearTimeout(this._uiTimeoutId);
|
|
348
|
+
this._uiTimeoutId = null;
|
|
349
|
+
}
|
|
215
350
|
if (this.overlay) {
|
|
216
351
|
this.overlay.style.opacity = "0";
|
|
217
352
|
setTimeout(() => {
|
|
218
353
|
try {
|
|
219
354
|
this.overlay?.remove();
|
|
220
355
|
}
|
|
221
|
-
catch (_) { /* extension interference
|
|
356
|
+
catch (_) { /* extension interference — safe to ignore */ }
|
|
222
357
|
this.overlay = null;
|
|
223
358
|
this.modal = null;
|
|
224
359
|
this.contentArea = null;
|
|
@@ -226,9 +361,9 @@ export class SmartComplyFlow {
|
|
|
226
361
|
}
|
|
227
362
|
this.options.onClose?.();
|
|
228
363
|
}
|
|
229
|
-
//
|
|
364
|
+
// ─────────────────────────────────────────────────────────────────
|
|
230
365
|
// Initialization
|
|
231
|
-
//
|
|
366
|
+
// ─────────────────────────────────────────────────────────────────
|
|
232
367
|
mount() {
|
|
233
368
|
// Inject keyframes
|
|
234
369
|
this.injectStyles();
|
|
@@ -246,6 +381,9 @@ export class SmartComplyFlow {
|
|
|
246
381
|
// Modal
|
|
247
382
|
this.modal = document.createElement("div");
|
|
248
383
|
this.modal.id = "sc-flow-modal";
|
|
384
|
+
this.modal.setAttribute("role", "dialog");
|
|
385
|
+
this.modal.setAttribute("aria-modal", "true");
|
|
386
|
+
this.modal.setAttribute("aria-label", "Identity verification");
|
|
249
387
|
this.modal.style.cssText = `
|
|
250
388
|
width:100%;max-width:580px;max-height:95vh;
|
|
251
389
|
background:${this.theme.bg};
|
|
@@ -271,6 +409,9 @@ export class SmartComplyFlow {
|
|
|
271
409
|
// Content area (scrollable)
|
|
272
410
|
this.contentArea = document.createElement("div");
|
|
273
411
|
this.contentArea.id = "sc-flow-content";
|
|
412
|
+
this.contentArea.setAttribute("role", "main");
|
|
413
|
+
this.contentArea.setAttribute("aria-live", "polite");
|
|
414
|
+
this.contentArea.setAttribute("aria-atomic", "false");
|
|
274
415
|
this.contentArea.style.cssText = `
|
|
275
416
|
flex:1;overflow-y:auto;padding:16px 20px;
|
|
276
417
|
-webkit-overflow-scrolling:touch;
|
|
@@ -307,12 +448,12 @@ export class SmartComplyFlow {
|
|
|
307
448
|
}
|
|
308
449
|
// Load config
|
|
309
450
|
this.sdkConfig = await this.sdk.initializeConfig();
|
|
310
|
-
// Apply theme from config
|
|
451
|
+
// Apply theme from config — use theme_hex to override primary colour if set
|
|
311
452
|
if (this.sdkConfig.theme) {
|
|
312
|
-
this.theme = getTheme(this.sdkConfig.theme);
|
|
453
|
+
this.theme = getTheme(this.sdkConfig.theme, this.sdkConfig.theme_hex || null);
|
|
313
454
|
this.applyTheme();
|
|
314
455
|
}
|
|
315
|
-
// Update brand name
|
|
456
|
+
// Update brand name in header
|
|
316
457
|
if (this.brandLabel && this.sdkConfig.brand_name) {
|
|
317
458
|
this.brandLabel.textContent = this.sdkConfig.brand_name;
|
|
318
459
|
}
|
|
@@ -324,13 +465,18 @@ export class SmartComplyFlow {
|
|
|
324
465
|
this.showErrorStep(err.message || "Failed to initialize. Please try again.");
|
|
325
466
|
}
|
|
326
467
|
}
|
|
327
|
-
//
|
|
468
|
+
// ─────────────────────────────────────────────────────────────────
|
|
328
469
|
// Step rendering
|
|
329
|
-
//
|
|
470
|
+
// ─────────────────────────────────────────────────────────────────
|
|
330
471
|
showStep(step) {
|
|
331
472
|
this.currentStep = step;
|
|
332
473
|
if (!this.contentArea)
|
|
333
474
|
return;
|
|
475
|
+
// P1/B6: cancel any in-flight OCR gate poll when user navigates away
|
|
476
|
+
if (this._ocrPollTimer) {
|
|
477
|
+
clearTimeout(this._ocrPollTimer);
|
|
478
|
+
this._ocrPollTimer = null;
|
|
479
|
+
}
|
|
334
480
|
// Update progress
|
|
335
481
|
this.updateProgress();
|
|
336
482
|
// Animate out current content
|
|
@@ -368,20 +514,32 @@ export class SmartComplyFlow {
|
|
|
368
514
|
case "id_input":
|
|
369
515
|
this.renderIdInput(wrapper);
|
|
370
516
|
break;
|
|
517
|
+
case "confirm_identity":
|
|
518
|
+
this.renderConfirmIdentity(wrapper);
|
|
519
|
+
break;
|
|
371
520
|
case "document_capture":
|
|
372
521
|
this.renderDocumentCapture(wrapper);
|
|
373
522
|
break;
|
|
523
|
+
case "ocr_gate":
|
|
524
|
+
this.renderOcrGate(wrapper);
|
|
525
|
+
break;
|
|
526
|
+
case "document_confirm":
|
|
527
|
+
this.renderDocumentConfirm(wrapper);
|
|
528
|
+
break;
|
|
374
529
|
case "processing":
|
|
375
|
-
|
|
530
|
+
// C1: processing step is dead code — flow now goes to 'done' after submit
|
|
376
531
|
break;
|
|
377
532
|
case "liveness":
|
|
378
533
|
this.renderLiveness(wrapper);
|
|
379
534
|
break;
|
|
535
|
+
case "done":
|
|
536
|
+
this.renderDone(wrapper);
|
|
537
|
+
break;
|
|
380
538
|
case "result":
|
|
381
|
-
|
|
539
|
+
// C2: result step is dead code — showResult/renderResult removed
|
|
382
540
|
break;
|
|
383
541
|
case "error":
|
|
384
|
-
break;
|
|
542
|
+
break;
|
|
385
543
|
}
|
|
386
544
|
this.contentArea.appendChild(wrapper);
|
|
387
545
|
// Animate in
|
|
@@ -390,7 +548,7 @@ export class SmartComplyFlow {
|
|
|
390
548
|
wrapper.style.transform = "translateX(0)";
|
|
391
549
|
});
|
|
392
550
|
}
|
|
393
|
-
//
|
|
551
|
+
// ── Loading ─────────────────────────────────────────────────────
|
|
394
552
|
renderLoading(container) {
|
|
395
553
|
container.style.cssText += "display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:16px;";
|
|
396
554
|
const spinnerWrap = document.createElement("div");
|
|
@@ -421,7 +579,7 @@ export class SmartComplyFlow {
|
|
|
421
579
|
sub.textContent = "Connecting to the verification service...";
|
|
422
580
|
container.appendChild(sub);
|
|
423
581
|
}
|
|
424
|
-
//
|
|
582
|
+
// ── Welcome ────────────────────────────────────────────────────
|
|
425
583
|
renderWelcome(container) {
|
|
426
584
|
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:12px;";
|
|
427
585
|
const isDocFlow = this.isDocumentFlow();
|
|
@@ -436,7 +594,7 @@ export class SmartComplyFlow {
|
|
|
436
594
|
background:linear-gradient(135deg,${this.theme.primary}22,${this.theme.primaryGlow});
|
|
437
595
|
border:1.5px solid ${this.theme.primary}33;
|
|
438
596
|
`;
|
|
439
|
-
iconWrap.textContent = isDocFlow ? "
|
|
597
|
+
iconWrap.textContent = isDocFlow ? "🪪" : "🛡ï¸";
|
|
440
598
|
topRow.appendChild(iconWrap);
|
|
441
599
|
const badge = document.createElement("div");
|
|
442
600
|
badge.style.cssText = `
|
|
@@ -447,7 +605,7 @@ export class SmartComplyFlow {
|
|
|
447
605
|
color:${this.theme.primary};
|
|
448
606
|
border:1px solid ${this.theme.primary}30;
|
|
449
607
|
`;
|
|
450
|
-
badge.textContent = isDocFlow ? "
|
|
608
|
+
badge.textContent = isDocFlow ? "📄 Document Verification" : "🔠Data Verification";
|
|
451
609
|
topRow.appendChild(badge);
|
|
452
610
|
container.appendChild(topRow);
|
|
453
611
|
// Title + subtitle (compact)
|
|
@@ -458,7 +616,11 @@ export class SmartComplyFlow {
|
|
|
458
616
|
color:${this.theme.text};font-size:20px;font-weight:700;
|
|
459
617
|
margin:0;line-height:1.2;
|
|
460
618
|
`;
|
|
461
|
-
|
|
619
|
+
// W2: Use client brand name in title
|
|
620
|
+
const brandName = this.sdkConfig?.brand_name;
|
|
621
|
+
title.textContent = brandName
|
|
622
|
+
? `Verify your identity for ${brandName}`
|
|
623
|
+
: "Identity Verification";
|
|
462
624
|
titleWrap.appendChild(title);
|
|
463
625
|
const desc = document.createElement("p");
|
|
464
626
|
desc.style.cssText = `
|
|
@@ -466,21 +628,21 @@ export class SmartComplyFlow {
|
|
|
466
628
|
margin:0;max-width:300px;
|
|
467
629
|
`;
|
|
468
630
|
desc.textContent = isDocFlow
|
|
469
|
-
? "
|
|
470
|
-
: "
|
|
631
|
+
? "You'll need your document and camera access. This takes about 2 minutes."
|
|
632
|
+
: "You'll need your ID number and camera access. This takes about 2 minutes.";
|
|
471
633
|
titleWrap.appendChild(desc);
|
|
472
634
|
container.appendChild(titleWrap);
|
|
473
|
-
// Steps preview
|
|
635
|
+
// Steps preview — compact rows
|
|
474
636
|
const steps = isDocFlow
|
|
475
637
|
? [
|
|
476
|
-
{ icon: "
|
|
477
|
-
{ icon: "
|
|
478
|
-
{ icon: "
|
|
638
|
+
{ icon: "🪪", text: "Choose your document type", sub: "Passport, National ID, Driver License" },
|
|
639
|
+
{ icon: "📷", text: "Scan your document", sub: "Upload or capture a photo of your ID" },
|
|
640
|
+
{ icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
|
|
479
641
|
]
|
|
480
642
|
: [
|
|
481
|
-
{ icon: "
|
|
482
|
-
{ icon: "
|
|
483
|
-
{ icon: "
|
|
643
|
+
{ icon: "🆔", text: "Choose your ID type", sub: "NIN, BVN, Voter's Card and more" },
|
|
644
|
+
{ icon: "ðŸ“", text: "Enter your ID details", sub: "Exactly as shown on your ID" },
|
|
645
|
+
{ icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
|
|
484
646
|
];
|
|
485
647
|
const stepsWrap = document.createElement("div");
|
|
486
648
|
stepsWrap.style.cssText = `
|
|
@@ -521,21 +683,21 @@ export class SmartComplyFlow {
|
|
|
521
683
|
});
|
|
522
684
|
container.appendChild(stepsWrap);
|
|
523
685
|
// CTA
|
|
524
|
-
const btn = this.createPrimaryButton("Get Started
|
|
686
|
+
const btn = this.createPrimaryButton("Get Started →");
|
|
525
687
|
btn.style.cssText += "margin-top:2px;";
|
|
526
688
|
btn.addEventListener("click", () => this.showStep("country"));
|
|
527
689
|
container.appendChild(btn);
|
|
528
|
-
// Trust badge
|
|
690
|
+
// Trust badge — "Powered by Adhere" already lives in the persistent
|
|
529
691
|
// modal footer below; repeating it here just cost vertical space.
|
|
530
692
|
const trust = document.createElement("div");
|
|
531
693
|
trust.style.cssText = `
|
|
532
694
|
display:flex;align-items:center;gap:6px;
|
|
533
695
|
color:${this.theme.textMuted};font-size:10.5px;
|
|
534
696
|
`;
|
|
535
|
-
trust.textContent = "
|
|
697
|
+
trust.textContent = "🔒 End-to-end encrypted";
|
|
536
698
|
container.appendChild(trust);
|
|
537
699
|
}
|
|
538
|
-
//
|
|
700
|
+
// ── Country Select ──────────────────────────────────────────────
|
|
539
701
|
renderCountrySelect(container) {
|
|
540
702
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
541
703
|
const channels = this.sdkConfig?.channels || {};
|
|
@@ -560,7 +722,7 @@ export class SmartComplyFlow {
|
|
|
560
722
|
container.appendChild(card);
|
|
561
723
|
}
|
|
562
724
|
}
|
|
563
|
-
//
|
|
725
|
+
// ── ID Type Select ──────────────────────────────────────────────
|
|
564
726
|
renderIdTypeSelect(container) {
|
|
565
727
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
566
728
|
const globalDocFlow = this.isDocumentFlow();
|
|
@@ -588,9 +750,9 @@ export class SmartComplyFlow {
|
|
|
588
750
|
// Add back side indicator to subtitle if needed
|
|
589
751
|
let subtitle = info.desc;
|
|
590
752
|
if (requiresBack && isThisDocFlow) {
|
|
591
|
-
subtitle += "
|
|
753
|
+
subtitle += " âš ï¸ Both sides required";
|
|
592
754
|
}
|
|
593
|
-
// Clean display name
|
|
755
|
+
// Clean display name — strip "with face", "(NIN)", "(BVN)" from user-visible label
|
|
594
756
|
const displayName = typeName
|
|
595
757
|
.replace(/\s*with\s+face\s*/gi, ' ')
|
|
596
758
|
.replace(/\s*\(NIN\)\s*/gi, ' ')
|
|
@@ -627,7 +789,7 @@ export class SmartComplyFlow {
|
|
|
627
789
|
container.appendChild(card);
|
|
628
790
|
}
|
|
629
791
|
}
|
|
630
|
-
//
|
|
792
|
+
// ── ID Input (data verification — dynamic fields) ─────────────
|
|
631
793
|
renderIdInput(container) {
|
|
632
794
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
633
795
|
const dataFields = this.selectedChannelFields.filter((f) => f.type !== "upload");
|
|
@@ -646,7 +808,7 @@ export class SmartComplyFlow {
|
|
|
646
808
|
? `Enter your ${this.selectedIdTypeName || this.selectedIdType} details exactly as they appear on your ID.`
|
|
647
809
|
: "Fill in the fields below to continue.";
|
|
648
810
|
container.appendChild(subtitle);
|
|
649
|
-
// Edge case: no data fields configured
|
|
811
|
+
// Edge case: no data fields configured — show fallback and allow submission
|
|
650
812
|
if (dataFields.length === 0) {
|
|
651
813
|
const empty = document.createElement("div");
|
|
652
814
|
empty.style.cssText = `
|
|
@@ -661,10 +823,45 @@ export class SmartComplyFlow {
|
|
|
661
823
|
container.appendChild(autoBtn);
|
|
662
824
|
return;
|
|
663
825
|
}
|
|
826
|
+
const _FIELD_RULES = [
|
|
827
|
+
{ match: "BVN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "BVN must be exactly 11 digits" } },
|
|
828
|
+
{ match: "NIN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
|
|
829
|
+
{ match: "NATIONAL IDENTITY", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
|
|
830
|
+
{ match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6–20 alphanumeric characters" } },
|
|
831
|
+
{ match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3–30 alphanumeric characters" } },
|
|
832
|
+
{ match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6–30 alphanumeric characters" } },
|
|
833
|
+
{ match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8–20 alphanumeric characters" } },
|
|
834
|
+
{ match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7–15 alphanumeric characters" } },
|
|
835
|
+
{ match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8–20 characters" } },
|
|
836
|
+
{ match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10–12 characters" } },
|
|
837
|
+
];
|
|
838
|
+
function getFieldRule(label) {
|
|
839
|
+
const upper = label.toUpperCase();
|
|
840
|
+
for (const entry of _FIELD_RULES) {
|
|
841
|
+
if (upper.includes(entry.match))
|
|
842
|
+
return entry.rule;
|
|
843
|
+
}
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
function validateFieldValue(label, value) {
|
|
847
|
+
const rule = getFieldRule(label);
|
|
848
|
+
if (!rule)
|
|
849
|
+
return null; // no rule = no validation
|
|
850
|
+
const v = value.trim();
|
|
851
|
+
if (rule.numeric && !/^\d+$/.test(v))
|
|
852
|
+
return `${label} must contain digits only`;
|
|
853
|
+
if (rule.alphanumeric && !/^[A-Za-z0-9]+$/.test(v))
|
|
854
|
+
return `${label} must be alphanumeric (letters and digits only)`;
|
|
855
|
+
if (rule.minLen && v.length < rule.minLen)
|
|
856
|
+
return rule.hint;
|
|
857
|
+
if (rule.maxLen && v.length > rule.maxLen)
|
|
858
|
+
return rule.hint;
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
664
861
|
// Track input elements for validation
|
|
665
862
|
const inputEls = [];
|
|
666
863
|
for (const field of dataFields) {
|
|
667
|
-
// Send the raw label as the payload key
|
|
864
|
+
// Send the raw label as the payload key — the backend's
|
|
668
865
|
// _SDK_FIELD_KEY_MAP looks up the exact label text (e.g. "National ID",
|
|
669
866
|
// "CAC Number") to remap it to the canonical IVS key. Pre-normalizing
|
|
670
867
|
// it here (as this used to do) produced keys like "national_id" that
|
|
@@ -673,7 +870,10 @@ export class SmartComplyFlow {
|
|
|
673
870
|
const fieldKey = field.label;
|
|
674
871
|
const fieldWrap = document.createElement("div");
|
|
675
872
|
fieldWrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
|
|
873
|
+
const labelId = `sc-field-label-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
|
|
676
874
|
const label = document.createElement("label");
|
|
875
|
+
label.id = labelId;
|
|
876
|
+
label.htmlFor = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
|
|
677
877
|
label.style.cssText = `color:${this.theme.textMuted};font-size:10px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;`;
|
|
678
878
|
label.textContent = field.label;
|
|
679
879
|
fieldWrap.appendChild(label);
|
|
@@ -711,7 +911,7 @@ export class SmartComplyFlow {
|
|
|
711
911
|
inputEls.push({ field, el: select });
|
|
712
912
|
}
|
|
713
913
|
else if (field.type === "image" || field.type === "file") {
|
|
714
|
-
// File upload field
|
|
914
|
+
// File upload field — converts to base64 for API submission
|
|
715
915
|
const fileInput = document.createElement("input");
|
|
716
916
|
fileInput.type = "file";
|
|
717
917
|
fileInput.accept = "image/*";
|
|
@@ -747,10 +947,18 @@ export class SmartComplyFlow {
|
|
|
747
947
|
}
|
|
748
948
|
else {
|
|
749
949
|
// Text input field
|
|
950
|
+
const inputId = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
|
|
951
|
+
const hintId = `sc-field-hint-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
|
|
952
|
+
const rule = getFieldRule(field.label);
|
|
750
953
|
const input = document.createElement("input");
|
|
751
954
|
input.type = "text";
|
|
955
|
+
input.id = inputId;
|
|
956
|
+
input.setAttribute("aria-labelledby", labelId);
|
|
957
|
+
input.setAttribute("aria-describedby", hintId);
|
|
752
958
|
input.placeholder = `Enter ${field.label}`;
|
|
753
|
-
input.maxLength = 50;
|
|
959
|
+
input.maxLength = rule?.maxLen ? rule.maxLen + 2 : 50;
|
|
960
|
+
if (rule?.numeric)
|
|
961
|
+
input.inputMode = "numeric";
|
|
754
962
|
input.style.cssText = `
|
|
755
963
|
width:100%;padding:14px 16px;border-radius:12px;
|
|
756
964
|
font-size:16px;font-weight:500;
|
|
@@ -761,17 +969,50 @@ export class SmartComplyFlow {
|
|
|
761
969
|
box-sizing:border-box;
|
|
762
970
|
letter-spacing:0.5px;
|
|
763
971
|
`;
|
|
972
|
+
// Inline hint/error element
|
|
973
|
+
const hintEl = document.createElement("div");
|
|
974
|
+
hintEl.id = hintId;
|
|
975
|
+
hintEl.setAttribute("aria-live", "polite");
|
|
976
|
+
hintEl.style.cssText = `font-size:11px;min-height:14px;margin-top:-2px;`;
|
|
977
|
+
if (rule) {
|
|
978
|
+
hintEl.style.color = this.theme.textMuted;
|
|
979
|
+
hintEl.textContent = rule.hint;
|
|
980
|
+
}
|
|
764
981
|
input.addEventListener("focus", () => {
|
|
765
982
|
input.style.borderColor = this.theme.primary;
|
|
766
983
|
});
|
|
767
984
|
input.addEventListener("blur", () => {
|
|
768
|
-
|
|
985
|
+
const v = input.value.trim();
|
|
986
|
+
if (v) {
|
|
987
|
+
const err = validateFieldValue(field.label, v);
|
|
988
|
+
if (err) {
|
|
989
|
+
input.style.borderColor = this.theme.error;
|
|
990
|
+
hintEl.style.color = this.theme.error;
|
|
991
|
+
hintEl.textContent = err;
|
|
992
|
+
input.setAttribute("aria-invalid", "true");
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
input.style.borderColor = this.theme.success || this.theme.primary;
|
|
996
|
+
hintEl.style.color = this.theme.textMuted;
|
|
997
|
+
hintEl.textContent = rule?.hint || "";
|
|
998
|
+
input.setAttribute("aria-invalid", "false");
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
else {
|
|
1002
|
+
input.style.borderColor = this.theme.border;
|
|
1003
|
+
}
|
|
769
1004
|
});
|
|
770
1005
|
input.addEventListener("input", () => {
|
|
1006
|
+
// Reset error styling while typing
|
|
1007
|
+
input.style.borderColor = this.theme.primary;
|
|
1008
|
+
hintEl.style.color = this.theme.textMuted;
|
|
1009
|
+
hintEl.textContent = rule?.hint || "";
|
|
1010
|
+
input.removeAttribute("aria-invalid");
|
|
771
1011
|
this.collectedFields[fieldKey] = input.value.trim();
|
|
772
1012
|
updateSubmitState();
|
|
773
1013
|
});
|
|
774
1014
|
fieldWrap.appendChild(input);
|
|
1015
|
+
fieldWrap.appendChild(hintEl);
|
|
775
1016
|
inputEls.push({ field, el: input });
|
|
776
1017
|
}
|
|
777
1018
|
container.appendChild(fieldWrap);
|
|
@@ -789,9 +1030,16 @@ export class SmartComplyFlow {
|
|
|
789
1030
|
submitBtn.disabled = true;
|
|
790
1031
|
submitBtn.style.opacity = "0.5";
|
|
791
1032
|
const updateSubmitState = () => {
|
|
1033
|
+
// All inputs must have a value AND pass format validation
|
|
792
1034
|
const allFilled = inputEls.every(({ el }) => el.value.trim().length > 0);
|
|
793
|
-
|
|
794
|
-
|
|
1035
|
+
const allValid = inputEls.every(({ field, el }) => {
|
|
1036
|
+
if (el.tagName === "SELECT")
|
|
1037
|
+
return el.value.trim().length > 0;
|
|
1038
|
+
return !validateFieldValue(field.label, el.value.trim());
|
|
1039
|
+
});
|
|
1040
|
+
const ok = allFilled && allValid;
|
|
1041
|
+
submitBtn.disabled = !ok;
|
|
1042
|
+
submitBtn.style.opacity = ok ? "1" : "0.5";
|
|
795
1043
|
};
|
|
796
1044
|
submitBtn.addEventListener("click", async () => {
|
|
797
1045
|
submitBtn.disabled = true;
|
|
@@ -803,15 +1051,21 @@ export class SmartComplyFlow {
|
|
|
803
1051
|
fields: { ...this.collectedFields },
|
|
804
1052
|
});
|
|
805
1053
|
if (this.verificationResult.status === "success") {
|
|
806
|
-
// Save
|
|
1054
|
+
// Save identifier and identity_check_id for liveness step
|
|
807
1055
|
const fieldValues = Object.values(this.collectedFields);
|
|
808
1056
|
this.idNumber = fieldValues[0] || "";
|
|
809
|
-
// A4: Save identity_check_id from verify response to pass to liveness
|
|
810
1057
|
this.identityCheckId = this.verificationResult.data?.identity_check_id || null;
|
|
811
|
-
|
|
1058
|
+
// W3: Store name + gender from IVS result for confirmation screen
|
|
1059
|
+
const d = this.verificationResult.data || {};
|
|
1060
|
+
const nd = d.normalized_data || d.data || d;
|
|
1061
|
+
this._ivsName = [nd.first_name || nd.firstname || "", nd.last_name || nd.lastname || nd.surname || ""]
|
|
1062
|
+
.map(s => String(s || "").trim()).filter(Boolean).join(" ");
|
|
1063
|
+
this._ivsGender = nd.gender || "";
|
|
1064
|
+
// Route to confirmation screen — user must confirm before liveness
|
|
1065
|
+
this.showStep("confirm_identity");
|
|
812
1066
|
}
|
|
813
1067
|
else {
|
|
814
|
-
errorEl.textContent = this.verificationResult.message || "
|
|
1068
|
+
errorEl.textContent = this.verificationResult.message || "We could not verify your details. Please check and try again.";
|
|
815
1069
|
errorEl.style.display = "block";
|
|
816
1070
|
submitBtn.textContent = "Verify & Continue";
|
|
817
1071
|
submitBtn.disabled = false;
|
|
@@ -819,14 +1073,26 @@ export class SmartComplyFlow {
|
|
|
819
1073
|
}
|
|
820
1074
|
}
|
|
821
1075
|
catch (err) {
|
|
822
|
-
//
|
|
1076
|
+
// Show a single plain-language error — never raw field:message format
|
|
823
1077
|
const fieldErrors = err.data?.errors;
|
|
824
1078
|
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
|
|
825
|
-
|
|
826
|
-
|
|
1079
|
+
// Pick the first meaningful error message — strip field prefix
|
|
1080
|
+
const firstMsg = fieldErrors[0]?.message || fieldErrors[0]?.detail || "";
|
|
1081
|
+
errorEl.textContent = _sanitizeUserMessage(firstMsg)
|
|
1082
|
+
|| "Please check your details and try again.";
|
|
1083
|
+
}
|
|
1084
|
+
else if (err.code === "VERIFICATION_FAILED") {
|
|
1085
|
+
errorEl.textContent = "We could not verify those details. Please check your ID number and try again.";
|
|
1086
|
+
}
|
|
1087
|
+
else if (err.code === "CHANNEL_NOT_CONFIGURED") {
|
|
1088
|
+
errorEl.textContent = "This ID type is not currently available. Please choose a different option.";
|
|
1089
|
+
}
|
|
1090
|
+
else if (err.code === "INSUFFICIENT_BALANCE") {
|
|
1091
|
+
errorEl.textContent = "Verification is temporarily unavailable. Please contact support.";
|
|
827
1092
|
}
|
|
828
1093
|
else {
|
|
829
|
-
errorEl.textContent = err.message
|
|
1094
|
+
errorEl.textContent = _sanitizeUserMessage(err.message)
|
|
1095
|
+
|| "Something went wrong. Please try again.";
|
|
830
1096
|
}
|
|
831
1097
|
errorEl.style.display = "block";
|
|
832
1098
|
submitBtn.textContent = "Verify & Continue";
|
|
@@ -841,7 +1107,7 @@ export class SmartComplyFlow {
|
|
|
841
1107
|
setTimeout(() => firstInput.focus(), 350);
|
|
842
1108
|
}
|
|
843
1109
|
}
|
|
844
|
-
//
|
|
1110
|
+
// ── Document Capture ────────────────────────────────────────────
|
|
845
1111
|
renderDocumentCapture(container) {
|
|
846
1112
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
847
1113
|
// Strip "with face", "(NIN)", "(BVN)" noise from the user-visible title
|
|
@@ -882,144 +1148,408 @@ export class SmartComplyFlow {
|
|
|
882
1148
|
this.showErrorStep(err.message || "Document capture failed. Please try again.");
|
|
883
1149
|
});
|
|
884
1150
|
}
|
|
885
|
-
//
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
|
903
|
-
font-size:20px;
|
|
904
|
-
`;
|
|
905
|
-
centerIcon.textContent = "🔍";
|
|
906
|
-
animationWrap.appendChild(centerIcon);
|
|
907
|
-
container.appendChild(animationWrap);
|
|
908
|
-
const title = document.createElement("div");
|
|
909
|
-
title.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:700;text-align:center;`;
|
|
910
|
-
title.textContent = "Processing your document";
|
|
1151
|
+
// ── W3: Identity Confirmation (data verification) ───────────────
|
|
1152
|
+
renderConfirmIdentity(container) {
|
|
1153
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:8px 0;";
|
|
1154
|
+
// W14: Check retry limit before showing screen
|
|
1155
|
+
if (this._confirmRetries >= SmartComplyFlow.MAX_RETRIES) {
|
|
1156
|
+
this._renderRetryLimitReached(container);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
// W13: Pre-warm camera silently while user reads this screen
|
|
1160
|
+
this._prewarmCamera();
|
|
1161
|
+
const iconWrap = document.createElement("div");
|
|
1162
|
+
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;`;
|
|
1163
|
+
iconWrap.textContent = "🛡ï¸";
|
|
1164
|
+
container.appendChild(iconWrap);
|
|
1165
|
+
const title = document.createElement("h2");
|
|
1166
|
+
title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1167
|
+
title.textContent = this._ivsName ? `Is this you?` : "Identity Confirmed";
|
|
911
1168
|
container.appendChild(title);
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
{
|
|
924
|
-
{
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1169
|
+
if (this._ivsName) {
|
|
1170
|
+
// Profile card
|
|
1171
|
+
const card = document.createElement("div");
|
|
1172
|
+
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;`;
|
|
1173
|
+
const rows = [
|
|
1174
|
+
{ label: "Name", value: this._ivsName },
|
|
1175
|
+
];
|
|
1176
|
+
if (this._ivsGender) {
|
|
1177
|
+
const gLabel = this._ivsGender.toLowerCase();
|
|
1178
|
+
rows.push({ label: "Gender", value: gLabel === "m" || gLabel === "male" || gLabel === "1" ? "Male" : gLabel === "f" || gLabel === "female" || gLabel === "2" ? "Female" : this._ivsGender });
|
|
1179
|
+
}
|
|
1180
|
+
rows.push({ label: "ID Type", value: _formatDocType(this.selectedIdType) || this.selectedIdTypeName || this.selectedIdType });
|
|
1181
|
+
rows.forEach(({ label, value }) => {
|
|
1182
|
+
const row = document.createElement("div");
|
|
1183
|
+
row.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
|
|
1184
|
+
const lEl = document.createElement("span");
|
|
1185
|
+
lEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;font-weight:500;`;
|
|
1186
|
+
lEl.textContent = label;
|
|
1187
|
+
const vEl = document.createElement("span");
|
|
1188
|
+
vEl.style.cssText = `color:${this.theme.text};font-size:13px;font-weight:600;max-width:60%;text-align:right;`;
|
|
1189
|
+
vEl.textContent = value;
|
|
1190
|
+
row.appendChild(lEl);
|
|
1191
|
+
row.appendChild(vEl);
|
|
1192
|
+
card.appendChild(row);
|
|
1193
|
+
});
|
|
1194
|
+
container.appendChild(card);
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
const sub = document.createElement("p");
|
|
1198
|
+
sub.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
|
|
1199
|
+
sub.textContent = "Your details have been confirmed. Please continue to the face check.";
|
|
1200
|
+
container.appendChild(sub);
|
|
1201
|
+
}
|
|
1202
|
+
const yesBtn = this.createPrimaryButton("Yes, that's me — continue");
|
|
1203
|
+
yesBtn.addEventListener("click", () => {
|
|
1204
|
+
// B5: Reset retry counter on successful confirmation
|
|
1205
|
+
this._confirmRetries = 0;
|
|
1206
|
+
this.showStep("liveness");
|
|
1207
|
+
});
|
|
1208
|
+
container.appendChild(yesBtn);
|
|
1209
|
+
const noBtn = this.createSecondaryButton("No, use a different ID");
|
|
1210
|
+
noBtn.addEventListener("click", () => {
|
|
1211
|
+
this._confirmRetries++;
|
|
1212
|
+
this._ivsName = "";
|
|
1213
|
+
this._ivsGender = "";
|
|
1214
|
+
this.identityCheckId = null;
|
|
1215
|
+
this.verificationResult = null;
|
|
1216
|
+
this.showStep("id_input");
|
|
954
1217
|
});
|
|
955
|
-
container.appendChild(
|
|
956
|
-
this.startProcessingAnimation();
|
|
1218
|
+
container.appendChild(noBtn);
|
|
957
1219
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1220
|
+
// ── W4: Document Confirmation (document verification) ────────────
|
|
1221
|
+
renderDocumentConfirm(container) {
|
|
1222
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:8px 0;";
|
|
1223
|
+
// W14: Check retry limit
|
|
1224
|
+
if (this._confirmRetries >= SmartComplyFlow.MAX_RETRIES) {
|
|
1225
|
+
this._renderRetryLimitReached(container);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
// W13: Pre-warm camera while user reads
|
|
1229
|
+
this._prewarmCamera();
|
|
1230
|
+
const iconWrap = document.createElement("div");
|
|
1231
|
+
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;`;
|
|
1232
|
+
iconWrap.textContent = "📄";
|
|
1233
|
+
container.appendChild(iconWrap);
|
|
1234
|
+
const title = document.createElement("h2");
|
|
1235
|
+
title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1236
|
+
title.textContent = "Are these your details?";
|
|
1237
|
+
container.appendChild(title);
|
|
1238
|
+
const sub = document.createElement("p");
|
|
1239
|
+
sub.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.5;`;
|
|
1240
|
+
sub.textContent = "We read the following from your document. Please confirm they are correct before continuing.";
|
|
1241
|
+
container.appendChild(sub);
|
|
1242
|
+
// Document face thumbnail if available
|
|
1243
|
+
if (this._ocrFaceImageUrl) {
|
|
1244
|
+
const faceWrap = document.createElement("div");
|
|
1245
|
+
faceWrap.style.cssText = `width:80px;height:80px;border-radius:50%;overflow:hidden;border:3px solid ${this.theme.primary};flex-shrink:0;margin:0 auto;`;
|
|
1246
|
+
const faceImg = document.createElement("img");
|
|
1247
|
+
faceImg.src = this._ocrFaceImageUrl;
|
|
1248
|
+
faceImg.alt = "Photo from your document";
|
|
1249
|
+
faceImg.style.cssText = "width:100%;height:100%;object-fit:cover;";
|
|
1250
|
+
faceWrap.appendChild(faceImg);
|
|
1251
|
+
container.appendChild(faceWrap);
|
|
1252
|
+
}
|
|
1253
|
+
// Expiry soon warning
|
|
1254
|
+
if (this._ocrExpirySoon && !this._ocrIsExpired) {
|
|
1255
|
+
const warn = document.createElement("div");
|
|
1256
|
+
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;`;
|
|
1257
|
+
warn.textContent = "âš ï¸ Your document expires soon. Some services may not accept it, but you can still continue.";
|
|
1258
|
+
container.appendChild(warn);
|
|
1259
|
+
}
|
|
1260
|
+
// Details card — show whatever OCR returned, never block on missing fields
|
|
1261
|
+
const card = document.createElement("div");
|
|
1262
|
+
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;`;
|
|
1263
|
+
const docRows = [];
|
|
1264
|
+
if (this._ocrName)
|
|
1265
|
+
docRows.push({ label: "Name", value: this._ocrName });
|
|
1266
|
+
if (this._ocrDocNumber)
|
|
1267
|
+
docRows.push({ label: "Document No", value: this._ocrDocNumber });
|
|
1268
|
+
if (this._ocrExpiry)
|
|
1269
|
+
docRows.push({ label: "Expires", value: this._ocrExpiry });
|
|
1270
|
+
docRows.push({ label: "Document Type", value: _formatDocType(this.selectedIdType) || this.selectedIdTypeName });
|
|
1271
|
+
if (docRows.length === 0) {
|
|
1272
|
+
const fallback = document.createElement("div");
|
|
1273
|
+
fallback.style.cssText = `color:${this.theme.textSecondary};font-size:12px;`;
|
|
1274
|
+
fallback.textContent = "Document scanned successfully.";
|
|
1275
|
+
card.appendChild(fallback);
|
|
1276
|
+
}
|
|
1277
|
+
else {
|
|
1278
|
+
docRows.forEach(({ label, value }) => {
|
|
1279
|
+
if (!value)
|
|
1280
|
+
return;
|
|
1281
|
+
const row = document.createElement("div");
|
|
1282
|
+
row.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
|
|
1283
|
+
const lEl = document.createElement("span");
|
|
1284
|
+
lEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;font-weight:500;`;
|
|
1285
|
+
lEl.textContent = label;
|
|
1286
|
+
const vEl = document.createElement("span");
|
|
1287
|
+
vEl.style.cssText = `color:${this.theme.text};font-size:13px;font-weight:600;max-width:60%;text-align:right;`;
|
|
1288
|
+
vEl.textContent = value;
|
|
1289
|
+
row.appendChild(lEl);
|
|
1290
|
+
row.appendChild(vEl);
|
|
1291
|
+
card.appendChild(row);
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
container.appendChild(card);
|
|
1295
|
+
const yesBtn = this.createPrimaryButton("Yes, continue");
|
|
1296
|
+
yesBtn.addEventListener("click", () => {
|
|
1297
|
+
// B5: Reset retry counter on successful confirmation
|
|
1298
|
+
this._confirmRetries = 0;
|
|
1299
|
+
this.showStep("liveness");
|
|
1300
|
+
});
|
|
1301
|
+
container.appendChild(yesBtn);
|
|
1302
|
+
const noBtn = this.createSecondaryButton("No, retake document");
|
|
1303
|
+
noBtn.addEventListener("click", () => {
|
|
1304
|
+
this._confirmRetries++;
|
|
1305
|
+
this._ocrName = "";
|
|
1306
|
+
this._ocrDocNumber = "";
|
|
1307
|
+
this._ocrExpiry = "";
|
|
1308
|
+
this._ocrFaceImageUrl = "";
|
|
1309
|
+
this._ocrIsExpired = false;
|
|
1310
|
+
this._ocrExpirySoon = false;
|
|
1311
|
+
this.livenessEntryId = null;
|
|
1312
|
+
this.documentBlob = null;
|
|
1313
|
+
this.documentBackBlob = null;
|
|
1314
|
+
this.showStep("document_capture");
|
|
1315
|
+
});
|
|
1316
|
+
container.appendChild(noBtn);
|
|
1317
|
+
}
|
|
1318
|
+
// ── W6+W7+W8: OCR Gate (document verification) ───────────────────
|
|
1319
|
+
renderOcrGate(container) {
|
|
1320
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:14px;padding:20px 0;text-align:center;";
|
|
1321
|
+
const spinnerWrap = document.createElement("div");
|
|
1322
|
+
spinnerWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
|
|
1323
|
+
const ring = document.createElement("div");
|
|
1324
|
+
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;`;
|
|
1325
|
+
const centre = document.createElement("div");
|
|
1326
|
+
centre.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;";
|
|
1327
|
+
centre.textContent = "📄";
|
|
1328
|
+
spinnerWrap.appendChild(ring);
|
|
1329
|
+
spinnerWrap.appendChild(centre);
|
|
1330
|
+
container.appendChild(spinnerWrap);
|
|
1331
|
+
const statusEl = document.createElement("div");
|
|
1332
|
+
statusEl.setAttribute("aria-live", "polite");
|
|
1333
|
+
statusEl.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:600;`;
|
|
1334
|
+
statusEl.textContent = "Reading your document…";
|
|
1335
|
+
container.appendChild(statusEl);
|
|
1336
|
+
const subEl = document.createElement("div");
|
|
1337
|
+
subEl.style.cssText = `color:${this.theme.textSecondary};font-size:12px;max-width:260px;line-height:1.5;`;
|
|
1338
|
+
subEl.textContent = "This usually takes 5–15 seconds. Please wait.";
|
|
1339
|
+
container.appendChild(subEl);
|
|
1340
|
+
if (!this.livenessEntryId) {
|
|
1341
|
+
this.showErrorStep("Document upload failed. Please try again.");
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
const entryId = this.livenessEntryId;
|
|
1345
|
+
const MAX_POLLS = 10; // 10 × 2s = 20s timeout
|
|
1346
|
+
let polls = 0;
|
|
1347
|
+
const poll = async () => {
|
|
1348
|
+
if (this.isDestroyed)
|
|
1349
|
+
return;
|
|
1350
|
+
polls++;
|
|
1351
|
+
try {
|
|
1352
|
+
const result = await this.sdk.liveness.pollResult(entryId);
|
|
1353
|
+
// W7: Expired document — stop, do not proceed to liveness
|
|
1354
|
+
if (result.is_expired === true) {
|
|
1355
|
+
container.innerHTML = "";
|
|
1356
|
+
this._renderExpiredDocument(container);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
// W8: OCR quality fail — offer retake
|
|
1360
|
+
if (result.ocr_quality_failed === true) {
|
|
1361
|
+
container.innerHTML = "";
|
|
1362
|
+
this._renderOcrQualityFail(container, result.ocr_rejection_reason);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
// OCR complete — store data and show confirmation screen
|
|
1366
|
+
const ocrDone = result.ocr_status === "done" || result.ocr_status === "ocr_complete"
|
|
1367
|
+
|| result.ocr_name || result.ocr_document_number || result.ocr_expiry_date;
|
|
1368
|
+
if (ocrDone) {
|
|
1369
|
+
this._ocrName = result.ocr_name || "";
|
|
1370
|
+
this._ocrDocNumber = result.ocr_document_number || "";
|
|
1371
|
+
this._ocrExpiry = result.ocr_expiry_date || "";
|
|
1372
|
+
this._ocrFaceImageUrl = result.ocr_face_image_url || "";
|
|
1373
|
+
this._ocrIsExpired = result.is_expired === true;
|
|
1374
|
+
// B8: Normalise expiry date string before parsing — OCR may return
|
|
1375
|
+
// DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD, or "12 May 2029" etc.
|
|
1376
|
+
// new Date() alone is locale-dependent on non-ISO strings.
|
|
1377
|
+
if (this._ocrExpiry) {
|
|
1378
|
+
const expDate = _parseOcrDate(this._ocrExpiry);
|
|
1379
|
+
if (expDate) {
|
|
1380
|
+
const sixMonths = new Date();
|
|
1381
|
+
sixMonths.setMonth(sixMonths.getMonth() + 6);
|
|
1382
|
+
this._ocrExpirySoon = expDate <= sixMonths && expDate > new Date();
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
this.showStep("document_confirm");
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
// Still pending — keep polling
|
|
1389
|
+
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1390
|
+
this._ocrPollTimer = setTimeout(poll, 2000);
|
|
1391
|
+
}
|
|
1392
|
+
else if (!this.isDestroyed) {
|
|
1393
|
+
// Timeout — proceed anyway with whatever we have
|
|
1394
|
+
statusEl.textContent = "Taking longer than usual…";
|
|
1395
|
+
subEl.textContent = "We'll continue — you can confirm your details on the next screen.";
|
|
1396
|
+
this._ocrPollTimer = setTimeout(() => {
|
|
1397
|
+
if (!this.isDestroyed)
|
|
1398
|
+
this.showStep("document_confirm");
|
|
1399
|
+
}, 2000);
|
|
1400
|
+
}
|
|
984
1401
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
if (stepEl && iconEl && textEl && statusEl) {
|
|
989
|
-
stepEl.style.borderColor = this.theme.success;
|
|
990
|
-
stepEl.style.background = `${this.theme.success}10`;
|
|
991
|
-
iconEl.style.background = this.theme.success;
|
|
992
|
-
statusEl.innerHTML = `
|
|
993
|
-
<div style="
|
|
994
|
-
position:absolute;inset:3px;border-radius:50%;
|
|
995
|
-
background:${this.theme.success};
|
|
996
|
-
display:flex;align-items:center;justify-content:center;
|
|
997
|
-
color:white;font-size:10px;font-weight:bold;
|
|
998
|
-
">✓</div>
|
|
999
|
-
`;
|
|
1402
|
+
catch {
|
|
1403
|
+
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1404
|
+
this._ocrPollTimer = setTimeout(poll, 3000);
|
|
1000
1405
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
animateStep(steps[currentStepIndex]);
|
|
1406
|
+
else if (!this.isDestroyed) {
|
|
1407
|
+
this.showErrorStep("We could not read your document. Please check your connection and try again.");
|
|
1004
1408
|
}
|
|
1005
|
-
}
|
|
1409
|
+
}
|
|
1006
1410
|
};
|
|
1007
|
-
//
|
|
1008
|
-
|
|
1411
|
+
// First poll after 3s — give OCR submission time to start
|
|
1412
|
+
this._ocrPollTimer = setTimeout(poll, 3000);
|
|
1413
|
+
}
|
|
1414
|
+
_renderExpiredDocument(container) {
|
|
1415
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
|
|
1416
|
+
const icon = document.createElement("div");
|
|
1417
|
+
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;`;
|
|
1418
|
+
icon.textContent = "📅";
|
|
1419
|
+
container.appendChild(icon);
|
|
1420
|
+
const t = document.createElement("h2");
|
|
1421
|
+
t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1422
|
+
t.textContent = "Document Expired";
|
|
1423
|
+
container.appendChild(t);
|
|
1424
|
+
const d = document.createElement("p");
|
|
1425
|
+
d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
|
|
1426
|
+
d.textContent = "The document you submitted has expired. Please use a valid, current ID.";
|
|
1427
|
+
container.appendChild(d);
|
|
1428
|
+
const retryBtn = this.createPrimaryButton("Try a different document");
|
|
1429
|
+
retryBtn.addEventListener("click", () => {
|
|
1430
|
+
this.livenessEntryId = null;
|
|
1431
|
+
this.documentBlob = null;
|
|
1432
|
+
this.documentBackBlob = null;
|
|
1433
|
+
this.showStep("document_capture");
|
|
1434
|
+
});
|
|
1435
|
+
container.appendChild(retryBtn);
|
|
1436
|
+
const endBtn = this.createSecondaryButton("End session");
|
|
1437
|
+
endBtn.addEventListener("click", () => { this.options.onError?.(new Error("Document expired")); this.close(); });
|
|
1438
|
+
container.appendChild(endBtn);
|
|
1439
|
+
}
|
|
1440
|
+
_renderOcrQualityFail(container, reason) {
|
|
1441
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
|
|
1442
|
+
const icon = document.createElement("div");
|
|
1443
|
+
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;`;
|
|
1444
|
+
icon.textContent = "ðŸ”";
|
|
1445
|
+
container.appendChild(icon);
|
|
1446
|
+
const t = document.createElement("h2");
|
|
1447
|
+
t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1448
|
+
t.textContent = "Document Photo Unclear";
|
|
1449
|
+
container.appendChild(t);
|
|
1450
|
+
const msg = reason === "image_too_blurry" ? "Your document photo is too blurry. Please retake it in good lighting with a steady hand."
|
|
1451
|
+
: reason === "image_too_dark" ? "Your document photo is too dark. Please retake it in better lighting."
|
|
1452
|
+
: reason === "image_too_bright" ? "Your document photo has too much glare. Please reduce reflections and retake."
|
|
1453
|
+
: reason === "document_not_detected" ? "We could not detect a document in the photo. Please ensure the full ID is visible."
|
|
1454
|
+
: "We could not read your document clearly. Please retake the photo in good lighting with no glare.";
|
|
1455
|
+
const d = document.createElement("p");
|
|
1456
|
+
d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
|
|
1457
|
+
d.textContent = msg;
|
|
1458
|
+
container.appendChild(d);
|
|
1459
|
+
const retryBtn = this.createPrimaryButton("Retake photo");
|
|
1460
|
+
retryBtn.addEventListener("click", () => {
|
|
1461
|
+
this.livenessEntryId = null;
|
|
1462
|
+
this.documentBlob = null;
|
|
1463
|
+
this.documentBackBlob = null;
|
|
1464
|
+
this.showStep("document_capture");
|
|
1465
|
+
});
|
|
1466
|
+
container.appendChild(retryBtn);
|
|
1467
|
+
}
|
|
1468
|
+
// ── W11: You're all done (end-user final screen) ─────────────────
|
|
1469
|
+
renderDone(container) {
|
|
1470
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:24px 0;";
|
|
1471
|
+
container.setAttribute("role", "status");
|
|
1472
|
+
// B7: Fire onComplete immediately when the done screen renders — the end
|
|
1473
|
+
// user has completed their part regardless of whether they click Close.
|
|
1474
|
+
// Firing here means the integrator always gets the event even if the user
|
|
1475
|
+
// closes the modal via the ✕ button instead of the Close button.
|
|
1476
|
+
this.options.onComplete?.({
|
|
1477
|
+
entryId: this.livenessEntryId || 0,
|
|
1478
|
+
sessionId: this.sdk.sessionToken,
|
|
1479
|
+
status: "processing",
|
|
1480
|
+
submittedAt: new Date().toISOString(),
|
|
1481
|
+
verificationResult: this.verificationResult || undefined,
|
|
1482
|
+
});
|
|
1483
|
+
const iconWrap = document.createElement("div");
|
|
1484
|
+
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;`;
|
|
1485
|
+
iconWrap.textContent = "✓";
|
|
1486
|
+
container.appendChild(iconWrap);
|
|
1487
|
+
const title = document.createElement("h2");
|
|
1488
|
+
title.style.cssText = `color:${this.theme.text};font-size:22px;font-weight:700;margin:0;`;
|
|
1489
|
+
title.textContent = "You're all done!";
|
|
1490
|
+
container.appendChild(title);
|
|
1491
|
+
const brandName = this.sdkConfig?.brand_name || "the service";
|
|
1492
|
+
const desc = document.createElement("p");
|
|
1493
|
+
desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
|
|
1494
|
+
desc.textContent = `Your verification is complete. ${brandName} will review and be in touch shortly.`;
|
|
1495
|
+
container.appendChild(desc);
|
|
1496
|
+
// Subtle info line
|
|
1497
|
+
const info = document.createElement("div");
|
|
1498
|
+
info.style.cssText = `color:${this.theme.textMuted};font-size:11px;display:flex;align-items:center;gap:4px;`;
|
|
1499
|
+
info.textContent = "🔒 Your data is encrypted and secure";
|
|
1500
|
+
container.appendChild(info);
|
|
1501
|
+
const doneBtn = this.createPrimaryButton("Close");
|
|
1502
|
+
doneBtn.addEventListener("click", () => {
|
|
1503
|
+
// onComplete already fired on render — just close the modal
|
|
1504
|
+
this.close();
|
|
1505
|
+
});
|
|
1506
|
+
container.appendChild(doneBtn);
|
|
1507
|
+
}
|
|
1508
|
+
// ── W14: Retry limit reached ─────────────────────────────────────
|
|
1509
|
+
_renderRetryLimitReached(container) {
|
|
1510
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:24px 0;";
|
|
1511
|
+
const icon = document.createElement("div");
|
|
1512
|
+
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;`;
|
|
1513
|
+
icon.textContent = "âš ï¸";
|
|
1514
|
+
container.appendChild(icon);
|
|
1515
|
+
const t = document.createElement("h2");
|
|
1516
|
+
t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
|
|
1517
|
+
t.textContent = "Unable to Complete Verification";
|
|
1518
|
+
container.appendChild(t);
|
|
1519
|
+
const d = document.createElement("p");
|
|
1520
|
+
d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
|
|
1521
|
+
d.textContent = "We were unable to complete your verification at this time. Please contact support or try again later.";
|
|
1522
|
+
container.appendChild(d);
|
|
1523
|
+
const closeBtn = this.createPrimaryButton("Close");
|
|
1524
|
+
closeBtn.addEventListener("click", () => { this.options.onError?.(new Error("Retry limit reached")); this.close(); });
|
|
1525
|
+
container.appendChild(closeBtn);
|
|
1526
|
+
}
|
|
1527
|
+
// ── W13: Camera pre-warm ─────────────────────────────────────────
|
|
1528
|
+
_prewarmCamera() {
|
|
1529
|
+
if (this._prewarmedStream || this.isDestroyed)
|
|
1530
|
+
return;
|
|
1531
|
+
navigator.mediaDevices?.getUserMedia?.({ video: { facingMode: "user" } })
|
|
1532
|
+
.then(stream => {
|
|
1533
|
+
if (this.isDestroyed) {
|
|
1534
|
+
stream.getTracks().forEach(t => t.stop());
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
this._prewarmedStream = stream;
|
|
1538
|
+
})
|
|
1539
|
+
.catch(() => { });
|
|
1009
1540
|
}
|
|
1010
1541
|
/**
|
|
1011
1542
|
* Process document capture result with front/back support
|
|
1012
1543
|
*/
|
|
1013
1544
|
async processDocumentCaptureResult(result, captureContainer, container) {
|
|
1014
|
-
//
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
//
|
|
1018
|
-
await
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
}
|
|
1545
|
+
// B2+P2: Compress both images once here. Do NOT assign documentBlob twice
|
|
1546
|
+
// (old code set it to the raw blob first, then overwrote with compressed).
|
|
1547
|
+
// processDocumentBlob also ran _compressImage — that second pass is removed
|
|
1548
|
+
// below so compression only happens once.
|
|
1549
|
+
this.documentBlob = await _compressImage(result.front, 1600, 0.85);
|
|
1550
|
+
this.documentBackBlob = result.back ? await _compressImage(result.back, 1600, 0.85) : null;
|
|
1551
|
+
// Process the front image — pass the already-compressed blob
|
|
1552
|
+
await this.processDocumentBlob(this.documentBlob, captureContainer, container);
|
|
1023
1553
|
}
|
|
1024
1554
|
/**
|
|
1025
1555
|
* Process document blob - common method for both CameraView and DocumentCapture
|
|
@@ -1029,7 +1559,7 @@ export class SmartComplyFlow {
|
|
|
1029
1559
|
let retryCount = 0;
|
|
1030
1560
|
const attemptProcessing = async () => {
|
|
1031
1561
|
try {
|
|
1032
|
-
// Validate blob
|
|
1562
|
+
// Validate blob — compression already done in processDocumentCaptureResult (P2)
|
|
1033
1563
|
if (!blob || blob.size === 0) {
|
|
1034
1564
|
throw new Error("Failed to capture document. Please try again.");
|
|
1035
1565
|
}
|
|
@@ -1042,13 +1572,20 @@ export class SmartComplyFlow {
|
|
|
1042
1572
|
<div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:28px 20px;">
|
|
1043
1573
|
<div style="position:relative;width:52px;height:52px;">
|
|
1044
1574
|
<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>
|
|
1045
|
-
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;"
|
|
1575
|
+
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">ðŸ”</div>
|
|
1046
1576
|
</div>
|
|
1047
1577
|
<div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
|
|
1048
1578
|
<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>
|
|
1049
1579
|
</div>`;
|
|
1050
|
-
// Create liveness entry with document
|
|
1051
|
-
|
|
1580
|
+
// Create liveness entry with document.
|
|
1581
|
+
// Use the first collected field value as identifier when available
|
|
1582
|
+
// (document flow has no data-verification step, so collectedFields is
|
|
1583
|
+
// empty — fall back to the selected ID type code so the webhook
|
|
1584
|
+
// subject.identifier is at least human-meaningful, not a raw timestamp).
|
|
1585
|
+
const identifier = Object.values(this.collectedFields)[0]
|
|
1586
|
+
|| this.idNumber
|
|
1587
|
+
|| this.selectedIdType
|
|
1588
|
+
|| `DOC-${Date.now()}`;
|
|
1052
1589
|
const COUNTRY_CODES = {
|
|
1053
1590
|
nigeria: "NG", ghana: "GH", kenya: "KE",
|
|
1054
1591
|
south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
|
|
@@ -1092,10 +1629,9 @@ export class SmartComplyFlow {
|
|
|
1092
1629
|
}
|
|
1093
1630
|
}
|
|
1094
1631
|
this.livenessEntryId = createResult.id;
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
|
|
1098
|
-
this.showStep("liveness");
|
|
1632
|
+
// W6: Route to OCR gate — poll until OCR result is ready, validate
|
|
1633
|
+
// expiry/quality, then show document_confirm before liveness starts.
|
|
1634
|
+
this.showStep("ocr_gate");
|
|
1099
1635
|
}
|
|
1100
1636
|
catch (err) {
|
|
1101
1637
|
console.error("Document processing failed:", err);
|
|
@@ -1108,7 +1644,7 @@ export class SmartComplyFlow {
|
|
|
1108
1644
|
<div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
|
|
1109
1645
|
<div style="position:relative;width:52px;height:52px;">
|
|
1110
1646
|
<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>
|
|
1111
|
-
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;"
|
|
1647
|
+
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔄</div>
|
|
1112
1648
|
</div>
|
|
1113
1649
|
<div style="color:${this.theme.text};font-size:14px;font-weight:600;">Retrying...</div>
|
|
1114
1650
|
<div style="color:${this.theme.textSecondary};font-size:12px;max-width:240px;line-height:1.5;">Attempt ${retryCount} of ${maxRetries}</div>
|
|
@@ -1127,55 +1663,65 @@ export class SmartComplyFlow {
|
|
|
1127
1663
|
await attemptProcessing();
|
|
1128
1664
|
}
|
|
1129
1665
|
handleDocumentProcessingError(error, captureContainer) {
|
|
1130
|
-
|
|
1666
|
+
// Map technical errors to user-friendly messages — never pass raw error strings
|
|
1667
|
+
let userMessage = "We could not verify your document. Please try again.";
|
|
1131
1668
|
let canRetry = true;
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1669
|
+
const raw = (error?.message || "").toLowerCase();
|
|
1670
|
+
if (raw.includes("expired")) {
|
|
1671
|
+
userMessage = "The document you submitted has expired. Please use a valid, current ID.";
|
|
1672
|
+
canRetry = false;
|
|
1673
|
+
}
|
|
1674
|
+
else if (raw.includes("quality too low") || raw.includes("blurry") || raw.includes("blur")) {
|
|
1675
|
+
userMessage = "The document photo isn't clear enough. Please retake it in good lighting with no glare.";
|
|
1676
|
+
}
|
|
1677
|
+
else if (raw.includes("not detected") || raw.includes("document_not_detected")) {
|
|
1678
|
+
userMessage = "We could not detect a document in the photo. Please ensure the full ID is visible and try again.";
|
|
1679
|
+
}
|
|
1680
|
+
else if (raw.includes("network") || raw.includes("fetch") || raw.includes("connection")) {
|
|
1681
|
+
userMessage = "A connection error occurred. Please check your internet and try again.";
|
|
1682
|
+
}
|
|
1683
|
+
else if (raw.includes("too large") || raw.includes("413")) {
|
|
1684
|
+
userMessage = "The document image is too large. Please retake the photo.";
|
|
1685
|
+
}
|
|
1686
|
+
else if (raw.includes("format") || raw.includes("415")) {
|
|
1687
|
+
userMessage = "Unsupported image format. Please use JPEG or PNG.";
|
|
1688
|
+
}
|
|
1689
|
+
else if (raw.includes("type mismatch") || raw.includes("document_type_mismatch")) {
|
|
1690
|
+
userMessage = "The document does not match the selected ID type. Please choose the correct ID type.";
|
|
1149
1691
|
}
|
|
1150
1692
|
if (captureContainer) {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1693
|
+
// Build with DOM — never interpolate user-derived content into innerHTML
|
|
1694
|
+
const wrap = document.createElement("div");
|
|
1695
|
+
wrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;";
|
|
1696
|
+
wrap.setAttribute("role", "alert");
|
|
1697
|
+
const icon = document.createElement("div");
|
|
1698
|
+
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;`;
|
|
1699
|
+
icon.textContent = "ðŸ”";
|
|
1700
|
+
wrap.appendChild(icon);
|
|
1701
|
+
const titleEl = document.createElement("div");
|
|
1702
|
+
titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:700;`;
|
|
1703
|
+
titleEl.textContent = "Document Could Not Be Verified";
|
|
1704
|
+
wrap.appendChild(titleEl);
|
|
1705
|
+
const msgEl = document.createElement("div");
|
|
1706
|
+
msgEl.style.cssText = `color:${this.theme.textSecondary};font-size:13px;max-width:260px;line-height:1.6;`;
|
|
1707
|
+
msgEl.textContent = userMessage;
|
|
1708
|
+
wrap.appendChild(msgEl);
|
|
1165
1709
|
if (canRetry) {
|
|
1166
|
-
const retryBtn =
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1710
|
+
const retryBtn = document.createElement("button");
|
|
1711
|
+
retryBtn.textContent = "Try Again";
|
|
1712
|
+
retryBtn.setAttribute("aria-label", "Retake document photo");
|
|
1713
|
+
retryBtn.style.cssText = `margin-top:8px;padding:12px 24px;border-radius:8px;background:${this.theme.primary};color:#fff;border:none;font-size:14px;font-weight:600;cursor:pointer;`;
|
|
1714
|
+
retryBtn.addEventListener("click", () => this.showStep("document_capture"));
|
|
1715
|
+
wrap.appendChild(retryBtn);
|
|
1172
1716
|
}
|
|
1717
|
+
captureContainer.innerHTML = "";
|
|
1718
|
+
captureContainer.appendChild(wrap);
|
|
1173
1719
|
}
|
|
1174
1720
|
else {
|
|
1175
|
-
this.showErrorStep(
|
|
1721
|
+
this.showErrorStep(userMessage);
|
|
1176
1722
|
}
|
|
1177
1723
|
}
|
|
1178
|
-
//
|
|
1724
|
+
// ── Liveness ────────────────────────────────────────────────────
|
|
1179
1725
|
renderLiveness(container) {
|
|
1180
1726
|
container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
|
|
1181
1727
|
const title = this.createStepTitle("Face verification");
|
|
@@ -1188,15 +1734,14 @@ export class SmartComplyFlow {
|
|
|
1188
1734
|
const livenessContainer = document.createElement("div");
|
|
1189
1735
|
livenessContainer.style.cssText = "margin:8px 0;width:100%;";
|
|
1190
1736
|
container.appendChild(livenessContainer);
|
|
1191
|
-
|
|
1737
|
+
// W10: For data verification, identifier is already held by the backend
|
|
1738
|
+
// via identity_check_id — do not re-send it in liveness create.
|
|
1739
|
+
const identifier = this.identityCheckId
|
|
1740
|
+
? "" // data verification — session holds context via identity_check_id
|
|
1741
|
+
: (this.idNumber || Object.values(this.collectedFields)[0] || this.selectedIdType || `DOC-${Date.now()}`);
|
|
1192
1742
|
const COUNTRY_CODES = {
|
|
1193
|
-
nigeria: "NG",
|
|
1194
|
-
|
|
1195
|
-
kenya: "KE",
|
|
1196
|
-
south_africa: "ZA",
|
|
1197
|
-
united_states: "US",
|
|
1198
|
-
united_kingdom: "GB",
|
|
1199
|
-
global: "US",
|
|
1743
|
+
nigeria: "NG", ghana: "GH", kenya: "KE",
|
|
1744
|
+
south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
|
|
1200
1745
|
};
|
|
1201
1746
|
const country = COUNTRY_CODES[this.selectedCountry] || this.selectedCountry.toUpperCase().slice(0, 2);
|
|
1202
1747
|
// Start liveness check (this handles camera, detection, recording, submission)
|
|
@@ -1209,237 +1754,86 @@ export class SmartComplyFlow {
|
|
|
1209
1754
|
document: this.documentBlob || undefined,
|
|
1210
1755
|
document_back: this.documentBackBlob || undefined,
|
|
1211
1756
|
identity_check: this.identityCheckId || undefined,
|
|
1757
|
+
verification_type: this.identityCheckId
|
|
1758
|
+
? "data_verification"
|
|
1759
|
+
: this.documentBlob
|
|
1760
|
+
? "document_verification"
|
|
1761
|
+
: "liveness_only",
|
|
1762
|
+
// B4: pass pre-warmed stream so camera.open() is skipped
|
|
1763
|
+
prewarmedStream: this._prewarmedStream || undefined,
|
|
1212
1764
|
}, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
|
|
1213
1765
|
.then((result) => {
|
|
1214
|
-
|
|
1766
|
+
// W11: After submit, end user is done — show "You're all done" screen.
|
|
1767
|
+
// Backend processing continues in background; client receives webhook.
|
|
1768
|
+
// Track liveness failures for retry limit (W14).
|
|
1769
|
+
if (result.status === "failed") {
|
|
1770
|
+
this._livenessFailures++;
|
|
1771
|
+
}
|
|
1772
|
+
if (result.id) {
|
|
1773
|
+
this.livenessEntryId = result.id;
|
|
1774
|
+
}
|
|
1775
|
+
this.showStep("done");
|
|
1215
1776
|
})
|
|
1216
1777
|
.catch((err) => {
|
|
1778
|
+
this._livenessFailures++;
|
|
1217
1779
|
this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
|
|
1218
1780
|
});
|
|
1219
1781
|
}
|
|
1220
|
-
// ── Result ──────────────────────────────────────────────────────
|
|
1221
|
-
showResult(submitResult) {
|
|
1222
|
-
this.currentStep = "result";
|
|
1223
|
-
this.updateProgress();
|
|
1224
|
-
if (!this.contentArea)
|
|
1225
|
-
return;
|
|
1226
|
-
this.contentArea.innerHTML = "";
|
|
1227
|
-
const wrapper = document.createElement("div");
|
|
1228
|
-
wrapper.style.cssText = `
|
|
1229
|
-
display:flex;flex-direction:column;align-items:center;
|
|
1230
|
-
text-align:center;gap:16px;padding:20px 0;
|
|
1231
|
-
opacity:0;transform:scale(0.95);transition:all 0.4s ease;
|
|
1232
|
-
`;
|
|
1233
|
-
// Icon — starts as a spinner, updates when status resolves
|
|
1234
|
-
const iconWrap = document.createElement("div");
|
|
1235
|
-
iconWrap.style.cssText = `
|
|
1236
|
-
width:80px;height:80px;border-radius:50%;
|
|
1237
|
-
display:flex;align-items:center;justify-content:center;
|
|
1238
|
-
font-size:36px;
|
|
1239
|
-
background:${this.theme.cardBg};
|
|
1240
|
-
border:3px solid ${this.theme.border};
|
|
1241
|
-
`;
|
|
1242
|
-
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>`;
|
|
1243
|
-
wrapper.appendChild(iconWrap);
|
|
1244
|
-
const title = document.createElement("h2");
|
|
1245
|
-
title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
|
|
1246
|
-
title.textContent = "Verification Submitted";
|
|
1247
|
-
wrapper.appendChild(title);
|
|
1248
|
-
const desc = document.createElement("p");
|
|
1249
|
-
desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
|
|
1250
|
-
desc.textContent = "Processing your identity check. This takes around 30 seconds.";
|
|
1251
|
-
wrapper.appendChild(desc);
|
|
1252
|
-
// Details card
|
|
1253
|
-
const details = document.createElement("div");
|
|
1254
|
-
details.style.cssText = `
|
|
1255
|
-
width:100%;padding:16px;border-radius:12px;
|
|
1256
|
-
background:${this.theme.cardBg};border:1px solid ${this.theme.border};
|
|
1257
|
-
text-align:left;
|
|
1258
|
-
`;
|
|
1259
|
-
// Status badge — mutable DOM node so polling can update it
|
|
1260
|
-
const statusBadge = document.createElement("span");
|
|
1261
|
-
statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
|
|
1262
|
-
statusBadge.textContent = "Processing…";
|
|
1263
|
-
const statusRow = document.createElement("div");
|
|
1264
|
-
statusRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
|
|
1265
|
-
statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;">Status</span>`;
|
|
1266
|
-
statusRow.appendChild(statusBadge);
|
|
1267
|
-
// Build rows via DOM (not innerHTML+=) to keep statusBadge live in the document
|
|
1268
|
-
const entryRow = document.createElement("div");
|
|
1269
|
-
entryRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
|
|
1270
|
-
entryRow.innerHTML = `
|
|
1271
|
-
<span style="color:${this.theme.textMuted};font-size:12px;">Entry ID</span>
|
|
1272
|
-
<span style="color:${this.theme.text};font-size:12px;font-weight:600;">${submitResult.id}</span>
|
|
1273
|
-
`;
|
|
1274
|
-
details.appendChild(entryRow);
|
|
1275
|
-
details.appendChild(statusRow);
|
|
1276
|
-
const submittedRow = document.createElement("div");
|
|
1277
|
-
submittedRow.style.cssText = "display:flex;justify-content:space-between;margin-top:8px;";
|
|
1278
|
-
submittedRow.innerHTML = `
|
|
1279
|
-
<span style="color:${this.theme.textMuted};font-size:12px;">Submitted</span>
|
|
1280
|
-
<span style="color:${this.theme.text};font-size:12px;">${submitResult.submitted_at ? new Date(submitResult.submitted_at).toLocaleString() : "Just now"}</span>
|
|
1281
|
-
`;
|
|
1282
|
-
details.appendChild(submittedRow);
|
|
1283
|
-
wrapper.appendChild(details);
|
|
1284
|
-
// Failure reason — hidden until status = failed
|
|
1285
|
-
const failureMsg = document.createElement("div");
|
|
1286
|
-
failureMsg.style.cssText = `
|
|
1287
|
-
display:none;width:100%;padding:12px 14px;border-radius:10px;
|
|
1288
|
-
background:${this.theme.errorBg};border:1px solid ${this.theme.error};
|
|
1289
|
-
color:${this.theme.error};font-size:12px;text-align:left;line-height:1.5;
|
|
1290
|
-
`;
|
|
1291
|
-
wrapper.appendChild(failureMsg);
|
|
1292
|
-
// Done button
|
|
1293
|
-
const doneBtn = this.createPrimaryButton("Done");
|
|
1294
|
-
let finalStatus = submitResult.status;
|
|
1295
|
-
doneBtn.addEventListener("click", () => {
|
|
1296
|
-
if (this._pollStatusTimer) {
|
|
1297
|
-
clearTimeout(this._pollStatusTimer);
|
|
1298
|
-
this._pollStatusTimer = null;
|
|
1299
|
-
}
|
|
1300
|
-
this.options.onComplete?.({
|
|
1301
|
-
entryId: submitResult.id,
|
|
1302
|
-
sessionId: this.sdk.sessionToken,
|
|
1303
|
-
status: finalStatus,
|
|
1304
|
-
submittedAt: submitResult.submitted_at,
|
|
1305
|
-
verificationResult: this.verificationResult || undefined,
|
|
1306
|
-
});
|
|
1307
|
-
this.close();
|
|
1308
|
-
});
|
|
1309
|
-
wrapper.appendChild(doneBtn);
|
|
1310
|
-
this.contentArea.appendChild(wrapper);
|
|
1311
|
-
requestAnimationFrame(() => {
|
|
1312
|
-
wrapper.style.opacity = "1";
|
|
1313
|
-
wrapper.style.transform = "scale(1)";
|
|
1314
|
-
});
|
|
1315
|
-
// Start polling for the async result
|
|
1316
|
-
this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, failureMsg, (resolved) => { finalStatus = resolved; });
|
|
1317
|
-
}
|
|
1318
|
-
_startStatusPolling(entryId, iconWrap, statusBadge, title, desc, failureMsg, onResolved) {
|
|
1319
|
-
const MAX_POLLS = 25; // 25 × 3 s = 75 s + 4 s initial = ~79 s max
|
|
1320
|
-
let polls = 0;
|
|
1321
|
-
// Hard 30 s UI timer — stops spinner even if backend is still processing
|
|
1322
|
-
let uiTimeoutId = setTimeout(() => {
|
|
1323
|
-
if (!this.isDestroyed)
|
|
1324
|
-
applyTimeout();
|
|
1325
|
-
}, 30000);
|
|
1326
|
-
const clearUiTimeout = () => { if (uiTimeoutId) {
|
|
1327
|
-
clearTimeout(uiTimeoutId);
|
|
1328
|
-
uiTimeoutId = null;
|
|
1329
|
-
} };
|
|
1330
|
-
const applyTimeout = () => {
|
|
1331
|
-
// Stop polling for real — the message below tells the user it's safe
|
|
1332
|
-
// to close and that the result will arrive via webhook/dashboard.
|
|
1333
|
-
// Silently continuing to poll in the background (up to another ~49s)
|
|
1334
|
-
// used to mean the UI could suddenly flip to Verified/Failed after
|
|
1335
|
-
// already telling the user to walk away, with no warning either way.
|
|
1336
|
-
if (this._pollStatusTimer) {
|
|
1337
|
-
clearTimeout(this._pollStatusTimer);
|
|
1338
|
-
this._pollStatusTimer = null;
|
|
1339
|
-
}
|
|
1340
|
-
iconWrap.innerHTML = "⏱";
|
|
1341
|
-
iconWrap.style.background = this.theme.cardBg;
|
|
1342
|
-
iconWrap.style.border = `3px solid ${this.theme.warning}`;
|
|
1343
|
-
iconWrap.style.color = this.theme.warning;
|
|
1344
|
-
iconWrap.style.fontSize = "30px";
|
|
1345
|
-
statusBadge.style.color = this.theme.textMuted;
|
|
1346
|
-
statusBadge.textContent = "Still processing…";
|
|
1347
|
-
title.textContent = "Verification In Progress";
|
|
1348
|
-
desc.textContent = "Your verification is being processed. Your result will be delivered via your dashboard or webhook. You can safely close this.";
|
|
1349
|
-
};
|
|
1350
|
-
const applyPassed = () => {
|
|
1351
|
-
clearUiTimeout();
|
|
1352
|
-
iconWrap.innerHTML = "✓";
|
|
1353
|
-
iconWrap.style.background = this.theme.successBg;
|
|
1354
|
-
iconWrap.style.border = `3px solid ${this.theme.success}`;
|
|
1355
|
-
iconWrap.style.color = this.theme.success;
|
|
1356
|
-
iconWrap.style.fontSize = "36px";
|
|
1357
|
-
statusBadge.style.color = this.theme.success;
|
|
1358
|
-
statusBadge.textContent = "Verified ✓";
|
|
1359
|
-
title.textContent = "Identity Verified";
|
|
1360
|
-
desc.textContent = "Your identity check passed successfully.";
|
|
1361
|
-
};
|
|
1362
|
-
const applyFailed = (reason) => {
|
|
1363
|
-
clearUiTimeout();
|
|
1364
|
-
iconWrap.innerHTML = "✕";
|
|
1365
|
-
iconWrap.style.background = this.theme.errorBg;
|
|
1366
|
-
iconWrap.style.border = `3px solid ${this.theme.error}`;
|
|
1367
|
-
iconWrap.style.color = this.theme.error;
|
|
1368
|
-
iconWrap.style.fontSize = "32px";
|
|
1369
|
-
statusBadge.style.color = this.theme.error;
|
|
1370
|
-
statusBadge.textContent = "Failed";
|
|
1371
|
-
title.textContent = "Verification Failed";
|
|
1372
|
-
desc.textContent = "Your identity check could not be completed.";
|
|
1373
|
-
if (reason) {
|
|
1374
|
-
failureMsg.textContent = reason;
|
|
1375
|
-
failureMsg.style.display = "block";
|
|
1376
|
-
}
|
|
1377
|
-
};
|
|
1378
|
-
const poll = async () => {
|
|
1379
|
-
if (this.isDestroyed)
|
|
1380
|
-
return;
|
|
1381
|
-
polls++;
|
|
1382
|
-
try {
|
|
1383
|
-
const result = await this.sdk.liveness.pollResult(entryId);
|
|
1384
|
-
if (result.status === "passed") {
|
|
1385
|
-
onResolved("passed");
|
|
1386
|
-
applyPassed();
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
if (result.status === "failed") {
|
|
1390
|
-
onResolved(result.status);
|
|
1391
|
-
const reason = result.failure_reason || "Verification could not be completed. Please try again.";
|
|
1392
|
-
applyFailed(reason);
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1395
|
-
// Still pending/processing — schedule next poll if budget remains
|
|
1396
|
-
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1397
|
-
this._pollStatusTimer = setTimeout(poll, 3000);
|
|
1398
|
-
}
|
|
1399
|
-
else if (!this.isDestroyed) {
|
|
1400
|
-
applyTimeout(); // budget exhausted — stop spinner, show timeout state
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
catch {
|
|
1404
|
-
// Network hiccup — retry with a slightly longer delay
|
|
1405
|
-
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1406
|
-
this._pollStatusTimer = setTimeout(poll, 5000);
|
|
1407
|
-
}
|
|
1408
|
-
else if (!this.isDestroyed) {
|
|
1409
|
-
applyTimeout(); // budget exhausted — stop spinner
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
};
|
|
1413
|
-
// First poll after 4 s — gives Celery task time to complete
|
|
1414
|
-
this._pollStatusTimer = setTimeout(poll, 4000);
|
|
1415
|
-
}
|
|
1416
1782
|
renderResult(_container) {
|
|
1417
|
-
//
|
|
1783
|
+
// C2: Dead code — flow routes to 'done' step, not 'result'. Kept as stub.
|
|
1418
1784
|
}
|
|
1419
|
-
//
|
|
1785
|
+
// ── Error ───────────────────────────────────────────────────────
|
|
1420
1786
|
showErrorStep(message) {
|
|
1421
1787
|
this.currentStep = "error";
|
|
1422
1788
|
if (!this.contentArea)
|
|
1423
1789
|
return;
|
|
1424
1790
|
this.contentArea.innerHTML = "";
|
|
1425
1791
|
const wrapper = document.createElement("div");
|
|
1792
|
+
wrapper.setAttribute("role", "alert");
|
|
1426
1793
|
wrapper.style.cssText = `
|
|
1427
1794
|
display:flex;flex-direction:column;align-items:center;
|
|
1428
1795
|
text-align:center;gap:16px;padding:20px 0;
|
|
1429
1796
|
`;
|
|
1797
|
+
// Classify the error into a user-friendly category
|
|
1430
1798
|
const isCameraErr = /camera|permission|notallowed|notfound/i.test(message);
|
|
1431
1799
|
const isNetworkErr = /network|timeout|connection|fetch/i.test(message);
|
|
1432
|
-
const isAuthErr = /session|token
|
|
1433
|
-
const
|
|
1800
|
+
const isAuthErr = /session.*expired|token.*expired|authentication/i.test(message);
|
|
1801
|
+
const isDocQuality = /blurry|blur|quality|glare|dark|bright|lighting/i.test(message);
|
|
1802
|
+
const isExpiredDoc = /document.*expired|expired.*document/i.test(message);
|
|
1803
|
+
const isFaceMatch = /face.*match|selfie.*match|does not match/i.test(message);
|
|
1804
|
+
const errorIcon = isCameraErr ? "📷"
|
|
1805
|
+
: isNetworkErr ? "📡"
|
|
1806
|
+
: isAuthErr ? "â±"
|
|
1807
|
+
: isDocQuality ? "ðŸ”"
|
|
1808
|
+
: isExpiredDoc ? "📅"
|
|
1809
|
+
: isFaceMatch ? "🤳"
|
|
1810
|
+
: "✕";
|
|
1434
1811
|
const errorTitle = isCameraErr ? "Camera Access Required"
|
|
1435
1812
|
: isNetworkErr ? "Connection Problem"
|
|
1436
1813
|
: isAuthErr ? "Session Expired"
|
|
1437
|
-
: "
|
|
1814
|
+
: isDocQuality ? "Document Photo Unclear"
|
|
1815
|
+
: isExpiredDoc ? "Document Expired"
|
|
1816
|
+
: isFaceMatch ? "Face Match Failed"
|
|
1817
|
+
: "Verification Unsuccessful";
|
|
1818
|
+
// Map to user-facing guidance — never show raw internal message
|
|
1819
|
+
const userMessage = isCameraErr
|
|
1820
|
+
? "Please allow camera access in your browser settings and try again."
|
|
1821
|
+
: isNetworkErr
|
|
1822
|
+
? "Check your internet connection and try again."
|
|
1823
|
+
: isAuthErr
|
|
1824
|
+
? "Your session has expired. Please start again."
|
|
1825
|
+
: isDocQuality
|
|
1826
|
+
? _sanitizeUserMessage(message) || "Please retake your document photo in good lighting with no glare."
|
|
1827
|
+
: isExpiredDoc
|
|
1828
|
+
? "The document you submitted has expired. Please use a valid, current ID."
|
|
1829
|
+
: isFaceMatch
|
|
1830
|
+
? "Your selfie did not match the photo on your ID. Please ensure you are using your own valid document and face the camera directly."
|
|
1831
|
+
: _sanitizeUserMessage(message) || "We were unable to complete your verification. Please try again.";
|
|
1438
1832
|
const iconWrap = document.createElement("div");
|
|
1439
1833
|
iconWrap.style.cssText = `
|
|
1440
1834
|
width:72px;height:72px;border-radius:50%;
|
|
1441
1835
|
display:flex;align-items:center;justify-content:center;
|
|
1442
|
-
font-size
|
|
1836
|
+
font-size:28px;
|
|
1443
1837
|
background:${this.theme.errorBg};
|
|
1444
1838
|
border:3px solid ${this.theme.error};
|
|
1445
1839
|
`;
|
|
@@ -1451,22 +1845,16 @@ export class SmartComplyFlow {
|
|
|
1451
1845
|
wrapper.appendChild(title);
|
|
1452
1846
|
const desc = document.createElement("p");
|
|
1453
1847
|
desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
|
|
1454
|
-
desc.textContent =
|
|
1848
|
+
desc.textContent = userMessage;
|
|
1455
1849
|
wrapper.appendChild(desc);
|
|
1456
1850
|
const retryBtn = this.createPrimaryButton("Try Again");
|
|
1851
|
+
retryBtn.setAttribute("aria-label", "Try verification again");
|
|
1457
1852
|
retryBtn.addEventListener("click", () => {
|
|
1458
|
-
|
|
1459
|
-
// (re-initializing would fail with CLIENT_ID_ALREADY_USED)
|
|
1460
|
-
if (this.sdk.sessionToken && this.sdkConfig) {
|
|
1461
|
-
this.showStep("welcome");
|
|
1462
|
-
}
|
|
1463
|
-
else {
|
|
1464
|
-
// Only re-initialize if session was never created
|
|
1465
|
-
this.initialize();
|
|
1466
|
-
}
|
|
1853
|
+
this.initialize();
|
|
1467
1854
|
});
|
|
1468
1855
|
wrapper.appendChild(retryBtn);
|
|
1469
1856
|
const closeBtn = this.createSecondaryButton("Close");
|
|
1857
|
+
closeBtn.setAttribute("aria-label", "Close verification widget");
|
|
1470
1858
|
closeBtn.addEventListener("click", () => {
|
|
1471
1859
|
this.options.onError?.(new Error(message));
|
|
1472
1860
|
this.close();
|
|
@@ -1474,11 +1862,12 @@ export class SmartComplyFlow {
|
|
|
1474
1862
|
wrapper.appendChild(closeBtn);
|
|
1475
1863
|
this.contentArea.appendChild(wrapper);
|
|
1476
1864
|
}
|
|
1477
|
-
//
|
|
1865
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1478
1866
|
// UI Helpers
|
|
1479
|
-
//
|
|
1867
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1480
1868
|
createHeader() {
|
|
1481
1869
|
const header = document.createElement("div");
|
|
1870
|
+
header.setAttribute("role", "banner");
|
|
1482
1871
|
header.style.cssText = `
|
|
1483
1872
|
display:flex;align-items:center;justify-content:space-between;
|
|
1484
1873
|
padding:11px 20px;border-bottom:1px solid ${this.theme.border};
|
|
@@ -1488,6 +1877,7 @@ export class SmartComplyFlow {
|
|
|
1488
1877
|
leftArea.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
|
|
1489
1878
|
this.headerBackBtn = document.createElement("button");
|
|
1490
1879
|
this.headerBackBtn.innerHTML = "←";
|
|
1880
|
+
this.headerBackBtn.setAttribute("aria-label", "Go back to previous step");
|
|
1491
1881
|
this.headerBackBtn.style.cssText = `
|
|
1492
1882
|
width:30px;height:30px;border-radius:8px;flex-shrink:0;
|
|
1493
1883
|
display:none;align-items:center;justify-content:center;
|
|
@@ -1496,6 +1886,7 @@ export class SmartComplyFlow {
|
|
|
1496
1886
|
background:${this.theme.cardBg};color:${this.theme.text};
|
|
1497
1887
|
transition:all 0.2s ease;
|
|
1498
1888
|
`;
|
|
1889
|
+
this.headerBackBtn.style.cssText += `:focus-visible{outline:2px solid ${this.theme.primary};outline-offset:2px;}`;
|
|
1499
1890
|
this.headerBackBtn.addEventListener("mouseenter", () => {
|
|
1500
1891
|
if (this.headerBackBtn) {
|
|
1501
1892
|
this.headerBackBtn.style.background = this.theme.shimmer;
|
|
@@ -1508,6 +1899,7 @@ export class SmartComplyFlow {
|
|
|
1508
1899
|
});
|
|
1509
1900
|
leftArea.appendChild(this.headerBackBtn);
|
|
1510
1901
|
this.brandLabel = document.createElement("div");
|
|
1902
|
+
this.brandLabel.setAttribute("aria-label", "Verification provider");
|
|
1511
1903
|
this.brandLabel.style.cssText = `
|
|
1512
1904
|
font-size:15px;font-weight:700;color:${this.theme.text};
|
|
1513
1905
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
|
@@ -1517,6 +1909,8 @@ export class SmartComplyFlow {
|
|
|
1517
1909
|
header.appendChild(leftArea);
|
|
1518
1910
|
// Step indicator
|
|
1519
1911
|
this.stepLabel = document.createElement("div");
|
|
1912
|
+
this.stepLabel.setAttribute("aria-live", "polite");
|
|
1913
|
+
this.stepLabel.setAttribute("aria-atomic", "true");
|
|
1520
1914
|
this.stepLabel.style.cssText = `
|
|
1521
1915
|
font-size:12px;color:${this.theme.textMuted};
|
|
1522
1916
|
font-variant-numeric:tabular-nums;flex-shrink:0;
|
|
@@ -1524,7 +1918,8 @@ export class SmartComplyFlow {
|
|
|
1524
1918
|
header.appendChild(this.stepLabel);
|
|
1525
1919
|
// Close button
|
|
1526
1920
|
const closeBtn = document.createElement("button");
|
|
1527
|
-
closeBtn.innerHTML = "
|
|
1921
|
+
closeBtn.innerHTML = "✕";
|
|
1922
|
+
closeBtn.setAttribute("aria-label", "Close identity verification");
|
|
1528
1923
|
closeBtn.style.cssText = `
|
|
1529
1924
|
width:32px;height:32px;border-radius:8px;flex-shrink:0;
|
|
1530
1925
|
display:flex;align-items:center;justify-content:center;
|
|
@@ -1571,6 +1966,7 @@ export class SmartComplyFlow {
|
|
|
1571
1966
|
}
|
|
1572
1967
|
createOptionCard(title, subtitle, onClick) {
|
|
1573
1968
|
const card = document.createElement("button");
|
|
1969
|
+
card.setAttribute("aria-label", subtitle ? `${title}: ${subtitle}` : title);
|
|
1574
1970
|
card.style.cssText = `
|
|
1575
1971
|
width:100%;padding:13px 16px;border-radius:12px;
|
|
1576
1972
|
background:${this.theme.cardBg};
|
|
@@ -1603,7 +1999,7 @@ export class SmartComplyFlow {
|
|
|
1603
1999
|
}
|
|
1604
2000
|
const arrow = document.createElement("span");
|
|
1605
2001
|
arrow.style.cssText = `color:${this.theme.textMuted};font-size:18px;flex-shrink:0;`;
|
|
1606
|
-
arrow.textContent = "
|
|
2002
|
+
arrow.textContent = "→";
|
|
1607
2003
|
card.appendChild(textWrap);
|
|
1608
2004
|
card.appendChild(arrow);
|
|
1609
2005
|
return card;
|
|
@@ -1648,7 +2044,7 @@ export class SmartComplyFlow {
|
|
|
1648
2044
|
return btn;
|
|
1649
2045
|
}
|
|
1650
2046
|
isDocumentFlow() {
|
|
1651
|
-
// 1. Check verification_type
|
|
2047
|
+
// 1. Check verification_type — handle both string and array from backend
|
|
1652
2048
|
const vType = this.sdkConfig?.verification_type;
|
|
1653
2049
|
if (vType) {
|
|
1654
2050
|
const types = Array.isArray(vType) ? vType : [vType];
|
|
@@ -1657,7 +2053,7 @@ export class SmartComplyFlow {
|
|
|
1657
2053
|
if (types.includes("data_verification"))
|
|
1658
2054
|
return false;
|
|
1659
2055
|
}
|
|
1660
|
-
// 2. Fallback: inspect channel fields
|
|
2056
|
+
// 2. Fallback: inspect channel fields — any upload field = document flow
|
|
1661
2057
|
const channels = this.sdkConfig?.channels || {};
|
|
1662
2058
|
const allFields = Object.values(channels)
|
|
1663
2059
|
.flat()
|
|
@@ -1668,38 +2064,47 @@ export class SmartComplyFlow {
|
|
|
1668
2064
|
const isDocFlow = this.isDocumentFlow();
|
|
1669
2065
|
const countries = Object.keys(this.sdkConfig?.channels || {});
|
|
1670
2066
|
const singleCountry = countries.length <= 1;
|
|
1671
|
-
//
|
|
1672
|
-
const
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
const
|
|
2067
|
+
// Two-stage action steps — Stage 1: validate identity, Stage 2: liveness
|
|
2068
|
+
const stage1Steps = isDocFlow
|
|
2069
|
+
? ["country", "id_type", "document_capture", "ocr_gate", "document_confirm"].filter(s => !(s === "country" && singleCountry))
|
|
2070
|
+
: ["country", "id_type", "id_input", "confirm_identity"].filter(s => !(s === "country" && singleCountry));
|
|
2071
|
+
const stage2Steps = ["liveness"];
|
|
2072
|
+
const allActionSteps = [...stage1Steps, ...stage2Steps];
|
|
2073
|
+
const totalSteps = allActionSteps.length;
|
|
2074
|
+
const actionIndex = allActionSteps.indexOf(this.currentStep);
|
|
2075
|
+
const pct = this.currentStep === "done" || this.currentStep === "result" ? 100
|
|
1676
2076
|
: actionIndex >= 0 ? ((actionIndex + 1) / totalSteps) * 100
|
|
1677
2077
|
: 0;
|
|
1678
2078
|
if (this.progressBar) {
|
|
1679
2079
|
this.progressBar.style.width = `${pct}%`;
|
|
1680
2080
|
}
|
|
2081
|
+
// Stage label: "Stage 1 of 2: Verify Identity" / "Stage 2 of 2: Face Check"
|
|
1681
2082
|
if (this.stepLabel) {
|
|
1682
2083
|
if (this.currentStep === "loading" || this.currentStep === "welcome") {
|
|
1683
2084
|
this.stepLabel.textContent = "";
|
|
1684
2085
|
}
|
|
1685
|
-
else if (this.currentStep === "result") {
|
|
1686
|
-
this.stepLabel.textContent = "Complete
|
|
2086
|
+
else if (this.currentStep === "done" || this.currentStep === "result") {
|
|
2087
|
+
this.stepLabel.textContent = "Complete ✓";
|
|
2088
|
+
}
|
|
2089
|
+
else if (stage2Steps.includes(this.currentStep)) {
|
|
2090
|
+
this.stepLabel.textContent = "Stage 2 of 2: Face Check";
|
|
1687
2091
|
}
|
|
1688
|
-
else if (
|
|
1689
|
-
this.stepLabel.textContent =
|
|
2092
|
+
else if (stage1Steps.includes(this.currentStep)) {
|
|
2093
|
+
this.stepLabel.textContent = isDocFlow ? "Stage 1 of 2: Scan Document" : "Stage 1 of 2: Verify Identity";
|
|
1690
2094
|
}
|
|
1691
2095
|
else {
|
|
1692
2096
|
this.stepLabel.textContent = "";
|
|
1693
2097
|
}
|
|
1694
2098
|
}
|
|
1695
|
-
// Back button
|
|
2099
|
+
// Back button — show for navigable steps only
|
|
1696
2100
|
if (this.headerBackBtn) {
|
|
1697
|
-
const countries = Object.keys(this.sdkConfig?.channels || {});
|
|
1698
2101
|
const backTargets = {
|
|
1699
2102
|
id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
|
|
1700
2103
|
country: () => this.showStep("welcome"),
|
|
1701
2104
|
id_input: () => this.showStep("id_type"),
|
|
2105
|
+
confirm_identity: () => this.showStep("id_input"),
|
|
1702
2106
|
document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
|
|
2107
|
+
document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
|
|
1703
2108
|
};
|
|
1704
2109
|
const backAction = backTargets[this.currentStep];
|
|
1705
2110
|
if (backAction) {
|
|
@@ -1749,6 +2154,12 @@ export class SmartComplyFlow {
|
|
|
1749
2154
|
#sc-flow-modal * {
|
|
1750
2155
|
box-sizing: border-box;
|
|
1751
2156
|
}
|
|
2157
|
+
#sc-flow-modal button:focus-visible,
|
|
2158
|
+
#sc-flow-modal select:focus-visible,
|
|
2159
|
+
#sc-flow-modal input:focus-visible {
|
|
2160
|
+
outline: 2px solid #3b82f6;
|
|
2161
|
+
outline-offset: 2px;
|
|
2162
|
+
}
|
|
1752
2163
|
#sc-flow-modal::-webkit-scrollbar {
|
|
1753
2164
|
width: 4px;
|
|
1754
2165
|
}
|
|
@@ -1763,4 +2174,5 @@ export class SmartComplyFlow {
|
|
|
1763
2174
|
document.head.appendChild(style);
|
|
1764
2175
|
}
|
|
1765
2176
|
}
|
|
2177
|
+
SmartComplyFlow.MAX_RETRIES = 3;
|
|
1766
2178
|
//# sourceMappingURL=SmartComplyFlow.js.map
|