smartcomply-web-sdk 1.0.42 β†’ 1.0.45

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.
@@ -22,6 +22,13 @@ const COUNTRY_LABELS = {
22
22
  united_states: "πŸ‡ΊπŸ‡Έ United States",
23
23
  united_kingdom: "πŸ‡¬πŸ‡§ United Kingdom",
24
24
  };
25
+ // Backend/admin data tags document-capture fields inconsistently β€” the field
26
+ // renderer (below) already treats "image" and "file" as file inputs alongside
27
+ // "upload", so flow-routing decisions must recognize the same set or a channel
28
+ // tagged "image"/"file" silently falls back to the number-entry screen.
29
+ function isUploadFieldType(type) {
30
+ return type === "upload" || type === "image" || type === "file";
31
+ }
25
32
  function resolveIdTypeInfo(typeName, isDocumentFlow) {
26
33
  const u = typeName.toUpperCase();
27
34
  const advanced = u.includes("ADVANCED");
@@ -116,6 +123,55 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
116
123
  };
117
124
  }
118
125
  // ─────────────────────────────────────────────────────────────────────
126
+ // ── Widget-side message sanitizer ─────────────────────────────────────────────
127
+ // Strips anything technical before showing text to the end user.
128
+ // Keeps messages that are already written plainly in natural language.
129
+ function _sanitizeUserMessage(raw) {
130
+ if (!raw)
131
+ return null;
132
+ const lower = raw.toLowerCase();
133
+ // Block anything that looks internal / technical
134
+ const blocklist = [
135
+ "smartcomply", "face comparison service", "session id", "session_id",
136
+ "provider", "ocr", "textract", "rekognition", "deepface",
137
+ "ivs", "celery", "exception", "traceback", "str(e)",
138
+ "error:", "failed:", "django", "python", "http ",
139
+ "comparison mode", "liveness mode", "callback",
140
+ "face_comparison", "face_match", "confidence_percentage",
141
+ "threshold_percentage", "polling_fallback", "resolved_by",
142
+ ];
143
+ for (const blocked of blocklist) {
144
+ if (lower.includes(blocked))
145
+ return null;
146
+ }
147
+ // Block snake_case (internal field names accidentally leaking through)
148
+ if (/[a-z]+_[a-z]+/.test(raw) && !/\s/.test(raw.trim()))
149
+ return null;
150
+ // Block anything starting with a lowercase letter (likely a code/key)
151
+ if (raw.trim()[0] === raw.trim()[0].toLowerCase() && /^[a-z]/.test(raw.trim()))
152
+ return null;
153
+ return raw.trim();
154
+ }
155
+ // ── Document type code β†’ human label ─────────────────────────────────────────
156
+ function _formatDocType(code) {
157
+ const map = {
158
+ passport: "Passport",
159
+ national_id: "National ID",
160
+ nin_slip: "NIN Slip",
161
+ nin_with_face: "National Identity Number",
162
+ drivers_license: "Driver's Licence",
163
+ voters_card: "Voter's Card",
164
+ kenya_id: "Kenya National ID",
165
+ ghana_id: "Ghana Card",
166
+ };
167
+ if (!code)
168
+ return "";
169
+ const key = code.toLowerCase().replace(/[\s-]/g, "_");
170
+ if (map[key])
171
+ return map[key];
172
+ // Fallback: title-case the code
173
+ return code.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
174
+ }
119
175
  // SmartComplyFlow
120
176
  // ─────────────────────────────────────────────────────────────────────
121
177
  // Add CSS animation keyframes for spin animations
