smartcomply-web-sdk 1.0.45 β†’ 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.
Files changed (35) hide show
  1. package/dist/esm/flow/SmartComplyFlow.d.ts +22 -5
  2. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
  3. package/dist/esm/flow/SmartComplyFlow.js +638 -504
  4. package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
  5. package/dist/esm/flow/theme.d.ts +7 -1
  6. package/dist/esm/flow/theme.d.ts.map +1 -1
  7. package/dist/esm/flow/theme.js +27 -2
  8. package/dist/esm/flow/theme.js.map +1 -1
  9. package/dist/esm/modules/liveness/LivenessUI.d.ts +1 -1
  10. package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
  11. package/dist/esm/modules/liveness/LivenessUI.js +5 -5
  12. package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
  13. package/dist/esm/modules/liveness/liveness.d.ts +1 -0
  14. package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
  15. package/dist/esm/modules/liveness/liveness.js +51 -33
  16. package/dist/esm/modules/liveness/liveness.js.map +1 -1
  17. package/dist/flow/SmartComplyFlow.d.ts +22 -5
  18. package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
  19. package/dist/flow/SmartComplyFlow.js +638 -504
  20. package/dist/flow/SmartComplyFlow.js.map +1 -1
  21. package/dist/flow/theme.d.ts +7 -1
  22. package/dist/flow/theme.d.ts.map +1 -1
  23. package/dist/flow/theme.js +27 -2
  24. package/dist/flow/theme.js.map +1 -1
  25. package/dist/modules/liveness/LivenessUI.d.ts +1 -1
  26. package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
  27. package/dist/modules/liveness/LivenessUI.js +5 -5
  28. package/dist/modules/liveness/LivenessUI.js.map +1 -1
  29. package/dist/modules/liveness/liveness.d.ts +1 -0
  30. package/dist/modules/liveness/liveness.d.ts.map +1 -1
  31. package/dist/modules/liveness/liveness.js +51 -33
  32. package/dist/modules/liveness/liveness.js.map +1 -1
  33. package/dist/smartcomply.browser.js +572 -428
  34. package/dist/smartcomply.browser.js.map +3 -3
  35. 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", // or document_capture
13
+ "id_input",
14
+ "confirm_identity",
14
15
  "document_capture",
15
- "processing",
16
+ "ocr_gate",
17
+ "document_confirm",
16
18
  "liveness",
19
+ "done",
17
20
  "result",
18
21
  ];
19
22
  const COUNTRY_LABELS = {
20
- nigeria: "πŸ‡³πŸ‡¬ Nigeria",
21
- global: "🌍 Other Countries",
22
- ghana: "πŸ‡¬πŸ‡­ Ghana",
23
- kenya: "πŸ‡°πŸ‡ͺ Kenya",
24
- south_africa: "πŸ‡ΏπŸ‡¦ South Africa",
25
- united_states: "πŸ‡ΊπŸ‡Έ United States",
26
- united_kingdom: "πŸ‡¬πŸ‡§ United Kingdom",
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 β€” the field
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 β€” users don't need "with face" or "(NIN)" suffixes
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 β€” always 11 digits
60
+ // BVN Ò€” always 11 digits
58
61
  if (u.includes("BVN") || u.includes("BANK VERIFICATION")) {
59
- return { icon: "🏦", desc: "Enter your 11-digit Bank Verification Number (BVN)", requiresBack: false };
62
+ return { icon: "🏦", desc: "Enter your 11-digit Bank Verification Number (BVN)", requiresBack: false };
60
63
  }
61
- // NIN β€” always 11 digits (covers nin_with_face, NIN slip, etc.)
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: "πŸ†”", desc: "Enter your 11-digit National Identification Number (NIN)", requiresBack: false };
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: "πŸͺͺ", desc: "Capture or upload your Ghana Card", requiresBack: true };
69
- return { icon: "πŸͺͺ", desc: "Enter your Ghana Card number", requiresBack: false };
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: "πŸ›οΈ", desc: "Enter your SSNIT number", requiresBack: false };
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: "πŸ†”", desc: "Enter your Kenya National ID number", requiresBack: false };
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: "🧾", desc: "Enter your Tax Identification Number (TIN)", requiresBack: false };
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: "🏒", desc: advanced ? "Enter RC number, company name & type" : "Enter your company registration number", requiresBack: false };
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: "πŸ“˜", desc: isDocumentFlow ? "Capture or upload your passport" : "Enter your passport number", requiresBack: false };
99
+ return { icon: "Γ°ΕΈβ€œΛœ", desc: isDocumentFlow ? "Capture or upload your passport" : "Enter your passport number", requiresBack: false };
97
100
  // Driver's license
