smartcomply-web-sdk 1.0.43 → 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.
- package/dist/esm/flow/SmartComplyFlow.d.ts +2 -1
- package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/esm/flow/SmartComplyFlow.js +532 -254
- package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
- package/dist/esm/modules/liveness/liveness.d.ts +9 -5
- package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/esm/modules/liveness/liveness.js +36 -10
- package/dist/esm/modules/liveness/liveness.js.map +1 -1
- package/dist/flow/SmartComplyFlow.d.ts +2 -1
- package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/flow/SmartComplyFlow.js +532 -254
- package/dist/flow/SmartComplyFlow.js.map +1 -1
- package/dist/modules/liveness/liveness.d.ts +9 -5
- package/dist/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/modules/liveness/liveness.js +36 -10
- package/dist/modules/liveness/liveness.js.map +1 -1
- package/dist/smartcomply.browser.js +444 -221
- package/dist/smartcomply.browser.js.map +3 -3
- package/package.json +1 -1
|
@@ -126,6 +126,55 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
|
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
128
|
// ─────────────────────────────────────────────────────────────────────
|
|
129
|
+
// ── Widget-side message sanitizer ─────────────────────────────────────────────
|
|
130
|
+
// Strips anything technical before showing text to the end user.
|
|
131
|
+
// Keeps messages that are already written plainly in natural language.
|
|
132
|
+
function _sanitizeUserMessage(raw) {
|
|
133
|
+
if (!raw)
|
|
134
|
+
return null;
|
|
135
|
+
const lower = raw.toLowerCase();
|
|
136
|
+
// Block anything that looks internal / technical
|
|
137
|
+
const blocklist = [
|
|
138
|
+
"smartcomply", "face comparison service", "session id", "session_id",
|
|
139
|
+
"provider", "ocr", "textract", "rekognition", "deepface",
|
|
140
|
+
"ivs", "celery", "exception", "traceback", "str(e)",
|
|
141
|
+
"error:", "failed:", "django", "python", "http ",
|
|
142
|
+
"comparison mode", "liveness mode", "callback",
|
|
143
|
+
"face_comparison", "face_match", "confidence_percentage",
|
|
144
|
+
"threshold_percentage", "polling_fallback", "resolved_by",
|
|
145
|
+
];
|
|
146
|
+
for (const blocked of blocklist) {
|
|
147
|
+
if (lower.includes(blocked))
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// Block snake_case (internal field names accidentally leaking through)
|
|
151
|
+
if (/[a-z]+_[a-z]+/.test(raw) && !/\s/.test(raw.trim()))
|
|
152
|
+
return null;
|
|
153
|
+
// Block anything starting with a lowercase letter (likely a code/key)
|
|
154
|
+
if (raw.trim()[0] === raw.trim()[0].toLowerCase() && /^[a-z]/.test(raw.trim()))
|
|
155
|
+
return null;
|
|
156
|
+
return raw.trim();
|
|
157
|
+
}
|
|
158
|
+
// ── Document type code → human label ─────────────────────────────────────────
|
|
159
|
+
function _formatDocType(code) {
|
|
160
|
+
const map = {
|
|
161
|
+
passport: "Passport",
|
|
162
|
+
national_id: "National ID",
|
|
163
|
+
nin_slip: "NIN Slip",
|
|
164
|
+
nin_with_face: "National Identity Number",
|
|
165
|
+
drivers_license: "Driver's Licence",
|
|
166
|
+
voters_card: "Voter's Card",
|
|
167
|
+
kenya_id: "Kenya National ID",
|
|
168
|
+
ghana_id: "Ghana Card",
|
|
169
|
+
};
|
|
170
|
+
if (!code)
|
|
171
|
+
return "";
|
|
172
|
+
const key = code.toLowerCase().replace(/[\s-]/g, "_");
|
|
173
|
+
if (map[key])
|
|
174
|
+
return map[key];
|
|
175
|
+
// Fallback: title-case the code
|
|
176
|
+
return code.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
177
|
+
}
|
|
129
178
|
// SmartComplyFlow
|
|
130
179
|
// ─────────────────────────────────────────────────────────────────────
|
|
131
180
|
// Add CSS animation keyframes for spin animations
|
|
@@ -174,6 +223,7 @@ class SmartComplyFlow {
|
|
|
174
223
|
this._pollStatusTimer = null;
|
|
175
224
|
// Components
|
|
176
225
|
this.docCapture = null;
|
|
226
|
+
this._livenessSubmitResult = null;
|
|
177
227
|
this.options = options;
|
|
178
228
|
this.sdk = new Smartcomply_1.SmartComply({
|
|
179
229
|
apiKey: options.apiKey,
|
|
@@ -249,6 +299,9 @@ class SmartComplyFlow {
|
|
|
249
299
|
// Modal
|
|
250
300
|
this.modal = document.createElement("div");
|
|
251
301
|
this.modal.id = "sc-flow-modal";
|
|
302
|
+
this.modal.setAttribute("role", "dialog");
|
|
303
|
+
this.modal.setAttribute("aria-modal", "true");
|
|
304
|
+
this.modal.setAttribute("aria-label", "Identity verification");
|
|
252
305
|
this.modal.style.cssText = `
|
|
253
306
|
width:100%;max-width:580px;max-height:95vh;
|
|
254
307
|
background:${this.theme.bg};
|
|
@@ -274,6 +327,9 @@ class SmartComplyFlow {
|
|
|
274
327
|
// Content area (scrollable)
|
|
275
328
|
this.contentArea = document.createElement("div");
|
|
276
329
|
this.contentArea.id = "sc-flow-content";
|
|
330
|
+
this.contentArea.setAttribute("role", "main");
|
|
331
|
+
this.contentArea.setAttribute("aria-live", "polite");
|
|
332
|
+
this.contentArea.setAttribute("aria-atomic", "false");
|
|
277
333
|
this.contentArea.style.cssText = `
|
|
278
334
|
flex:1;overflow-y:auto;padding:16px 20px;
|
|
279
335
|
-webkit-overflow-scrolling:touch;
|
|
@@ -375,7 +431,7 @@ class SmartComplyFlow {
|
|
|
375
431
|
this.renderDocumentCapture(wrapper);
|
|
376
432
|
break;
|
|
377
433
|
case "processing":
|
|
378
|
-
this.renderProcessing(wrapper);
|
|
434
|
+
this.renderProcessing(wrapper, this.livenessEntryId || undefined);
|
|
379
435
|
break;
|
|
380
436
|
case "liveness":
|
|
381
437
|
this.renderLiveness(wrapper);
|
|
@@ -664,6 +720,41 @@ class SmartComplyFlow {
|
|
|
664
720
|
container.appendChild(autoBtn);
|
|
665
721
|
return;
|
|
666
722
|
}
|
|
723
|
+
const _FIELD_RULES = [
|
|
724
|
+
{ match: "BVN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "BVN must be exactly 11 digits" } },
|
|
725
|
+
{ match: "NIN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
|
|
726
|
+
{ match: "NATIONAL IDENTITY", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
|
|
727
|
+
{ match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6–20 alphanumeric characters" } },
|
|
728
|
+
{ match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3–30 alphanumeric characters" } },
|
|
729
|
+
{ match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6–30 alphanumeric characters" } },
|
|
730
|
+
{ match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8–20 alphanumeric characters" } },
|
|
731
|
+
{ match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7–15 alphanumeric characters" } },
|
|
732
|
+
{ match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8–20 characters" } },
|
|
733
|
+
{ match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10–12 characters" } },
|
|
734
|
+
];
|
|
735
|
+
function getFieldRule(label) {
|
|
736
|
+
const upper = label.toUpperCase();
|
|
737
|
+
for (const entry of _FIELD_RULES) {
|
|
738
|
+
if (upper.includes(entry.match))
|
|
739
|
+
return entry.rule;
|
|
740
|
+
}
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
function validateFieldValue(label, value) {
|
|
744
|
+
const rule = getFieldRule(label);
|
|
745
|
+
if (!rule)
|
|
746
|
+
return null; // no rule = no validation
|
|
747
|
+
const v = value.trim();
|
|
748
|
+
if (rule.numeric && !/^\d+$/.test(v))
|
|
749
|
+
return `${label} must contain digits only`;
|
|
750
|
+
if (rule.alphanumeric && !/^[A-Za-z0-9]+$/.test(v))
|
|
751
|
+
return `${label} must be alphanumeric (letters and digits only)`;
|
|
752
|
+
if (rule.minLen && v.length < rule.minLen)
|
|
753
|
+
return rule.hint;
|
|
754
|
+
if (rule.maxLen && v.length > rule.maxLen)
|
|
755
|
+
return rule.hint;
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
667
758
|
// Track input elements for validation
|
|
668
759
|
const inputEls = [];
|
|
669
760
|
for (const field of dataFields) {
|
|
@@ -676,7 +767,10 @@ class SmartComplyFlow {
|
|
|
676
767
|
const fieldKey = field.label;
|
|
677
768
|
const fieldWrap = document.createElement("div");
|
|
678
769
|
fieldWrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
|
|
770
|
+
const labelId = `sc-field-label-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
|
|
679
771
|
const label = document.createElement("label");
|
|
772
|
+
label.id = labelId;
|
|
773
|
+
label.htmlFor = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
|
|
680
774
|
label.style.cssText = `color:${this.theme.textMuted};font-size:10px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;`;
|
|
681
775
|
label.textContent = field.label;
|
|
682
776
|
fieldWrap.appendChild(label);
|
|
@@ -750,10 +844,18 @@ class SmartComplyFlow {
|
|
|
750
844
|
}
|
|
751
845
|
else {
|
|
752
846
|
// Text input field
|
|
847
|
+
const inputId = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
|
|
848
|
+
const hintId = `sc-field-hint-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
|
|
849
|
+
const rule = getFieldRule(field.label);
|
|
753
850
|
const input = document.createElement("input");
|
|
754
851
|
input.type = "text";
|
|
852
|
+
input.id = inputId;
|
|
853
|
+
input.setAttribute("aria-labelledby", labelId);
|
|
854
|
+
input.setAttribute("aria-describedby", hintId);
|
|
755
855
|
input.placeholder = `Enter ${field.label}`;
|
|
756
|
-
input.maxLength = 50;
|
|
856
|
+
input.maxLength = rule?.maxLen ? rule.maxLen + 2 : 50;
|
|
857
|
+
if (rule?.numeric)
|
|
858
|
+
input.inputMode = "numeric";
|
|
757
859
|
input.style.cssText = `
|
|
758
860
|
width:100%;padding:14px 16px;border-radius:12px;
|
|
759
861
|
font-size:16px;font-weight:500;
|
|
@@ -764,17 +866,50 @@ class SmartComplyFlow {
|
|
|
764
866
|
box-sizing:border-box;
|
|
765
867
|
letter-spacing:0.5px;
|
|
766
868
|
`;
|
|
869
|
+
// Inline hint/error element
|
|
870
|
+
const hintEl = document.createElement("div");
|
|
871
|
+
hintEl.id = hintId;
|
|
872
|
+
hintEl.setAttribute("aria-live", "polite");
|
|
873
|
+
hintEl.style.cssText = `font-size:11px;min-height:14px;margin-top:-2px;`;
|
|
874
|
+
if (rule) {
|
|
875
|
+
hintEl.style.color = this.theme.textMuted;
|
|
876
|
+
hintEl.textContent = rule.hint;
|
|
877
|
+
}
|
|
767
878
|
input.addEventListener("focus", () => {
|
|
768
879
|
input.style.borderColor = this.theme.primary;
|
|
769
880
|
});
|
|
770
881
|
input.addEventListener("blur", () => {
|
|
771
|
-
|
|
882
|
+
const v = input.value.trim();
|
|
883
|
+
if (v) {
|
|
884
|
+
const err = validateFieldValue(field.label, v);
|
|
885
|
+
if (err) {
|
|
886
|
+
input.style.borderColor = this.theme.error;
|
|
887
|
+
hintEl.style.color = this.theme.error;
|
|
888
|
+
hintEl.textContent = err;
|
|
889
|
+
input.setAttribute("aria-invalid", "true");
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
input.style.borderColor = this.theme.success || this.theme.primary;
|
|
893
|
+
hintEl.style.color = this.theme.textMuted;
|
|
894
|
+
hintEl.textContent = rule?.hint || "";
|
|
895
|
+
input.setAttribute("aria-invalid", "false");
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
input.style.borderColor = this.theme.border;
|
|
900
|
+
}
|
|
772
901
|
});
|
|
773
902
|
input.addEventListener("input", () => {
|
|
903
|
+
// Reset error styling while typing
|
|
904
|
+
input.style.borderColor = this.theme.primary;
|
|
905
|
+
hintEl.style.color = this.theme.textMuted;
|
|
906
|
+
hintEl.textContent = rule?.hint || "";
|
|
907
|
+
input.removeAttribute("aria-invalid");
|
|
774
908
|
this.collectedFields[fieldKey] = input.value.trim();
|
|
775
909
|
updateSubmitState();
|
|
776
910
|
});
|
|
777
911
|
fieldWrap.appendChild(input);
|
|
912
|
+
fieldWrap.appendChild(hintEl);
|
|
778
913
|
inputEls.push({ field, el: input });
|
|
779
914
|
}
|
|
780
915
|
container.appendChild(fieldWrap);
|
|
@@ -792,9 +927,16 @@ class SmartComplyFlow {
|
|
|
792
927
|
submitBtn.disabled = true;
|
|
793
928
|
submitBtn.style.opacity = "0.5";
|
|
794
929
|
const updateSubmitState = () => {
|
|
930
|
+
// All inputs must have a value AND pass format validation
|
|
795
931
|
const allFilled = inputEls.every(({ el }) => el.value.trim().length > 0);
|
|
796
|
-
|
|
797
|
-
|
|
932
|
+
const allValid = inputEls.every(({ field, el }) => {
|
|
933
|
+
if (el.tagName === "SELECT")
|
|
934
|
+
return el.value.trim().length > 0;
|
|
935
|
+
return !validateFieldValue(field.label, el.value.trim());
|
|
936
|
+
});
|
|
937
|
+
const ok = allFilled && allValid;
|
|
938
|
+
submitBtn.disabled = !ok;
|
|
939
|
+
submitBtn.style.opacity = ok ? "1" : "0.5";
|
|
798
940
|
};
|
|
799
941
|
submitBtn.addEventListener("click", async () => {
|
|
800
942
|
submitBtn.disabled = true;
|
|
@@ -814,7 +956,7 @@ class SmartComplyFlow {
|
|
|
814
956
|
this.showStep("liveness");
|
|
815
957
|
}
|
|
816
958
|
else {
|
|
817
|
-
errorEl.textContent = this.verificationResult.message || "
|
|
959
|
+
errorEl.textContent = this.verificationResult.message || "We could not verify your details. Please check and try again.";
|
|
818
960
|
errorEl.style.display = "block";
|
|
819
961
|
submitBtn.textContent = "Verify & Continue";
|
|
820
962
|
submitBtn.disabled = false;
|
|
@@ -822,14 +964,26 @@ class SmartComplyFlow {
|
|
|
822
964
|
}
|
|
823
965
|
}
|
|
824
966
|
catch (err) {
|
|
825
|
-
//
|
|
967
|
+
// Show a single plain-language error — never raw field:message format
|
|
826
968
|
const fieldErrors = err.data?.errors;
|
|
827
969
|
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
|
|
828
|
-
|
|
829
|
-
|
|
970
|
+
// Pick the first meaningful error message — strip field prefix
|
|
971
|
+
const firstMsg = fieldErrors[0]?.message || fieldErrors[0]?.detail || "";
|
|
972
|
+
errorEl.textContent = _sanitizeUserMessage(firstMsg)
|
|
973
|
+
|| "Please check your details and try again.";
|
|
974
|
+
}
|
|
975
|
+
else if (err.code === "VERIFICATION_FAILED") {
|
|
976
|
+
errorEl.textContent = "We could not verify those details. Please check your ID number and try again.";
|
|
977
|
+
}
|
|
978
|
+
else if (err.code === "CHANNEL_NOT_CONFIGURED") {
|
|
979
|
+
errorEl.textContent = "This ID type is not currently available. Please choose a different option.";
|
|
980
|
+
}
|
|
981
|
+
else if (err.code === "INSUFFICIENT_BALANCE") {
|
|
982
|
+
errorEl.textContent = "Verification is temporarily unavailable. Please contact support.";
|
|
830
983
|
}
|
|
831
984
|
else {
|
|
832
|
-
errorEl.textContent = err.message
|
|
985
|
+
errorEl.textContent = _sanitizeUserMessage(err.message)
|
|
986
|
+
|| "Something went wrong. Please try again.";
|
|
833
987
|
}
|
|
834
988
|
errorEl.style.display = "block";
|
|
835
989
|
submitBtn.textContent = "Verify & Continue";
|
|
@@ -885,130 +1039,140 @@ class SmartComplyFlow {
|
|
|
885
1039
|
this.showErrorStep(err.message || "Document capture failed. Please try again.");
|
|
886
1040
|
});
|
|
887
1041
|
}
|
|
888
|
-
// ── Processing
|
|
889
|
-
renderProcessing(container) {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
position:absolute;inset:0;border-radius:50%;
|
|
898
|
-
border:3px solid ${this.theme.border};
|
|
899
|
-
border-top-color:${this.theme.primary};
|
|
900
|
-
animation:sc-spin 0.9s linear infinite;
|
|
901
|
-
`;
|
|
902
|
-
animationWrap.appendChild(spinner);
|
|
1042
|
+
// ── Processing (real polling-driven, document flow only) ────────────
|
|
1043
|
+
renderProcessing(container, entryId) {
|
|
1044
|
+
container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px;";
|
|
1045
|
+
// Spinner
|
|
1046
|
+
const spinnerWrap = document.createElement("div");
|
|
1047
|
+
spinnerWrap.style.cssText = "position:relative;width:56px;height:56px;flex-shrink:0;";
|
|
1048
|
+
const ring = document.createElement("div");
|
|
1049
|
+
ring.id = "sc-proc-ring";
|
|
1050
|
+
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;`;
|
|
903
1051
|
const centerIcon = document.createElement("div");
|
|
904
|
-
centerIcon.style.cssText =
|
|
905
|
-
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
|
906
|
-
font-size:20px;
|
|
907
|
-
`;
|
|
1052
|
+
centerIcon.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;";
|
|
908
1053
|
centerIcon.textContent = "🔍";
|
|
909
|
-
|
|
910
|
-
|
|
1054
|
+
spinnerWrap.appendChild(ring);
|
|
1055
|
+
spinnerWrap.appendChild(centerIcon);
|
|
1056
|
+
container.appendChild(spinnerWrap);
|
|
911
1057
|
const title = document.createElement("div");
|
|
912
1058
|
title.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:700;text-align:center;`;
|
|
913
|
-
title.textContent = "
|
|
1059
|
+
title.textContent = "Verifying your document";
|
|
914
1060
|
container.appendChild(title);
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
const
|
|
925
|
-
{ id:
|
|
926
|
-
{ id:
|
|
927
|
-
{ id:
|
|
928
|
-
{ id:
|
|
1061
|
+
// Live status line — updated by polling
|
|
1062
|
+
const statusLine = document.createElement("div");
|
|
1063
|
+
statusLine.id = "sc-proc-status";
|
|
1064
|
+
statusLine.setAttribute("aria-live", "polite");
|
|
1065
|
+
statusLine.setAttribute("aria-atomic", "true");
|
|
1066
|
+
statusLine.style.cssText = `color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:260px;line-height:1.5;`;
|
|
1067
|
+
statusLine.textContent = "Scanning document…";
|
|
1068
|
+
container.appendChild(statusLine);
|
|
1069
|
+
// Step checklist — reflects real pipeline stages
|
|
1070
|
+
const stages = [
|
|
1071
|
+
{ id: "ocr", label: "Reading document", icon: "📄" },
|
|
1072
|
+
{ id: "expiry", label: "Checking expiry", icon: "📅" },
|
|
1073
|
+
{ id: "face", label: "Face match", icon: "🤳" },
|
|
1074
|
+
{ id: "result", label: "Finalising result", icon: "✓" },
|
|
929
1075
|
];
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
width:26px;height:26px;border-radius:7px;flex-shrink:0;
|
|
941
|
-
display:flex;align-items:center;justify-content:center;
|
|
942
|
-
font-size:12px;background:${this.theme.shimmer};
|
|
1076
|
+
const grid = document.createElement("div");
|
|
1077
|
+
grid.style.cssText = "width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;";
|
|
1078
|
+
stages.forEach((s) => {
|
|
1079
|
+
const el = document.createElement("div");
|
|
1080
|
+
el.id = `sc-stage-${s.id}`;
|
|
1081
|
+
el.style.cssText = `display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;background:${this.theme.cardBg};border:1px solid ${this.theme.border};transition:all 0.3s ease;`;
|
|
1082
|
+
el.innerHTML = `
|
|
1083
|
+
<div style="width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;background:${this.theme.shimmer};">${s.icon}</div>
|
|
1084
|
+
<div style="flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;">${s.label}</div>
|
|
1085
|
+
<div id="sc-stage-status-${s.id}" style="width:14px;height:14px;border-radius:50%;border:2px solid ${this.theme.border};flex-shrink:0;"></div>
|
|
943
1086
|
`;
|
|
944
|
-
|
|
945
|
-
iconEl.id = `step-icon-${step.id}`;
|
|
946
|
-
const textEl = document.createElement("div");
|
|
947
|
-
textEl.style.cssText = `flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;`;
|
|
948
|
-
textEl.textContent = step.text;
|
|
949
|
-
textEl.id = `step-text-${step.id}`;
|
|
950
|
-
const statusEl = document.createElement("div");
|
|
951
|
-
statusEl.style.cssText = `width:16px;height:16px;border-radius:50%;border:2px solid ${this.theme.border};position:relative;flex-shrink:0;`;
|
|
952
|
-
statusEl.id = `step-status-${step.id}`;
|
|
953
|
-
stepEl.appendChild(iconEl);
|
|
954
|
-
stepEl.appendChild(textEl);
|
|
955
|
-
stepEl.appendChild(statusEl);
|
|
956
|
-
stepsContainer.appendChild(stepEl);
|
|
1087
|
+
grid.appendChild(el);
|
|
957
1088
|
});
|
|
958
|
-
container.appendChild(
|
|
959
|
-
|
|
1089
|
+
container.appendChild(grid);
|
|
1090
|
+
// Start polling immediately — update stages as result comes in
|
|
1091
|
+
if (entryId) {
|
|
1092
|
+
this._pollProcessingStages(entryId, statusLine, grid, stages);
|
|
1093
|
+
}
|
|
960
1094
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1095
|
+
_pollProcessingStages(entryId, statusLine, _grid, stages) {
|
|
1096
|
+
// Animate stages sequentially on a timer — OCR takes ~5–15s,
|
|
1097
|
+
// face match another ~5–15s. We can't observe sub-steps from the
|
|
1098
|
+
// public sdk-result endpoint, so we advance stages on elapsed time
|
|
1099
|
+
// AND on the final status transition.
|
|
1100
|
+
const stageTimings = [0, 4000, 10000, 18000]; // ms after poll start
|
|
1101
|
+
const startTime = Date.now();
|
|
1102
|
+
const setStageActive = (stageId) => {
|
|
1103
|
+
const el = document.getElementById(`sc-stage-${stageId}`);
|
|
1104
|
+
const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
|
|
1105
|
+
if (!el || !statusEl)
|
|
1106
|
+
return;
|
|
1107
|
+
el.style.borderColor = this.theme.primary;
|
|
1108
|
+
el.style.background = `${this.theme.primary}10`;
|
|
1109
|
+
statusEl.innerHTML = `<div style="position:absolute;inset:1px;border-radius:50%;border:2px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>`;
|
|
1110
|
+
statusEl.style.position = "relative";
|
|
1111
|
+
};
|
|
1112
|
+
const setStageComplete = (stageId) => {
|
|
1113
|
+
const el = document.getElementById(`sc-stage-${stageId}`);
|
|
1114
|
+
const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
|
|
1115
|
+
if (!el || !statusEl)
|
|
1116
|
+
return;
|
|
1117
|
+
el.style.borderColor = this.theme.success || "#22c55e";
|
|
1118
|
+
el.style.background = `${this.theme.success || "#22c55e"}10`;
|
|
1119
|
+
statusEl.innerHTML = `<div style="position:absolute;inset:2px;border-radius:50%;background:${this.theme.success || "#22c55e"};display:flex;align-items:center;justify-content:center;color:#fff;font-size:8px;font-weight:bold;">✓</div>`;
|
|
1120
|
+
statusEl.style.position = "relative";
|
|
1121
|
+
};
|
|
1122
|
+
// Advance stage indicators on timer
|
|
1123
|
+
stages.forEach((s, i) => {
|
|
989
1124
|
setTimeout(() => {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1125
|
+
if (this.isDestroyed)
|
|
1126
|
+
return;
|
|
1127
|
+
setStageActive(s.id);
|
|
1128
|
+
if (i > 0)
|
|
1129
|
+
setStageComplete(stages[i - 1].id);
|
|
1130
|
+
statusLine.textContent = s.id === "ocr"
|
|
1131
|
+
? "Scanning document…"
|
|
1132
|
+
: s.id === "expiry"
|
|
1133
|
+
? "Checking expiry date…"
|
|
1134
|
+
: s.id === "face"
|
|
1135
|
+
? "Running face match…"
|
|
1136
|
+
: "Finalising result…";
|
|
1137
|
+
}, stageTimings[i]);
|
|
1138
|
+
});
|
|
1139
|
+
// Poll for real result
|
|
1140
|
+
const MAX_POLLS = 30; // 30 × 3s = 90s max
|
|
1141
|
+
let polls = 0;
|
|
1142
|
+
const poll = async () => {
|
|
1143
|
+
if (this.isDestroyed)
|
|
1144
|
+
return;
|
|
1145
|
+
polls++;
|
|
1146
|
+
try {
|
|
1147
|
+
const result = await this.sdk.liveness.pollResult(entryId);
|
|
1148
|
+
if (result.status === "passed" || result.status === "failed") {
|
|
1149
|
+
// Mark all remaining stages complete
|
|
1150
|
+
stages.forEach((s) => setStageComplete(s.id));
|
|
1151
|
+
statusLine.textContent = result.status === "passed"
|
|
1152
|
+
? "Verification complete ✓"
|
|
1153
|
+
: "Verification finished";
|
|
1154
|
+
// Short delay so user sees the completed states, then show result
|
|
1155
|
+
setTimeout(() => {
|
|
1156
|
+
if (!this.isDestroyed) {
|
|
1157
|
+
this._livenessSubmitResult = result;
|
|
1158
|
+
this.showResult(result);
|
|
1159
|
+
}
|
|
1160
|
+
}, 800);
|
|
1161
|
+
return;
|
|
1007
1162
|
}
|
|
1008
|
-
}
|
|
1163
|
+
}
|
|
1164
|
+
catch { /* network hiccup — keep polling */ }
|
|
1165
|
+
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1166
|
+
this._pollStatusTimer = setTimeout(poll, 3000);
|
|
1167
|
+
}
|
|
1168
|
+
else if (!this.isDestroyed) {
|
|
1169
|
+
// Timeout — transition to result screen with current status
|
|
1170
|
+
stages.forEach((s) => setStageComplete(s.id));
|
|
1171
|
+
statusLine.textContent = "Still processing — check back soon";
|
|
1172
|
+
}
|
|
1009
1173
|
};
|
|
1010
|
-
//
|
|
1011
|
-
|
|
1174
|
+
// First poll after 5s — give Celery task time to start
|
|
1175
|
+
this._pollStatusTimer = setTimeout(poll, 5000);
|
|
1012
1176
|
}
|
|
1013
1177
|
/**
|
|
1014
1178
|
* Process document capture result with front/back support
|
|
@@ -1050,8 +1214,15 @@ class SmartComplyFlow {
|
|
|
1050
1214
|
<div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
|
|
1051
1215
|
<div style="color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:200px;line-height:1.5;">Running OCR and verifying your identity</div>
|
|
1052
1216
|
</div>`;
|
|
1053
|
-
// Create liveness entry with document
|
|
1054
|
-
|
|
1217
|
+
// Create liveness entry with document.
|
|
1218
|
+
// Use the first collected field value as identifier when available
|
|
1219
|
+
// (document flow has no data-verification step, so collectedFields is
|
|
1220
|
+
// empty — fall back to the selected ID type code so the webhook
|
|
1221
|
+
// subject.identifier is at least human-meaningful, not a raw timestamp).
|
|
1222
|
+
const identifier = Object.values(this.collectedFields)[0]
|
|
1223
|
+
|| this.idNumber
|
|
1224
|
+
|| this.selectedIdType
|
|
1225
|
+
|| `DOC-${Date.now()}`;
|
|
1055
1226
|
const COUNTRY_CODES = {
|
|
1056
1227
|
nigeria: "NG", ghana: "GH", kenya: "KE",
|
|
1057
1228
|
south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
|
|
@@ -1130,52 +1301,62 @@ class SmartComplyFlow {
|
|
|
1130
1301
|
await attemptProcessing();
|
|
1131
1302
|
}
|
|
1132
1303
|
handleDocumentProcessingError(error, captureContainer) {
|
|
1133
|
-
|
|
1304
|
+
// Map technical errors to user-friendly messages — never pass raw error strings
|
|
1305
|
+
let userMessage = "We could not verify your document. Please try again.";
|
|
1134
1306
|
let canRetry = true;
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1307
|
+
const raw = (error?.message || "").toLowerCase();
|
|
1308
|
+
if (raw.includes("expired")) {
|
|
1309
|
+
userMessage = "The document you submitted has expired. Please use a valid, current ID.";
|
|
1310
|
+
canRetry = false;
|
|
1311
|
+
}
|
|
1312
|
+
else if (raw.includes("quality too low") || raw.includes("blurry") || raw.includes("blur")) {
|
|
1313
|
+
userMessage = "The document photo isn't clear enough. Please retake it in good lighting with no glare.";
|
|
1314
|
+
}
|
|
1315
|
+
else if (raw.includes("not detected") || raw.includes("document_not_detected")) {
|
|
1316
|
+
userMessage = "We could not detect a document in the photo. Please ensure the full ID is visible and try again.";
|
|
1317
|
+
}
|
|
1318
|
+
else if (raw.includes("network") || raw.includes("fetch") || raw.includes("connection")) {
|
|
1319
|
+
userMessage = "A connection error occurred. Please check your internet and try again.";
|
|
1320
|
+
}
|
|
1321
|
+
else if (raw.includes("too large") || raw.includes("413")) {
|
|
1322
|
+
userMessage = "The document image is too large. Please retake the photo.";
|
|
1323
|
+
}
|
|
1324
|
+
else if (raw.includes("format") || raw.includes("415")) {
|
|
1325
|
+
userMessage = "Unsupported image format. Please use JPEG or PNG.";
|
|
1326
|
+
}
|
|
1327
|
+
else if (raw.includes("type mismatch") || raw.includes("document_type_mismatch")) {
|
|
1328
|
+
userMessage = "The document does not match the selected ID type. Please choose the correct ID type.";
|
|
1152
1329
|
}
|
|
1153
1330
|
if (captureContainer) {
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1331
|
+
// Build with DOM — never interpolate user-derived content into innerHTML
|
|
1332
|
+
const wrap = document.createElement("div");
|
|
1333
|
+
wrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;";
|
|
1334
|
+
wrap.setAttribute("role", "alert");
|
|
1335
|
+
const icon = document.createElement("div");
|
|
1336
|
+
icon.style.cssText = `width:56px;height:56px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:26px;`;
|
|
1337
|
+
icon.textContent = "🔍";
|
|
1338
|
+
wrap.appendChild(icon);
|
|
1339
|
+
const titleEl = document.createElement("div");
|
|
1340
|
+
titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:700;`;
|
|
1341
|
+
titleEl.textContent = "Document Could Not Be Verified";
|
|
1342
|
+
wrap.appendChild(titleEl);
|
|
1343
|
+
const msgEl = document.createElement("div");
|
|
1344
|
+
msgEl.style.cssText = `color:${this.theme.textSecondary};font-size:13px;max-width:260px;line-height:1.6;`;
|
|
1345
|
+
msgEl.textContent = userMessage;
|
|
1346
|
+
wrap.appendChild(msgEl);
|
|
1168
1347
|
if (canRetry) {
|
|
1169
|
-
const retryBtn =
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1348
|
+
const retryBtn = document.createElement("button");
|
|
1349
|
+
retryBtn.textContent = "Try Again";
|
|
1350
|
+
retryBtn.setAttribute("aria-label", "Retake document photo");
|
|
1351
|
+
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;`;
|
|
1352
|
+
retryBtn.addEventListener("click", () => this.showStep("document_capture"));
|
|
1353
|
+
wrap.appendChild(retryBtn);
|
|
1175
1354
|
}
|
|
1355
|
+
captureContainer.innerHTML = "";
|
|
1356
|
+
captureContainer.appendChild(wrap);
|
|
1176
1357
|
}
|
|
1177
1358
|
else {
|
|
1178
|
-
this.showErrorStep(
|
|
1359
|
+
this.showErrorStep(userMessage);
|
|
1179
1360
|
}
|
|
1180
1361
|
}
|
|
1181
1362
|
// ── Liveness ────────────────────────────────────────────────────
|
|
@@ -1191,7 +1372,10 @@ class SmartComplyFlow {
|
|
|
1191
1372
|
const livenessContainer = document.createElement("div");
|
|
1192
1373
|
livenessContainer.style.cssText = "margin:8px 0;width:100%;";
|
|
1193
1374
|
container.appendChild(livenessContainer);
|
|
1194
|
-
const identifier = this.idNumber
|
|
1375
|
+
const identifier = this.idNumber
|
|
1376
|
+
|| Object.values(this.collectedFields)[0]
|
|
1377
|
+
|| this.selectedIdType
|
|
1378
|
+
|| `DOC-${Date.now()}`;
|
|
1195
1379
|
const COUNTRY_CODES = {
|
|
1196
1380
|
nigeria: "NG",
|
|
1197
1381
|
ghana: "GH",
|
|
@@ -1212,9 +1396,25 @@ class SmartComplyFlow {
|
|
|
1212
1396
|
document: this.documentBlob || undefined,
|
|
1213
1397
|
document_back: this.documentBackBlob || undefined,
|
|
1214
1398
|
identity_check: this.identityCheckId || undefined,
|
|
1399
|
+
// Tell the module which flow we're in so it can skip video for data verification
|
|
1400
|
+
verification_type: this.identityCheckId
|
|
1401
|
+
? "data_verification"
|
|
1402
|
+
: this.documentBlob
|
|
1403
|
+
? "document_verification"
|
|
1404
|
+
: "liveness_only",
|
|
1215
1405
|
}, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
|
|
1216
1406
|
.then((result) => {
|
|
1217
|
-
|
|
1407
|
+
// For document/data verification the backend processes async.
|
|
1408
|
+
// Route through the processing step so the user sees real pipeline
|
|
1409
|
+
// progress instead of jumping straight to result.
|
|
1410
|
+
const isAsync = !!(this.documentBlob || this.identityCheckId);
|
|
1411
|
+
if (isAsync && result.id) {
|
|
1412
|
+
this.livenessEntryId = result.id;
|
|
1413
|
+
this.showStep("processing");
|
|
1414
|
+
}
|
|
1415
|
+
else {
|
|
1416
|
+
this.showResult(result);
|
|
1417
|
+
}
|
|
1218
1418
|
})
|
|
1219
1419
|
.catch((err) => {
|
|
1220
1420
|
this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
|
|
@@ -1228,12 +1428,14 @@ class SmartComplyFlow {
|
|
|
1228
1428
|
return;
|
|
1229
1429
|
this.contentArea.innerHTML = "";
|
|
1230
1430
|
const wrapper = document.createElement("div");
|
|
1431
|
+
wrapper.setAttribute("role", "region");
|
|
1432
|
+
wrapper.setAttribute("aria-label", "Verification result");
|
|
1231
1433
|
wrapper.style.cssText = `
|
|
1232
1434
|
display:flex;flex-direction:column;align-items:center;
|
|
1233
1435
|
text-align:center;gap:16px;padding:20px 0;
|
|
1234
1436
|
opacity:0;transform:scale(0.95);transition:all 0.4s ease;
|
|
1235
1437
|
`;
|
|
1236
|
-
// Icon —
|
|
1438
|
+
// ── Icon — spinner until status resolves ────────────────────────
|
|
1237
1439
|
const iconWrap = document.createElement("div");
|
|
1238
1440
|
iconWrap.style.cssText = `
|
|
1239
1441
|
width:80px;height:80px;border-radius:50%;
|
|
@@ -1241,59 +1443,68 @@ class SmartComplyFlow {
|
|
|
1241
1443
|
font-size:36px;
|
|
1242
1444
|
background:${this.theme.cardBg};
|
|
1243
1445
|
border:3px solid ${this.theme.border};
|
|
1446
|
+
transition:all 0.4s ease;
|
|
1244
1447
|
`;
|
|
1245
1448
|
iconWrap.innerHTML = `<div style="width:32px;height:32px;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.9s linear infinite;"></div>`;
|
|
1246
1449
|
wrapper.appendChild(iconWrap);
|
|
1450
|
+
// ── Title and description — updated by polling ───────────────────
|
|
1247
1451
|
const title = document.createElement("h2");
|
|
1452
|
+
title.setAttribute("aria-live", "polite");
|
|
1248
1453
|
title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
|
|
1249
|
-
title.textContent = "
|
|
1454
|
+
title.textContent = "Checking your identity…";
|
|
1250
1455
|
wrapper.appendChild(title);
|
|
1251
1456
|
const desc = document.createElement("p");
|
|
1457
|
+
desc.setAttribute("aria-live", "polite");
|
|
1252
1458
|
desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
|
|
1253
|
-
desc.textContent = "
|
|
1459
|
+
desc.textContent = "This usually takes a few seconds. Please wait.";
|
|
1254
1460
|
wrapper.appendChild(desc);
|
|
1255
|
-
//
|
|
1256
|
-
|
|
1257
|
-
|
|
1461
|
+
// ── Result card — initially shows status only ────────────────────
|
|
1462
|
+
// Populated with name/doc type when verification completes.
|
|
1463
|
+
const resultCard = document.createElement("div");
|
|
1464
|
+
resultCard.style.cssText = `
|
|
1258
1465
|
width:100%;padding:16px;border-radius:12px;
|
|
1259
1466
|
background:${this.theme.cardBg};border:1px solid ${this.theme.border};
|
|
1260
|
-
text-align:left;
|
|
1467
|
+
text-align:left;display:flex;flex-direction:column;gap:10px;
|
|
1468
|
+
transition:border-color 0.3s ease;
|
|
1261
1469
|
`;
|
|
1262
|
-
// Status
|
|
1470
|
+
// Status row — always shown
|
|
1263
1471
|
const statusBadge = document.createElement("span");
|
|
1472
|
+
statusBadge.setAttribute("role", "status");
|
|
1473
|
+
statusBadge.setAttribute("aria-live", "polite");
|
|
1474
|
+
statusBadge.setAttribute("aria-atomic", "true");
|
|
1264
1475
|
statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
|
|
1265
1476
|
statusBadge.textContent = "Processing…";
|
|
1266
1477
|
const statusRow = document.createElement("div");
|
|
1267
|
-
statusRow.style.cssText = "display:flex;justify-content:space-between;
|
|
1268
|
-
statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;">Status</span>`;
|
|
1478
|
+
statusRow.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
|
|
1479
|
+
statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Status</span>`;
|
|
1269
1480
|
statusRow.appendChild(statusBadge);
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
`;
|
|
1285
|
-
details.appendChild(submittedRow);
|
|
1286
|
-
wrapper.appendChild(details);
|
|
1287
|
-
// Failure reason — hidden until status = failed
|
|
1481
|
+
resultCard.appendChild(statusRow);
|
|
1482
|
+
// Verified details — shown only when passed.
|
|
1483
|
+
// nameRow: shows full name from IVS result or OCR
|
|
1484
|
+
// docTypeRow: shows document type verified
|
|
1485
|
+
const nameRow = document.createElement("div");
|
|
1486
|
+
nameRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
|
|
1487
|
+
nameRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Name</span><span id="sc-result-name" style="color:${this.theme.text};font-size:12px;font-weight:600;text-align:right;max-width:60%;"></span>`;
|
|
1488
|
+
resultCard.appendChild(nameRow);
|
|
1489
|
+
const docTypeRow = document.createElement("div");
|
|
1490
|
+
docTypeRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
|
|
1491
|
+
docTypeRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">ID Type</span><span id="sc-result-doctype" style="color:${this.theme.text};font-size:12px;font-weight:600;"></span>`;
|
|
1492
|
+
resultCard.appendChild(docTypeRow);
|
|
1493
|
+
wrapper.appendChild(resultCard);
|
|
1494
|
+
// ── Failure reason — shown only when failed ──────────────────────
|
|
1288
1495
|
const failureMsg = document.createElement("div");
|
|
1496
|
+
failureMsg.setAttribute("role", "alert");
|
|
1289
1497
|
failureMsg.style.cssText = `
|
|
1290
1498
|
display:none;width:100%;padding:12px 14px;border-radius:10px;
|
|
1291
1499
|
background:${this.theme.errorBg};border:1px solid ${this.theme.error};
|
|
1292
|
-
color:${this.theme.error};font-size:
|
|
1500
|
+
color:${this.theme.error};font-size:13px;text-align:left;line-height:1.6;
|
|
1293
1501
|
`;
|
|
1294
1502
|
wrapper.appendChild(failureMsg);
|
|
1295
|
-
//
|
|
1503
|
+
// ── Action buttons ───────────────────────────────────────────────
|
|
1504
|
+
const btnRow = document.createElement("div");
|
|
1505
|
+
btnRow.style.cssText = "display:flex;flex-direction:column;gap:8px;width:100%;";
|
|
1296
1506
|
const doneBtn = this.createPrimaryButton("Done");
|
|
1507
|
+
doneBtn.setAttribute("aria-label", "Close verification and continue");
|
|
1297
1508
|
let finalStatus = submitResult.status;
|
|
1298
1509
|
doneBtn.addEventListener("click", () => {
|
|
1299
1510
|
if (this._pollStatusTimer) {
|
|
@@ -1309,74 +1520,107 @@ class SmartComplyFlow {
|
|
|
1309
1520
|
});
|
|
1310
1521
|
this.close();
|
|
1311
1522
|
});
|
|
1312
|
-
|
|
1523
|
+
btnRow.appendChild(doneBtn);
|
|
1524
|
+
// "Try again" button — shown only on failure, hidden initially
|
|
1525
|
+
const retryBtn = this.createSecondaryButton("Try Again");
|
|
1526
|
+
retryBtn.setAttribute("aria-label", "Start verification again");
|
|
1527
|
+
retryBtn.style.display = "none";
|
|
1528
|
+
retryBtn.addEventListener("click", () => {
|
|
1529
|
+
if (this._pollStatusTimer) {
|
|
1530
|
+
clearTimeout(this._pollStatusTimer);
|
|
1531
|
+
this._pollStatusTimer = null;
|
|
1532
|
+
}
|
|
1533
|
+
this.initialize();
|
|
1534
|
+
});
|
|
1535
|
+
btnRow.appendChild(retryBtn);
|
|
1536
|
+
wrapper.appendChild(btnRow);
|
|
1313
1537
|
this.contentArea.appendChild(wrapper);
|
|
1314
1538
|
requestAnimationFrame(() => {
|
|
1315
1539
|
wrapper.style.opacity = "1";
|
|
1316
1540
|
wrapper.style.transform = "scale(1)";
|
|
1317
1541
|
});
|
|
1318
|
-
// Start polling
|
|
1319
|
-
this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, failureMsg, (resolved) => { finalStatus = resolved; });
|
|
1542
|
+
// ── Start polling ────────────────────────────────────────────────
|
|
1543
|
+
this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, (resolved) => { finalStatus = resolved; });
|
|
1320
1544
|
}
|
|
1321
|
-
_startStatusPolling(entryId, iconWrap, statusBadge, title, desc, failureMsg, onResolved) {
|
|
1322
|
-
const MAX_POLLS = 25; // 25 ×
|
|
1545
|
+
_startStatusPolling(entryId, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, onResolved) {
|
|
1546
|
+
const MAX_POLLS = 25; // 25 × 3s = 75s + 4s initial ≈ 79s max
|
|
1323
1547
|
let polls = 0;
|
|
1324
|
-
// Hard
|
|
1548
|
+
// Hard 30s UI timer — stop spinner and tell user to wait even if backend is slower
|
|
1325
1549
|
let uiTimeoutId = setTimeout(() => {
|
|
1326
1550
|
if (!this.isDestroyed)
|
|
1327
1551
|
applyTimeout();
|
|
1328
1552
|
}, 30000);
|
|
1329
|
-
const clearUiTimeout = () => {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1553
|
+
const clearUiTimeout = () => {
|
|
1554
|
+
if (uiTimeoutId) {
|
|
1555
|
+
clearTimeout(uiTimeoutId);
|
|
1556
|
+
uiTimeoutId = null;
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1333
1559
|
const applyTimeout = () => {
|
|
1334
|
-
// Stop polling for real — the message below tells the user it's safe
|
|
1335
|
-
// to close and that the result will arrive via webhook/dashboard.
|
|
1336
|
-
// Silently continuing to poll in the background (up to another ~49s)
|
|
1337
|
-
// used to mean the UI could suddenly flip to Verified/Failed after
|
|
1338
|
-
// already telling the user to walk away, with no warning either way.
|
|
1339
1560
|
if (this._pollStatusTimer) {
|
|
1340
1561
|
clearTimeout(this._pollStatusTimer);
|
|
1341
1562
|
this._pollStatusTimer = null;
|
|
1342
1563
|
}
|
|
1343
|
-
iconWrap.innerHTML = "
|
|
1344
|
-
iconWrap.style.background = this.theme.cardBg;
|
|
1564
|
+
iconWrap.innerHTML = "⏳";
|
|
1345
1565
|
iconWrap.style.border = `3px solid ${this.theme.warning}`;
|
|
1346
|
-
iconWrap.style.color = this.theme.warning;
|
|
1347
|
-
iconWrap.style.fontSize = "30px";
|
|
1348
1566
|
statusBadge.style.color = this.theme.textMuted;
|
|
1349
1567
|
statusBadge.textContent = "Still processing…";
|
|
1350
|
-
title.textContent = "
|
|
1351
|
-
desc.textContent = "
|
|
1568
|
+
title.textContent = "Still verifying";
|
|
1569
|
+
desc.textContent = "This is taking a little longer than usual. You can safely close this — your result will be available in your account.";
|
|
1352
1570
|
};
|
|
1353
|
-
const applyPassed = () => {
|
|
1571
|
+
const applyPassed = (result) => {
|
|
1354
1572
|
clearUiTimeout();
|
|
1355
1573
|
iconWrap.innerHTML = "✓";
|
|
1356
|
-
iconWrap.style.
|
|
1357
|
-
|
|
1358
|
-
iconWrap.style.color = this.theme.success;
|
|
1359
|
-
iconWrap.style.fontSize = "36px";
|
|
1574
|
+
iconWrap.style.cssText += `background:${this.theme.successBg};border:3px solid ${this.theme.success};color:${this.theme.success};font-size:36px;font-weight:700;`;
|
|
1575
|
+
resultCard.style.borderColor = this.theme.success;
|
|
1360
1576
|
statusBadge.style.color = this.theme.success;
|
|
1361
1577
|
statusBadge.textContent = "Verified ✓";
|
|
1362
1578
|
title.textContent = "Identity Verified";
|
|
1363
|
-
desc.textContent = "Your identity
|
|
1579
|
+
desc.textContent = "Your identity has been confirmed successfully.";
|
|
1580
|
+
// ── Show name if available ───────────────────────────────────────
|
|
1581
|
+
// Pull from IVS customer_profile (data verification) or
|
|
1582
|
+
// document OCR result (document verification) — whichever has data.
|
|
1583
|
+
const profile = result?.customer_profile || result?.data || {};
|
|
1584
|
+
const docResult = result?.document || {};
|
|
1585
|
+
const ocr = docResult?.ocr_result || {};
|
|
1586
|
+
const firstName = (profile?.first_name || ocr?.first_name || docResult?.first_name || "").trim();
|
|
1587
|
+
const lastName = (profile?.last_name || profile?.surname ||
|
|
1588
|
+
ocr?.last_name || ocr?.surname || docResult?.last_name || "").trim();
|
|
1589
|
+
const fullName = [firstName, lastName].filter(Boolean).join(" ");
|
|
1590
|
+
const nameEl = document.getElementById("sc-result-name");
|
|
1591
|
+
if (fullName && nameEl) {
|
|
1592
|
+
nameEl.textContent = fullName;
|
|
1593
|
+
nameRow.style.display = "flex";
|
|
1594
|
+
}
|
|
1595
|
+
// ── Show ID type ─────────────────────────────────────────────────
|
|
1596
|
+
const rawDocType = (docResult?.document_type ||
|
|
1597
|
+
this.selectedIdType ||
|
|
1598
|
+
result?.identifier_type ||
|
|
1599
|
+
"");
|
|
1600
|
+
const docTypeLabel = _formatDocType(rawDocType);
|
|
1601
|
+
const docTypeEl = document.getElementById("sc-result-doctype");
|
|
1602
|
+
if (docTypeLabel && docTypeEl) {
|
|
1603
|
+
docTypeEl.textContent = docTypeLabel;
|
|
1604
|
+
docTypeRow.style.display = "flex";
|
|
1605
|
+
}
|
|
1364
1606
|
};
|
|
1365
1607
|
const applyFailed = (reason) => {
|
|
1366
1608
|
clearUiTimeout();
|
|
1367
1609
|
iconWrap.innerHTML = "✕";
|
|
1368
|
-
iconWrap.style.
|
|
1369
|
-
|
|
1370
|
-
iconWrap.style.color = this.theme.error;
|
|
1371
|
-
iconWrap.style.fontSize = "32px";
|
|
1610
|
+
iconWrap.style.cssText += `background:${this.theme.errorBg};border:3px solid ${this.theme.error};color:${this.theme.error};font-size:32px;`;
|
|
1611
|
+
resultCard.style.borderColor = this.theme.error;
|
|
1372
1612
|
statusBadge.style.color = this.theme.error;
|
|
1373
|
-
statusBadge.textContent = "
|
|
1374
|
-
title.textContent = "Verification
|
|
1375
|
-
desc.textContent = "
|
|
1376
|
-
|
|
1377
|
-
|
|
1613
|
+
statusBadge.textContent = "Not verified";
|
|
1614
|
+
title.textContent = "Verification Unsuccessful";
|
|
1615
|
+
desc.textContent = "We were unable to confirm your identity this time.";
|
|
1616
|
+
// Show a clean, actionable reason — never raw technical strings
|
|
1617
|
+
const safeReason = _sanitizeUserMessage(reason);
|
|
1618
|
+
if (safeReason) {
|
|
1619
|
+
failureMsg.textContent = safeReason;
|
|
1378
1620
|
failureMsg.style.display = "block";
|
|
1379
1621
|
}
|
|
1622
|
+
// Show retry button on failure
|
|
1623
|
+
retryBtn.style.display = "block";
|
|
1380
1624
|
};
|
|
1381
1625
|
const poll = async () => {
|
|
1382
1626
|
if (this.isDestroyed)
|
|
@@ -1386,34 +1630,32 @@ class SmartComplyFlow {
|
|
|
1386
1630
|
const result = await this.sdk.liveness.pollResult(entryId);
|
|
1387
1631
|
if (result.status === "passed") {
|
|
1388
1632
|
onResolved("passed");
|
|
1389
|
-
applyPassed();
|
|
1633
|
+
applyPassed(result);
|
|
1390
1634
|
return;
|
|
1391
1635
|
}
|
|
1392
1636
|
if (result.status === "failed") {
|
|
1393
|
-
onResolved(
|
|
1394
|
-
|
|
1395
|
-
applyFailed(reason);
|
|
1637
|
+
onResolved("failed");
|
|
1638
|
+
applyFailed(result.failure_reason || null);
|
|
1396
1639
|
return;
|
|
1397
1640
|
}
|
|
1398
|
-
// Still
|
|
1641
|
+
// Still processing — schedule next poll
|
|
1399
1642
|
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1400
1643
|
this._pollStatusTimer = setTimeout(poll, 3000);
|
|
1401
1644
|
}
|
|
1402
1645
|
else if (!this.isDestroyed) {
|
|
1403
|
-
applyTimeout();
|
|
1646
|
+
applyTimeout();
|
|
1404
1647
|
}
|
|
1405
1648
|
}
|
|
1406
1649
|
catch {
|
|
1407
|
-
// Network hiccup — retry with a slightly longer delay
|
|
1408
1650
|
if (polls < MAX_POLLS && !this.isDestroyed) {
|
|
1409
1651
|
this._pollStatusTimer = setTimeout(poll, 5000);
|
|
1410
1652
|
}
|
|
1411
1653
|
else if (!this.isDestroyed) {
|
|
1412
|
-
applyTimeout();
|
|
1654
|
+
applyTimeout();
|
|
1413
1655
|
}
|
|
1414
1656
|
}
|
|
1415
1657
|
};
|
|
1416
|
-
// First poll after
|
|
1658
|
+
// First poll after 4s — gives Celery task time to start
|
|
1417
1659
|
this._pollStatusTimer = setTimeout(poll, 4000);
|
|
1418
1660
|
}
|
|
1419
1661
|
renderResult(_container) {
|
|
@@ -1426,23 +1668,51 @@ class SmartComplyFlow {
|
|
|
1426
1668
|
return;
|
|
1427
1669
|
this.contentArea.innerHTML = "";
|
|
1428
1670
|
const wrapper = document.createElement("div");
|
|
1671
|
+
wrapper.setAttribute("role", "alert");
|
|
1429
1672
|
wrapper.style.cssText = `
|
|
1430
1673
|
display:flex;flex-direction:column;align-items:center;
|
|
1431
1674
|
text-align:center;gap:16px;padding:20px 0;
|
|
1432
1675
|
`;
|
|
1676
|
+
// Classify the error into a user-friendly category
|
|
1433
1677
|
const isCameraErr = /camera|permission|notallowed|notfound/i.test(message);
|
|
1434
1678
|
const isNetworkErr = /network|timeout|connection|fetch/i.test(message);
|
|
1435
|
-
const isAuthErr = /session|token
|
|
1436
|
-
const
|
|
1679
|
+
const isAuthErr = /session.*expired|token.*expired|authentication/i.test(message);
|
|
1680
|
+
const isDocQuality = /blurry|blur|quality|glare|dark|bright|lighting/i.test(message);
|
|
1681
|
+
const isExpiredDoc = /document.*expired|expired.*document/i.test(message);
|
|
1682
|
+
const isFaceMatch = /face.*match|selfie.*match|does not match/i.test(message);
|
|
1683
|
+
const errorIcon = isCameraErr ? "📷"
|
|
1684
|
+
: isNetworkErr ? "📡"
|
|
1685
|
+
: isAuthErr ? "⏱"
|
|
1686
|
+
: isDocQuality ? "🔍"
|
|
1687
|
+
: isExpiredDoc ? "📅"
|
|
1688
|
+
: isFaceMatch ? "🤳"
|
|
1689
|
+
: "✕";
|
|
1437
1690
|
const errorTitle = isCameraErr ? "Camera Access Required"
|
|
1438
1691
|
: isNetworkErr ? "Connection Problem"
|
|
1439
1692
|
: isAuthErr ? "Session Expired"
|
|
1440
|
-
: "
|
|
1693
|
+
: isDocQuality ? "Document Photo Unclear"
|
|
1694
|
+
: isExpiredDoc ? "Document Expired"
|
|
1695
|
+
: isFaceMatch ? "Face Match Failed"
|
|
1696
|
+
: "Verification Unsuccessful";
|
|
1697
|
+
// Map to user-facing guidance — never show raw internal message
|
|
1698
|
+
const userMessage = isCameraErr
|
|
1699
|
+
? "Please allow camera access in your browser settings and try again."
|
|
1700
|
+
: isNetworkErr
|
|
1701
|
+
? "Check your internet connection and try again."
|
|
1702
|
+
: isAuthErr
|
|
1703
|
+
? "Your session has expired. Please start again."
|
|
1704
|
+
: isDocQuality
|
|
1705
|
+
? _sanitizeUserMessage(message) || "Please retake your document photo in good lighting with no glare."
|
|
1706
|
+
: isExpiredDoc
|
|
1707
|
+
? "The document you submitted has expired. Please use a valid, current ID."
|
|
1708
|
+
: isFaceMatch
|
|
1709
|
+
? "Your selfie did not match the photo on your ID. Please ensure you are using your own valid document and face the camera directly."
|
|
1710
|
+
: _sanitizeUserMessage(message) || "We were unable to complete your verification. Please try again.";
|
|
1441
1711
|
const iconWrap = document.createElement("div");
|
|
1442
1712
|
iconWrap.style.cssText = `
|
|
1443
1713
|
width:72px;height:72px;border-radius:50%;
|
|
1444
1714
|
display:flex;align-items:center;justify-content:center;
|
|
1445
|
-
font-size
|
|
1715
|
+
font-size:28px;
|
|
1446
1716
|
background:${this.theme.errorBg};
|
|
1447
1717
|
border:3px solid ${this.theme.error};
|
|
1448
1718
|
`;
|
|
@@ -1454,22 +1724,16 @@ class SmartComplyFlow {
|
|
|
1454
1724
|
wrapper.appendChild(title);
|
|
1455
1725
|
const desc = document.createElement("p");
|
|
1456
1726
|
desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
|
|
1457
|
-
desc.textContent =
|
|
1727
|
+
desc.textContent = userMessage;
|
|
1458
1728
|
wrapper.appendChild(desc);
|
|
1459
1729
|
const retryBtn = this.createPrimaryButton("Try Again");
|
|
1730
|
+
retryBtn.setAttribute("aria-label", "Try verification again");
|
|
1460
1731
|
retryBtn.addEventListener("click", () => {
|
|
1461
|
-
|
|
1462
|
-
// (re-initializing would fail with CLIENT_ID_ALREADY_USED)
|
|
1463
|
-
if (this.sdk.sessionToken && this.sdkConfig) {
|
|
1464
|
-
this.showStep("welcome");
|
|
1465
|
-
}
|
|
1466
|
-
else {
|
|
1467
|
-
// Only re-initialize if session was never created
|
|
1468
|
-
this.initialize();
|
|
1469
|
-
}
|
|
1732
|
+
this.initialize();
|
|
1470
1733
|
});
|
|
1471
1734
|
wrapper.appendChild(retryBtn);
|
|
1472
1735
|
const closeBtn = this.createSecondaryButton("Close");
|
|
1736
|
+
closeBtn.setAttribute("aria-label", "Close verification widget");
|
|
1473
1737
|
closeBtn.addEventListener("click", () => {
|
|
1474
1738
|
this.options.onError?.(new Error(message));
|
|
1475
1739
|
this.close();
|
|
@@ -1482,6 +1746,7 @@ class SmartComplyFlow {
|
|
|
1482
1746
|
// ─────────────────────────────────────────────────────────────────
|
|
1483
1747
|
createHeader() {
|
|
1484
1748
|
const header = document.createElement("div");
|
|
1749
|
+
header.setAttribute("role", "banner");
|
|
1485
1750
|
header.style.cssText = `
|
|
1486
1751
|
display:flex;align-items:center;justify-content:space-between;
|
|
1487
1752
|
padding:11px 20px;border-bottom:1px solid ${this.theme.border};
|
|
@@ -1491,6 +1756,7 @@ class SmartComplyFlow {
|
|
|
1491
1756
|
leftArea.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
|
|
1492
1757
|
this.headerBackBtn = document.createElement("button");
|
|
1493
1758
|
this.headerBackBtn.innerHTML = "←";
|
|
1759
|
+
this.headerBackBtn.setAttribute("aria-label", "Go back to previous step");
|
|
1494
1760
|
this.headerBackBtn.style.cssText = `
|
|
1495
1761
|
width:30px;height:30px;border-radius:8px;flex-shrink:0;
|
|
1496
1762
|
display:none;align-items:center;justify-content:center;
|
|
@@ -1499,6 +1765,7 @@ class SmartComplyFlow {
|
|
|
1499
1765
|
background:${this.theme.cardBg};color:${this.theme.text};
|
|
1500
1766
|
transition:all 0.2s ease;
|
|
1501
1767
|
`;
|
|
1768
|
+
this.headerBackBtn.style.cssText += `:focus-visible{outline:2px solid ${this.theme.primary};outline-offset:2px;}`;
|
|
1502
1769
|
this.headerBackBtn.addEventListener("mouseenter", () => {
|
|
1503
1770
|
if (this.headerBackBtn) {
|
|
1504
1771
|
this.headerBackBtn.style.background = this.theme.shimmer;
|
|
@@ -1511,6 +1778,7 @@ class SmartComplyFlow {
|
|
|
1511
1778
|
});
|
|
1512
1779
|
leftArea.appendChild(this.headerBackBtn);
|
|
1513
1780
|
this.brandLabel = document.createElement("div");
|
|
1781
|
+
this.brandLabel.setAttribute("aria-label", "Verification provider");
|
|
1514
1782
|
this.brandLabel.style.cssText = `
|
|
1515
1783
|
font-size:15px;font-weight:700;color:${this.theme.text};
|
|
1516
1784
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
|
@@ -1520,6 +1788,8 @@ class SmartComplyFlow {
|
|
|
1520
1788
|
header.appendChild(leftArea);
|
|
1521
1789
|
// Step indicator
|
|
1522
1790
|
this.stepLabel = document.createElement("div");
|
|
1791
|
+
this.stepLabel.setAttribute("aria-live", "polite");
|
|
1792
|
+
this.stepLabel.setAttribute("aria-atomic", "true");
|
|
1523
1793
|
this.stepLabel.style.cssText = `
|
|
1524
1794
|
font-size:12px;color:${this.theme.textMuted};
|
|
1525
1795
|
font-variant-numeric:tabular-nums;flex-shrink:0;
|
|
@@ -1528,6 +1798,7 @@ class SmartComplyFlow {
|
|
|
1528
1798
|
// Close button
|
|
1529
1799
|
const closeBtn = document.createElement("button");
|
|
1530
1800
|
closeBtn.innerHTML = "✕";
|
|
1801
|
+
closeBtn.setAttribute("aria-label", "Close identity verification");
|
|
1531
1802
|
closeBtn.style.cssText = `
|
|
1532
1803
|
width:32px;height:32px;border-radius:8px;flex-shrink:0;
|
|
1533
1804
|
display:flex;align-items:center;justify-content:center;
|
|
@@ -1574,6 +1845,7 @@ class SmartComplyFlow {
|
|
|
1574
1845
|
}
|
|
1575
1846
|
createOptionCard(title, subtitle, onClick) {
|
|
1576
1847
|
const card = document.createElement("button");
|
|
1848
|
+
card.setAttribute("aria-label", subtitle ? `${title}: ${subtitle}` : title);
|
|
1577
1849
|
card.style.cssText = `
|
|
1578
1850
|
width:100%;padding:13px 16px;border-radius:12px;
|
|
1579
1851
|
background:${this.theme.cardBg};
|
|
@@ -1752,6 +2024,12 @@ class SmartComplyFlow {
|
|
|
1752
2024
|
#sc-flow-modal * {
|
|
1753
2025
|
box-sizing: border-box;
|
|
1754
2026
|
}
|
|
2027
|
+
#sc-flow-modal button:focus-visible,
|
|
2028
|
+
#sc-flow-modal select:focus-visible,
|
|
2029
|
+
#sc-flow-modal input:focus-visible {
|
|
2030
|
+
outline: 2px solid #3b82f6;
|
|
2031
|
+
outline-offset: 2px;
|
|
2032
|
+
}
|
|
1755
2033
|
#sc-flow-modal::-webkit-scrollbar {
|
|
1756
2034
|
width: 4px;
|
|
1757
2035
|
}
|