@@ -164,6 +220,7 @@ export class SmartComplyFlow {
164
220
  this._pollStatusTimer = null;
165
221
  // Components
166
222
  this.docCapture = null;
223
+ this._livenessSubmitResult = null;
167
224
  this.options = options;
168
225
  this.sdk = new SmartComply({
169
226
  apiKey: options.apiKey,
@@ -239,6 +296,9 @@ export class SmartComplyFlow {
239
296
  // Modal
240
297
  this.modal = document.createElement("div");
241
298
  this.modal.id = "sc-flow-modal";
299
+ this.modal.setAttribute("role", "dialog");
300
+ this.modal.setAttribute("aria-modal", "true");
301
+ this.modal.setAttribute("aria-label", "Identity verification");
242
302
  this.modal.style.cssText = `
243
303
  width:100%;max-width:580px;max-height:95vh;
244
304
  background:${this.theme.bg};
@@ -264,6 +324,9 @@ export class SmartComplyFlow {
264
324
  // Content area (scrollable)
265
325
  this.contentArea = document.createElement("div");
266
326
  this.contentArea.id = "sc-flow-content";
327
+ this.contentArea.setAttribute("role", "main");
328
+ this.contentArea.setAttribute("aria-live", "polite");
329
+ this.contentArea.setAttribute("aria-atomic", "false");
267
330
  this.contentArea.style.cssText = `
268
331
  flex:1;overflow-y:auto;padding:16px 20px;
269
332
  -webkit-overflow-scrolling:touch;
@@ -365,7 +428,7 @@ export class SmartComplyFlow {
365
428
  this.renderDocumentCapture(wrapper);
366
429
  break;
367
430
  case "processing":
368
- this.renderProcessing(wrapper);
431
+ this.renderProcessing(wrapper, this.livenessEntryId || undefined);
369
432
  break;
370
433
  case "liveness":
371
434
  this.renderLiveness(wrapper);
@@ -573,7 +636,7 @@ export class SmartComplyFlow {
573
636
  const typeCode = typeof idType === "string" ? idType : (idType.code || idType.name);
574
637
  // Detect per-channel: does THIS specific channel have upload fields?
575
638
  const channelFields = typeof idType !== "string" ? (idType.fields || []) : [];
576
- const isThisDocFlow = channelFields.some((f) => f.type === "upload") || globalDocFlow;
639
+ const isThisDocFlow = channelFields.some((f) => isUploadFieldType(f.type)) || globalDocFlow;
577
640
  const info = resolveIdTypeInfo(typeName, isThisDocFlow);
578
641
  // Prefer the backend-provided back-side flag; fall back to local heuristics only when absent.
579
642
  const requiresBackFromBackend = typeof idType !== "string" ? idType.requires_back_side : undefined;
@@ -611,7 +674,7 @@ export class SmartComplyFlow {
611
674
  }
612
675
  // Route: use explicit verification_type when set (more reliable than inspecting all channels)
613
676
  // Fallback to per-channel upload field check for mixed / unconfigured setups
614
- const hasUpload = (this.selectedChannelFields).some((f) => f.type === "upload");
677
+ const hasUpload = (this.selectedChannelFields).some((f) => isUploadFieldType(f.type));
615
678
  const vType = this.sdkConfig?.verification_type;
616
679
  const vTypes = vType ? (Array.isArray(vType) ? vType : [vType]) : [];
617
680
  const isDocChannel = hasUpload || (vTypes.length > 0 && vTypes.includes("document_verification"));
@@ -654,6 +717,41 @@ export class SmartComplyFlow {
654
717
  container.appendChild(autoBtn);
655
718
  return;
656
719
  }
720
+ const _FIELD_RULES = [
721
+ { match: "BVN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "BVN must be exactly 11 digits" } },
722
+ { match: "NIN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
723
+ { 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" } },
731
+ ];
732
+ function getFieldRule(label) {
733
+ const upper = label.toUpperCase();
734
+ for (const entry of _FIELD_RULES) {
735
+ if (upper.includes(entry.match))
736
+ return entry.rule;
737
+ }
738
+ return null;
739
+ }
740
+ function validateFieldValue(label, value) {
741
+ const rule = getFieldRule(label);
742
+ if (!rule)
743
+ return null; // no rule = no validation
744
+ const v = value.trim();
745
+ if (rule.numeric && !/^\d+$/.test(v))
746
+ return `${label} must contain digits only`;
747
+ if (rule.alphanumeric && !/^[A-Za-z0-9]+$/.test(v))
748
+ return `${label} must be alphanumeric (letters and digits only)`;
749
+ if (rule.minLen && v.length < rule.minLen)
750
+ return rule.hint;
751
+ if (rule.maxLen && v.length > rule.maxLen)
752
+ return rule.hint;
753
+ return null;
754
+ }
657
755
  // Track input elements for validation
658
756
  const inputEls = [];
659
757
  for (const field of dataFields) {
@@ -666,7 +764,10 @@ export class SmartComplyFlow {
666
764
  const fieldKey = field.label;
667
765
  const fieldWrap = document.createElement("div");
668
766
  fieldWrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
767
+ const labelId = `sc-field-label-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
669
768
  const label = document.createElement("label");
769
+ label.id = labelId;
770
+ label.htmlFor = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
670
771
  label.style.cssText = `color:${this.theme.textMuted};font-size:10px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;`;
671
772
  label.textContent = field.label;
672
773
  fieldWrap.appendChild(label);
@@ -740,10 +841,18 @@ export class SmartComplyFlow {
740
841
  }
741
842
  else {
742
843
  // Text input field
844
+ const inputId = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
845
+ const hintId = `sc-field-hint-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
846
+ const rule = getFieldRule(field.label);
743
847
  const input = document.createElement("input");
744
848
  input.type = "text";
849
+ input.id = inputId;
850
+ input.setAttribute("aria-labelledby", labelId);
851
+ input.setAttribute("aria-describedby", hintId);
745
852
  input.placeholder = `Enter ${field.label}`;
746
- input.maxLength = 50;
853
+ input.maxLength = rule?.maxLen ? rule.maxLen + 2 : 50;
854
+ if (rule?.numeric)
855
+ input.inputMode = "numeric";
747
856
  input.style.cssText = `
748
857
  width:100%;padding:14px 16px;border-radius:12px;
749
858
  font-size:16px;font-weight:500;
@@ -754,17 +863,50 @@ export class SmartComplyFlow {
754
863
  box-sizing:border-box;
755
864
  letter-spacing:0.5px;
756
865
  `;
866
+ // Inline hint/error element
867
+ const hintEl = document.createElement("div");
868
+ hintEl.id = hintId;
869
+ hintEl.setAttribute("aria-live", "polite");
870
+ hintEl.style.cssText = `font-size:11px;min-height:14px;margin-top:-2px;`;
871
+ if (rule) {
872
+ hintEl.style.color = this.theme.textMuted;
873
+ hintEl.textContent = rule.hint;
874
+ }
757
875
  input.addEventListener("focus", () => {
758
876
  input.style.borderColor = this.theme.primary;
759
877
  });
760
878
  input.addEventListener("blur", () => {
761
- input.style.borderColor = this.theme.border;
879
+ const v = input.value.trim();
880
+ if (v) {
881
+ const err = validateFieldValue(field.label, v);
882
+ if (err) {
883
+ input.style.borderColor = this.theme.error;
884
+ hintEl.style.color = this.theme.error;
885
+ hintEl.textContent = err;
886
+ input.setAttribute("aria-invalid", "true");
887
+ }
888
+ else {
889
+ input.style.borderColor = this.theme.success || this.theme.primary;
890
+ hintEl.style.color = this.theme.textMuted;
891
+ hintEl.textContent = rule?.hint || "";
892
+ input.setAttribute("aria-invalid", "false");
893
+ }
894
+ }
895
+ else {
896
+ input.style.borderColor = this.theme.border;
897
+ }
762
898
  });
763
899
  input.addEventListener("input", () => {
900
+ // Reset error styling while typing
901
+ input.style.borderColor = this.theme.primary;
902
+ hintEl.style.color = this.theme.textMuted;
903
+ hintEl.textContent = rule?.hint || "";
904
+ input.removeAttribute("aria-invalid");
764
905
  this.collectedFields[fieldKey] = input.value.trim();
765
906
  updateSubmitState();
766
907
  });
767
908
  fieldWrap.appendChild(input);
909
+ fieldWrap.appendChild(hintEl);
768
910
  inputEls.push({ field, el: input });
769
911
  }
770
912
  container.appendChild(fieldWrap);
@@ -782,9 +924,16 @@ export class SmartComplyFlow {
782
924
  submitBtn.disabled = true;
783
925
  submitBtn.style.opacity = "0.5";
784
926
  const updateSubmitState = () => {
927
+ // All inputs must have a value AND pass format validation
785
928
  const allFilled = inputEls.every(({ el }) => el.value.trim().length > 0);
786
- submitBtn.disabled = !allFilled;
787
- submitBtn.style.opacity = allFilled ? "1" : "0.5";
929
+ const allValid = inputEls.every(({ field, el }) => {
930
+ if (el.tagName === "SELECT")
931
+ return el.value.trim().length > 0;
932
+ return !validateFieldValue(field.label, el.value.trim());
933
+ });
934
+ const ok = allFilled && allValid;
935
+ submitBtn.disabled = !ok;
936
+ submitBtn.style.opacity = ok ? "1" : "0.5";
788
937
  };
789
938
  submitBtn.addEventListener("click", async () => {
790
939
  submitBtn.disabled = true;
@@ -804,7 +953,7 @@ export class SmartComplyFlow {
804
953
  this.showStep("liveness");
805
954
  }
806
955
  else {
807
- errorEl.textContent = this.verificationResult.message || "Verification failed. Please check your details.";
956
+ errorEl.textContent = this.verificationResult.message || "We could not verify your details. Please check and try again.";
808
957
  errorEl.style.display = "block";
809
958
  submitBtn.textContent = "Verify & Continue";
810
959
  submitBtn.disabled = false;
@@ -812,14 +961,26 @@ export class SmartComplyFlow {
812
961
  }
813
962
  }
814
963
  catch (err) {
815
- // C3: Show field-level errors if available
964
+ // Show a single plain-language error β€” never raw field:message format
816
965
  const fieldErrors = err.data?.errors;
817
966
  if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
818
- const messages = fieldErrors.map((e) => `${e.field}: ${e.message}`).join("\n");
819
- errorEl.textContent = messages;
967
+ // Pick the first meaningful error message β€” strip field prefix
968
+ const firstMsg = fieldErrors[0]?.message || fieldErrors[0]?.detail || "";
969
+ errorEl.textContent = _sanitizeUserMessage(firstMsg)
970
+ || "Please check your details and try again.";
971
+ }
972
+ else if (err.code === "VERIFICATION_FAILED") {
973
+ errorEl.textContent = "We could not verify those details. Please check your ID number and try again.";
974
+ }
975
+ else if (err.code === "CHANNEL_NOT_CONFIGURED") {
976
+ errorEl.textContent = "This ID type is not currently available. Please choose a different option.";
977
+ }
978
+ else if (err.code === "INSUFFICIENT_BALANCE") {
979
+ errorEl.textContent = "Verification is temporarily unavailable. Please contact support.";
820
980
  }
821
981
  else {
822
- errorEl.textContent = err.message || "Verification failed";
982
+ errorEl.textContent = _sanitizeUserMessage(err.message)
983
+ || "Something went wrong. Please try again.";
823
984
  }
824
985
  errorEl.style.display = "block";
825
986
  submitBtn.textContent = "Verify & Continue";
@@ -875,130 +1036,140 @@ export class SmartComplyFlow {
875
1036
  this.showErrorStep(err.message || "Document capture failed. Please try again.");
876
1037
  });
877
1038
  }
878
- // ── Processing ────────────────────────────────────────────────────
879
- renderProcessing(container) {
880
- // Compact layout β€” fits modal without scroll
881
- container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:14px;padding:16px;";
882
- // Small spinner
883
- const animationWrap = document.createElement("div");
884
- animationWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
885
- const spinner = document.createElement("div");
886
- spinner.style.cssText = `
887
- position:absolute;inset:0;border-radius:50%;
888
- border:3px solid ${this.theme.border};
889
- border-top-color:${this.theme.primary};
890
- animation:sc-spin 0.9s linear infinite;
891
- `;
892
- animationWrap.appendChild(spinner);
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
1043
+ const spinnerWrap = document.createElement("div");
1044
+ spinnerWrap.style.cssText = "position:relative;width:56px;height:56px;flex-shrink:0;";
1045
+ const ring = document.createElement("div");
1046
+ ring.id = "sc-proc-ring";
1047
+ 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;`;
893
1048
  const centerIcon = document.createElement("div");
894
- centerIcon.style.cssText = `
895
- position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
896
- font-size:20px;
897
- `;
1049
+ centerIcon.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;";
898
1050
  centerIcon.textContent = "πŸ”";
899
- animationWrap.appendChild(centerIcon);
900
- container.appendChild(animationWrap);
1051
+ spinnerWrap.appendChild(ring);
1052
+ spinnerWrap.appendChild(centerIcon);
1053
+ container.appendChild(spinnerWrap);
901
1054
  const title = document.createElement("div");
902
1055
  title.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:700;text-align:center;`;
903
- title.textContent = "Processing your document";
1056
+ title.textContent = "Verifying your document";
904
1057
  container.appendChild(title);
905
- const subtitle = document.createElement("div");
906
- subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:260px;line-height:1.5;`;
907
- subtitle.textContent = "Scanning and verifying your ID. This usually takes a few seconds.";
908
- container.appendChild(subtitle);
909
- // Compact steps β€” 2-col grid so they fit without scroll
910
- const stepsContainer = document.createElement("div");
911
- stepsContainer.style.cssText = `
912
- width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;
913
- `;
914
- const steps = [
915
- { id: 'uploading', text: 'Uploading', icon: 'πŸ“€' },
916
- { id: 'scanning', text: 'Scanning', icon: 'πŸ”' },
917
- { id: 'verifying', text: 'Verifying', icon: 'βœ“' },
918
- { id: 'analyzing', text: 'Checking quality', icon: 'πŸ”¬' },
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: "βœ“" },
919
1072
  ];
920
- steps.forEach((step) => {
921
- const stepEl = document.createElement("div");
922
- stepEl.style.cssText = `
923
- display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;
924
- background:${this.theme.cardBg};border:1px solid ${this.theme.border};
925
- transition:all 0.3s ease;
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>
926
1083
  `;
927
- stepEl.id = `processing-step-${step.id}`;
928
- const iconEl = document.createElement("div");
929
- iconEl.style.cssText = `
930
- width:26px;height:26px;border-radius:7px;flex-shrink:0;
931
- display:flex;align-items:center;justify-content:center;
932
- font-size:12px;background:${this.theme.shimmer};
933
- `;
934
- iconEl.textContent = step.icon;
935
- iconEl.id = `step-icon-${step.id}`;
936
- const textEl = document.createElement("div");
937
- textEl.style.cssText = `flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;`;
938
- textEl.textContent = step.text;
939
- textEl.id = `step-text-${step.id}`;
940
- const statusEl = document.createElement("div");
941
- statusEl.style.cssText = `width:16px;height:16px;border-radius:50%;border:2px solid ${this.theme.border};position:relative;flex-shrink:0;`;
942
- statusEl.id = `step-status-${step.id}`;
943
- stepEl.appendChild(iconEl);
944
- stepEl.appendChild(textEl);
945
- stepEl.appendChild(statusEl);
946
- stepsContainer.appendChild(stepEl);
1084
+ grid.appendChild(el);
947
1085
  });
948
- container.appendChild(stepsContainer);
949
- this.startProcessingAnimation();
1086
+ container.appendChild(grid);
1087
+ // Start polling immediately β€” update stages as result comes in
1088
+ if (entryId) {
1089
+ this._pollProcessingStages(entryId, statusLine, grid, stages);
1090
+ }
950
1091
  }
951
- startProcessingAnimation() {
952
- const steps = ['uploading', 'scanning', 'verifying', 'analyzing'];
953
- let currentStepIndex = 0;
954
- const animateStep = (stepId) => {
955
- // Update step appearance
956
- const stepEl = document.getElementById(`processing-step-${stepId}`);
957
- const iconEl = document.getElementById(`step-icon-${stepId}`);
958
- const textEl = document.getElementById(`step-text-${stepId}`);
959
- const statusEl = document.getElementById(`step-status-${stepId}`);
960
- if (stepEl && iconEl && textEl && statusEl) {
961
- // Active state
962
- stepEl.style.borderColor = this.theme.primary;
963
- stepEl.style.background = `${this.theme.primary}10`;
964
- iconEl.style.background = this.theme.primary;
965
- iconEl.style.color = 'white';
966
- textEl.style.color = this.theme.text;
967
- textEl.style.fontWeight = '600';
968
- // Add spinner to status
969
- statusEl.innerHTML = `
970
- <div style="
971
- position:absolute;inset:2px;border-radius:50%;
972
- border:2px solid ${this.theme.border};
973
- border-top-color:${this.theme.primary};
974
- animation:sc-spin 0.8s linear infinite;
975
- "></div>
976
- `;
977
- }
978
- // Move to next step after delay
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) => {
979
1121
  setTimeout(() => {
980
- // Complete current step
981
- if (stepEl && iconEl && textEl && statusEl) {
982
- stepEl.style.borderColor = this.theme.success;
983
- stepEl.style.background = `${this.theme.success}10`;
984
- iconEl.style.background = this.theme.success;
985
- statusEl.innerHTML = `
986
- <div style="
987
- position:absolute;inset:3px;border-radius:50%;
988
- background:${this.theme.success};
989
- display:flex;align-items:center;justify-content:center;
990
- color:white;font-size:10px;font-weight:bold;
991
- ">βœ“</div>
992
- `;
993
- }
994
- currentStepIndex++;
995
- if (currentStepIndex < steps.length) {
996
- animateStep(steps[currentStepIndex]);
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
1138
+ let polls = 0;
1139
+ const poll = async () => {
1140
+ if (this.isDestroyed)
1141
+ return;
1142
+ polls++;
1143
+ try {
1144
+ 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);
1156
+ }
1157
+ }, 800);
1158
+ return;
997
1159
  }
998
- }, 2000); // 2 seconds per step
1160
+ }
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";
1169
+ }
999
1170
  };
1000
- // Start animation
1001
- setTimeout(() => animateStep(steps[0]), 500);
1171
+ // First poll after 5s β€” give Celery task time to start
1172
+ this._pollStatusTimer = setTimeout(poll, 5000);
1002
1173
  }
1003
1174
  /**
1004
1175
  * Process document capture result with front/back support
@@ -1040,8 +1211,15 @@ export class SmartComplyFlow {
1040
1211
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
1041
1212
  <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>
1042
1213
  </div>`;
1043
- // Create liveness entry with document
1044
- const identifier = `DOC-${Date.now()}`;
1214
+ // Create liveness entry with document.
1215
+ // Use the first collected field value as identifier when available
1216
+ // (document flow has no data-verification step, so collectedFields is
1217
+ // empty β€” fall back to the selected ID type code so the webhook
1218
+ // subject.identifier is at least human-meaningful, not a raw timestamp).
1219
+ const identifier = Object.values(this.collectedFields)[0]
1220
+ || this.idNumber
1221
+ || this.selectedIdType
1222
+ || `DOC-${Date.now()}`;
1045
1223
  const COUNTRY_CODES = {
1046
1224
  nigeria: "NG", ghana: "GH", kenya: "KE",
1047
1225
  south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
@@ -1120,52 +1298,62 @@ export class SmartComplyFlow {
1120
1298
  await attemptProcessing();
1121
1299
  }
1122
1300
  handleDocumentProcessingError(error, captureContainer) {
1123
- let errorMessage = "Document verification failed. Please try again.";
1301
+ // Map technical errors to user-friendly messages β€” never pass raw error strings
1302
+ let userMessage = "We could not verify your document. Please try again.";
1124
1303
  let canRetry = true;
1125
- if (error.message) {
1126
- if (error.message.includes("expired")) {
1127
- errorMessage = "Document has expired. Please use a valid document.";
1128
- canRetry = false;
1129
- }
1130
- else if (error.message.includes("quality too low")) {
1131
- errorMessage = "Image quality too low. Please ensure the document is well-lit and clearly visible.";
1132
- }
1133
- else if (error.message.includes("Network") || error.message.includes("fetch")) {
1134
- errorMessage = "Network connection failed. Please check your internet connection and try again.";
1135
- }
1136
- else if (error.message.includes("413") || error.message.includes("too large")) {
1137
- errorMessage = "Document image is too large. Please use a smaller image or retake the photo.";
1138
- }
1139
- else if (error.message.includes("415") || error.message.includes("format")) {
1140
- errorMessage = "Unsupported image format. Please use JPEG or PNG images.";
1141
- }
1304
+ const raw = (error?.message || "").toLowerCase();
1305
+ if (raw.includes("expired")) {
1306
+ userMessage = "The document you submitted has expired. Please use a valid, current ID.";
1307
+ canRetry = false;
1308
+ }
1309
+ else if (raw.includes("quality too low") || raw.includes("blurry") || raw.includes("blur")) {
1310
+ userMessage = "The document photo isn't clear enough. Please retake it in good lighting with no glare.";
1311
+ }
1312
+ else if (raw.includes("not detected") || raw.includes("document_not_detected")) {
1313
+ userMessage = "We could not detect a document in the photo. Please ensure the full ID is visible and try again.";
1314
+ }
1315
+ else if (raw.includes("network") || raw.includes("fetch") || raw.includes("connection")) {
1316
+ userMessage = "A connection error occurred. Please check your internet and try again.";
1317
+ }
1318
+ else if (raw.includes("too large") || raw.includes("413")) {
1319
+ userMessage = "The document image is too large. Please retake the photo.";
1320
+ }
1321
+ else if (raw.includes("format") || raw.includes("415")) {
1322
+ userMessage = "Unsupported image format. Please use JPEG or PNG.";
1323
+ }
1324
+ else if (raw.includes("type mismatch") || raw.includes("document_type_mismatch")) {
1325
+ userMessage = "The document does not match the selected ID type. Please choose the correct ID type.";
1142
1326
  }
1143
1327
  if (captureContainer) {
1144
- captureContainer.innerHTML = `
1145
- <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1146
- <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>
1147
- <div style="color:${this.theme.text};font-size:14px;font-weight:700;">Verification Failed</div>
1148
- <div style="color:${this.theme.textSecondary};font-size:13px;max-width:240px;line-height:1.6;">${errorMessage}</div>
1149
- ${canRetry ? `
1150
- <button id="retry-btn" style="
1151
- margin-top:8px;padding:12px 24px;border-radius:8px;
1152
- background:${this.theme.primary};color:white;
1153
- border:none;font-size:14px;font-weight:600;cursor:pointer;
1154
- transition:all 0.2s ease;
1155
- ">Try Again</button>
1156
- ` : ''}
1157
- </div>`;
1328
+ // Build with DOM β€” never interpolate user-derived content into innerHTML
1329
+ const wrap = document.createElement("div");
1330
+ wrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;";
1331
+ wrap.setAttribute("role", "alert");
1332
+ const icon = document.createElement("div");
1333
+ 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 = "πŸ”";
1335
+ wrap.appendChild(icon);
1336
+ const titleEl = document.createElement("div");
1337
+ titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:700;`;
1338
+ titleEl.textContent = "Document Could Not Be Verified";
1339
+ wrap.appendChild(titleEl);
1340
+ const msgEl = document.createElement("div");
1341
+ msgEl.style.cssText = `color:${this.theme.textSecondary};font-size:13px;max-width:260px;line-height:1.6;`;
1342
+ msgEl.textContent = userMessage;
1343
+ wrap.appendChild(msgEl);
1158
1344
  if (canRetry) {
1159
- const retryBtn = captureContainer.querySelector("#retry-btn");
1160
- if (retryBtn) {
1161
- retryBtn.addEventListener("click", () => {
1162
- this.showStep("document_capture");
1163
- });
1164
- }
1345
+ const retryBtn = document.createElement("button");
1346
+ retryBtn.textContent = "Try Again";
1347
+ retryBtn.setAttribute("aria-label", "Retake document photo");
1348
+ retryBtn.style.cssText = `margin-top:8px;padding:12px 24px;border-radius:8px;background:${this.theme.primary};color:#fff;border:none;font-size:14px;font-weight:600;cursor:pointer;`;
1349
+ retryBtn.addEventListener("click", () => this.showStep("document_capture"));
1350
+ wrap.appendChild(retryBtn);
1165
1351
  }
1352
+ captureContainer.innerHTML = "";
1353
+ captureContainer.appendChild(wrap);
1166
1354
  }
1167
1355
  else {
1168
- this.showErrorStep(errorMessage);
1356
+ this.showErrorStep(userMessage);
1169
1357
  }
1170
1358
  }
1171
1359
  // ── Liveness ────────────────────────────────────────────────────
@@ -1181,7 +1369,10 @@ export class SmartComplyFlow {
1181
1369
  const livenessContainer = document.createElement("div");
1182
1370
  livenessContainer.style.cssText = "margin:8px 0;width:100%;";
1183
1371
  container.appendChild(livenessContainer);
1184
- const identifier = this.idNumber || `DOC-${Date.now()}`;
1372
+ const identifier = this.idNumber
1373
+ || Object.values(this.collectedFields)[0]
1374
+ || this.selectedIdType
1375
+ || `DOC-${Date.now()}`;
1185
1376
  const COUNTRY_CODES = {
1186
1377
  nigeria: "NG",
1187
1378
  ghana: "GH",
@@ -1202,9 +1393,25 @@ export class SmartComplyFlow {
1202
1393
  document: this.documentBlob || undefined,
1203
1394
  document_back: this.documentBackBlob || undefined,
1204
1395
  identity_check: this.identityCheckId || undefined,
1396
+ // Tell the module which flow we're in so it can skip video for data verification
1397
+ verification_type: this.identityCheckId
1398
+ ? "data_verification"
1399
+ : this.documentBlob
1400
+ ? "document_verification"
1401
+ : "liveness_only",
1205
1402
  }, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
1206
1403
  .then((result) => {
1207
- this.showResult(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");
1411
+ }
1412
+ else {
1413
+ this.showResult(result);
1414
+ }
1208
1415
  })
1209
1416
  .catch((err) => {
1210
1417
  this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
@@ -1218,12 +1425,14 @@ export class SmartComplyFlow {
1218
1425
  return;
1219
1426
  this.contentArea.innerHTML = "";
1220
1427
  const wrapper = document.createElement("div");
1428
+ wrapper.setAttribute("role", "region");
1429
+ wrapper.setAttribute("aria-label", "Verification result");
1221
1430
  wrapper.style.cssText = `
1222
1431
  display:flex;flex-direction:column;align-items:center;
1223
1432
  text-align:center;gap:16px;padding:20px 0;
1224
1433
  opacity:0;transform:scale(0.95);transition:all 0.4s ease;
1225
1434
  `;
1226
- // Icon β€” starts as a spinner, updates when status resolves
1435
+ // ── Icon β€” spinner until status resolves ────────────────────────
1227
1436
  const iconWrap = document.createElement("div");
1228
1437
  iconWrap.style.cssText = `
1229
1438
  width:80px;height:80px;border-radius:50%;
@@ -1231,59 +1440,68 @@ export class SmartComplyFlow {
1231
1440
  font-size:36px;
1232
1441
  background:${this.theme.cardBg};
1233
1442
  border:3px solid ${this.theme.border};
1443
+ transition:all 0.4s ease;
1234
1444
  `;
1235
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>`;
1236
1446
  wrapper.appendChild(iconWrap);
1447
+ // ── Title and description β€” updated by polling ───────────────────
1237
1448
  const title = document.createElement("h2");
1449
+ title.setAttribute("aria-live", "polite");
1238
1450
  title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
1239
- title.textContent = "Verification Submitted";
1451
+ title.textContent = "Checking your identity…";
1240
1452
  wrapper.appendChild(title);
1241
1453
  const desc = document.createElement("p");
1454
+ desc.setAttribute("aria-live", "polite");
1242
1455
  desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1243
- desc.textContent = "Processing your identity check. This takes around 30 seconds.";
1456
+ desc.textContent = "This usually takes a few seconds. Please wait.";
1244
1457
  wrapper.appendChild(desc);
1245
- // Details card
1246
- const details = document.createElement("div");
1247
- details.style.cssText = `
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 = `
1248
1462
  width:100%;padding:16px;border-radius:12px;
1249
1463
  background:${this.theme.cardBg};border:1px solid ${this.theme.border};
1250
- text-align:left;
1464
+ text-align:left;display:flex;flex-direction:column;gap:10px;
1465
+ transition:border-color 0.3s ease;
1251
1466
  `;
1252
- // Status badge β€” mutable DOM node so polling can update it
1467
+ // Status row β€” always shown
1253
1468
  const statusBadge = document.createElement("span");
1469
+ statusBadge.setAttribute("role", "status");
1470
+ statusBadge.setAttribute("aria-live", "polite");
1471
+ statusBadge.setAttribute("aria-atomic", "true");
1254
1472
  statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
1255
1473
  statusBadge.textContent = "Processing…";
1256
1474
  const statusRow = document.createElement("div");
1257
- statusRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1258
- statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;">Status</span>`;
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>`;
1259
1477
  statusRow.appendChild(statusBadge);
1260
- // Build rows via DOM (not innerHTML+=) to keep statusBadge live in the document
1261
- const entryRow = document.createElement("div");
1262
- entryRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1263
- entryRow.innerHTML = `
1264
- <span style="color:${this.theme.textMuted};font-size:12px;">Entry ID</span>
1265
- <span style="color:${this.theme.text};font-size:12px;font-weight:600;">${submitResult.id}</span>
1266
- `;
1267
- details.appendChild(entryRow);
1268
- details.appendChild(statusRow);
1269
- const submittedRow = document.createElement("div");
1270
- submittedRow.style.cssText = "display:flex;justify-content:space-between;margin-top:8px;";
1271
- submittedRow.innerHTML = `
1272
- <span style="color:${this.theme.textMuted};font-size:12px;">Submitted</span>
1273
- <span style="color:${this.theme.text};font-size:12px;">${submitResult.submitted_at ? new Date(submitResult.submitted_at).toLocaleString() : "Just now"}</span>
1274
- `;
1275
- details.appendChild(submittedRow);
1276
- wrapper.appendChild(details);
1277
- // Failure reason β€” hidden until status = failed
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 ──────────────────────
1278
1492
  const failureMsg = document.createElement("div");
1493
+ failureMsg.setAttribute("role", "alert");
1279
1494
  failureMsg.style.cssText = `
1280
1495
  display:none;width:100%;padding:12px 14px;border-radius:10px;
1281
1496
  background:${this.theme.errorBg};border:1px solid ${this.theme.error};
1282
- color:${this.theme.error};font-size:12px;text-align:left;line-height:1.5;
1497
+ color:${this.theme.error};font-size:13px;text-align:left;line-height:1.6;
1283
1498
  `;
1284
1499
  wrapper.appendChild(failureMsg);
1285
- // Done button
1500
+ // ── Action buttons ───────────────────────────────────────────────
1501
+ const btnRow = document.createElement("div");
1502
+ btnRow.style.cssText = "display:flex;flex-direction:column;gap:8px;width:100%;";
1286
1503
  const doneBtn = this.createPrimaryButton("Done");
1504
+ doneBtn.setAttribute("aria-label", "Close verification and continue");
1287
1505
  let finalStatus = submitResult.status;
1288
1506
  doneBtn.addEventListener("click", () => {
1289
1507
  if (this._pollStatusTimer) {
@@ -1299,74 +1517,107 @@ export class SmartComplyFlow {
1299
1517
  });
1300
1518
  this.close();
1301
1519
  });
1302
- wrapper.appendChild(doneBtn);
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);
1303
1534
  this.contentArea.appendChild(wrapper);
1304
1535
  requestAnimationFrame(() => {
1305
1536
  wrapper.style.opacity = "1";
1306
1537
  wrapper.style.transform = "scale(1)";
1307
1538
  });
1308
- // Start polling for the async result
1309
- this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, failureMsg, (resolved) => { finalStatus = resolved; });
1539
+ // ── Start polling ────────────────────────────────────────────────
1540
+ this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, (resolved) => { finalStatus = resolved; });
1310
1541
  }
1311
- _startStatusPolling(entryId, iconWrap, statusBadge, title, desc, failureMsg, onResolved) {
1312
- const MAX_POLLS = 25; // 25 Γ— 3 s = 75 s + 4 s initial = ~79 s max
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
1313
1544
  let polls = 0;
1314
- // Hard 30 s UI timer β€” stops spinner even if backend is still processing
1545
+ // Hard 30s UI timer β€” stop spinner and tell user to wait even if backend is slower
1315
1546
  let uiTimeoutId = setTimeout(() => {
1316
1547
  if (!this.isDestroyed)
1317
1548
  applyTimeout();
1318
1549
  }, 30000);
1319
- const clearUiTimeout = () => { if (uiTimeoutId) {
1320
- clearTimeout(uiTimeoutId);
1321
- uiTimeoutId = null;
1322
- } };
1550
+ const clearUiTimeout = () => {
1551
+ if (uiTimeoutId) {
1552
+ clearTimeout(uiTimeoutId);
1553
+ uiTimeoutId = null;
1554
+ }
1555
+ };
1323
1556
  const applyTimeout = () => {
1324
- // Stop polling for real β€” the message below tells the user it's safe
1325
- // to close and that the result will arrive via webhook/dashboard.
1326
- // Silently continuing to poll in the background (up to another ~49s)
1327
- // used to mean the UI could suddenly flip to Verified/Failed after
1328
- // already telling the user to walk away, with no warning either way.
1329
1557
  if (this._pollStatusTimer) {
1330
1558
  clearTimeout(this._pollStatusTimer);
1331
1559
  this._pollStatusTimer = null;
1332
1560
  }
1333
- iconWrap.innerHTML = "⏱";
1334
- iconWrap.style.background = this.theme.cardBg;
1561
+ iconWrap.innerHTML = "⏳";
1335
1562
  iconWrap.style.border = `3px solid ${this.theme.warning}`;
1336
- iconWrap.style.color = this.theme.warning;
1337
- iconWrap.style.fontSize = "30px";
1338
1563
  statusBadge.style.color = this.theme.textMuted;
1339
1564
  statusBadge.textContent = "Still processing…";
1340
- title.textContent = "Verification In Progress";
1341
- desc.textContent = "Your verification is being processed. Your result will be delivered via your dashboard or webhook. You can safely close this.";
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.";
1342
1567
  };
1343
- const applyPassed = () => {
1568
+ const applyPassed = (result) => {
1344
1569
  clearUiTimeout();
1345
1570
  iconWrap.innerHTML = "βœ“";
1346
- iconWrap.style.background = this.theme.successBg;
1347
- iconWrap.style.border = `3px solid ${this.theme.success}`;
1348
- iconWrap.style.color = this.theme.success;
1349
- iconWrap.style.fontSize = "36px";
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;
1350
1573
  statusBadge.style.color = this.theme.success;
1351
1574
  statusBadge.textContent = "Verified βœ“";
1352
1575
  title.textContent = "Identity Verified";
1353
- desc.textContent = "Your identity check passed successfully.";
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
+ }
1354
1603
  };
1355
1604
  const applyFailed = (reason) => {
1356
1605
  clearUiTimeout();
1357
1606
  iconWrap.innerHTML = "βœ•";
1358
- iconWrap.style.background = this.theme.errorBg;
1359
- iconWrap.style.border = `3px solid ${this.theme.error}`;
1360
- iconWrap.style.color = this.theme.error;
1361
- iconWrap.style.fontSize = "32px";
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;
1362
1609
  statusBadge.style.color = this.theme.error;
1363
- statusBadge.textContent = "Failed";
1364
- title.textContent = "Verification Failed";
1365
- desc.textContent = "Your identity check could not be completed.";
1366
- if (reason) {
1367
- failureMsg.textContent = reason;
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;
1368
1617
  failureMsg.style.display = "block";
1369
1618
  }
1619
+ // Show retry button on failure
1620
+ retryBtn.style.display = "block";
1370
1621
  };
1371
1622
  const poll = async () => {
1372
1623
  if (this.isDestroyed)
@@ -1376,34 +1627,32 @@ export class SmartComplyFlow {
1376
1627
  const result = await this.sdk.liveness.pollResult(entryId);
1377
1628
  if (result.status === "passed") {
1378
1629
  onResolved("passed");
1379
- applyPassed();
1630
+ applyPassed(result);
1380
1631
  return;
1381
1632
  }
1382
1633
  if (result.status === "failed") {
1383
- onResolved(result.status);
1384
- const reason = result.failure_reason || "Verification could not be completed. Please try again.";
1385
- applyFailed(reason);
1634
+ onResolved("failed");
1635
+ applyFailed(result.failure_reason || null);
1386
1636
  return;
1387
1637
  }
1388
- // Still pending/processing β€” schedule next poll if budget remains
1638
+ // Still processing β€” schedule next poll
1389
1639
  if (polls < MAX_POLLS && !this.isDestroyed) {
1390
1640
  this._pollStatusTimer = setTimeout(poll, 3000);
1391
1641
  }
1392
1642
  else if (!this.isDestroyed) {
1393
- applyTimeout(); // budget exhausted β€” stop spinner, show timeout state
1643
+ applyTimeout();
1394
1644
  }
1395
1645
  }
1396
1646
  catch {
1397
- // Network hiccup β€” retry with a slightly longer delay
1398
1647
  if (polls < MAX_POLLS && !this.isDestroyed) {
1399
1648
  this._pollStatusTimer = setTimeout(poll, 5000);
1400
1649
  }
1401
1650
  else if (!this.isDestroyed) {
1402
- applyTimeout(); // budget exhausted β€” stop spinner
1651
+ applyTimeout();
1403
1652
  }
1404
1653
  }
1405
1654
  };
1406
- // First poll after 4 s β€” gives Celery task time to complete
1655
+ // First poll after 4s β€” gives Celery task time to start
1407
1656
  this._pollStatusTimer = setTimeout(poll, 4000);
1408
1657
  }
1409
1658
  renderResult(_container) {
@@ -1416,23 +1665,51 @@ export class SmartComplyFlow {
1416
1665
  return;
1417
1666
  this.contentArea.innerHTML = "";
1418
1667
  const wrapper = document.createElement("div");
1668
+ wrapper.setAttribute("role", "alert");
1419
1669
  wrapper.style.cssText = `
1420
1670
  display:flex;flex-direction:column;align-items:center;
1421
1671
  text-align:center;gap:16px;padding:20px 0;
1422
1672
  `;
1673
+ // Classify the error into a user-friendly category
1423
1674
  const isCameraErr = /camera|permission|notallowed|notfound/i.test(message);
1424
1675
  const isNetworkErr = /network|timeout|connection|fetch/i.test(message);
1425
- const isAuthErr = /session|token|expired|authentication/i.test(message);
1426
- const errorIcon = isCameraErr ? "\uD83D\uDCF7" : isNetworkErr ? "\uD83D\uDCE1" : isAuthErr ? "\u23F1" : "\u2717";
1676
+ const isAuthErr = /session.*expired|token.*expired|authentication/i.test(message);
1677
+ const isDocQuality = /blurry|blur|quality|glare|dark|bright|lighting/i.test(message);
1678
+ const isExpiredDoc = /document.*expired|expired.*document/i.test(message);
1679
+ 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
+ : "βœ•";
1427
1687
  const errorTitle = isCameraErr ? "Camera Access Required"
1428
1688
  : isNetworkErr ? "Connection Problem"
1429
1689
  : isAuthErr ? "Session Expired"
1430
- : "Verification Failed";
1690
+ : isDocQuality ? "Document Photo Unclear"
1691
+ : isExpiredDoc ? "Document Expired"
1692
+ : isFaceMatch ? "Face Match Failed"
1693
+ : "Verification Unsuccessful";
1694
+ // Map to user-facing guidance β€” never show raw internal message
1695
+ const userMessage = isCameraErr
1696
+ ? "Please allow camera access in your browser settings and try again."
1697
+ : isNetworkErr
1698
+ ? "Check your internet connection and try again."
1699
+ : isAuthErr
1700
+ ? "Your session has expired. Please start again."
1701
+ : isDocQuality
1702
+ ? _sanitizeUserMessage(message) || "Please retake your document photo in good lighting with no glare."
1703
+ : isExpiredDoc
1704
+ ? "The document you submitted has expired. Please use a valid, current ID."
1705
+ : isFaceMatch
1706
+ ? "Your selfie did not match the photo on your ID. Please ensure you are using your own valid document and face the camera directly."
1707
+ : _sanitizeUserMessage(message) || "We were unable to complete your verification. Please try again.";
1431
1708
  const iconWrap = document.createElement("div");
1432
1709
  iconWrap.style.cssText = `
1433
1710
  width:72px;height:72px;border-radius:50%;
1434
1711
  display:flex;align-items:center;justify-content:center;
1435
- font-size:${errorIcon === "\u2717" ? "32px" : "28px"};
1712
+ font-size:28px;
1436
1713
  background:${this.theme.errorBg};
1437
1714
  border:3px solid ${this.theme.error};
1438
1715
  `;
@@ -1444,22 +1721,16 @@ export class SmartComplyFlow {
1444
1721
  wrapper.appendChild(title);
1445
1722
  const desc = document.createElement("p");
1446
1723
  desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1447
- desc.textContent = message;
1724
+ desc.textContent = userMessage;
1448
1725
  wrapper.appendChild(desc);
1449
1726
  const retryBtn = this.createPrimaryButton("Try Again");
1727
+ retryBtn.setAttribute("aria-label", "Try verification again");
1450
1728
  retryBtn.addEventListener("click", () => {
1451
- // If session already exists, go back to welcome without re-initializing
1452
- // (re-initializing would fail with CLIENT_ID_ALREADY_USED)
1453
- if (this.sdk.sessionToken && this.sdkConfig) {
1454
- this.showStep("welcome");
1455
- }
1456
- else {
1457
- // Only re-initialize if session was never created
1458
- this.initialize();
1459
- }
1729
+ this.initialize();
1460
1730
  });
1461
1731
  wrapper.appendChild(retryBtn);
1462
1732
  const closeBtn = this.createSecondaryButton("Close");
1733
+ closeBtn.setAttribute("aria-label", "Close verification widget");
1463
1734
  closeBtn.addEventListener("click", () => {
1464
1735
  this.options.onError?.(new Error(message));
1465
1736
  this.close();
@@ -1472,6 +1743,7 @@ export class SmartComplyFlow {
1472
1743
  // ─────────────────────────────────────────────────────────────────
1473
1744
  createHeader() {
1474
1745
  const header = document.createElement("div");
1746
+ header.setAttribute("role", "banner");
1475
1747
  header.style.cssText = `
1476
1748
  display:flex;align-items:center;justify-content:space-between;
1477
1749
  padding:11px 20px;border-bottom:1px solid ${this.theme.border};
@@ -1481,6 +1753,7 @@ export class SmartComplyFlow {
1481
1753
  leftArea.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
1482
1754
  this.headerBackBtn = document.createElement("button");
1483
1755
  this.headerBackBtn.innerHTML = "&#8592;";
1756
+ this.headerBackBtn.setAttribute("aria-label", "Go back to previous step");
1484
1757
  this.headerBackBtn.style.cssText = `
1485
1758
  width:30px;height:30px;border-radius:8px;flex-shrink:0;
1486
1759
  display:none;align-items:center;justify-content:center;
@@ -1489,6 +1762,7 @@ export class SmartComplyFlow {
1489
1762
  background:${this.theme.cardBg};color:${this.theme.text};
1490
1763
  transition:all 0.2s ease;
1491
1764
  `;
1765
+ this.headerBackBtn.style.cssText += `:focus-visible{outline:2px solid ${this.theme.primary};outline-offset:2px;}`;
1492
1766
  this.headerBackBtn.addEventListener("mouseenter", () => {
1493
1767
  if (this.headerBackBtn) {
1494
1768
  this.headerBackBtn.style.background = this.theme.shimmer;
@@ -1501,6 +1775,7 @@ export class SmartComplyFlow {
1501
1775
  });
1502
1776
  leftArea.appendChild(this.headerBackBtn);
1503
1777
  this.brandLabel = document.createElement("div");
1778
+ this.brandLabel.setAttribute("aria-label", "Verification provider");
1504
1779
  this.brandLabel.style.cssText = `
1505
1780
  font-size:15px;font-weight:700;color:${this.theme.text};
1506
1781
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
@@ -1510,6 +1785,8 @@ export class SmartComplyFlow {
1510
1785
  header.appendChild(leftArea);
1511
1786
  // Step indicator
1512
1787
  this.stepLabel = document.createElement("div");
1788
+ this.stepLabel.setAttribute("aria-live", "polite");
1789
+ this.stepLabel.setAttribute("aria-atomic", "true");
1513
1790
  this.stepLabel.style.cssText = `
1514
1791
  font-size:12px;color:${this.theme.textMuted};
1515
1792
  font-variant-numeric:tabular-nums;flex-shrink:0;
@@ -1518,6 +1795,7 @@ export class SmartComplyFlow {
1518
1795
  // Close button
1519
1796
  const closeBtn = document.createElement("button");
1520
1797
  closeBtn.innerHTML = "βœ•";
1798
+ closeBtn.setAttribute("aria-label", "Close identity verification");
1521
1799
  closeBtn.style.cssText = `
1522
1800
  width:32px;height:32px;border-radius:8px;flex-shrink:0;
1523
1801
  display:flex;align-items:center;justify-content:center;
@@ -1564,6 +1842,7 @@ export class SmartComplyFlow {
1564
1842
  }
1565
1843
  createOptionCard(title, subtitle, onClick) {
1566
1844
  const card = document.createElement("button");
1845
+ card.setAttribute("aria-label", subtitle ? `${title}: ${subtitle}` : title);
1567
1846
  card.style.cssText = `
1568
1847
  width:100%;padding:13px 16px;border-radius:12px;
1569
1848
  background:${this.theme.cardBg};
@@ -1655,7 +1934,7 @@ export class SmartComplyFlow {
1655
1934
  const allFields = Object.values(channels)
1656
1935
  .flat()
1657
1936
  .flatMap((ch) => ch.fields || []);
1658
- return allFields.some((f) => f.type === "upload");
1937
+ return allFields.some((f) => isUploadFieldType(f.type));
1659
1938
  }
1660
1939
  updateProgress() {
1661
1940
  const isDocFlow = this.isDocumentFlow();
@@ -1742,6 +2021,12 @@ export class SmartComplyFlow {
1742
2021
  #sc-flow-modal * {
1743
2022
  box-sizing: border-box;
1744
2023
  }
2024
+ #sc-flow-modal button:focus-visible,
2025
+ #sc-flow-modal select:focus-visible,
2026
+ #sc-flow-modal input:focus-visible {
2027
+ outline: 2px solid #3b82f6;
2028
+ outline-offset: 2px;
2029
+ }
1745
2030
  #sc-flow-modal::-webkit-scrollbar {
1746
2031
  width: 4px;
1747
2032
  }