smartcomply-web-sdk 1.0.3 → 1.0.4

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 (130) hide show
  1. package/README.md +43 -0
  2. package/dist/esm/camera/ActionDetector.d.ts +26 -0
  3. package/dist/esm/camera/ActionDetector.d.ts.map +1 -0
  4. package/dist/esm/camera/ActionDetector.js +110 -0
  5. package/dist/esm/camera/ActionDetector.js.map +1 -0
  6. package/dist/esm/camera/CameraManager.d.ts +7 -0
  7. package/dist/esm/camera/CameraManager.d.ts.map +1 -0
  8. package/dist/esm/camera/CameraManager.js +27 -0
  9. package/dist/esm/camera/CameraManager.js.map +1 -0
  10. package/dist/esm/camera/FaceDetector.d.ts +12 -0
  11. package/dist/esm/camera/FaceDetector.d.ts.map +1 -0
  12. package/dist/esm/camera/FaceDetector.js +51 -0
  13. package/dist/esm/camera/FaceDetector.js.map +1 -0
  14. package/dist/esm/camera/Permission.d.ts +42 -0
  15. package/dist/esm/camera/Permission.d.ts.map +1 -0
  16. package/dist/esm/camera/Permission.js +101 -0
  17. package/dist/esm/camera/Permission.js.map +1 -0
  18. package/dist/esm/camera/VideoRecorder.d.ts +12 -0
  19. package/dist/esm/camera/VideoRecorder.d.ts.map +1 -0
  20. package/dist/esm/camera/VideoRecorder.js +47 -0
  21. package/dist/esm/camera/VideoRecorder.js.map +1 -0
  22. package/dist/esm/client/Config.d.ts +9 -0
  23. package/dist/esm/client/Config.d.ts.map +1 -0
  24. package/dist/esm/client/Config.js +5 -0
  25. package/dist/esm/client/Config.js.map +1 -0
  26. package/dist/esm/client/HttpClient.d.ts +44 -0
  27. package/dist/esm/client/HttpClient.d.ts.map +1 -0
  28. package/dist/esm/client/HttpClient.js +145 -0
  29. package/dist/esm/client/HttpClient.js.map +1 -0
  30. package/dist/esm/client/Smartcomply.d.ts +50 -0
  31. package/dist/esm/client/Smartcomply.d.ts.map +1 -0
  32. package/dist/esm/client/Smartcomply.js +73 -0
  33. package/dist/esm/client/Smartcomply.js.map +1 -0
  34. package/dist/esm/components/CameraView.d.ts +59 -0
  35. package/dist/esm/components/CameraView.d.ts.map +1 -0
  36. package/dist/esm/components/CameraView.js +238 -0
  37. package/dist/esm/components/CameraView.js.map +1 -0
  38. package/dist/esm/components/DocumentUpload.d.ts +21 -0
  39. package/dist/esm/components/DocumentUpload.d.ts.map +1 -0
  40. package/dist/esm/components/DocumentUpload.js +216 -0
  41. package/dist/esm/components/DocumentUpload.js.map +1 -0
  42. package/dist/esm/errors/AuthError.d.ts +8 -0
  43. package/dist/esm/errors/AuthError.d.ts.map +1 -0
  44. package/dist/esm/errors/AuthError.js +11 -0
  45. package/dist/esm/errors/AuthError.js.map +1 -0
  46. package/dist/esm/errors/NetworkError.d.ts +8 -0
  47. package/dist/esm/errors/NetworkError.d.ts.map +1 -0
  48. package/dist/esm/errors/NetworkError.js +11 -0
  49. package/dist/esm/errors/NetworkError.js.map +1 -0
  50. package/dist/esm/errors/SDKError.d.ts +7 -0
  51. package/dist/esm/errors/SDKError.d.ts.map +1 -0
  52. package/dist/esm/errors/SDKError.js +10 -0
  53. package/dist/esm/errors/SDKError.js.map +1 -0
  54. package/dist/esm/flow/DocumentCapture.d.ts +54 -0
  55. package/dist/esm/flow/DocumentCapture.d.ts.map +1 -0
  56. package/dist/esm/flow/DocumentCapture.js +797 -0
  57. package/dist/esm/flow/DocumentCapture.js.map +1 -0
  58. package/dist/esm/flow/SmartComplyFlow.d.ts +104 -0
  59. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -0
  60. package/dist/esm/flow/SmartComplyFlow.js +1722 -0
  61. package/dist/esm/flow/SmartComplyFlow.js.map +1 -0
  62. package/dist/esm/flow/theme.d.ts +30 -0
  63. package/dist/esm/flow/theme.d.ts.map +1 -0
  64. package/dist/esm/flow/theme.js +99 -0
  65. package/dist/esm/flow/theme.js.map +1 -0
  66. package/dist/esm/index.d.ts +20 -0
  67. package/dist/esm/index.d.ts.map +1 -0
  68. package/dist/esm/index.js +15 -0
  69. package/dist/esm/index.js.map +1 -0
  70. package/dist/esm/modules/liveness/LivenessUI.d.ts +46 -0
  71. package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -0
  72. package/dist/esm/modules/liveness/LivenessUI.js +605 -0
  73. package/dist/esm/modules/liveness/LivenessUI.js.map +1 -0
  74. package/dist/esm/modules/liveness/index.d.ts +5 -0
  75. package/dist/esm/modules/liveness/index.d.ts.map +1 -0
  76. package/dist/esm/modules/liveness/index.js +4 -0
  77. package/dist/esm/modules/liveness/index.js.map +1 -0
  78. package/dist/esm/modules/liveness/liveness.d.ts +85 -0
  79. package/dist/esm/modules/liveness/liveness.d.ts.map +1 -0
  80. package/dist/esm/modules/liveness/liveness.js +421 -0
  81. package/dist/esm/modules/liveness/liveness.js.map +1 -0
  82. package/dist/esm/modules/liveness/uploader.d.ts +34 -0
  83. package/dist/esm/modules/liveness/uploader.d.ts.map +1 -0
  84. package/dist/esm/modules/liveness/uploader.js +102 -0
  85. package/dist/esm/modules/liveness/uploader.js.map +1 -0
  86. package/dist/esm/modules/onboarding/index.d.ts +2 -0
  87. package/dist/esm/modules/onboarding/index.d.ts.map +1 -0
  88. package/dist/esm/modules/onboarding/index.js +2 -0
  89. package/dist/esm/modules/onboarding/index.js.map +1 -0
  90. package/dist/esm/modules/onboarding/onboarding.d.ts +25 -0
  91. package/dist/esm/modules/onboarding/onboarding.d.ts.map +1 -0
  92. package/dist/esm/modules/onboarding/onboarding.js +29 -0
  93. package/dist/esm/modules/onboarding/onboarding.js.map +1 -0
  94. package/dist/esm/types/common.d.ts +27 -0
  95. package/dist/esm/types/common.d.ts.map +1 -0
  96. package/dist/esm/types/common.js +2 -0
  97. package/dist/esm/types/common.js.map +1 -0
  98. package/dist/esm/types/liveness.d.ts +142 -0
  99. package/dist/esm/types/liveness.d.ts.map +1 -0
  100. package/dist/esm/types/liveness.js +2 -0
  101. package/dist/esm/types/liveness.js.map +1 -0
  102. package/dist/esm/types/onboarding.d.ts +53 -0
  103. package/dist/esm/types/onboarding.d.ts.map +1 -0
  104. package/dist/esm/types/onboarding.js +2 -0
  105. package/dist/esm/types/onboarding.js.map +1 -0
  106. package/dist/esm/types/quality.d.ts +23 -0
  107. package/dist/esm/types/quality.d.ts.map +1 -0
  108. package/dist/esm/types/quality.js +5 -0
  109. package/dist/esm/types/quality.js.map +1 -0
  110. package/dist/esm/utils/ImageQuality.d.ts +48 -0
  111. package/dist/esm/utils/ImageQuality.d.ts.map +1 -0
  112. package/dist/esm/utils/ImageQuality.js +177 -0
  113. package/dist/esm/utils/ImageQuality.js.map +1 -0
  114. package/dist/esm/utils/constants.d.ts +59 -0
  115. package/dist/esm/utils/constants.d.ts.map +1 -0
  116. package/dist/esm/utils/constants.js +67 -0
  117. package/dist/esm/utils/constants.js.map +1 -0
  118. package/dist/esm/utils/device.d.ts +14 -0
  119. package/dist/esm/utils/device.d.ts.map +1 -0
  120. package/dist/esm/utils/device.js +52 -0
  121. package/dist/esm/utils/device.js.map +1 -0
  122. package/dist/esm/utils/logger.d.ts +28 -0
  123. package/dist/esm/utils/logger.d.ts.map +1 -0
  124. package/dist/esm/utils/logger.js +64 -0
  125. package/dist/esm/utils/logger.js.map +1 -0
  126. package/dist/esm/utils/validator.d.ts +35 -0
  127. package/dist/esm/utils/validator.d.ts.map +1 -0
  128. package/dist/esm/utils/validator.js +102 -0
  129. package/dist/esm/utils/validator.js.map +1 -0
  130. package/package.json +13 -5
