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