98
101
  if (u.includes("DRIVER") || u.includes("LICENSE") || u.includes("LICENCE")) {
99
102
  const requiresBack = requiresBackSide(typeName);
100
103
  return {
101
- icon: "πŸš—",
104
+ icon: "Γ°ΕΈΕ‘β€”",
102
105
  desc: isDocumentFlow
103
106
  ? `Capture or upload your driver's license${requiresBack ? " (front & back)" : ""}`
104
107
  : "Enter your driver's license number",
@@ -109,7 +112,7 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
109
112
  if (u.includes("NATIONAL ID") || (u.includes("NATIONAL") && u.includes("CARD"))) {
110
113
  const requiresBack = requiresBackSide(typeName);
111
114
  return {
112
- icon: "πŸͺͺ",
115
+ icon: "Γ°ΕΈΒͺΒͺ",
113
116
  desc: isDocumentFlow
114
117
  ? `Capture or upload your ID card${requiresBack ? " (front & back)" : ""}`
115
118
  : "Enter your National ID number",
@@ -118,15 +121,15 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
118
121
  }
119
122
  // Face liveness / comparison
120
123
  if (u.includes("LIVENESS") || u.includes("FACE COMPARISON") || u.includes("FACE MATCH"))
121
- return { icon: "🀳", desc: "We'll capture a short selfie video to verify you", requiresBack: false };
124
+ return { icon: "🀳", desc: "We'll capture a short selfie video to verify you", requiresBack: false };
122
125
  return {
123
- icon: isDocumentFlow ? "πŸ“„" : "πŸ†”",
126
+ icon: isDocumentFlow ? "Γ°ΕΈβ€œβ€ž" : "🆔",
124
127
  desc: isDocumentFlow ? "Capture or upload your document" : "Enter your ID number",
125
128
  requiresBack: false,
126
129
  };
127
130
  }
128
- // ─────────────────────────────────────────────────────────────────────
129
- // ── Widget-side message sanitizer ─────────────────────────────────────────────
131
+ // Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
132
+ // Ò”€Ò”€ Widget-side message sanitizer Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
130
133
  // Strips anything technical before showing text to the end user.
131
134
  // Keeps messages that are already written plainly in natural language.
132
135
  function _sanitizeUserMessage(raw) {
@@ -155,7 +158,56 @@ function _sanitizeUserMessage(raw) {
155
158
  return null;
156
159
  return raw.trim();
157
160
  }
158
- // ── Document type code β†’ human label ─────────────────────────────────────────
161
+ // Ò”€Ò”€ B8: OCR date string normaliser Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
162
+ // OCR providers return dates in wildly different formats. new Date(str) is
163
+ // locale-dependent for anything other than ISO 8601 (YYYY-MM-DD), so we
164
+ // attempt a set of known patterns before falling back to the native parser.
165
+ function _parseOcrDate(raw) {
166
+ if (!raw)
167
+ return null;
168
+ const s = raw.trim();
169
+ // ISO 8601 or near-ISO Ò€” try first, always unambiguous
170
+ if (/^\d{4}-\d{2}-\d{2}/.test(s))
171
+ return new Date(s.slice(0, 10));
172
+ // DD/MM/YYYY or DD-MM-YYYY
173
+ const dmy = s.match(/^(\d{2})[\/\-](\d{2})[\/\-](\d{4})/);
174
+ if (dmy)
175
+ return new Date(`${dmy[3]}-${dmy[2]}-${dmy[1]}`);
176
+ // MM/DD/YYYY (US format Ò€” less common from OCR but possible)
177
+ const mdy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/);
178
+ if (mdy) {
179
+ // Heuristic: if "month" > 12 it must be day-first
180
+ if (parseInt(mdy[1], 10) > 12)
181
+ return new Date(`${mdy[3]}-${mdy[2]}-${mdy[1]}`);
182
+ return new Date(`${mdy[3]}-${mdy[1]}-${mdy[2]}`);
183
+ }
184
+ // DD Mon YYYY e.g. "12 May 2029" or "12 MAY 2029"
185
+ const dMonY = s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})/);
186
+ if (dMonY)
187
+ return new Date(`${dMonY[2]} ${dMonY[1]} ${dMonY[3]}`);
188
+ // Last resort: native parser
189
+ const d = new Date(s);
190
+ return isNaN(d.getTime()) ? null : d;
191
+ }
192
+ // Ò”€Ò”€ W12: Image compression before document upload Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
193
+ async function _compressImage(blob, maxWidth = 1600, quality = 0.85) {
194
+ try {
195
+ const bmp = await createImageBitmap(blob);
196
+ const scale = Math.min(1, maxWidth / bmp.width);
197
+ const w = Math.round(bmp.width * scale);
198
+ const h = Math.round(bmp.height * scale);
199
+ const canvas = document.createElement("canvas");
200
+ canvas.width = w;
201
+ canvas.height = h;
202
+ canvas.getContext("2d").drawImage(bmp, 0, 0, w, h);
203
+ bmp.close();
204
+ return await new Promise((res, rej) => canvas.toBlob(b => b ? res(b) : rej(new Error("Compression failed")), "image/jpeg", quality));
205
+ }
206
+ catch {
207
+ return blob; // fallback to original on any error
208
+ }
209
+ }
210
+ // Ò”€Ò”€ Document type code Ò†’ human label Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
159
211
  function _formatDocType(code) {
160
212
  const map = {
161
213
  passport: "Passport",
@@ -176,7 +228,7 @@ function _formatDocType(code) {
176
228
  return code.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
177
229
  }
178
230
  // SmartComplyFlow
179
- // ─────────────────────────────────────────────────────────────────────
231
+ // Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
180
232
  // Add CSS animation keyframes for spin animations
181
233
  const style = document.createElement('style');
182
234
  style.textContent = `
@@ -217,13 +269,31 @@ class SmartComplyFlow {
217
269
  this.livenessEntryId = null;
218
270
  this.isDestroyed = false;
219
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;
220
291
  // Session expiry
221
292
  this.sessionExpiryTimer = null;
222
293
  // Status polling (result screen)
223
294
  this._pollStatusTimer = null;
224
295
  // Components
225
296
  this.docCapture = null;
226
- this._livenessSubmitResult = null;
227
297
  this.options = options;
228
298
  this.sdk = new Smartcomply_1.SmartComply({
229
299
  apiKey: options.apiKey,
@@ -257,6 +327,11 @@ class SmartComplyFlow {
257
327
  close() {
258
328
  this.isDestroyed = true;
259
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
+ }
260
335
  if (this.sessionExpiryTimer) {
261
336
  clearTimeout(this.sessionExpiryTimer);
262
337
  this.sessionExpiryTimer = null;
@@ -265,13 +340,23 @@ class SmartComplyFlow {
265
340
  clearTimeout(this._pollStatusTimer);
266
341
  this._pollStatusTimer = null;
267
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
+ }
268
353
  if (this.overlay) {
269
354
  this.overlay.style.opacity = "0";
270
355
  setTimeout(() => {
271
356
  try {
272
357
  this.overlay?.remove();
273
358
  }
274
- catch (_) { /* extension interference β€” safe to ignore */ }
359
+ catch (_) { /* extension interference Ò€” safe to ignore */ }
275
360
  this.overlay = null;
276
361
  this.modal = null;
277
362
  this.contentArea = null;
@@ -279,9 +364,9 @@ class SmartComplyFlow {
279
364
  }
280
365
  this.options.onClose?.();
281
366
  }
282
- // ─────────────────────────────────────────────────────────────────
367
+ // Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
283
368
  // Initialization
284
- // ─────────────────────────────────────────────────────────────────
369
+ // Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
285
370
  mount() {
286
371
  // Inject keyframes
287
372
  this.injectStyles();
@@ -366,12 +451,12 @@ class SmartComplyFlow {
366
451
  }
367
452
  // Load config
368
453
  this.sdkConfig = await this.sdk.initializeConfig();
369
- // Apply theme from config
454
+ // Apply theme from config Ò€” use theme_hex to override primary colour if set
370
455
  if (this.sdkConfig.theme) {
371
- this.theme = (0, theme_1.getTheme)(this.sdkConfig.theme);
456
+ this.theme = (0, theme_1.getTheme)(this.sdkConfig.theme, this.sdkConfig.theme_hex || null);
372
457
  this.applyTheme();
373
458
  }
374
- // Update brand name
459
+ // Update brand name in header
375
460
  if (this.brandLabel && this.sdkConfig.brand_name) {
376
461
  this.brandLabel.textContent = this.sdkConfig.brand_name;
377
462
  }
@@ -383,13 +468,18 @@ class SmartComplyFlow {
383
468
  this.showErrorStep(err.message || "Failed to initialize. Please try again.");
384
469
  }
385
470
  }
386
- // ─────────────────────────────────────────────────────────────────
471
+ // Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
387
472
  // Step rendering
388
- // ─────────────────────────────────────────────────────────────────
473
+ // Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
389
474
  showStep(step) {
390
475
  this.currentStep = step;
391
476
  if (!this.contentArea)
392
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
+ }
393
483
  // Update progress
394
484
  this.updateProgress();
395
485
  // Animate out current content
@@ -427,20 +517,32 @@ class SmartComplyFlow {
427
517
  case "id_input":
428
518
  this.renderIdInput(wrapper);
429
519
  break;
520
+ case "confirm_identity":
521
+ this.renderConfirmIdentity(wrapper);
522
+ break;
430
523
  case "document_capture":
431
524
  this.renderDocumentCapture(wrapper);
432
525
  break;
526
+ case "ocr_gate":
527
+ this.renderOcrGate(wrapper);
528
+ break;
529
+ case "document_confirm":
530
+ this.renderDocumentConfirm(wrapper);
531
+ break;
433
532
  case "processing":
434
- this.renderProcessing(wrapper, this.livenessEntryId || undefined);
533
+ // C1: processing step is dead code Ò€” flow now goes to 'done' after submit
435
534
  break;
436
535
  case "liveness":
437
536
  this.renderLiveness(wrapper);
438
537
  break;
538
+ case "done":
539
+ this.renderDone(wrapper);
540
+ break;
439
541
  case "result":
440
- this.renderResult(wrapper);
542
+ // C2: result step is dead code Ò€” showResult/renderResult removed
441
543
  break;
442
544
  case "error":
443
- break; // handled separately
545
+ break;
444
546
  }
445
547
  this.contentArea.appendChild(wrapper);
446
548
  // Animate in
@@ -449,7 +551,7 @@ class SmartComplyFlow {
449
551
  wrapper.style.transform = "translateX(0)";
450
552
  });
451
553
  }
452
- // ── Loading ─────────────────────────────────────────────────────
554
+ // Ò”€Ò”€ Loading Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
453
555
  renderLoading(container) {
454
556
  container.style.cssText += "display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:16px;";
455
557
  const spinnerWrap = document.createElement("div");
@@ -480,7 +582,7 @@ class SmartComplyFlow {
480
582
  sub.textContent = "Connecting to the verification service...";
481
583
  container.appendChild(sub);
482
584
  }
483
- // ── Welcome ────────────────────────────────────────────────────
585
+ // Ò”€Ò”€ Welcome Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
484
586
  renderWelcome(container) {
485
587
  container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:12px;";
486
588
  const isDocFlow = this.isDocumentFlow();
@@ -495,7 +597,7 @@ class SmartComplyFlow {
495
597
  background:linear-gradient(135deg,${this.theme.primary}22,${this.theme.primaryGlow});
496
598
  border:1.5px solid ${this.theme.primary}33;
497
599
  `;
498
- iconWrap.textContent = isDocFlow ? "πŸͺͺ" : "πŸ›‘οΈ";
600
+ iconWrap.textContent = isDocFlow ? "Γ°ΕΈΒͺΒͺ" : "🛑️";
499
601
  topRow.appendChild(iconWrap);
500
602
  const badge = document.createElement("div");
501
603
  badge.style.cssText = `
@@ -506,7 +608,7 @@ class SmartComplyFlow {
506
608
  color:${this.theme.primary};
507
609
  border:1px solid ${this.theme.primary}30;
508
610
  `;
509
- badge.textContent = isDocFlow ? "πŸ“„ Document Verification" : "πŸ” Data Verification";
611
+ badge.textContent = isDocFlow ? "Γ°ΕΈβ€œβ€ž Document Verification" : "🔍 Data Verification";
510
612
  topRow.appendChild(badge);
511
613
  container.appendChild(topRow);
512
614
  // Title + subtitle (compact)
@@ -517,7 +619,11 @@ class SmartComplyFlow {
517
619
  color:${this.theme.text};font-size:20px;font-weight:700;
518
620
  margin:0;line-height:1.2;
519
621
  `;
520
- title.textContent = "Identity Verification";
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";
521
627
  titleWrap.appendChild(title);
522
628
  const desc = document.createElement("p");
523
629
  desc.style.cssText = `
@@ -525,21 +631,21 @@ class SmartComplyFlow {
525
631
  margin:0;max-width:300px;
526
632
  `;
527
633
  desc.textContent = isDocFlow
528
- ? "Scan your ID document and take a quick selfie to confirm your identity."
529
- : "Enter your ID details and take a quick selfie to confirm your identity.";
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.";
530
636
  titleWrap.appendChild(desc);
531
637
  container.appendChild(titleWrap);
532
- // Steps preview β€” compact rows
638
+ // Steps preview Ò€” compact rows
533
639
  const steps = isDocFlow
534
640
  ? [
535
- { icon: "πŸͺͺ", text: "Choose your document type", sub: "Passport, National ID, Driver License" },
536
- { icon: "πŸ“·", text: "Scan your document", sub: "Upload or capture a photo of your ID" },
537
- { icon: "🀳", text: "Face check", sub: "Quick selfie to confirm it's you" },
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" },
538
644
  ]
539
645
  : [
540
- { icon: "πŸ†”", text: "Choose your ID type", sub: "NIN, BVN, Voter's Card and more" },
541
- { icon: "πŸ“", text: "Enter your ID details", sub: "Exactly as shown on your ID" },
542
- { icon: "🀳", text: "Face check", sub: "Quick selfie to confirm it's you" },
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" },
543
649
  ];
544
650
  const stepsWrap = document.createElement("div");
545
651
  stepsWrap.style.cssText = `
@@ -580,21 +686,21 @@ class SmartComplyFlow {
580
686
  });
581
687
  container.appendChild(stepsWrap);
582
688
  // CTA
583
- const btn = this.createPrimaryButton("Get Started β†’");
689
+ const btn = this.createPrimaryButton("Get Started Ò†’");
584
690
  btn.style.cssText += "margin-top:2px;";
585
691
  btn.addEventListener("click", () => this.showStep("country"));
586
692
  container.appendChild(btn);
587
- // Trust badge β€” "Powered by Adhere" already lives in the persistent
693
+ // Trust badge Ò€” "Powered by Adhere" already lives in the persistent
588
694
  // modal footer below; repeating it here just cost vertical space.
589
695
  const trust = document.createElement("div");
590
696
  trust.style.cssText = `
591
697
  display:flex;align-items:center;gap:6px;
592
698
  color:${this.theme.textMuted};font-size:10.5px;
593
699
  `;
594
- trust.textContent = "πŸ”’ End-to-end encrypted";
700
+ trust.textContent = "🔒 End-to-end encrypted";
595
701
  container.appendChild(trust);
596
702
  }
597
- // ── Country Select ──────────────────────────────────────────────
703
+ // Ò”€Ò”€ Country Select Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
598
704
  renderCountrySelect(container) {
599
705
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
600
706
  const channels = this.sdkConfig?.channels || {};
@@ -619,7 +725,7 @@ class SmartComplyFlow {
619
725
  container.appendChild(card);
620
726
  }
621
727
  }
622
- // ── ID Type Select ──────────────────────────────────────────────
728
+ // Ò”€Ò”€ ID Type Select Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
623
729
  renderIdTypeSelect(container) {
624
730
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
625
731
  const globalDocFlow = this.isDocumentFlow();
@@ -647,9 +753,9 @@ class SmartComplyFlow {
647
753
  // Add back side indicator to subtitle if needed
648
754
  let subtitle = info.desc;
649
755
  if (requiresBack && isThisDocFlow) {
650
- subtitle += " ⚠️ Both sides required";
756
+ subtitle += " Òő ï¸ Both sides required";
651
757
  }
652
- // Clean display name β€” strip "with face", "(NIN)", "(BVN)" from user-visible label
758
+ // Clean display name Ò€” strip "with face", "(NIN)", "(BVN)" from user-visible label
653
759
  const displayName = typeName
654
760
  .replace(/\s*with\s+face\s*/gi, ' ')
655
761
  .replace(/\s*\(NIN\)\s*/gi, ' ')
@@ -686,7 +792,7 @@ class SmartComplyFlow {
686
792
  container.appendChild(card);
687
793
  }
688
794
  }
689
- // ── ID Input (data verification β€” dynamic fields) ─────────────
795
+ // Ò”€Ò”€ ID Input (data verification Ò€” dynamic fields) Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
690
796
  renderIdInput(container) {
691
797
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
692
798
  const dataFields = this.selectedChannelFields.filter((f) => f.type !== "upload");
@@ -705,7 +811,7 @@ class SmartComplyFlow {
705
811
  ? `Enter your ${this.selectedIdTypeName || this.selectedIdType} details exactly as they appear on your ID.`
706
812
  : "Fill in the fields below to continue.";
707
813
  container.appendChild(subtitle);
708
- // Edge case: no data fields configured β€” show fallback and allow submission
814
+ // Edge case: no data fields configured Ò€” show fallback and allow submission
709
815
  if (dataFields.length === 0) {
710
816
  const empty = document.createElement("div");
711
817
  empty.style.cssText = `
@@ -724,13 +830,13 @@ class SmartComplyFlow {
724
830
  { match: "BVN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "BVN must be exactly 11 digits" } },
725
831
  { match: "NIN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
726
832
  { match: "NATIONAL IDENTITY", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
727
- { match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6–20 alphanumeric characters" } },
728
- { match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3–30 alphanumeric characters" } },
729
- { match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6–30 alphanumeric characters" } },
730
- { match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8–20 alphanumeric characters" } },
731
- { match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7–15 alphanumeric characters" } },
732
- { match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8–20 characters" } },
733
- { match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10–12 characters" } },
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" } },
734
840
  ];
735
841
  function getFieldRule(label) {
736
842
  const upper = label.toUpperCase();
@@ -758,7 +864,7 @@ class SmartComplyFlow {
758
864
  // Track input elements for validation
759
865
  const inputEls = [];
760
866
  for (const field of dataFields) {
761
- // Send the raw label as the payload key β€” the backend's
867
+ // Send the raw label as the payload key Ò€” the backend's
762
868
  // _SDK_FIELD_KEY_MAP looks up the exact label text (e.g. "National ID",
763
869
  // "CAC Number") to remap it to the canonical IVS key. Pre-normalizing
764
870
  // it here (as this used to do) produced keys like "national_id" that
@@ -808,7 +914,7 @@ class SmartComplyFlow {
808
914
  inputEls.push({ field, el: select });
809
915
  }
810
916
  else if (field.type === "image" || field.type === "file") {
811
- // File upload field β€” converts to base64 for API submission
917
+ // File upload field Ò€” converts to base64 for API submission
812
918
  const fileInput = document.createElement("input");
813
919
  fileInput.type = "file";
814
920
  fileInput.accept = "image/*";
@@ -948,12 +1054,18 @@ class SmartComplyFlow {
948
1054
  fields: { ...this.collectedFields },
949
1055
  });
950
1056
  if (this.verificationResult.status === "success") {
951
- // Save the first collected field value as identifier for liveness
1057
+ // Save identifier and identity_check_id for liveness step
952
1058
  const fieldValues = Object.values(this.collectedFields);
953
1059
  this.idNumber = fieldValues[0] || "";
954
- // A4: Save identity_check_id from verify response to pass to liveness
955
1060
  this.identityCheckId = this.verificationResult.data?.identity_check_id || null;
956
- this.showStep("liveness");
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");
957
1069
  }
958
1070
  else {
959
1071
  errorEl.textContent = this.verificationResult.message || "We could not verify your details. Please check and try again.";
@@ -964,10 +1076,10 @@ class SmartComplyFlow {
964
1076
  }
965
1077
  }
966
1078
  catch (err) {
967
- // Show a single plain-language error β€” never raw field:message format
1079
+ // Show a single plain-language error Ò€” never raw field:message format
968
1080
  const fieldErrors = err.data?.errors;
969
1081
  if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
970
- // Pick the first meaningful error message β€” strip field prefix
1082
+ // Pick the first meaningful error message Ò€” strip field prefix
971
1083
  const firstMsg = fieldErrors[0]?.message || fieldErrors[0]?.detail || "";
972
1084
  errorEl.textContent = _sanitizeUserMessage(firstMsg)
973
1085
  || "Please check your details and try again.";
@@ -998,7 +1110,7 @@ class SmartComplyFlow {
998
1110
  setTimeout(() => firstInput.focus(), 350);
999
1111
  }
1000
1112
  }
1001
- // ── Document Capture ────────────────────────────────────────────
1113
+ // Ò”€Ò”€ Document Capture Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
1002
1114
  renderDocumentCapture(container) {
1003
1115
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
1004
1116
  // Strip "with face", "(NIN)", "(BVN)" noise from the user-visible title
@@ -1039,105 +1151,201 @@ class SmartComplyFlow {
1039
1151
  this.showErrorStep(err.message || "Document capture failed. Please try again.");
1040
1152
  });
1041
1153
  }
1042
- // ── Processing (real polling-driven, document flow only) ────────────
1043
- renderProcessing(container, entryId) {
1044
- container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px;";
1045
- // Spinner
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";
1171
+ container.appendChild(title);
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");
1220
+ });
1221
+ container.appendChild(noBtn);
1222
+ }
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;";
1046
1324
  const spinnerWrap = document.createElement("div");
1047
- spinnerWrap.style.cssText = "position:relative;width:56px;height:56px;flex-shrink:0;";
1325
+ spinnerWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
1048
1326
  const ring = document.createElement("div");
1049
- ring.id = "sc-proc-ring";
1050
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;`;
1051
- const centerIcon = document.createElement("div");
1052
- centerIcon.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;";
1053
- centerIcon.textContent = "πŸ”";
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 = "Γ°ΕΈβ€œβ€ž";
1054
1331
  spinnerWrap.appendChild(ring);
1055
- spinnerWrap.appendChild(centerIcon);
1332
+ spinnerWrap.appendChild(centre);
1056
1333
  container.appendChild(spinnerWrap);
1057
- const title = document.createElement("div");
1058
- title.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:700;text-align:center;`;
1059
- title.textContent = "Verifying your document";
1060
- container.appendChild(title);
1061
- // Live status line β€” updated by polling
1062
- const statusLine = document.createElement("div");
1063
- statusLine.id = "sc-proc-status";
1064
- statusLine.setAttribute("aria-live", "polite");
1065
- statusLine.setAttribute("aria-atomic", "true");
1066
- statusLine.style.cssText = `color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:260px;line-height:1.5;`;
1067
- statusLine.textContent = "Scanning document…";
1068
- container.appendChild(statusLine);
1069
- // Step checklist β€” reflects real pipeline stages
1070
- const stages = [
1071
- { id: "ocr", label: "Reading document", icon: "πŸ“„" },
1072
- { id: "expiry", label: "Checking expiry", icon: "πŸ“…" },
1073
- { id: "face", label: "Face match", icon: "🀳" },
1074
- { id: "result", label: "Finalising result", icon: "βœ“" },
1075
- ];
1076
- const grid = document.createElement("div");
1077
- grid.style.cssText = "width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;";
1078
- stages.forEach((s) => {
1079
- const el = document.createElement("div");
1080
- el.id = `sc-stage-${s.id}`;
1081
- el.style.cssText = `display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;background:${this.theme.cardBg};border:1px solid ${this.theme.border};transition:all 0.3s ease;`;
1082
- el.innerHTML = `
1083
- <div style="width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;background:${this.theme.shimmer};">${s.icon}</div>
1084
- <div style="flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;">${s.label}</div>
1085
- <div id="sc-stage-status-${s.id}" style="width:14px;height:14px;border-radius:50%;border:2px solid ${this.theme.border};flex-shrink:0;"></div>
1086
- `;
1087
- grid.appendChild(el);
1088
- });
1089
- container.appendChild(grid);
1090
- // Start polling immediately β€” update stages as result comes in
1091
- if (entryId) {
1092
- this._pollProcessingStages(entryId, statusLine, grid, stages);
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;
1093
1346
  }
1094
- }
1095
- _pollProcessingStages(entryId, statusLine, _grid, stages) {
1096
- // Animate stages sequentially on a timer β€” OCR takes ~5–15s,
1097
- // face match another ~5–15s. We can't observe sub-steps from the
1098
- // public sdk-result endpoint, so we advance stages on elapsed time
1099
- // AND on the final status transition.
1100
- const stageTimings = [0, 4000, 10000, 18000]; // ms after poll start
1101
- const startTime = Date.now();
1102
- const setStageActive = (stageId) => {
1103
- const el = document.getElementById(`sc-stage-${stageId}`);
1104
- const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
1105
- if (!el || !statusEl)
1106
- return;
1107
- el.style.borderColor = this.theme.primary;
1108
- el.style.background = `${this.theme.primary}10`;
1109
- statusEl.innerHTML = `<div style="position:absolute;inset:1px;border-radius:50%;border:2px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>`;
1110
- statusEl.style.position = "relative";
1111
- };
1112
- const setStageComplete = (stageId) => {
1113
- const el = document.getElementById(`sc-stage-${stageId}`);
1114
- const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
1115
- if (!el || !statusEl)
1116
- return;
1117
- el.style.borderColor = this.theme.success || "#22c55e";
1118
- el.style.background = `${this.theme.success || "#22c55e"}10`;
1119
- statusEl.innerHTML = `<div style="position:absolute;inset:2px;border-radius:50%;background:${this.theme.success || "#22c55e"};display:flex;align-items:center;justify-content:center;color:#fff;font-size:8px;font-weight:bold;">βœ“</div>`;
1120
- statusEl.style.position = "relative";
1121
- };
1122
- // Advance stage indicators on timer
1123
- stages.forEach((s, i) => {
1124
- setTimeout(() => {
1125
- if (this.isDestroyed)
1126
- return;
1127
- setStageActive(s.id);
1128
- if (i > 0)
1129
- setStageComplete(stages[i - 1].id);
1130
- statusLine.textContent = s.id === "ocr"
1131
- ? "Scanning document…"
1132
- : s.id === "expiry"
1133
- ? "Checking expiry date…"
1134
- : s.id === "face"
1135
- ? "Running face match…"
1136
- : "Finalising result…";
1137
- }, stageTimings[i]);
1138
- });
1139
- // Poll for real result
1140
- const MAX_POLLS = 30; // 30 Γ— 3s = 90s max
1347
+ const entryId = this.livenessEntryId;
1348
+ const MAX_POLLS = 10; // 10 Γƒβ€” 2s = 20s timeout
1141
1349
  let polls = 0;
1142
1350
  const poll = async () => {
1143
1351
  if (this.isDestroyed)
@@ -1145,48 +1353,206 @@ class SmartComplyFlow {
1145
1353
  polls++;
1146
1354
  try {
1147
1355
  const result = await this.sdk.liveness.pollResult(entryId);
1148
- if (result.status === "passed" || result.status === "failed") {
1149
- // Mark all remaining stages complete
1150
- stages.forEach((s) => setStageComplete(s.id));
1151
- statusLine.textContent = result.status === "passed"
1152
- ? "Verification complete βœ“"
1153
- : "Verification finished";
1154
- // Short delay so user sees the completed states, then show result
1155
- setTimeout(() => {
1156
- if (!this.isDestroyed) {
1157
- this._livenessSubmitResult = result;
1158
- this.showResult(result);
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();
1159
1386
  }
1160
- }, 800);
1387
+ }
1388
+ this.showStep("document_confirm");
1161
1389
  return;
1162
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
+ }
1163
1404
  }
1164
- catch { /* network hiccup β€” keep polling */ }
1165
- if (polls < MAX_POLLS && !this.isDestroyed) {
1166
- this._pollStatusTimer = setTimeout(poll, 3000);
1167
- }
1168
- else if (!this.isDestroyed) {
1169
- // Timeout β€” transition to result screen with current status
1170
- stages.forEach((s) => setStageComplete(s.id));
1171
- statusLine.textContent = "Still processing β€” check back soon";
1405
+ catch {
1406
+ if (polls < MAX_POLLS && !this.isDestroyed) {
1407
+ this._ocrPollTimer = setTimeout(poll, 3000);
1408
+ }
1409
+ else if (!this.isDestroyed) {
1410
+ this.showErrorStep("We could not read your document. Please check your connection and try again.");
1411
+ }
1172
1412
  }
1173
1413
  };
1174
- // First poll after 5s β€” give Celery task time to start
1175
- this._pollStatusTimer = setTimeout(poll, 5000);
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(() => { });
1176
1543
  }
1177
1544
  /**
1178
1545
  * Process document capture result with front/back support
1179
1546
  */
1180
1547
  async processDocumentCaptureResult(result, captureContainer, container) {
1181
- // Store both front and back blobs
1182
- this.documentBlob = result.front;
1183
- this.documentBackBlob = result.back || null;
1184
- // Process the front image through the existing flow
1185
- await this.processDocumentBlob(result.front, captureContainer, container);
1186
- // Log back image capture
1187
- if (result.back) {
1188
- console.log("Back image captured, size:", result.back.size);
1189
- }
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);
1190
1556
  }
1191
1557
  /**
1192
1558
  * Process document blob - common method for both CameraView and DocumentCapture
@@ -1196,7 +1562,7 @@ class SmartComplyFlow {
1196
1562
  let retryCount = 0;
1197
1563
  const attemptProcessing = async () => {
1198
1564
  try {
1199
- // Validate blob
1565
+ // Validate blob Ò€” compression already done in processDocumentCaptureResult (P2)
1200
1566
  if (!blob || blob.size === 0) {
1201
1567
  throw new Error("Failed to capture document. Please try again.");
1202
1568
  }
@@ -1209,7 +1575,7 @@ class SmartComplyFlow {
1209
1575
  <div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:28px 20px;">
1210
1576
  <div style="position:relative;width:52px;height:52px;">
1211
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>
1212
- <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">πŸ”</div>
1578
+ <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔍</div>
1213
1579
  </div>
1214
1580
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
1215
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>
@@ -1217,7 +1583,7 @@ class SmartComplyFlow {
1217
1583
  // Create liveness entry with document.
1218
1584
  // Use the first collected field value as identifier when available
1219
1585
  // (document flow has no data-verification step, so collectedFields is
1220
- // empty β€” fall back to the selected ID type code so the webhook
1586
+ // empty Ò€” fall back to the selected ID type code so the webhook
1221
1587
  // subject.identifier is at least human-meaningful, not a raw timestamp).
1222
1588
  const identifier = Object.values(this.collectedFields)[0]
1223
1589
  || this.idNumber
@@ -1266,10 +1632,9 @@ class SmartComplyFlow {
1266
1632
  }
1267
1633
  }
1268
1634
  this.livenessEntryId = createResult.id;
1269
- // Document is already uploaded as part of liveness.create() above.
1270
- // Proceed straight to liveness β€” the backend runs OCR + face comparison
1271
- // after the liveness video is submitted, so there is no need to wait here.
1272
- 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");
1273
1638
  }
1274
1639
  catch (err) {
1275
1640
  console.error("Document processing failed:", err);
@@ -1282,7 +1647,7 @@ class SmartComplyFlow {
1282
1647
  <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1283
1648
  <div style="position:relative;width:52px;height:52px;">
1284
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>
1285
- <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">πŸ”„</div>
1650
+ <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">Γ°ΕΈβ€β€ž</div>
1286
1651
  </div>
1287
1652
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Retrying...</div>
1288
1653
  <div style="color:${this.theme.textSecondary};font-size:12px;max-width:240px;line-height:1.5;">Attempt ${retryCount} of ${maxRetries}</div>
@@ -1301,7 +1666,7 @@ class SmartComplyFlow {
1301
1666
  await attemptProcessing();
1302
1667
  }
1303
1668
  handleDocumentProcessingError(error, captureContainer) {
1304
- // Map technical errors to user-friendly messages β€” never pass raw error strings
1669
+ // Map technical errors to user-friendly messages Ò€” never pass raw error strings
1305
1670
  let userMessage = "We could not verify your document. Please try again.";
1306
1671
  let canRetry = true;
1307
1672
  const raw = (error?.message || "").toLowerCase();
@@ -1328,13 +1693,13 @@ class SmartComplyFlow {
1328
1693
  userMessage = "The document does not match the selected ID type. Please choose the correct ID type.";
1329
1694
  }
1330
1695
  if (captureContainer) {
1331
- // Build with DOM β€” never interpolate user-derived content into innerHTML
1696
+ // Build with DOM Ò€” never interpolate user-derived content into innerHTML
1332
1697
  const wrap = document.createElement("div");
1333
1698
  wrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;";
1334
1699
  wrap.setAttribute("role", "alert");
1335
1700
  const icon = document.createElement("div");
1336
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;`;
1337
- icon.textContent = "πŸ”";
1702
+ icon.textContent = "🔍";
1338
1703
  wrap.appendChild(icon);
1339
1704
  const titleEl = document.createElement("div");
1340
1705
  titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:700;`;
@@ -1359,7 +1724,7 @@ class SmartComplyFlow {
1359
1724
  this.showErrorStep(userMessage);
1360
1725
  }
1361
1726
  }
1362
- // ── Liveness ────────────────────────────────────────────────────
1727
+ // Ò”€Ò”€ Liveness Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
1363
1728
  renderLiveness(container) {
1364
1729
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
1365
1730
  const title = this.createStepTitle("Face verification");
@@ -1372,18 +1737,14 @@ class SmartComplyFlow {
1372
1737
  const livenessContainer = document.createElement("div");
1373
1738
  livenessContainer.style.cssText = "margin:8px 0;width:100%;";
1374
1739
  container.appendChild(livenessContainer);
1375
- const identifier = this.idNumber
1376
- || Object.values(this.collectedFields)[0]
1377
- || this.selectedIdType
1378
- || `DOC-${Date.now()}`;
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()}`);
1379
1745
  const COUNTRY_CODES = {
1380
- nigeria: "NG",
1381
- ghana: "GH",
1382
- kenya: "KE",
1383
- south_africa: "ZA",
1384
- united_states: "US",
1385
- united_kingdom: "GB",
1386
- global: "US",
1746
+ nigeria: "NG", ghana: "GH", kenya: "KE",
1747
+ south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
1387
1748
  };
1388
1749
  const country = COUNTRY_CODES[this.selectedCountry] || this.selectedCountry.toUpperCase().slice(0, 2);
1389
1750
  // Start liveness check (this handles camera, detection, recording, submission)
@@ -1396,272 +1757,35 @@ class SmartComplyFlow {
1396
1757
  document: this.documentBlob || undefined,
1397
1758
  document_back: this.documentBackBlob || undefined,
1398
1759
  identity_check: this.identityCheckId || undefined,
1399
- // Tell the module which flow we're in so it can skip video for data verification
1400
1760
  verification_type: this.identityCheckId
1401
1761
  ? "data_verification"
1402
1762
  : this.documentBlob
1403
1763
  ? "document_verification"
1404
1764
  : "liveness_only",
1765
+ // B4: pass pre-warmed stream so camera.open() is skipped
1766
+ prewarmedStream: this._prewarmedStream || undefined,
1405
1767
  }, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
1406
1768
  .then((result) => {
1407
- // For document/data verification the backend processes async.
1408
- // Route through the processing step so the user sees real pipeline
1409
- // progress instead of jumping straight to result.
1410
- const isAsync = !!(this.documentBlob || this.identityCheckId);
1411
- if (isAsync && result.id) {
1412
- this.livenessEntryId = result.id;
1413
- this.showStep("processing");
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++;
1414
1774
  }
1415
- else {
1416
- this.showResult(result);
1775
+ if (result.id) {
1776
+ this.livenessEntryId = result.id;
1417
1777
  }
1778
+ this.showStep("done");
1418
1779
  })
1419
1780
  .catch((err) => {
1781
+ this._livenessFailures++;
1420
1782
  this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
1421
1783
  });
1422
1784
  }
1423
- // ── Result ──────────────────────────────────────────────────────
1424
- showResult(submitResult) {
1425
- this.currentStep = "result";
1426
- this.updateProgress();
1427
- if (!this.contentArea)
1428
- return;
1429
- this.contentArea.innerHTML = "";
1430
- const wrapper = document.createElement("div");
1431
- wrapper.setAttribute("role", "region");
1432
- wrapper.setAttribute("aria-label", "Verification result");
1433
- wrapper.style.cssText = `
1434
- display:flex;flex-direction:column;align-items:center;
1435
- text-align:center;gap:16px;padding:20px 0;
1436
- opacity:0;transform:scale(0.95);transition:all 0.4s ease;
1437
- `;
1438
- // ── Icon β€” spinner until status resolves ────────────────────────
1439
- const iconWrap = document.createElement("div");
1440
- iconWrap.style.cssText = `
1441
- width:80px;height:80px;border-radius:50%;
1442
- display:flex;align-items:center;justify-content:center;
1443
- font-size:36px;
1444
- background:${this.theme.cardBg};
1445
- border:3px solid ${this.theme.border};
1446
- transition:all 0.4s ease;
1447
- `;
1448
- iconWrap.innerHTML = `<div style="width:32px;height:32px;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.9s linear infinite;"></div>`;
1449
- wrapper.appendChild(iconWrap);
1450
- // ── Title and description β€” updated by polling ───────────────────
1451
- const title = document.createElement("h2");
1452
- title.setAttribute("aria-live", "polite");
1453
- title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
1454
- title.textContent = "Checking your identity…";
1455
- wrapper.appendChild(title);
1456
- const desc = document.createElement("p");
1457
- desc.setAttribute("aria-live", "polite");
1458
- desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1459
- desc.textContent = "This usually takes a few seconds. Please wait.";
1460
- wrapper.appendChild(desc);
1461
- // ── Result card β€” initially shows status only ────────────────────
1462
- // Populated with name/doc type when verification completes.
1463
- const resultCard = document.createElement("div");
1464
- resultCard.style.cssText = `
1465
- width:100%;padding:16px;border-radius:12px;
1466
- background:${this.theme.cardBg};border:1px solid ${this.theme.border};
1467
- text-align:left;display:flex;flex-direction:column;gap:10px;
1468
- transition:border-color 0.3s ease;
1469
- `;
1470
- // Status row β€” always shown
1471
- const statusBadge = document.createElement("span");
1472
- statusBadge.setAttribute("role", "status");
1473
- statusBadge.setAttribute("aria-live", "polite");
1474
- statusBadge.setAttribute("aria-atomic", "true");
1475
- statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
1476
- statusBadge.textContent = "Processing…";
1477
- const statusRow = document.createElement("div");
1478
- statusRow.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
1479
- statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Status</span>`;
1480
- statusRow.appendChild(statusBadge);
1481
- resultCard.appendChild(statusRow);
1482
- // Verified details β€” shown only when passed.
1483
- // nameRow: shows full name from IVS result or OCR
1484
- // docTypeRow: shows document type verified
1485
- const nameRow = document.createElement("div");
1486
- nameRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
1487
- nameRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Name</span><span id="sc-result-name" style="color:${this.theme.text};font-size:12px;font-weight:600;text-align:right;max-width:60%;"></span>`;
1488
- resultCard.appendChild(nameRow);
1489
- const docTypeRow = document.createElement("div");
1490
- docTypeRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
1491
- docTypeRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">ID Type</span><span id="sc-result-doctype" style="color:${this.theme.text};font-size:12px;font-weight:600;"></span>`;
1492
- resultCard.appendChild(docTypeRow);
1493
- wrapper.appendChild(resultCard);
1494
- // ── Failure reason β€” shown only when failed ──────────────────────
1495
- const failureMsg = document.createElement("div");
1496
- failureMsg.setAttribute("role", "alert");
1497
- failureMsg.style.cssText = `
1498
- display:none;width:100%;padding:12px 14px;border-radius:10px;
1499
- background:${this.theme.errorBg};border:1px solid ${this.theme.error};
1500
- color:${this.theme.error};font-size:13px;text-align:left;line-height:1.6;
1501
- `;
1502
- wrapper.appendChild(failureMsg);
1503
- // ── Action buttons ───────────────────────────────────────────────
1504
- const btnRow = document.createElement("div");
1505
- btnRow.style.cssText = "display:flex;flex-direction:column;gap:8px;width:100%;";
1506
- const doneBtn = this.createPrimaryButton("Done");
1507
- doneBtn.setAttribute("aria-label", "Close verification and continue");
1508
- let finalStatus = submitResult.status;
1509
- doneBtn.addEventListener("click", () => {
1510
- if (this._pollStatusTimer) {
1511
- clearTimeout(this._pollStatusTimer);
1512
- this._pollStatusTimer = null;
1513
- }
1514
- this.options.onComplete?.({
1515
- entryId: submitResult.id,
1516
- sessionId: this.sdk.sessionToken,
1517
- status: finalStatus,
1518
- submittedAt: submitResult.submitted_at,
1519
- verificationResult: this.verificationResult || undefined,
1520
- });
1521
- this.close();
1522
- });
1523
- btnRow.appendChild(doneBtn);
1524
- // "Try again" button β€” shown only on failure, hidden initially
1525
- const retryBtn = this.createSecondaryButton("Try Again");
1526
- retryBtn.setAttribute("aria-label", "Start verification again");
1527
- retryBtn.style.display = "none";
1528
- retryBtn.addEventListener("click", () => {
1529
- if (this._pollStatusTimer) {
1530
- clearTimeout(this._pollStatusTimer);
1531
- this._pollStatusTimer = null;
1532
- }
1533
- this.initialize();
1534
- });
1535
- btnRow.appendChild(retryBtn);
1536
- wrapper.appendChild(btnRow);
1537
- this.contentArea.appendChild(wrapper);
1538
- requestAnimationFrame(() => {
1539
- wrapper.style.opacity = "1";
1540
- wrapper.style.transform = "scale(1)";
1541
- });
1542
- // ── Start polling ────────────────────────────────────────────────
1543
- this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, (resolved) => { finalStatus = resolved; });
1544
- }
1545
- _startStatusPolling(entryId, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, onResolved) {
1546
- const MAX_POLLS = 25; // 25 Γ— 3s = 75s + 4s initial β‰ˆ 79s max
1547
- let polls = 0;
1548
- // Hard 30s UI timer β€” stop spinner and tell user to wait even if backend is slower
1549
- let uiTimeoutId = setTimeout(() => {
1550
- if (!this.isDestroyed)
1551
- applyTimeout();
1552
- }, 30000);
1553
- const clearUiTimeout = () => {
1554
- if (uiTimeoutId) {
1555
- clearTimeout(uiTimeoutId);
1556
- uiTimeoutId = null;
1557
- }
1558
- };
1559
- const applyTimeout = () => {
1560
- if (this._pollStatusTimer) {
1561
- clearTimeout(this._pollStatusTimer);
1562
- this._pollStatusTimer = null;
1563
- }
1564
- iconWrap.innerHTML = "⏳";
1565
- iconWrap.style.border = `3px solid ${this.theme.warning}`;
1566
- statusBadge.style.color = this.theme.textMuted;
1567
- statusBadge.textContent = "Still processing…";
1568
- title.textContent = "Still verifying";
1569
- desc.textContent = "This is taking a little longer than usual. You can safely close this β€” your result will be available in your account.";
1570
- };
1571
- const applyPassed = (result) => {
1572
- clearUiTimeout();
1573
- iconWrap.innerHTML = "βœ“";
1574
- iconWrap.style.cssText += `background:${this.theme.successBg};border:3px solid ${this.theme.success};color:${this.theme.success};font-size:36px;font-weight:700;`;
1575
- resultCard.style.borderColor = this.theme.success;
1576
- statusBadge.style.color = this.theme.success;
1577
- statusBadge.textContent = "Verified βœ“";
1578
- title.textContent = "Identity Verified";
1579
- desc.textContent = "Your identity has been confirmed successfully.";
1580
- // ── Show name if available ───────────────────────────────────────
1581
- // Pull from IVS customer_profile (data verification) or
1582
- // document OCR result (document verification) β€” whichever has data.
1583
- const profile = result?.customer_profile || result?.data || {};
1584
- const docResult = result?.document || {};
1585
- const ocr = docResult?.ocr_result || {};
1586
- const firstName = (profile?.first_name || ocr?.first_name || docResult?.first_name || "").trim();
1587
- const lastName = (profile?.last_name || profile?.surname ||
1588
- ocr?.last_name || ocr?.surname || docResult?.last_name || "").trim();
1589
- const fullName = [firstName, lastName].filter(Boolean).join(" ");
1590
- const nameEl = document.getElementById("sc-result-name");
1591
- if (fullName && nameEl) {
1592
- nameEl.textContent = fullName;
1593
- nameRow.style.display = "flex";
1594
- }
1595
- // ── Show ID type ─────────────────────────────────────────────────
1596
- const rawDocType = (docResult?.document_type ||
1597
- this.selectedIdType ||
1598
- result?.identifier_type ||
1599
- "");
1600
- const docTypeLabel = _formatDocType(rawDocType);
1601
- const docTypeEl = document.getElementById("sc-result-doctype");
1602
- if (docTypeLabel && docTypeEl) {
1603
- docTypeEl.textContent = docTypeLabel;
1604
- docTypeRow.style.display = "flex";
1605
- }
1606
- };
1607
- const applyFailed = (reason) => {
1608
- clearUiTimeout();
1609
- iconWrap.innerHTML = "βœ•";
1610
- iconWrap.style.cssText += `background:${this.theme.errorBg};border:3px solid ${this.theme.error};color:${this.theme.error};font-size:32px;`;
1611
- resultCard.style.borderColor = this.theme.error;
1612
- statusBadge.style.color = this.theme.error;
1613
- statusBadge.textContent = "Not verified";
1614
- title.textContent = "Verification Unsuccessful";
1615
- desc.textContent = "We were unable to confirm your identity this time.";
1616
- // Show a clean, actionable reason β€” never raw technical strings
1617
- const safeReason = _sanitizeUserMessage(reason);
1618
- if (safeReason) {
1619
- failureMsg.textContent = safeReason;
1620
- failureMsg.style.display = "block";
1621
- }
1622
- // Show retry button on failure
1623
- retryBtn.style.display = "block";
1624
- };
1625
- const poll = async () => {
1626
- if (this.isDestroyed)
1627
- return;
1628
- polls++;
1629
- try {
1630
- const result = await this.sdk.liveness.pollResult(entryId);
1631
- if (result.status === "passed") {
1632
- onResolved("passed");
1633
- applyPassed(result);
1634
- return;
1635
- }
1636
- if (result.status === "failed") {
1637
- onResolved("failed");
1638
- applyFailed(result.failure_reason || null);
1639
- return;
1640
- }
1641
- // Still processing β€” schedule next poll
1642
- if (polls < MAX_POLLS && !this.isDestroyed) {
1643
- this._pollStatusTimer = setTimeout(poll, 3000);
1644
- }
1645
- else if (!this.isDestroyed) {
1646
- applyTimeout();
1647
- }
1648
- }
1649
- catch {
1650
- if (polls < MAX_POLLS && !this.isDestroyed) {
1651
- this._pollStatusTimer = setTimeout(poll, 5000);
1652
- }
1653
- else if (!this.isDestroyed) {
1654
- applyTimeout();
1655
- }
1656
- }
1657
- };
1658
- // First poll after 4s β€” gives Celery task time to start
1659
- this._pollStatusTimer = setTimeout(poll, 4000);
1660
- }
1661
1785
  renderResult(_container) {
1662
- // Handled by showResult() directly
1786
+ // C2: Dead code Ò€” flow routes to 'done' step, not 'result'. Kept as stub.
1663
1787
  }
1664
- // ── Error ───────────────────────────────────────────────────────
1788
+ // Ò”€Ò”€ Error Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
1665
1789
  showErrorStep(message) {
1666
1790
  this.currentStep = "error";
1667
1791
  if (!this.contentArea)
@@ -1680,13 +1804,13 @@ class SmartComplyFlow {
1680
1804
  const isDocQuality = /blurry|blur|quality|glare|dark|bright|lighting/i.test(message);
1681
1805
  const isExpiredDoc = /document.*expired|expired.*document/i.test(message);
1682
1806
  const isFaceMatch = /face.*match|selfie.*match|does not match/i.test(message);
1683
- const errorIcon = isCameraErr ? "πŸ“·"
1684
- : isNetworkErr ? "πŸ“‘"
1685
- : isAuthErr ? "⏱"
1686
- : isDocQuality ? "πŸ”"
1687
- : isExpiredDoc ? "πŸ“…"
1688
- : isFaceMatch ? "🀳"
1689
- : "βœ•";
1807
+ const errorIcon = isCameraErr ? "Γ°ΕΈβ€œΒ·"
1808
+ : isNetworkErr ? "Γ°ΕΈβ€œΒ‘"
1809
+ : isAuthErr ? "ҏ±"
1810
+ : isDocQuality ? "🔍"
1811
+ : isExpiredDoc ? "Γ°ΕΈβ€œβ€¦"
1812
+ : isFaceMatch ? "🀳"
1813
+ : "Γ’Ε“β€’";
1690
1814
  const errorTitle = isCameraErr ? "Camera Access Required"
1691
1815
  : isNetworkErr ? "Connection Problem"
1692
1816
  : isAuthErr ? "Session Expired"
@@ -1694,7 +1818,7 @@ class SmartComplyFlow {
1694
1818
  : isExpiredDoc ? "Document Expired"
1695
1819
  : isFaceMatch ? "Face Match Failed"
1696
1820
  : "Verification Unsuccessful";
1697
- // Map to user-facing guidance β€” never show raw internal message
1821
+ // Map to user-facing guidance Ò€” never show raw internal message
1698
1822
  const userMessage = isCameraErr
1699
1823
  ? "Please allow camera access in your browser settings and try again."
1700
1824
  : isNetworkErr
@@ -1741,9 +1865,9 @@ class SmartComplyFlow {
1741
1865
  wrapper.appendChild(closeBtn);
1742
1866
  this.contentArea.appendChild(wrapper);
1743
1867
  }
1744
- // ─────────────────────────────────────────────────────────────────
1868
+ // Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
1745
1869
  // UI Helpers
1746
- // ─────────────────────────────────────────────────────────────────
1870
+ // Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
1747
1871
  createHeader() {
1748
1872
  const header = document.createElement("div");
1749
1873
  header.setAttribute("role", "banner");
@@ -1797,7 +1921,7 @@ class SmartComplyFlow {
1797
1921
  header.appendChild(this.stepLabel);
1798
1922
  // Close button
1799
1923
  const closeBtn = document.createElement("button");
1800
- closeBtn.innerHTML = "βœ•";
1924
+ closeBtn.innerHTML = "Γ’Ε“β€’";
1801
1925
  closeBtn.setAttribute("aria-label", "Close identity verification");
1802
1926
  closeBtn.style.cssText = `
1803
1927
  width:32px;height:32px;border-radius:8px;flex-shrink:0;
@@ -1878,7 +2002,7 @@ class SmartComplyFlow {
1878
2002
  }
1879
2003
  const arrow = document.createElement("span");
1880
2004
  arrow.style.cssText = `color:${this.theme.textMuted};font-size:18px;flex-shrink:0;`;
1881
- arrow.textContent = "β†’";
2005
+ arrow.textContent = "Ò†’";
1882
2006
  card.appendChild(textWrap);
1883
2007
  card.appendChild(arrow);
1884
2008
  return card;
@@ -1923,7 +2047,7 @@ class SmartComplyFlow {
1923
2047
  return btn;
1924
2048
  }
1925
2049
  isDocumentFlow() {
1926
- // 1. Check verification_type β€” handle both string and array from backend
2050
+ // 1. Check verification_type Ò€” handle both string and array from backend
1927
2051
  const vType = this.sdkConfig?.verification_type;
1928
2052
  if (vType) {
1929
2053
  const types = Array.isArray(vType) ? vType : [vType];
@@ -1932,7 +2056,7 @@ class SmartComplyFlow {
1932
2056
  if (types.includes("data_verification"))
1933
2057
  return false;
1934
2058
  }
1935
- // 2. Fallback: inspect channel fields β€” any upload field = document flow
2059
+ // 2. Fallback: inspect channel fields Ò€” any upload field = document flow
1936
2060
  const channels = this.sdkConfig?.channels || {};
1937
2061
  const allFields = Object.values(channels)
1938
2062
  .flat()
@@ -1943,38 +2067,47 @@ class SmartComplyFlow {
1943
2067
  const isDocFlow = this.isDocumentFlow();
1944
2068
  const countries = Object.keys(this.sdkConfig?.channels || {});
1945
2069
  const singleCountry = countries.length <= 1;
1946
- // User-action steps only (what the user actually does β€” no loading/welcome/result)
1947
- const actionSteps = ["country", "id_type", isDocFlow ? "document_capture" : "id_input", "liveness"].filter((s) => !(s === "country" && singleCountry));
1948
- const totalSteps = actionSteps.length;
1949
- const actionIndex = actionSteps.indexOf(this.currentStep);
1950
- const pct = this.currentStep === "result" ? 100
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
1951
2079
  : actionIndex >= 0 ? ((actionIndex + 1) / totalSteps) * 100
1952
2080
  : 0;
1953
2081
  if (this.progressBar) {
1954
2082
  this.progressBar.style.width = `${pct}%`;
1955
2083
  }
2084
+ // Stage label: "Stage 1 of 2: Verify Identity" / "Stage 2 of 2: Face Check"
1956
2085
  if (this.stepLabel) {
1957
2086
  if (this.currentStep === "loading" || this.currentStep === "welcome") {
1958
2087
  this.stepLabel.textContent = "";
1959
2088
  }
1960
- else if (this.currentStep === "result") {
1961
- 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";
1962
2094
  }
1963
- else if (actionIndex >= 0) {
1964
- this.stepLabel.textContent = `Step ${actionIndex + 1} of ${totalSteps}`;
2095
+ else if (stage1Steps.includes(this.currentStep)) {
2096
+ this.stepLabel.textContent = isDocFlow ? "Stage 1 of 2: Scan Document" : "Stage 1 of 2: Verify Identity";
1965
2097
  }
1966
2098
  else {
1967
2099
  this.stepLabel.textContent = "";
1968
2100
  }
1969
2101
  }
1970
- // Back button β€” show in header for navigable steps, hidden elsewhere
2102
+ // Back button Ò€” show for navigable steps only
1971
2103
  if (this.headerBackBtn) {
1972
- const countries = Object.keys(this.sdkConfig?.channels || {});
1973
2104
  const backTargets = {
1974
2105
  id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
1975
2106
  country: () => this.showStep("welcome"),
1976
2107
  id_input: () => this.showStep("id_type"),
2108
+ confirm_identity: () => this.showStep("id_input"),
1977
2109
  document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
2110
+ document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
1978
2111
  };
1979
2112
  const backAction = backTargets[this.currentStep];
1980
2113
  if (backAction) {
@@ -2045,4 +2178,5 @@ class SmartComplyFlow {
2045
2178
  }
2046
2179
  }
2047
2180
  exports.SmartComplyFlow = SmartComplyFlow;
2181
+ SmartComplyFlow.MAX_RETRIES = 3;
2048
2182
  //# sourceMappingURL=SmartComplyFlow.js.map