@@ -0,0 +1,1722 @@
1
+ import { SmartComply } from "../client/Smartcomply";
2
+ import { getTheme } from "./theme";
3
+ import { DocumentCapture } from "./DocumentCapture";
4
+ import { collectDeviceMetadata } from "../utils/device";
5
+ const STEP_ORDER = [
6
+ "loading",
7
+ "welcome",
8
+ "country",
9
+ "id_type",
10
+ "id_input", // or document_capture
11
+ "document_capture",
12
+ "processing",
13
+ "liveness",
14
+ "result",
15
+ ];
16
+ 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",
24
+ };
25
+ function resolveIdTypeInfo(typeName, isDocumentFlow) {
26
+ const u = typeName.toUpperCase();
27
+ const withFace = u.includes("WITH FACE") || u.includes("FACE");
28
+ const advanced = u.includes("ADVANCED");
29
+ // Helper function to check if document requires back side
30
+ const requiresBackSide = (type) => {
31
+ const t = type.toLowerCase();
32
+ return t.includes("voter") ||
33
+ t.includes("nin") ||
34
+ t.includes("driver") ||
35
+ t.includes("license") ||
36
+ t.includes("national id") ||
37
+ t.includes("national id card");
38
+ };
39
+ // BVN — always 11 digits
40
+ if (u.includes("BVN") || u.includes("BANK VERIFICATION")) {
41
+ const extra = withFace ? " + selfie photo" : "";
42
+ return { icon: "🏦", desc: `Enter your 11-digit BVN${extra}`, requiresBack: false };
43
+ }
44
+ // NIN — always 11 digits
45
+ if (u.includes("NIN") || u.includes("NATIONAL IDENTITY NUMBER")) {
46
+ const extra = withFace ? " + selfie photo" : "";
47
+ return { icon: "🆔", desc: `Enter your 11-digit NIN${extra}`, requiresBack: false };
48
+ }
49
+ // Ghana Card / Ghana National ID
50
+ if (u.includes("GHANA")) {
51
+ if (isDocumentFlow)
52
+ return { icon: "🪪", desc: "Capture or upload your Ghana Card", requiresBack: true };
53
+ const extra = withFace ? " + selfie photo" : "";
54
+ return { icon: "🪪", desc: `Enter your Ghana Card number${extra}`, requiresBack: false };
55
+ }
56
+ // SSNIT (Ghana social security)
57
+ if (u.includes("SSNIT"))
58
+ return { icon: "🏛️", desc: "Enter your SSNIT number", requiresBack: false };
59
+ // Kenya National ID
60
+ if (u.includes("KENYA") || (u.includes("NATIONAL") && u.includes("KENYA")))
61
+ return { icon: "🆔", desc: "Enter your Kenya National ID number", requiresBack: false };
62
+ // TIN
63
+ if (u.includes("TIN") || u.includes("TAX IDENTIFICATION"))
64
+ return { icon: "🧾", desc: "Enter your Tax Identification Number (TIN)", requiresBack: false };
65
+ // CAC / Company registration
66
+ if (u.includes("CAC") || u.includes("CORPORATE AFFAIRS") || u.includes("COMPANY"))
67
+ return { icon: "🏢", desc: advanced ? "Enter RC number, company name & type" : "Enter your company registration number", requiresBack: false };
68
+ // Voter / PVC
69
+ if (u.includes("VOTER") || u.includes("PVC")) {
70
+ const requiresBack = requiresBackSide(typeName);
71
+ return {
72
+ icon: "🗳️",
73
+ desc: isDocumentFlow
74
+ ? `Capture or upload your voter's card${requiresBack ? " (front & back)" : ""}`
75
+ : "Enter your voter ID number",
76
+ requiresBack: isDocumentFlow ? requiresBack : false
77
+ };
78
+ }
79
+ // Passport
80
+ if (u.includes("PASSPORT"))
81
+ return { icon: "📘", desc: isDocumentFlow ? "Capture or upload your passport" : "Enter your passport number", requiresBack: false };
82
+ // Driver's license
83
+ if (u.includes("DRIVER") || u.includes("LICENSE") || u.includes("LICENCE")) {
84
+ const requiresBack = requiresBackSide(typeName);
85
+ return {
86
+ icon: "🚗",
87
+ desc: isDocumentFlow
88
+ ? `Capture or upload your driver's license${requiresBack ? " (front & back)" : ""}`
89
+ : "Enter your driver's license number",
90
+ requiresBack: isDocumentFlow ? requiresBack : false
91
+ };
92
+ }
93
+ // National ID card (generic)
94
+ if (u.includes("NATIONAL ID") || (u.includes("NATIONAL") && u.includes("CARD"))) {
95
+ const requiresBack = requiresBackSide(typeName);
96
+ return {
97
+ icon: "🪪",
98
+ desc: isDocumentFlow
99
+ ? `Capture or upload your ID card${requiresBack ? " (front & back)" : ""}`
100
+ : "Enter your National ID number",
101
+ requiresBack: isDocumentFlow ? requiresBack : false
102
+ };
103
+ }
104
+ // Face liveness / comparison
105
+ if (u.includes("LIVENESS") || u.includes("FACE COMPARISON") || u.includes("FACE MATCH"))
106
+ return { icon: "🤳", desc: "We'll capture a short selfie video to verify you", requiresBack: false };
107
+ return {
108
+ icon: isDocumentFlow ? "📄" : "🆔",
109
+ desc: isDocumentFlow ? "Capture or upload your document" : "Enter your ID number",
110
+ requiresBack: false,
111
+ };
112
+ }
113
+ // ─────────────────────────────────────────────────────────────────────
114
+ // SmartComplyFlow
115
+ // ─────────────────────────────────────────────────────────────────────
116
+ // Add CSS animation keyframes for spin animations
117
+ const style = document.createElement('style');
118
+ style.textContent = `
119
+ @keyframes sc-spin {
120
+ 0% { transform: rotate(0deg); }
121
+ 100% { transform: rotate(360deg); }
122
+ }
123
+ `;
124
+ if (!document.head.querySelector('style[data-sc-animations]')) {
125
+ style.setAttribute('data-sc-animations', 'true');
126
+ document.head.appendChild(style);
127
+ }
128
+ export class SmartComplyFlow {
129
+ constructor(options) {
130
+ this.theme = getTheme("default");
131
+ // DOM
132
+ this.overlay = null;
133
+ this.modal = null;
134
+ this.contentArea = null;
135
+ this.progressBar = null;
136
+ this.stepLabel = null;
137
+ this.brandLabel = null;
138
+ // State
139
+ this.sdkConfig = null;
140
+ this.currentStep = "loading";
141
+ this.selectedCountry = "";
142
+ this.selectedChannelId = 0;
143
+ this.selectedChannelFields = [];
144
+ this.selectedIdType = ""; // Canonical code (e.g., "national_id", "nin_with_face")
145
+ this.selectedIdTypeName = ""; // Display name (e.g., "National Identity Number with Face (NIN)")
146
+ this.collectedFields = {};
147
+ this.idNumber = "";
148
+ this.documentBlob = null;
149
+ this.documentBackBlob = null;
150
+ this.identityCheckId = null;
151
+ this.verificationResult = null;
152
+ this.livenessEntryId = null;
153
+ this.isDestroyed = false;
154
+ this.headerBackBtn = null;
155
+ // Session expiry
156
+ this.sessionExpiryTimer = null;
157
+ // Status polling (result screen)
158
+ this._pollStatusTimer = null;
159
+ // Components
160
+ this.docCapture = null;
161
+ this.options = options;
162
+ this.sdk = new SmartComply({
163
+ apiKey: options.apiKey,
164
+ clientId: options.clientId,
165
+ environment: options.environment || "sandbox",
166
+ timeout: options.timeout,
167
+ });
168
+ }
169
+ /**
170
+ * Open the SmartComply verification flow.
171
+ *
172
+ * Usage:
173
+ * SmartComplyFlow.open({
174
+ * apiKey: "pk_live_...",
175
+ * clientId: "uuid-...",
176
+ * environment: "production",
177
+ * onComplete: (result) => console.log("Done!", result),
178
+ * onError: (err) => console.error(err),
179
+ * onClose: () => console.log("Closed"),
180
+ * });
181
+ */
182
+ static open(options) {
183
+ const flow = new SmartComplyFlow(options);
184
+ flow.mount();
185
+ flow.initialize();
186
+ return flow;
187
+ }
188
+ /**
189
+ * Close and destroy the flow widget.
190
+ */
191
+ close() {
192
+ this.isDestroyed = true;
193
+ this.docCapture?.destroy();
194
+ if (this.sessionExpiryTimer) {
195
+ clearTimeout(this.sessionExpiryTimer);
196
+ this.sessionExpiryTimer = null;
197
+ }
198
+ if (this._pollStatusTimer) {
199
+ clearTimeout(this._pollStatusTimer);
200
+ this._pollStatusTimer = null;
201
+ }
202
+ if (this.overlay) {
203
+ this.overlay.style.opacity = "0";
204
+ setTimeout(() => {
205
+ if (this.overlay?.parentElement) {
206
+ this.overlay.parentElement.removeChild(this.overlay);
207
+ }
208
+ this.overlay = null;
209
+ this.modal = null;
210
+ this.contentArea = null;
211
+ }, 300);
212
+ }
213
+ this.options.onClose?.();
214
+ }
215
+ // ─────────────────────────────────────────────────────────────────
216
+ // Initialization
217
+ // ─────────────────────────────────────────────────────────────────
218
+ mount() {
219
+ // Inject keyframes
220
+ this.injectStyles();
221
+ // Overlay
222
+ this.overlay = document.createElement("div");
223
+ this.overlay.id = "sc-flow-overlay";
224
+ this.overlay.style.cssText = `
225
+ position:fixed;inset:0;z-index:99999;
226
+ display:flex;align-items:center;justify-content:center;
227
+ background:${this.theme.overlay};
228
+ backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
229
+ opacity:0;transition:opacity 0.3s ease;
230
+ font-family:'Inter','Segoe UI',system-ui,-apple-system,sans-serif;
231
+ `;
232
+ // Modal
233
+ this.modal = document.createElement("div");
234
+ this.modal.id = "sc-flow-modal";
235
+ this.modal.style.cssText = `
236
+ width:100%;max-width:440px;max-height:92vh;
237
+ background:${this.theme.bg};
238
+ border-radius:20px;overflow:hidden;
239
+ box-shadow:${this.theme.shadow};
240
+ display:flex;flex-direction:column;
241
+ transform:translateY(20px) scale(0.97);
242
+ transition:transform 0.35s cubic-bezier(0.16,1,0.3,1);
243
+ `;
244
+ // Header
245
+ const header = this.createHeader();
246
+ this.modal.appendChild(header);
247
+ // Progress bar
248
+ const progressWrap = document.createElement("div");
249
+ progressWrap.style.cssText = `height:3px;background:${this.theme.shimmer};`;
250
+ this.progressBar = document.createElement("div");
251
+ this.progressBar.style.cssText = `
252
+ height:100%;width:0%;background:linear-gradient(90deg,${this.theme.primary},${this.theme.primaryHover});
253
+ border-radius:0 2px 2px 0;transition:width 0.5s ease;
254
+ `;
255
+ progressWrap.appendChild(this.progressBar);
256
+ this.modal.appendChild(progressWrap);
257
+ // Content area (scrollable)
258
+ this.contentArea = document.createElement("div");
259
+ this.contentArea.id = "sc-flow-content";
260
+ this.contentArea.style.cssText = `
261
+ flex:1;overflow-y:auto;padding:20px 24px;
262
+ -webkit-overflow-scrolling:touch;
263
+ `;
264
+ this.modal.appendChild(this.contentArea);
265
+ // Footer
266
+ const footer = this.createFooter();
267
+ this.modal.appendChild(footer);
268
+ this.overlay.appendChild(this.modal);
269
+ document.body.appendChild(this.overlay);
270
+ // Animate in
271
+ requestAnimationFrame(() => {
272
+ if (this.overlay)
273
+ this.overlay.style.opacity = "1";
274
+ if (this.modal)
275
+ this.modal.style.transform = "translateY(0) scale(1)";
276
+ });
277
+ }
278
+ async initialize() {
279
+ this.showStep("loading");
280
+ try {
281
+ // Create session
282
+ await this.sdk.createSession();
283
+ // C4: Start session expiry timer (warn 2 min before 30-min expiry)
284
+ const expiresAt = this.sdk.sessionExpiresAt;
285
+ if (expiresAt) {
286
+ const expiresMs = new Date(expiresAt).getTime() - Date.now();
287
+ const warnMs = Math.max(expiresMs - 120000, 0); // warn 2 min before
288
+ this.sessionExpiryTimer = setTimeout(() => {
289
+ if (!this.isDestroyed && this.currentStep !== "result") {
290
+ this.showErrorStep("Your session is about to expire. Please complete verification quickly or restart.");
291
+ }
292
+ }, warnMs);
293
+ }
294
+ // Load config
295
+ this.sdkConfig = await this.sdk.initializeConfig();
296
+ // Apply theme from config
297
+ if (this.sdkConfig.theme) {
298
+ this.theme = getTheme(this.sdkConfig.theme);
299
+ this.applyTheme();
300
+ }
301
+ // Update brand name
302
+ if (this.brandLabel && this.sdkConfig.brand_name) {
303
+ this.brandLabel.textContent = this.sdkConfig.brand_name;
304
+ }
305
+ if (this.isDestroyed)
306
+ return;
307
+ this.showStep("welcome");
308
+ }
309
+ catch (err) {
310
+ this.showErrorStep(err.message || "Failed to initialize. Please try again.");
311
+ }
312
+ }
313
+ // ─────────────────────────────────────────────────────────────────
314
+ // Step rendering
315
+ // ─────────────────────────────────────────────────────────────────
316
+ showStep(step) {
317
+ this.currentStep = step;
318
+ if (!this.contentArea)
319
+ return;
320
+ // Update progress
321
+ this.updateProgress();
322
+ // Animate out current content
323
+ const oldContent = this.contentArea.firstChild;
324
+ if (oldContent) {
325
+ oldContent.style.cssText += "opacity:0;transform:translateX(-20px);transition:all 0.2s ease;";
326
+ setTimeout(() => {
327
+ if (this.contentArea)
328
+ this.contentArea.innerHTML = "";
329
+ this.renderStep(step);
330
+ }, 200);
331
+ }
332
+ else {
333
+ this.renderStep(step);
334
+ }
335
+ }
336
+ renderStep(step) {
337
+ if (!this.contentArea)
338
+ return;
339
+ const wrapper = document.createElement("div");
340
+ wrapper.style.cssText = "opacity:0;transform:translateX(20px);transition:all 0.3s ease;";
341
+ switch (step) {
342
+ case "loading":
343
+ this.renderLoading(wrapper);
344
+ break;
345
+ case "welcome":
346
+ this.renderWelcome(wrapper);
347
+ break;
348
+ case "country":
349
+ this.renderCountrySelect(wrapper);
350
+ break;
351
+ case "id_type":
352
+ this.renderIdTypeSelect(wrapper);
353
+ break;
354
+ case "id_input":
355
+ this.renderIdInput(wrapper);
356
+ break;
357
+ case "document_capture":
358
+ this.renderDocumentCapture(wrapper);
359
+ break;
360
+ case "processing":
361
+ this.renderProcessing(wrapper);
362
+ break;
363
+ case "liveness":
364
+ this.renderLiveness(wrapper);
365
+ break;
366
+ case "result":
367
+ this.renderResult(wrapper);
368
+ break;
369
+ case "error":
370
+ break; // handled separately
371
+ }
372
+ this.contentArea.appendChild(wrapper);
373
+ // Animate in
374
+ requestAnimationFrame(() => {
375
+ wrapper.style.opacity = "1";
376
+ wrapper.style.transform = "translateX(0)";
377
+ });
378
+ }
379
+ // ── Loading ─────────────────────────────────────────────────────
380
+ renderLoading(container) {
381
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:16px;";
382
+ const spinnerWrap = document.createElement("div");
383
+ spinnerWrap.style.cssText = "position:relative;width:60px;height:60px;flex-shrink:0;";
384
+ const ring = document.createElement("div");
385
+ ring.style.cssText = `
386
+ position:absolute;inset:0;border-radius:50%;
387
+ border:3px solid ${this.theme.border};
388
+ border-top-color:${this.theme.primary};
389
+ animation:sc-spin 0.9s linear infinite;
390
+ `;
391
+ const centre = document.createElement("div");
392
+ centre.style.cssText = `
393
+ position:absolute;inset:0;
394
+ display:flex;align-items:center;justify-content:center;
395
+ font-size:22px;
396
+ `;
397
+ centre.textContent = "\uD83D\uDEE1\uFE0F";
398
+ spinnerWrap.appendChild(ring);
399
+ spinnerWrap.appendChild(centre);
400
+ container.appendChild(spinnerWrap);
401
+ const label = document.createElement("div");
402
+ label.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:600;`;
403
+ label.textContent = "Setting up secure session";
404
+ container.appendChild(label);
405
+ const sub = document.createElement("div");
406
+ sub.style.cssText = `color:${this.theme.textMuted};font-size:12px;text-align:center;max-width:220px;line-height:1.5;`;
407
+ sub.textContent = "Connecting to the verification service...";
408
+ container.appendChild(sub);
409
+ }
410
+ // ── Welcome ────────────────────────────────────────────────────
411
+ renderWelcome(container) {
412
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;";
413
+ const isDocFlow = this.isDocumentFlow();
414
+ // Icon + badge row (side by side to save vertical space)
415
+ const topRow = document.createElement("div");
416
+ topRow.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:10px;";
417
+ const iconWrap = document.createElement("div");
418
+ iconWrap.style.cssText = `
419
+ width:58px;height:58px;border-radius:16px;
420
+ display:flex;align-items:center;justify-content:center;
421
+ font-size:26px;
422
+ background:linear-gradient(135deg,${this.theme.primary}22,${this.theme.primaryGlow});
423
+ border:1.5px solid ${this.theme.primary}33;
424
+ `;
425
+ iconWrap.textContent = isDocFlow ? "🪪" : "🛡️";
426
+ topRow.appendChild(iconWrap);
427
+ const badge = document.createElement("div");
428
+ badge.style.cssText = `
429
+ display:inline-flex;align-items:center;gap:5px;
430
+ padding:3px 11px;border-radius:999px;
431
+ font-size:10.5px;font-weight:600;letter-spacing:0.2px;
432
+ background:${this.theme.primary}15;
433
+ color:${this.theme.primary};
434
+ border:1px solid ${this.theme.primary}30;
435
+ `;
436
+ badge.textContent = isDocFlow ? "📄 Document Verification" : "🔍 Data Verification";
437
+ topRow.appendChild(badge);
438
+ container.appendChild(topRow);
439
+ // Title + subtitle (compact)
440
+ const titleWrap = document.createElement("div");
441
+ titleWrap.style.cssText = "display:flex;flex-direction:column;gap:5px;";
442
+ const title = document.createElement("h2");
443
+ title.style.cssText = `
444
+ color:${this.theme.text};font-size:20px;font-weight:700;
445
+ margin:0;line-height:1.2;
446
+ `;
447
+ title.textContent = "Identity Verification";
448
+ titleWrap.appendChild(title);
449
+ const desc = document.createElement("p");
450
+ desc.style.cssText = `
451
+ color:${this.theme.textSecondary};font-size:13px;line-height:1.5;
452
+ margin:0;max-width:300px;
453
+ `;
454
+ desc.textContent = isDocFlow
455
+ ? "Scan your ID document and take a quick selfie to confirm your identity."
456
+ : "Enter your ID details and take a quick selfie to confirm your identity.";
457
+ titleWrap.appendChild(desc);
458
+ container.appendChild(titleWrap);
459
+ // Steps preview — compact rows
460
+ const steps = isDocFlow
461
+ ? [
462
+ { icon: "🪪", text: "Choose your document type", sub: "Passport, National ID, Driver License" },
463
+ { icon: "📷", text: "Scan your document", sub: "Upload or capture a photo of your ID" },
464
+ { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
465
+ ]
466
+ : [
467
+ { icon: "🆔", text: "Choose your ID type", sub: "NIN, BVN, Voter's Card and more" },
468
+ { icon: "📝", text: "Enter your ID details", sub: "Exactly as shown on your ID" },
469
+ { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
470
+ ];
471
+ const stepsWrap = document.createElement("div");
472
+ stepsWrap.style.cssText = `
473
+ width:100%;display:flex;flex-direction:column;gap:0;
474
+ border-radius:12px;overflow:hidden;
475
+ border:1.5px solid ${this.theme.border};
476
+ `;
477
+ steps.forEach((step, i) => {
478
+ const row = document.createElement("div");
479
+ row.style.cssText = `
480
+ display:flex;align-items:center;gap:12px;
481
+ padding:9px 14px;
482
+ background:${this.theme.cardBg};
483
+ ${i < steps.length - 1 ? `border-bottom:1px solid ${this.theme.border};` : ""}
484
+ `;
485
+ const iconEl = document.createElement("div");
486
+ iconEl.style.cssText = `
487
+ width:28px;height:28px;border-radius:8px;flex-shrink:0;
488
+ display:flex;align-items:center;justify-content:center;
489
+ font-size:13px;
490
+ background:${this.theme.primary}12;
491
+ border:1px solid ${this.theme.primary}20;
492
+ `;
493
+ iconEl.textContent = step.icon;
494
+ const textWrap = document.createElement("div");
495
+ textWrap.style.cssText = "display:flex;flex-direction:column;gap:1px;text-align:left;";
496
+ const textEl = document.createElement("span");
497
+ textEl.style.cssText = `color:${this.theme.text};font-size:12.5px;font-weight:600;`;
498
+ textEl.textContent = step.text;
499
+ const subEl = document.createElement("span");
500
+ subEl.style.cssText = `color:${this.theme.textMuted};font-size:10.5px;`;
501
+ subEl.textContent = step.sub;
502
+ textWrap.appendChild(textEl);
503
+ textWrap.appendChild(subEl);
504
+ row.appendChild(iconEl);
505
+ row.appendChild(textWrap);
506
+ stepsWrap.appendChild(row);
507
+ });
508
+ container.appendChild(stepsWrap);
509
+ // CTA
510
+ const btn = this.createPrimaryButton("Get Started →");
511
+ btn.style.cssText += "margin-top:2px;";
512
+ btn.addEventListener("click", () => this.showStep("country"));
513
+ container.appendChild(btn);
514
+ // Trust badge
515
+ const trust = document.createElement("div");
516
+ trust.style.cssText = `
517
+ display:flex;align-items:center;gap:6px;
518
+ color:${this.theme.textMuted};font-size:10.5px;
519
+ `;
520
+ trust.textContent = "🔒 End-to-end encrypted · Powered by Adhere";
521
+ container.appendChild(trust);
522
+ }
523
+ // ── Country Select ──────────────────────────────────────────────
524
+ renderCountrySelect(container) {
525
+ container.style.cssText += "display:flex;flex-direction:column;gap:16px;";
526
+ const channels = this.sdkConfig?.channels || {};
527
+ const countries = Object.keys(channels);
528
+ // Auto-skip country selection if only one country available
529
+ if (countries.length === 1) {
530
+ this.selectedCountry = countries[0];
531
+ this.showStep("id_type");
532
+ return;
533
+ }
534
+ const title = this.createStepTitle("Select your country");
535
+ container.appendChild(title);
536
+ const subtitle = document.createElement("div");
537
+ subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin-top:-8px;`;
538
+ subtitle.textContent = "Choose the country that issued your ID";
539
+ container.appendChild(subtitle);
540
+ for (const country of countries) {
541
+ const card = this.createOptionCard(COUNTRY_LABELS[country] || country, `${channels[country].length} ID type${channels[country].length > 1 ? "s" : ""} available`, () => {
542
+ this.selectedCountry = country;
543
+ this.showStep("id_type");
544
+ });
545
+ container.appendChild(card);
546
+ }
547
+ }
548
+ // ── ID Type Select ──────────────────────────────────────────────
549
+ renderIdTypeSelect(container) {
550
+ container.style.cssText += "display:flex;flex-direction:column;gap:16px;";
551
+ const globalDocFlow = this.isDocumentFlow();
552
+ const title = this.createStepTitle(globalDocFlow ? "Select document type" : "Select ID type");
553
+ container.appendChild(title);
554
+ const subtitle = document.createElement("div");
555
+ subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin-top:-8px;`;
556
+ subtitle.textContent = globalDocFlow
557
+ ? "Choose the document you want to scan"
558
+ : "Choose the ID you want to verify";
559
+ container.appendChild(subtitle);
560
+ const channels = this.sdkConfig?.channels || {};
561
+ const idTypes = channels[this.selectedCountry] || [];
562
+ for (const idType of idTypes) {
563
+ const typeName = typeof idType === "string" ? idType : idType.name;
564
+ // Use code (canonical) for API calls, fallback to name for backward compatibility
565
+ const typeCode = typeof idType === "string" ? idType : (idType.code || idType.name);
566
+ // Detect per-channel: does THIS specific channel have upload fields?
567
+ const channelFields = typeof idType !== "string" ? (idType.fields || []) : [];
568
+ const isThisDocFlow = channelFields.some((f) => f.type === "upload") || globalDocFlow;
569
+ const info = resolveIdTypeInfo(typeName, isThisDocFlow);
570
+ // Add back side indicator to subtitle if needed
571
+ let subtitle = info.desc;
572
+ if (info.requiresBack && isThisDocFlow) {
573
+ subtitle += " ⚠️ Both sides required";
574
+ }
575
+ const card = this.createOptionCard(`${info.icon} ${typeName}`, subtitle, () => {
576
+ this.selectedIdType = typeCode; // Store canonical code for API
577
+ this.selectedIdTypeName = typeName; // Keep display name for UI
578
+ // Always reset first to avoid stale state when user goes back and changes selection
579
+ this.selectedChannelId = 0;
580
+ this.selectedChannelFields = [];
581
+ if (typeof idType !== "string") {
582
+ this.selectedChannelId = idType.id;
583
+ this.selectedChannelFields = idType.fields || []; // guard null/undefined from backend
584
+ }
585
+ this.collectedFields = {};
586
+ // Route: use explicit verification_type when set (more reliable than inspecting all channels)
587
+ // Fallback to per-channel upload field check for mixed / unconfigured setups
588
+ const hasUpload = (this.selectedChannelFields).some((f) => f.type === "upload");
589
+ const vType = this.sdkConfig?.verification_type;
590
+ const vTypes = vType ? (Array.isArray(vType) ? vType : [vType]) : [];
591
+ const isDocChannel = hasUpload || (vTypes.length > 0 && vTypes.includes("document_verification"));
592
+ this.showStep(isDocChannel ? "document_capture" : "id_input");
593
+ });
594
+ container.appendChild(card);
595
+ }
596
+ }
597
+ // ── ID Input (data verification — dynamic fields) ─────────────
598
+ renderIdInput(container) {
599
+ container.style.cssText += "display:flex;flex-direction:column;gap:16px;";
600
+ const dataFields = this.selectedChannelFields.filter((f) => f.type !== "upload");
601
+ const info = resolveIdTypeInfo(this.selectedIdTypeName || this.selectedIdType, false);
602
+ const title = this.createStepTitle(`${info.icon} ${this.selectedIdTypeName || this.selectedIdType}`);
603
+ container.appendChild(title);
604
+ const subtitle = document.createElement("div");
605
+ subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin-top:-8px;`;
606
+ subtitle.textContent = dataFields.length > 0
607
+ ? `Enter your ${this.selectedIdTypeName || this.selectedIdType} details exactly as they appear on your ID.`
608
+ : "Fill in the fields below to continue.";
609
+ container.appendChild(subtitle);
610
+ // Edge case: no data fields configured — show fallback and allow submission
611
+ if (dataFields.length === 0) {
612
+ const empty = document.createElement("div");
613
+ empty.style.cssText = `
614
+ padding:16px;border-radius:10px;
615
+ background:${this.theme.cardBg};border:1px solid ${this.theme.border};
616
+ color:${this.theme.textSecondary};font-size:13px;text-align:center;
617
+ `;
618
+ empty.textContent = "No additional details required. Click below to continue.";
619
+ container.appendChild(empty);
620
+ const autoBtn = this.createPrimaryButton("Continue");
621
+ autoBtn.addEventListener("click", () => this.showStep("liveness"));
622
+ container.appendChild(autoBtn);
623
+ return;
624
+ }
625
+ // Track input elements for validation
626
+ const inputEls = [];
627
+ for (const field of dataFields) {
628
+ const fieldKey = field.label.toLowerCase().replace(/[^a-z0-9]+/g, "_");
629
+ const fieldWrap = document.createElement("div");
630
+ fieldWrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
631
+ const label = document.createElement("label");
632
+ label.style.cssText = `color:${this.theme.textMuted};font-size:10px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;`;
633
+ label.textContent = field.label;
634
+ fieldWrap.appendChild(label);
635
+ if (field.type === "dropdown" || field.type === "select") {
636
+ // Dropdown field
637
+ const select = document.createElement("select");
638
+ select.style.cssText = `
639
+ width:100%;padding:13px 16px;border-radius:12px;
640
+ font-size:15px;
641
+ background:${this.theme.inputBg};
642
+ color:${this.theme.text};
643
+ border:2px solid ${this.theme.border};
644
+ outline:none;box-sizing:border-box;
645
+ cursor:pointer;transition:border-color 0.2s ease;
646
+ `;
647
+ const placeholder = document.createElement("option");
648
+ placeholder.value = "";
649
+ placeholder.textContent = `Select ${field.label}`;
650
+ placeholder.disabled = true;
651
+ placeholder.selected = true;
652
+ select.appendChild(placeholder);
653
+ for (const opt of field.options || []) {
654
+ const option = document.createElement("option");
655
+ option.value = opt;
656
+ option.textContent = opt;
657
+ select.appendChild(option);
658
+ }
659
+ select.addEventListener("focus", () => { select.style.borderColor = this.theme.primary; });
660
+ select.addEventListener("blur", () => { select.style.borderColor = this.theme.border; });
661
+ select.addEventListener("change", () => {
662
+ this.collectedFields[fieldKey] = select.value;
663
+ updateSubmitState();
664
+ });
665
+ fieldWrap.appendChild(select);
666
+ inputEls.push({ field, el: select });
667
+ }
668
+ else if (field.type === "image" || field.type === "file") {
669
+ // File upload field — converts to base64 for API submission
670
+ const fileInput = document.createElement("input");
671
+ fileInput.type = "file";
672
+ fileInput.accept = "image/*";
673
+ fileInput.style.cssText = `
674
+ width:100%;padding:12px 16px;border-radius:12px;
675
+ font-size:14px;font-weight:500;
676
+ background:${this.theme.inputBg};
677
+ color:${this.theme.text};
678
+ border:2px dashed ${this.theme.border};
679
+ outline:none;transition:border-color 0.2s ease;
680
+ box-sizing:border-box;cursor:pointer;
681
+ `;
682
+ fileInput.addEventListener("focus", () => {
683
+ fileInput.style.borderColor = this.theme.primary;
684
+ });
685
+ fileInput.addEventListener("blur", () => {
686
+ fileInput.style.borderColor = this.theme.border;
687
+ });
688
+ fileInput.addEventListener("change", () => {
689
+ const file = fileInput.files?.[0];
690
+ if (file) {
691
+ const reader = new FileReader();
692
+ reader.onload = () => {
693
+ const base64 = reader.result.split(",")[1] || "";
694
+ this.collectedFields[fieldKey] = base64;
695
+ updateSubmitState();
696
+ };
697
+ reader.readAsDataURL(file);
698
+ }
699
+ });
700
+ fieldWrap.appendChild(fileInput);
701
+ inputEls.push({ field, el: fileInput });
702
+ }
703
+ else {
704
+ // Text input field
705
+ const input = document.createElement("input");
706
+ input.type = "text";
707
+ input.placeholder = `Enter ${field.label}`;
708
+ input.maxLength = 50;
709
+ input.style.cssText = `
710
+ width:100%;padding:14px 16px;border-radius:12px;
711
+ font-size:16px;font-weight:500;
712
+ background:${this.theme.inputBg};
713
+ color:${this.theme.text};
714
+ border:2px solid ${this.theme.border};
715
+ outline:none;transition:border-color 0.2s ease;
716
+ box-sizing:border-box;
717
+ letter-spacing:0.5px;
718
+ `;
719
+ input.addEventListener("focus", () => {
720
+ input.style.borderColor = this.theme.primary;
721
+ });
722
+ input.addEventListener("blur", () => {
723
+ input.style.borderColor = this.theme.border;
724
+ });
725
+ input.addEventListener("input", () => {
726
+ this.collectedFields[fieldKey] = input.value.trim();
727
+ updateSubmitState();
728
+ });
729
+ fieldWrap.appendChild(input);
730
+ inputEls.push({ field, el: input });
731
+ }
732
+ container.appendChild(fieldWrap);
733
+ }
734
+ // Error area
735
+ const errorEl = document.createElement("div");
736
+ errorEl.id = "sc-id-error";
737
+ errorEl.style.cssText = `
738
+ color:${this.theme.error};font-size:13px;display:none;
739
+ padding:8px 12px;border-radius:8px;background:${this.theme.errorBg};
740
+ `;
741
+ container.appendChild(errorEl);
742
+ // Submit
743
+ const submitBtn = this.createPrimaryButton("Verify & Continue");
744
+ submitBtn.disabled = true;
745
+ submitBtn.style.opacity = "0.5";
746
+ const updateSubmitState = () => {
747
+ const allFilled = inputEls.every(({ el }) => el.value.trim().length > 0);
748
+ submitBtn.disabled = !allFilled;
749
+ submitBtn.style.opacity = allFilled ? "1" : "0.5";
750
+ };
751
+ submitBtn.addEventListener("click", async () => {
752
+ submitBtn.disabled = true;
753
+ submitBtn.innerHTML = `<span style="display:inline-flex;align-items:center;justify-content:center;gap:8px;"><span style="width:13px;height:13px;border-radius:50%;border:2px solid rgba(255,255,255,0.3);border-top-color:#fff;animation:sc-spin 0.6s linear infinite;display:inline-block;flex-shrink:0;"></span>Verifying...</span>`;
754
+ errorEl.style.display = "none";
755
+ try {
756
+ this.verificationResult = await this.sdk.onboarding.verify({
757
+ identity_type_id: this.selectedChannelId,
758
+ fields: { ...this.collectedFields },
759
+ });
760
+ if (this.verificationResult.status === "verified" || this.verificationResult.status === "success") {
761
+ // Save the first collected field value as identifier for liveness
762
+ const fieldValues = Object.values(this.collectedFields);
763
+ this.idNumber = fieldValues[0] || "";
764
+ // A4: Save identity_check_id from verify response to pass to liveness
765
+ this.identityCheckId = this.verificationResult.data?.identity_check_id || null;
766
+ this.showStep("liveness");
767
+ }
768
+ else {
769
+ errorEl.textContent = this.verificationResult.message || "Verification failed. Please check your details.";
770
+ errorEl.style.display = "block";
771
+ submitBtn.textContent = "Verify & Continue";
772
+ submitBtn.disabled = false;
773
+ submitBtn.style.opacity = "1";
774
+ }
775
+ }
776
+ catch (err) {
777
+ // C3: Show field-level errors if available
778
+ const fieldErrors = err.data?.errors;
779
+ if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
780
+ const messages = fieldErrors.map((e) => `${e.field}: ${e.message}`).join("\n");
781
+ errorEl.textContent = messages;
782
+ }
783
+ else {
784
+ errorEl.textContent = err.message || "Verification failed";
785
+ }
786
+ errorEl.style.display = "block";
787
+ submitBtn.textContent = "Verify & Continue";
788
+ submitBtn.disabled = false;
789
+ submitBtn.style.opacity = "1";
790
+ }
791
+ });
792
+ container.appendChild(submitBtn);
793
+ // Focus first input
794
+ const firstInput = inputEls[0]?.el;
795
+ if (firstInput && firstInput instanceof HTMLInputElement) {
796
+ setTimeout(() => firstInput.focus(), 350);
797
+ }
798
+ }
799
+ // ── Document Capture ────────────────────────────────────────────
800
+ renderDocumentCapture(container) {
801
+ container.style.cssText += "display:flex;flex-direction:column;gap:16px;";
802
+ const title = this.createStepTitle(`Capture ${this.selectedIdTypeName || this.selectedIdType || "your document"}`);
803
+ container.appendChild(title);
804
+ const subtitle = document.createElement("div");
805
+ subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin-top:-8px;`;
806
+ subtitle.textContent = "Upload or capture your document photo. Ensure it's clear and readable.";
807
+ container.appendChild(subtitle);
808
+ const captureContainer = document.createElement("div");
809
+ captureContainer.style.cssText = `
810
+ width: 100%;
811
+ max-width: 400px;
812
+ margin: 0 auto;
813
+ position: relative;
814
+ overflow: hidden;
815
+ border-radius: 12px;
816
+ background: ${this.theme.cardBg};
817
+ border: 2px solid ${this.theme.border};
818
+ `;
819
+ container.appendChild(captureContainer);
820
+ // Use the new DocumentCapture component with front/back support
821
+ this.docCapture = new DocumentCapture();
822
+ this.docCapture
823
+ .capture(captureContainer, this.theme, this.selectedIdTypeName || this.selectedIdType)
824
+ .then(async (result) => {
825
+ await this.processDocumentCaptureResult(result, captureContainer, container);
826
+ })
827
+ .catch((err) => {
828
+ console.error("Document capture failed:", err);
829
+ this.showErrorStep(err.message || "Document capture failed. Please try again.");
830
+ });
831
+ }
832
+ // ── Processing ────────────────────────────────────────────────────
833
+ renderProcessing(container) {
834
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:20px;padding:20px 16px;min-height:400px;";
835
+ // Processing animation
836
+ const animationWrap = document.createElement("div");
837
+ animationWrap.style.cssText = "position:relative;width:80px;height:80px;";
838
+ const spinner = document.createElement("div");
839
+ spinner.style.cssText = `
840
+ position:absolute;inset:0;border-radius:50%;
841
+ border:4px solid ${this.theme.border};
842
+ border-top-color:${this.theme.primary};
843
+ animation:sc-spin 1s linear infinite;
844
+ `;
845
+ animationWrap.appendChild(spinner);
846
+ const centerIcon = document.createElement("div");
847
+ centerIcon.style.cssText = `
848
+ position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
849
+ font-size:28px;
850
+ `;
851
+ centerIcon.textContent = "🔍";
852
+ animationWrap.appendChild(centerIcon);
853
+ container.appendChild(animationWrap);
854
+ // Processing text
855
+ const title = document.createElement("div");
856
+ title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:600;text-align:center;`;
857
+ title.textContent = "Processing Your Documents";
858
+ container.appendChild(title);
859
+ const subtitle = document.createElement("div");
860
+ subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:13px;text-align:center;max-width:280px;line-height:1.5;`;
861
+ subtitle.textContent = "Scanning and verifying your identity documents. This may take a few moments.";
862
+ container.appendChild(subtitle);
863
+ // Processing steps
864
+ const stepsContainer = document.createElement("div");
865
+ stepsContainer.style.cssText = `
866
+ width:100%;max-width:400px;display:flex;flex-direction:column;gap:12px;
867
+ margin-top:16px;
868
+ `;
869
+ const steps = [
870
+ { id: 'uploading', text: 'Uploading documents', icon: '📤' },
871
+ { id: 'scanning', text: 'Scanning for information', icon: '🔍' },
872
+ { id: 'verifying', text: 'Verifying authenticity', icon: '✓' },
873
+ { id: 'analyzing', text: 'Analyzing document quality', icon: '🔬' }
874
+ ];
875
+ steps.forEach((step, index) => {
876
+ const stepEl = document.createElement("div");
877
+ stepEl.style.cssText = `
878
+ display:flex;align-items:center;gap:12px;padding:12px;border-radius:10px;
879
+ background:${this.theme.cardBg};border:1px solid ${this.theme.border};
880
+ transition:all 0.3s ease;
881
+ `;
882
+ stepEl.id = `processing-step-${step.id}`;
883
+ const iconEl = document.createElement("div");
884
+ iconEl.style.cssText = `
885
+ width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;
886
+ font-size:14px;background:${this.theme.shimmer};border:1px solid ${this.theme.border};
887
+ `;
888
+ iconEl.textContent = step.icon;
889
+ iconEl.id = `step-icon-${step.id}`;
890
+ const textEl = document.createElement("div");
891
+ textEl.style.cssText = `flex:1;color:${this.theme.textSecondary};font-size:13px;font-weight:500;`;
892
+ textEl.textContent = step.text;
893
+ textEl.id = `step-text-${step.id}`;
894
+ const statusEl = document.createElement("div");
895
+ statusEl.style.cssText = `
896
+ width:20px;height:20px;border-radius:50%;
897
+ border:2px solid ${this.theme.border};
898
+ position:relative;
899
+ `;
900
+ statusEl.id = `step-status-${step.id}`;
901
+ stepEl.appendChild(iconEl);
902
+ stepEl.appendChild(textEl);
903
+ stepEl.appendChild(statusEl);
904
+ stepsContainer.appendChild(stepEl);
905
+ });
906
+ container.appendChild(stepsContainer);
907
+ // Start processing animation
908
+ this.startProcessingAnimation();
909
+ }
910
+ startProcessingAnimation() {
911
+ const steps = ['uploading', 'scanning', 'verifying', 'analyzing'];
912
+ let currentStepIndex = 0;
913
+ const animateStep = (stepId) => {
914
+ // Update step appearance
915
+ const stepEl = document.getElementById(`processing-step-${stepId}`);
916
+ const iconEl = document.getElementById(`step-icon-${stepId}`);
917
+ const textEl = document.getElementById(`step-text-${stepId}`);
918
+ const statusEl = document.getElementById(`step-status-${stepId}`);
919
+ if (stepEl && iconEl && textEl && statusEl) {
920
+ // Active state
921
+ stepEl.style.borderColor = this.theme.primary;
922
+ stepEl.style.background = `${this.theme.primary}10`;
923
+ iconEl.style.background = this.theme.primary;
924
+ iconEl.style.color = 'white';
925
+ textEl.style.color = this.theme.text;
926
+ textEl.style.fontWeight = '600';
927
+ // Add spinner to status
928
+ statusEl.innerHTML = `
929
+ <div style="
930
+ position:absolute;inset:2px;border-radius:50%;
931
+ border:2px solid ${this.theme.border};
932
+ border-top-color:${this.theme.primary};
933
+ animation:sc-spin 0.8s linear infinite;
934
+ "></div>
935
+ `;
936
+ }
937
+ // Move to next step after delay
938
+ setTimeout(() => {
939
+ // Complete current step
940
+ if (stepEl && iconEl && textEl && statusEl) {
941
+ stepEl.style.borderColor = this.theme.success;
942
+ stepEl.style.background = `${this.theme.success}10`;
943
+ iconEl.style.background = this.theme.success;
944
+ statusEl.innerHTML = `
945
+ <div style="
946
+ position:absolute;inset:3px;border-radius:50%;
947
+ background:${this.theme.success};
948
+ display:flex;align-items:center;justify-content:center;
949
+ color:white;font-size:10px;font-weight:bold;
950
+ ">✓</div>
951
+ `;
952
+ }
953
+ currentStepIndex++;
954
+ if (currentStepIndex < steps.length) {
955
+ animateStep(steps[currentStepIndex]);
956
+ }
957
+ }, 2000); // 2 seconds per step
958
+ };
959
+ // Start animation
960
+ setTimeout(() => animateStep(steps[0]), 500);
961
+ }
962
+ /**
963
+ * Process document capture result with front/back support
964
+ */
965
+ async processDocumentCaptureResult(result, captureContainer, container) {
966
+ // Store both front and back blobs
967
+ this.documentBlob = result.front;
968
+ this.documentBackBlob = result.back || null;
969
+ // Process the front image through the existing flow
970
+ await this.processDocumentBlob(result.front, captureContainer, container);
971
+ // Log back image capture
972
+ if (result.back) {
973
+ console.log("Back image captured, size:", result.back.size);
974
+ }
975
+ }
976
+ /**
977
+ * Process document blob - common method for both CameraView and DocumentCapture
978
+ */
979
+ async processDocumentBlob(blob, captureContainer, container) {
980
+ const maxRetries = 2;
981
+ let retryCount = 0;
982
+ const attemptProcessing = async () => {
983
+ try {
984
+ // Validate blob
985
+ if (!blob || blob.size === 0) {
986
+ throw new Error("Failed to capture document. Please try again.");
987
+ }
988
+ if (blob.size > 2 * 1024 * 1024) {
989
+ throw new Error("Document image is too large. Please retake in better lighting or use a clearer photo.");
990
+ }
991
+ this.documentBlob = blob;
992
+ // Show scanning state
993
+ captureContainer.innerHTML = `
994
+ <div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:28px 20px;">
995
+ <div style="position:relative;width:52px;height:52px;">
996
+ <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>
997
+ <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔍</div>
998
+ </div>
999
+ <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
1000
+ <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>
1001
+ </div>`;
1002
+ // Create liveness entry with document
1003
+ const identifier = `DOC-${Date.now()}`;
1004
+ const COUNTRY_CODES = {
1005
+ nigeria: "NG", ghana: "GH", kenya: "KE",
1006
+ south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
1007
+ };
1008
+ const country = COUNTRY_CODES[this.selectedCountry] || this.selectedCountry.toUpperCase().slice(0, 2);
1009
+ const deviceMeta = collectDeviceMetadata();
1010
+ let createResult;
1011
+ try {
1012
+ createResult = await this.sdk.liveness.create({
1013
+ identifier,
1014
+ identifier_type: this.selectedIdType,
1015
+ country,
1016
+ challenge_actions: ["BLINK", "TURN_LEFT", "OPEN_MOUTH"],
1017
+ document: blob,
1018
+ document_back: this.documentBackBlob || undefined,
1019
+ device_type: deviceMeta.device_type,
1020
+ os_name: deviceMeta.os_name,
1021
+ browser_timezone: deviceMeta.browser_timezone,
1022
+ fingerprint_id: deviceMeta.fingerprint_id,
1023
+ });
1024
+ }
1025
+ catch (apiError) {
1026
+ // Handle specific network and API errors
1027
+ if (apiError.message?.includes('fetch') || apiError.message?.includes('network') || apiError.code === 'NETWORK_ERROR') {
1028
+ throw new Error("Network connection failed. Please check your internet connection and try again.");
1029
+ }
1030
+ else if (apiError.message?.includes('413') || apiError.message?.includes('too large')) {
1031
+ throw new Error("Document image is too large. Please use a smaller image or retake the photo.");
1032
+ }
1033
+ else if (apiError.message?.includes('415') || apiError.message?.includes('format')) {
1034
+ throw new Error("Unsupported image format. Please use JPEG or PNG images.");
1035
+ }
1036
+ else if (apiError.message?.includes('401') || apiError.message?.includes('unauthorized')) {
1037
+ throw new Error("Session expired. Please refresh the page and try again.");
1038
+ }
1039
+ else if (apiError.message?.includes('429') || apiError.message?.includes('rate limit')) {
1040
+ throw new Error("Too many requests. Please wait a moment and try again.");
1041
+ }
1042
+ else {
1043
+ throw new Error(apiError.message || "Failed to process document. Please try again.");
1044
+ }
1045
+ }
1046
+ this.livenessEntryId = createResult.id;
1047
+ // Check if document was expired
1048
+ const docOcr = createResult.document_ocr;
1049
+ if (docOcr && docOcr.status === "expired") {
1050
+ throw new Error(docOcr.message || "Document has expired. Please use a valid document.");
1051
+ }
1052
+ // Check image quality
1053
+ const imgQuality = docOcr?.image_quality;
1054
+ const isBlurry = imgQuality?.is_blurry === true;
1055
+ const lowConfidence = imgQuality?.ocr_confidence !== undefined && imgQuality.ocr_confidence < 0.45;
1056
+ if (isBlurry || lowConfidence) {
1057
+ throw new Error("Image quality too low. Please ensure the document is well-lit and clearly visible.");
1058
+ }
1059
+ // Show processing step instead of immediate success
1060
+ this.showStep("processing");
1061
+ // Simulate processing time and then proceed to liveness
1062
+ setTimeout(() => {
1063
+ this.showStep("liveness");
1064
+ }, 8000); // 8 seconds total processing time (4 steps × 2 seconds each)
1065
+ }
1066
+ catch (err) {
1067
+ console.error("Document processing failed:", err);
1068
+ if (retryCount < maxRetries) {
1069
+ retryCount++;
1070
+ console.log(`Retrying document processing (attempt ${retryCount}/${maxRetries})`);
1071
+ // Show retry message
1072
+ if (captureContainer) {
1073
+ captureContainer.innerHTML = `
1074
+ <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1075
+ <div style="position:relative;width:52px;height:52px;">
1076
+ <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>
1077
+ <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔄</div>
1078
+ </div>
1079
+ <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Retrying...</div>
1080
+ <div style="color:${this.theme.textSecondary};font-size:12px;max-width:240px;line-height:1.5;">Attempt ${retryCount} of ${maxRetries}</div>
1081
+ </div>`;
1082
+ }
1083
+ // Wait before retry
1084
+ await new Promise(resolve => setTimeout(resolve, 2000));
1085
+ return attemptProcessing();
1086
+ }
1087
+ else {
1088
+ // Final retry failed, show detailed error
1089
+ this.handleDocumentProcessingError(err, captureContainer);
1090
+ }
1091
+ }
1092
+ };
1093
+ await attemptProcessing();
1094
+ }
1095
+ handleDocumentProcessingError(error, captureContainer) {
1096
+ let errorMessage = "Document verification failed. Please try again.";
1097
+ let canRetry = true;
1098
+ if (error.message) {
1099
+ if (error.message.includes("expired")) {
1100
+ errorMessage = "Document has expired. Please use a valid document.";
1101
+ canRetry = false;
1102
+ }
1103
+ else if (error.message.includes("quality too low")) {
1104
+ errorMessage = "Image quality too low. Please ensure the document is well-lit and clearly visible.";
1105
+ }
1106
+ else if (error.message.includes("Network") || error.message.includes("fetch")) {
1107
+ errorMessage = "Network connection failed. Please check your internet connection and try again.";
1108
+ }
1109
+ else if (error.message.includes("413") || error.message.includes("too large")) {
1110
+ errorMessage = "Document image is too large. Please use a smaller image or retake the photo.";
1111
+ }
1112
+ else if (error.message.includes("415") || error.message.includes("format")) {
1113
+ errorMessage = "Unsupported image format. Please use JPEG or PNG images.";
1114
+ }
1115
+ }
1116
+ if (captureContainer) {
1117
+ captureContainer.innerHTML = `
1118
+ <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1119
+ <div style="width:56px;height:56px;border-radius:50%;background:${this.theme.errorBg || '#FEE'};border:3px solid ${this.theme.error || '#DC3545'};display:flex;align-items:center;justify-content:center;font-size:26px;">❌</div>
1120
+ <div style="color:${this.theme.text};font-size:14px;font-weight:700;">Verification Failed</div>
1121
+ <div style="color:${this.theme.textSecondary};font-size:13px;max-width:240px;line-height:1.6;">${errorMessage}</div>
1122
+ ${canRetry ? `
1123
+ <button id="retry-btn" style="
1124
+ margin-top:8px;padding:12px 24px;border-radius:8px;
1125
+ background:${this.theme.primary};color:white;
1126
+ border:none;font-size:14px;font-weight:600;cursor:pointer;
1127
+ transition:all 0.2s ease;
1128
+ ">Try Again</button>
1129
+ ` : ''}
1130
+ </div>`;
1131
+ if (canRetry) {
1132
+ const retryBtn = captureContainer.querySelector("#retry-btn");
1133
+ if (retryBtn) {
1134
+ retryBtn.addEventListener("click", () => {
1135
+ this.showStep("document_capture");
1136
+ });
1137
+ }
1138
+ }
1139
+ }
1140
+ else {
1141
+ this.showErrorStep(errorMessage);
1142
+ }
1143
+ }
1144
+ // ── Liveness ────────────────────────────────────────────────────
1145
+ renderLiveness(container) {
1146
+ container.style.cssText += "display:flex;flex-direction:column;gap:16px;";
1147
+ const title = this.createStepTitle("Face verification");
1148
+ container.appendChild(title);
1149
+ const desc = document.createElement("div");
1150
+ desc.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin-top:-8px;line-height:1.5;`;
1151
+ desc.textContent = "Position your face in the oval and follow the on-screen instructions.";
1152
+ container.appendChild(desc);
1153
+ // Liveness mount point
1154
+ const livenessContainer = document.createElement("div");
1155
+ livenessContainer.style.cssText = "margin:8px 0;";
1156
+ container.appendChild(livenessContainer);
1157
+ const identifier = this.idNumber || `DOC-${Date.now()}`;
1158
+ const COUNTRY_CODES = {
1159
+ nigeria: "NG",
1160
+ ghana: "GH",
1161
+ kenya: "KE",
1162
+ south_africa: "ZA",
1163
+ united_states: "US",
1164
+ united_kingdom: "GB",
1165
+ global: "US",
1166
+ };
1167
+ const country = COUNTRY_CODES[this.selectedCountry] || this.selectedCountry.toUpperCase().slice(0, 2);
1168
+ // Start liveness check (this handles camera, detection, recording, submission)
1169
+ // If livenessEntryId exists (document flow), reuse it instead of creating a new one
1170
+ this.sdk.liveness
1171
+ .startCheck(livenessContainer, {
1172
+ identifier,
1173
+ identifier_type: this.selectedIdType,
1174
+ country,
1175
+ document: this.documentBlob || undefined,
1176
+ document_back: this.documentBackBlob || undefined,
1177
+ identity_check: this.identityCheckId || undefined,
1178
+ }, ["BLINK", "TURN_LEFT", "OPEN_MOUTH"], this.livenessEntryId || undefined)
1179
+ .then((result) => {
1180
+ this.showResult(result);
1181
+ })
1182
+ .catch((err) => {
1183
+ this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
1184
+ });
1185
+ }
1186
+ // ── Result ──────────────────────────────────────────────────────
1187
+ showResult(submitResult) {
1188
+ this.currentStep = "result";
1189
+ this.updateProgress();
1190
+ if (!this.contentArea)
1191
+ return;
1192
+ this.contentArea.innerHTML = "";
1193
+ const wrapper = document.createElement("div");
1194
+ wrapper.style.cssText = `
1195
+ display:flex;flex-direction:column;align-items:center;
1196
+ text-align:center;gap:16px;padding:20px 0;
1197
+ opacity:0;transform:scale(0.95);transition:all 0.4s ease;
1198
+ `;
1199
+ // Icon — starts as a spinner, updates when status resolves
1200
+ const iconWrap = document.createElement("div");
1201
+ iconWrap.style.cssText = `
1202
+ width:80px;height:80px;border-radius:50%;
1203
+ display:flex;align-items:center;justify-content:center;
1204
+ font-size:36px;
1205
+ background:${this.theme.cardBg};
1206
+ border:3px solid ${this.theme.border};
1207
+ `;
1208
+ 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>`;
1209
+ wrapper.appendChild(iconWrap);
1210
+ const title = document.createElement("h2");
1211
+ title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
1212
+ title.textContent = "Verification Submitted";
1213
+ wrapper.appendChild(title);
1214
+ const desc = document.createElement("p");
1215
+ desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1216
+ desc.textContent = "Processing your identity check. This takes around 30 seconds.";
1217
+ wrapper.appendChild(desc);
1218
+ // Details card
1219
+ const details = document.createElement("div");
1220
+ details.style.cssText = `
1221
+ width:100%;padding:16px;border-radius:12px;
1222
+ background:${this.theme.cardBg};border:1px solid ${this.theme.border};
1223
+ text-align:left;
1224
+ `;
1225
+ // Status badge — mutable DOM node so polling can update it
1226
+ const statusBadge = document.createElement("span");
1227
+ statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
1228
+ statusBadge.textContent = "Processing…";
1229
+ const statusRow = document.createElement("div");
1230
+ statusRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1231
+ statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;">Status</span>`;
1232
+ statusRow.appendChild(statusBadge);
1233
+ // Build rows via DOM (not innerHTML+=) to keep statusBadge live in the document
1234
+ const entryRow = document.createElement("div");
1235
+ entryRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1236
+ entryRow.innerHTML = `
1237
+ <span style="color:${this.theme.textMuted};font-size:12px;">Entry ID</span>
1238
+ <span style="color:${this.theme.text};font-size:12px;font-weight:600;">${submitResult.id}</span>
1239
+ `;
1240
+ details.appendChild(entryRow);
1241
+ details.appendChild(statusRow);
1242
+ const submittedRow = document.createElement("div");
1243
+ submittedRow.style.cssText = "display:flex;justify-content:space-between;margin-top:8px;";
1244
+ submittedRow.innerHTML = `
1245
+ <span style="color:${this.theme.textMuted};font-size:12px;">Submitted</span>
1246
+ <span style="color:${this.theme.text};font-size:12px;">${submitResult.submitted_at ? new Date(submitResult.submitted_at).toLocaleString() : "Just now"}</span>
1247
+ `;
1248
+ details.appendChild(submittedRow);
1249
+ wrapper.appendChild(details);
1250
+ // Failure reason — hidden until status = failed
1251
+ const failureMsg = document.createElement("div");
1252
+ failureMsg.style.cssText = `
1253
+ display:none;width:100%;padding:12px 14px;border-radius:10px;
1254
+ background:${this.theme.errorBg};border:1px solid ${this.theme.error};
1255
+ color:${this.theme.error};font-size:12px;text-align:left;line-height:1.5;
1256
+ `;
1257
+ wrapper.appendChild(failureMsg);
1258
+ // Done button
1259
+ const doneBtn = this.createPrimaryButton("Done");
1260
+ let finalStatus = submitResult.status;
1261
+ doneBtn.addEventListener("click", () => {
1262
+ if (this._pollStatusTimer) {
1263
+ clearTimeout(this._pollStatusTimer);
1264
+ this._pollStatusTimer = null;
1265
+ }
1266
+ this.options.onComplete?.({
1267
+ entryId: submitResult.id,
1268
+ sessionId: this.sdk.sessionToken,
1269
+ status: finalStatus,
1270
+ submittedAt: submitResult.submitted_at,
1271
+ verificationResult: this.verificationResult || undefined,
1272
+ });
1273
+ this.close();
1274
+ });
1275
+ wrapper.appendChild(doneBtn);
1276
+ this.contentArea.appendChild(wrapper);
1277
+ requestAnimationFrame(() => {
1278
+ wrapper.style.opacity = "1";
1279
+ wrapper.style.transform = "scale(1)";
1280
+ });
1281
+ // Start polling for the async result
1282
+ this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, failureMsg, (resolved) => { finalStatus = resolved; });
1283
+ }
1284
+ _startStatusPolling(entryId, iconWrap, statusBadge, title, desc, failureMsg, onResolved) {
1285
+ const MAX_POLLS = 25; // 25 × 3 s = 75 s + 4 s initial = ~79 s max
1286
+ let polls = 0;
1287
+ // Hard 30 s UI timer — stops spinner even if backend is still processing
1288
+ let uiTimeoutId = setTimeout(() => {
1289
+ if (!this.isDestroyed)
1290
+ applyTimeout();
1291
+ }, 30000);
1292
+ const clearUiTimeout = () => { if (uiTimeoutId) {
1293
+ clearTimeout(uiTimeoutId);
1294
+ uiTimeoutId = null;
1295
+ } };
1296
+ const applyTimeout = () => {
1297
+ iconWrap.innerHTML = "⏱";
1298
+ iconWrap.style.background = this.theme.cardBg;
1299
+ iconWrap.style.border = `3px solid ${this.theme.warning}`;
1300
+ iconWrap.style.color = this.theme.warning;
1301
+ iconWrap.style.fontSize = "30px";
1302
+ statusBadge.style.color = this.theme.textMuted;
1303
+ statusBadge.textContent = "Still processing…";
1304
+ title.textContent = "Verification In Progress";
1305
+ desc.textContent = "Your verification is being processed. Your result will be delivered via your dashboard or webhook. You can safely close this.";
1306
+ };
1307
+ const applyPassed = () => {
1308
+ clearUiTimeout();
1309
+ iconWrap.innerHTML = "✓";
1310
+ iconWrap.style.background = this.theme.successBg;
1311
+ iconWrap.style.border = `3px solid ${this.theme.success}`;
1312
+ iconWrap.style.color = this.theme.success;
1313
+ iconWrap.style.fontSize = "36px";
1314
+ statusBadge.style.color = this.theme.success;
1315
+ statusBadge.textContent = "Verified ✓";
1316
+ title.textContent = "Identity Verified";
1317
+ desc.textContent = "Your identity check passed successfully.";
1318
+ };
1319
+ const applyFailed = (reason) => {
1320
+ clearUiTimeout();
1321
+ iconWrap.innerHTML = "✕";
1322
+ iconWrap.style.background = this.theme.errorBg;
1323
+ iconWrap.style.border = `3px solid ${this.theme.error}`;
1324
+ iconWrap.style.color = this.theme.error;
1325
+ iconWrap.style.fontSize = "32px";
1326
+ statusBadge.style.color = this.theme.error;
1327
+ statusBadge.textContent = "Failed";
1328
+ title.textContent = "Verification Failed";
1329
+ desc.textContent = "Your identity check could not be completed.";
1330
+ if (reason) {
1331
+ failureMsg.textContent = reason;
1332
+ failureMsg.style.display = "block";
1333
+ }
1334
+ };
1335
+ const poll = async () => {
1336
+ if (this.isDestroyed)
1337
+ return;
1338
+ polls++;
1339
+ try {
1340
+ const result = await this.sdk.liveness.pollResult(entryId);
1341
+ if (result.status === "passed") {
1342
+ onResolved("passed");
1343
+ applyPassed();
1344
+ return;
1345
+ }
1346
+ if (result.status === "failed" || result.status === "expired_document") {
1347
+ onResolved(result.status);
1348
+ applyFailed(result.failure_reason);
1349
+ return;
1350
+ }
1351
+ // Still pending/processing — schedule next poll if budget remains
1352
+ if (polls < MAX_POLLS && !this.isDestroyed) {
1353
+ this._pollStatusTimer = setTimeout(poll, 3000);
1354
+ }
1355
+ else if (!this.isDestroyed) {
1356
+ applyTimeout(); // budget exhausted — stop spinner, show timeout state
1357
+ }
1358
+ }
1359
+ catch {
1360
+ // Network hiccup — retry with a slightly longer delay
1361
+ if (polls < MAX_POLLS && !this.isDestroyed) {
1362
+ this._pollStatusTimer = setTimeout(poll, 5000);
1363
+ }
1364
+ else if (!this.isDestroyed) {
1365
+ applyTimeout(); // budget exhausted — stop spinner
1366
+ }
1367
+ }
1368
+ };
1369
+ // First poll after 4 s — gives Celery task time to complete
1370
+ this._pollStatusTimer = setTimeout(poll, 4000);
1371
+ }
1372
+ renderResult(_container) {
1373
+ // Handled by showResult() directly
1374
+ }
1375
+ // ── Error ───────────────────────────────────────────────────────
1376
+ showErrorStep(message) {
1377
+ this.currentStep = "error";
1378
+ if (!this.contentArea)
1379
+ return;
1380
+ this.contentArea.innerHTML = "";
1381
+ const wrapper = document.createElement("div");
1382
+ wrapper.style.cssText = `
1383
+ display:flex;flex-direction:column;align-items:center;
1384
+ text-align:center;gap:16px;padding:20px 0;
1385
+ `;
1386
+ const isCameraErr = /camera|permission|notallowed|notfound/i.test(message);
1387
+ const isNetworkErr = /network|timeout|connection|fetch/i.test(message);
1388
+ const isAuthErr = /session|token|expired|authentication/i.test(message);
1389
+ const errorIcon = isCameraErr ? "\uD83D\uDCF7" : isNetworkErr ? "\uD83D\uDCE1" : isAuthErr ? "\u23F1" : "\u2717";
1390
+ const errorTitle = isCameraErr ? "Camera Access Required"
1391
+ : isNetworkErr ? "Connection Problem"
1392
+ : isAuthErr ? "Session Expired"
1393
+ : "Verification Failed";
1394
+ const iconWrap = document.createElement("div");
1395
+ iconWrap.style.cssText = `
1396
+ width:72px;height:72px;border-radius:50%;
1397
+ display:flex;align-items:center;justify-content:center;
1398
+ font-size:${errorIcon === "\u2717" ? "32px" : "28px"};
1399
+ background:${this.theme.errorBg};
1400
+ border:3px solid ${this.theme.error};
1401
+ `;
1402
+ iconWrap.textContent = errorIcon;
1403
+ wrapper.appendChild(iconWrap);
1404
+ const title = document.createElement("h2");
1405
+ title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1406
+ title.textContent = errorTitle;
1407
+ wrapper.appendChild(title);
1408
+ const desc = document.createElement("p");
1409
+ desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1410
+ desc.textContent = message;
1411
+ wrapper.appendChild(desc);
1412
+ const retryBtn = this.createPrimaryButton("Try Again");
1413
+ retryBtn.addEventListener("click", () => {
1414
+ // If session already exists, go back to welcome without re-initializing
1415
+ // (re-initializing would fail with CLIENT_ID_ALREADY_USED)
1416
+ if (this.sdk.sessionToken && this.sdkConfig) {
1417
+ this.showStep("welcome");
1418
+ }
1419
+ else {
1420
+ // Only re-initialize if session was never created
1421
+ this.initialize();
1422
+ }
1423
+ });
1424
+ wrapper.appendChild(retryBtn);
1425
+ const closeBtn = this.createSecondaryButton("Close");
1426
+ closeBtn.addEventListener("click", () => {
1427
+ this.options.onError?.(new Error(message));
1428
+ this.close();
1429
+ });
1430
+ wrapper.appendChild(closeBtn);
1431
+ this.contentArea.appendChild(wrapper);
1432
+ }
1433
+ // ─────────────────────────────────────────────────────────────────
1434
+ // UI Helpers
1435
+ // ─────────────────────────────────────────────────────────────────
1436
+ createHeader() {
1437
+ const header = document.createElement("div");
1438
+ header.style.cssText = `
1439
+ display:flex;align-items:center;justify-content:space-between;
1440
+ padding:14px 20px;border-bottom:1px solid ${this.theme.border};
1441
+ `;
1442
+ // Left: back button + brand
1443
+ const leftArea = document.createElement("div");
1444
+ leftArea.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
1445
+ this.headerBackBtn = document.createElement("button");
1446
+ this.headerBackBtn.innerHTML = "&#8592;";
1447
+ this.headerBackBtn.style.cssText = `
1448
+ width:30px;height:30px;border-radius:8px;flex-shrink:0;
1449
+ display:none;align-items:center;justify-content:center;
1450
+ border:1.5px solid ${this.theme.border};cursor:pointer;
1451
+ font-size:16px;line-height:1;
1452
+ background:${this.theme.cardBg};color:${this.theme.text};
1453
+ transition:all 0.2s ease;
1454
+ `;
1455
+ this.headerBackBtn.addEventListener("mouseenter", () => {
1456
+ if (this.headerBackBtn) {
1457
+ this.headerBackBtn.style.background = this.theme.shimmer;
1458
+ }
1459
+ });
1460
+ this.headerBackBtn.addEventListener("mouseleave", () => {
1461
+ if (this.headerBackBtn) {
1462
+ this.headerBackBtn.style.background = this.theme.cardBg;
1463
+ }
1464
+ });
1465
+ leftArea.appendChild(this.headerBackBtn);
1466
+ this.brandLabel = document.createElement("div");
1467
+ this.brandLabel.style.cssText = `
1468
+ font-size:15px;font-weight:700;color:${this.theme.text};
1469
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
1470
+ `;
1471
+ this.brandLabel.textContent = this.sdkConfig?.brand_name || "SmartComply";
1472
+ leftArea.appendChild(this.brandLabel);
1473
+ header.appendChild(leftArea);
1474
+ // Step indicator
1475
+ this.stepLabel = document.createElement("div");
1476
+ this.stepLabel.style.cssText = `
1477
+ font-size:12px;color:${this.theme.textMuted};
1478
+ font-variant-numeric:tabular-nums;flex-shrink:0;
1479
+ `;
1480
+ header.appendChild(this.stepLabel);
1481
+ // Close button
1482
+ const closeBtn = document.createElement("button");
1483
+ closeBtn.innerHTML = "✕";
1484
+ closeBtn.style.cssText = `
1485
+ width:32px;height:32px;border-radius:8px;flex-shrink:0;
1486
+ display:flex;align-items:center;justify-content:center;
1487
+ border:none;cursor:pointer;font-size:16px;
1488
+ background:${this.theme.shimmer};color:${this.theme.textMuted};
1489
+ transition:all 0.2s ease;
1490
+ `;
1491
+ closeBtn.addEventListener("mouseenter", () => {
1492
+ closeBtn.style.background = this.theme.errorBg;
1493
+ closeBtn.style.color = this.theme.error;
1494
+ });
1495
+ closeBtn.addEventListener("mouseleave", () => {
1496
+ closeBtn.style.background = this.theme.shimmer;
1497
+ closeBtn.style.color = this.theme.textMuted;
1498
+ });
1499
+ closeBtn.addEventListener("click", () => this.close());
1500
+ header.appendChild(closeBtn);
1501
+ return header;
1502
+ }
1503
+ createFooter() {
1504
+ const footer = document.createElement("div");
1505
+ footer.style.cssText = `
1506
+ padding:12px 20px;border-top:1px solid ${this.theme.border};
1507
+ display:flex;align-items:center;justify-content:center;gap:6px;
1508
+ `;
1509
+ const powered = document.createElement("span");
1510
+ powered.style.cssText = `font-size:11px;color:${this.theme.textMuted};`;
1511
+ powered.textContent = "Powered by ";
1512
+ const brand = document.createElement("span");
1513
+ brand.style.cssText = `font-size:11px;font-weight:700;color:${this.theme.primary};`;
1514
+ brand.textContent = "Adhere";
1515
+ footer.appendChild(powered);
1516
+ footer.appendChild(brand);
1517
+ return footer;
1518
+ }
1519
+ createStepTitle(text) {
1520
+ const el = document.createElement("h3");
1521
+ el.style.cssText = `
1522
+ color:${this.theme.text};font-size:18px;font-weight:700;
1523
+ margin:0;
1524
+ `;
1525
+ el.textContent = text;
1526
+ return el;
1527
+ }
1528
+ createOptionCard(title, subtitle, onClick) {
1529
+ const card = document.createElement("button");
1530
+ card.style.cssText = `
1531
+ width:100%;padding:16px;border-radius:12px;
1532
+ background:${this.theme.cardBg};
1533
+ border:2px solid ${this.theme.border};
1534
+ cursor:pointer;text-align:left;
1535
+ display:flex;align-items:center;justify-content:space-between;
1536
+ transition:all 0.2s ease;
1537
+ `;
1538
+ card.addEventListener("mouseenter", () => {
1539
+ card.style.borderColor = this.theme.primary;
1540
+ card.style.background = this.theme.shimmer;
1541
+ card.style.transform = "translateY(-1px)";
1542
+ });
1543
+ card.addEventListener("mouseleave", () => {
1544
+ card.style.borderColor = this.theme.border;
1545
+ card.style.background = this.theme.cardBg;
1546
+ card.style.transform = "translateY(0)";
1547
+ });
1548
+ card.addEventListener("click", onClick);
1549
+ const textWrap = document.createElement("div");
1550
+ const titleEl = document.createElement("div");
1551
+ titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:600;`;
1552
+ titleEl.textContent = title;
1553
+ textWrap.appendChild(titleEl);
1554
+ if (subtitle) {
1555
+ const subEl = document.createElement("div");
1556
+ subEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;margin-top:2px;`;
1557
+ subEl.textContent = subtitle;
1558
+ textWrap.appendChild(subEl);
1559
+ }
1560
+ const arrow = document.createElement("span");
1561
+ arrow.style.cssText = `color:${this.theme.textMuted};font-size:18px;flex-shrink:0;`;
1562
+ arrow.textContent = "→";
1563
+ card.appendChild(textWrap);
1564
+ card.appendChild(arrow);
1565
+ return card;
1566
+ }
1567
+ createPrimaryButton(text) {
1568
+ const btn = document.createElement("button");
1569
+ btn.textContent = text;
1570
+ btn.style.cssText = `
1571
+ width:100%;padding:14px;border-radius:12px;
1572
+ font-size:15px;font-weight:600;
1573
+ background:${this.theme.primary};color:#fff;
1574
+ border:none;cursor:pointer;
1575
+ transition:all 0.2s ease;
1576
+ box-shadow:0 2px 8px ${this.theme.primaryGlow};
1577
+ `;
1578
+ btn.addEventListener("mouseenter", () => {
1579
+ btn.style.background = this.theme.primaryHover;
1580
+ btn.style.transform = "translateY(-1px)";
1581
+ });
1582
+ btn.addEventListener("mouseleave", () => {
1583
+ btn.style.background = this.theme.primary;
1584
+ btn.style.transform = "translateY(0)";
1585
+ });
1586
+ return btn;
1587
+ }
1588
+ createSecondaryButton(text) {
1589
+ const btn = document.createElement("button");
1590
+ btn.textContent = text;
1591
+ btn.style.cssText = `
1592
+ width:100%;padding:12px;border-radius:12px;
1593
+ font-size:14px;font-weight:500;
1594
+ background:transparent;color:${this.theme.textSecondary};
1595
+ border:none;cursor:pointer;
1596
+ transition:all 0.2s ease;
1597
+ `;
1598
+ btn.addEventListener("mouseenter", () => {
1599
+ btn.style.color = this.theme.text;
1600
+ });
1601
+ btn.addEventListener("mouseleave", () => {
1602
+ btn.style.color = this.theme.textSecondary;
1603
+ });
1604
+ return btn;
1605
+ }
1606
+ isDocumentFlow() {
1607
+ // 1. Check verification_type — handle both string and array from backend
1608
+ const vType = this.sdkConfig?.verification_type;
1609
+ if (vType) {
1610
+ const types = Array.isArray(vType) ? vType : [vType];
1611
+ if (types.includes("document_verification"))
1612
+ return true;
1613
+ if (types.includes("data_verification"))
1614
+ return false;
1615
+ }
1616
+ // 2. Fallback: inspect channel fields — any upload field = document flow
1617
+ const channels = this.sdkConfig?.channels || {};
1618
+ const allFields = Object.values(channels)
1619
+ .flat()
1620
+ .flatMap((ch) => ch.fields || []);
1621
+ return allFields.some((f) => f.type === "upload");
1622
+ }
1623
+ updateProgress() {
1624
+ const isDocFlow = this.isDocumentFlow();
1625
+ const countries = Object.keys(this.sdkConfig?.channels || {});
1626
+ const singleCountry = countries.length <= 1;
1627
+ // User-action steps only (what the user actually does — no loading/welcome/result)
1628
+ const actionSteps = ["country", "id_type", isDocFlow ? "document_capture" : "id_input", "liveness"].filter((s) => !(s === "country" && singleCountry));
1629
+ const totalSteps = actionSteps.length;
1630
+ const actionIndex = actionSteps.indexOf(this.currentStep);
1631
+ const pct = this.currentStep === "result" ? 100
1632
+ : actionIndex >= 0 ? ((actionIndex + 1) / totalSteps) * 100
1633
+ : 0;
1634
+ if (this.progressBar) {
1635
+ this.progressBar.style.width = `${pct}%`;
1636
+ }
1637
+ if (this.stepLabel) {
1638
+ if (this.currentStep === "loading" || this.currentStep === "welcome") {
1639
+ this.stepLabel.textContent = "";
1640
+ }
1641
+ else if (this.currentStep === "result") {
1642
+ this.stepLabel.textContent = "Complete ✓";
1643
+ }
1644
+ else if (actionIndex >= 0) {
1645
+ this.stepLabel.textContent = `Step ${actionIndex + 1} of ${totalSteps}`;
1646
+ }
1647
+ else {
1648
+ this.stepLabel.textContent = "";
1649
+ }
1650
+ }
1651
+ // Back button — show in header for navigable steps, hidden elsewhere
1652
+ if (this.headerBackBtn) {
1653
+ const countries = Object.keys(this.sdkConfig?.channels || {});
1654
+ const backTargets = {
1655
+ id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
1656
+ country: () => this.showStep("welcome"),
1657
+ id_input: () => this.showStep("id_type"),
1658
+ document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
1659
+ };
1660
+ const backAction = backTargets[this.currentStep];
1661
+ if (backAction) {
1662
+ this.headerBackBtn.style.display = "flex";
1663
+ const newBtn = this.headerBackBtn.cloneNode(true);
1664
+ newBtn.style.cssText = this.headerBackBtn.style.cssText;
1665
+ this.headerBackBtn.parentNode?.replaceChild(newBtn, this.headerBackBtn);
1666
+ this.headerBackBtn = newBtn;
1667
+ this.headerBackBtn.addEventListener("mouseenter", () => {
1668
+ if (this.headerBackBtn)
1669
+ this.headerBackBtn.style.background = this.theme.shimmer;
1670
+ });
1671
+ this.headerBackBtn.addEventListener("mouseleave", () => {
1672
+ if (this.headerBackBtn)
1673
+ this.headerBackBtn.style.background = this.theme.cardBg;
1674
+ });
1675
+ this.headerBackBtn.addEventListener("click", backAction);
1676
+ }
1677
+ else {
1678
+ this.headerBackBtn.style.display = "none";
1679
+ }
1680
+ }
1681
+ }
1682
+ applyTheme() {
1683
+ if (this.overlay) {
1684
+ this.overlay.style.background = this.theme.overlay;
1685
+ }
1686
+ if (this.modal) {
1687
+ this.modal.style.background = this.theme.bg;
1688
+ this.modal.style.boxShadow = this.theme.shadow;
1689
+ }
1690
+ }
1691
+ injectStyles() {
1692
+ if (document.getElementById("sc-flow-styles"))
1693
+ return;
1694
+ const style = document.createElement("style");
1695
+ style.id = "sc-flow-styles";
1696
+ style.textContent = `
1697
+ @keyframes sc-spin {
1698
+ to { transform: rotate(360deg); }
1699
+ }
1700
+ @keyframes sc-pop {
1701
+ 0% { transform: scale(0.8); opacity: 0; }
1702
+ 50% { transform: scale(1.05); }
1703
+ 100% { transform: scale(1); opacity: 1; }
1704
+ }
1705
+ #sc-flow-modal * {
1706
+ box-sizing: border-box;
1707
+ }
1708
+ #sc-flow-modal::-webkit-scrollbar {
1709
+ width: 4px;
1710
+ }
1711
+ #sc-flow-content::-webkit-scrollbar {
1712
+ width: 4px;
1713
+ }
1714
+ #sc-flow-content::-webkit-scrollbar-thumb {
1715
+ background: rgba(128,128,128,0.3);
1716
+ border-radius: 4px;
1717
+ }
1718
+ `;
1719
+ document.head.appendChild(style);
1720
+ }
1721
+ }
1722
+ //# sourceMappingURL=SmartComplyFlow.js.map