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