@arkyc/widget 1.0.0
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/README.md +85 -0
- package/dist/ArkycWidget.d.mts +50 -0
- package/dist/ArkycWidget.d.mts.map +1 -0
- package/dist/ArkycWidget.mjs +80 -0
- package/dist/ArkycWidget.mjs.map +1 -0
- package/dist/WidgetHandler.d.mts +24 -0
- package/dist/WidgetHandler.d.mts.map +1 -0
- package/dist/WidgetHandler.mjs +28 -0
- package/dist/WidgetHandler.mjs.map +1 -0
- package/dist/_virtual/_virtual_arkyc-theme-css.mjs +4 -0
- package/dist/arkyc-widget.iife.global.js +670 -0
- package/dist/arkyc-widget.iife.global.js.map +1 -0
- package/dist/capture.d.mts +73 -0
- package/dist/capture.d.mts.map +1 -0
- package/dist/capture.mjs +126 -0
- package/dist/capture.mjs.map +1 -0
- package/dist/client.d.mts +152 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +120 -0
- package/dist/client.mjs.map +1 -0
- package/dist/controller.d.mts +126 -0
- package/dist/controller.d.mts.map +1 -0
- package/dist/controller.mjs +582 -0
- package/dist/controller.mjs.map +1 -0
- package/dist/countries.mjs +967 -0
- package/dist/countries.mjs.map +1 -0
- package/dist/device.mjs +17 -0
- package/dist/device.mjs.map +1 -0
- package/dist/document.d.mts +108 -0
- package/dist/document.d.mts.map +1 -0
- package/dist/document.mjs +227 -0
- package/dist/document.mjs.map +1 -0
- package/dist/face.d.mts +82 -0
- package/dist/face.d.mts.map +1 -0
- package/dist/face.mjs +230 -0
- package/dist/face.mjs.map +1 -0
- package/dist/flow.d.mts +74 -0
- package/dist/flow.d.mts.map +1 -0
- package/dist/flow.mjs +132 -0
- package/dist/flow.mjs.map +1 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +16 -0
- package/dist/index.mjs.map +1 -0
- package/dist/qr.mjs +22 -0
- package/dist/qr.mjs.map +1 -0
- package/dist/realtime.d.mts +29 -0
- package/dist/realtime.d.mts.map +1 -0
- package/dist/realtime.mjs +107 -0
- package/dist/realtime.mjs.map +1 -0
- package/dist/theme.d.mts +42 -0
- package/dist/theme.d.mts.map +1 -0
- package/dist/theme.mjs +77 -0
- package/dist/theme.mjs.map +1 -0
- package/dist/types.d.mts +153 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/ui.mjs +931 -0
- package/dist/ui.mjs.map +1 -0
- package/package.json +43 -0
package/dist/ui.mjs
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import { Theme } from "./theme.mjs";
|
|
2
|
+
import { Camera } from "./capture.mjs";
|
|
3
|
+
import { DEFAULT_TUNING, createDefaultFaceAnalyzer, isSelfieReady, makeChallengeDetector } from "./face.mjs";
|
|
4
|
+
import { DEFAULT_DOCUMENT_TUNING, createDefaultDocumentAnalyzer, documentGuidance } from "./document.mjs";
|
|
5
|
+
import { countries } from "./countries.mjs";
|
|
6
|
+
//#region src/ui.ts
|
|
7
|
+
const DOCUMENT_LABELS = {
|
|
8
|
+
passport: "Passport",
|
|
9
|
+
id_card: "ID Card",
|
|
10
|
+
drivers_license: "Driver's License",
|
|
11
|
+
residence_permit: "Residence Permit"
|
|
12
|
+
};
|
|
13
|
+
const CHALLENGE_LABELS = {
|
|
14
|
+
turn_left: "Slowly turn your head to the left, and hold",
|
|
15
|
+
turn_right: "Slowly turn your head to the right, and hold",
|
|
16
|
+
blink: "Blink slowly",
|
|
17
|
+
smile: "Smile, and hold it",
|
|
18
|
+
nod: "Slowly nod your head",
|
|
19
|
+
move_closer: "Slowly move closer to the camera"
|
|
20
|
+
};
|
|
21
|
+
/** Fallback notice for an expired/unknown terminal session. */
|
|
22
|
+
const EXPIRED_NOTICE = {
|
|
23
|
+
cls: "warn",
|
|
24
|
+
icon: "⏳",
|
|
25
|
+
title: "Link expired",
|
|
26
|
+
copy: "This verification link has expired."
|
|
27
|
+
};
|
|
28
|
+
/** Close-only notice copy for a session that was already terminal on load. */
|
|
29
|
+
const TERMINAL_NOTICE = {
|
|
30
|
+
approved: {
|
|
31
|
+
cls: "ok",
|
|
32
|
+
icon: "✓",
|
|
33
|
+
title: "Already complete",
|
|
34
|
+
copy: "This verification has already been completed."
|
|
35
|
+
},
|
|
36
|
+
rejected: {
|
|
37
|
+
cls: "ok",
|
|
38
|
+
icon: "✓",
|
|
39
|
+
title: "Already complete",
|
|
40
|
+
copy: "This verification has already been completed."
|
|
41
|
+
},
|
|
42
|
+
requires_review: {
|
|
43
|
+
cls: "ok",
|
|
44
|
+
icon: "✓",
|
|
45
|
+
title: "Already complete",
|
|
46
|
+
copy: "This verification has already been submitted and is being reviewed."
|
|
47
|
+
},
|
|
48
|
+
expired: EXPIRED_NOTICE,
|
|
49
|
+
cancelled: {
|
|
50
|
+
cls: "warn",
|
|
51
|
+
icon: "⏳",
|
|
52
|
+
title: "Cancelled",
|
|
53
|
+
copy: "This verification was cancelled."
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const SVG = (body) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
|
|
57
|
+
/** Per-challenge directional cue icons, animated via CSS over the preview. */
|
|
58
|
+
const CUE_ICONS = {
|
|
59
|
+
turn_right: SVG("<path d=\"M5 12h14M13 6l6 6-6 6\"/>"),
|
|
60
|
+
turn_left: SVG("<path d=\"M19 12H5M11 6l-6 6 6 6\"/>"),
|
|
61
|
+
nod: SVG("<path d=\"M12 5v14M6 13l6 6 6-6\"/>"),
|
|
62
|
+
move_closer: SVG("<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v3M12 19v3M2 12h3M19 12h3\"/>"),
|
|
63
|
+
blink: SVG("<path d=\"M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>"),
|
|
64
|
+
smile: SVG("<path d=\"M8 14s1.5 2 4 2 4-2 4-2\"/><path d=\"M9 9h.01M15 9h.01\"/>")
|
|
65
|
+
};
|
|
66
|
+
const CHECK_ICON = SVG("<path d=\"M20 6 9 17l-5-5\"/>");
|
|
67
|
+
/** Larger line-art illustration used to dress the informative / lead-in screens. */
|
|
68
|
+
const ART = (body) => `<svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
|
|
69
|
+
/** Per-screen decorative illustrations, keyed by step (plus a welcome hero). */
|
|
70
|
+
const STEP_ART = {
|
|
71
|
+
welcome: ART("<path d=\"M32 7 52 14v14c0 12.5-8.2 21.6-20 25.6C20.2 49.6 12 40.5 12 28V14z\"/><path d=\"M23 30.5l6.2 6.2L41 24.5\"/>"),
|
|
72
|
+
front_capture: ART("<rect x=\"7\" y=\"15\" width=\"50\" height=\"34\" rx=\"5\"/><circle cx=\"22\" cy=\"28\" r=\"5\"/><path d=\"M14 42c1-5 5-7 8-7s7 2 8 7\"/><path d=\"M38 27h12M38 34h12M38 41h8\"/>"),
|
|
73
|
+
back_capture: ART("<rect x=\"7\" y=\"15\" width=\"50\" height=\"34\" rx=\"5\"/><rect x=\"7\" y=\"20\" width=\"50\" height=\"6\" fill=\"currentColor\" stroke=\"none\" opacity=\".18\"/><path d=\"M14 34h36M14 41h24\"/>"),
|
|
74
|
+
selfie_capture: ART("<path d=\"M10 20v-6a4 4 0 0 1 4-4h6\"/><path d=\"M44 10h6a4 4 0 0 1 4 4v6\"/><path d=\"M54 44v6a4 4 0 0 1-4 4h-6\"/><path d=\"M20 54h-6a4 4 0 0 1-4-4v-6\"/><circle cx=\"32\" cy=\"27\" r=\"8\"/><path d=\"M19 50c0-7 6-11 13-11s13 4 13 11\"/>"),
|
|
75
|
+
active_liveness: ART("<circle cx=\"32\" cy=\"30\" r=\"13\"/><circle cx=\"27\" cy=\"27\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/><circle cx=\"37\" cy=\"27\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/><path d=\"M26 34c2 2.5 10 2.5 12 0\"/><path d=\"M13 30H6M10 26l-4 4 4 4\"/><path d=\"M51 30h7M54 26l4 4-4 4\"/>")
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Renders the widget's screens into a host element. DOM- and camera-injectable
|
|
79
|
+
* so it can run under a fake DOM in tests. The view owns capture-screen camera
|
|
80
|
+
* mechanics and raises only high-level events to the controller.
|
|
81
|
+
*/
|
|
82
|
+
var WidgetView = class {
|
|
83
|
+
doc;
|
|
84
|
+
theme;
|
|
85
|
+
handlers;
|
|
86
|
+
root;
|
|
87
|
+
body;
|
|
88
|
+
footer;
|
|
89
|
+
/** The `<style>` carrying the theme's CSS vars; swapped on runtime re-brand. */
|
|
90
|
+
styleEl;
|
|
91
|
+
/** The header brand group (logo/name/title); rebuilt on runtime re-brand. */
|
|
92
|
+
brandEl;
|
|
93
|
+
camera;
|
|
94
|
+
analyzer;
|
|
95
|
+
tuning;
|
|
96
|
+
docAnalyzer;
|
|
97
|
+
docTuning;
|
|
98
|
+
/** Interval ids for live quality/recording timers, cleared on re-render. */
|
|
99
|
+
timers = [];
|
|
100
|
+
/** Extra teardown run on each re-render (cancels in-flight detection loops). */
|
|
101
|
+
cleanups = [];
|
|
102
|
+
constructor(doc, theme, handlers, nav = globalThis.navigator, analyzer = createDefaultFaceAnalyzer(), tuning = DEFAULT_TUNING, docAnalyzer = createDefaultDocumentAnalyzer(), docTuning = DEFAULT_DOCUMENT_TUNING) {
|
|
103
|
+
this.doc = doc;
|
|
104
|
+
this.theme = theme;
|
|
105
|
+
this.handlers = handlers;
|
|
106
|
+
this.camera = new Camera(doc, nav);
|
|
107
|
+
this.analyzer = analyzer;
|
|
108
|
+
this.tuning = tuning;
|
|
109
|
+
this.docAnalyzer = docAnalyzer;
|
|
110
|
+
this.docTuning = docTuning;
|
|
111
|
+
this.root = this.el("div", { class: "arkyc-root" });
|
|
112
|
+
this.styleEl = this.el("style", { text: theme.stylesheet() });
|
|
113
|
+
this.root.appendChild(this.styleEl);
|
|
114
|
+
const card = this.el("div", { class: "arkyc-card" });
|
|
115
|
+
const header = this.el("div", { class: "arkyc-header" });
|
|
116
|
+
this.brandEl = this.el("div", { class: "arkyc-brand" });
|
|
117
|
+
this.fillBrand();
|
|
118
|
+
header.appendChild(this.brandEl);
|
|
119
|
+
const close = this.el("button", {
|
|
120
|
+
class: "arkyc-close",
|
|
121
|
+
html: "×",
|
|
122
|
+
"aria-label": "Close"
|
|
123
|
+
});
|
|
124
|
+
close.addEventListener("click", () => this.handlers.onClose());
|
|
125
|
+
header.appendChild(close);
|
|
126
|
+
this.body = this.el("div", { class: "arkyc-body" });
|
|
127
|
+
this.footer = this.el("div", { class: "arkyc-footer" });
|
|
128
|
+
card.appendChild(header);
|
|
129
|
+
card.appendChild(this.body);
|
|
130
|
+
card.appendChild(this.footer);
|
|
131
|
+
this.root.appendChild(card);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Populate the header brand group from the current theme: the project logo
|
|
135
|
+
* and/or name when branding is shown, otherwise a neutral title.
|
|
136
|
+
*/
|
|
137
|
+
fillBrand() {
|
|
138
|
+
this.clear(this.brandEl);
|
|
139
|
+
const lastTenMins = Date.now() - Math.floor(Math.random() * (600 * 1e3));
|
|
140
|
+
if (this.theme.showBranding && (this.theme.logoUrl || this.theme.name)) {
|
|
141
|
+
if (this.theme.logoUrl) this.brandEl.appendChild(this.el("img", {
|
|
142
|
+
class: "arkyc-logo",
|
|
143
|
+
src: this.theme.logoUrl + "?stamp=" + lastTenMins
|
|
144
|
+
}));
|
|
145
|
+
if (this.theme.name) this.brandEl.appendChild(this.el("span", {
|
|
146
|
+
class: "arkyc-brand-name",
|
|
147
|
+
text: this.theme.name
|
|
148
|
+
}));
|
|
149
|
+
} else this.brandEl.appendChild(this.el("p", {
|
|
150
|
+
class: "arkyc-title",
|
|
151
|
+
text: "Verify your identity"
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Re-theme the widget at runtime from project branding (resolved server-side):
|
|
156
|
+
* swap the CSS-variable stylesheet (colours/radius) and rebuild the header
|
|
157
|
+
* brand (logo/name). Cascades to every already-rendered element via the vars.
|
|
158
|
+
*/
|
|
159
|
+
applyBranding(branding) {
|
|
160
|
+
this.theme = new Theme(branding);
|
|
161
|
+
this.styleEl.textContent = this.theme.stylesheet();
|
|
162
|
+
this.fillBrand();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The widget's root element — append this to the overlay / container.
|
|
166
|
+
*/
|
|
167
|
+
get element() {
|
|
168
|
+
return this.root;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether live camera capture is available (drives the active-liveness branch).
|
|
172
|
+
*/
|
|
173
|
+
get cameraSupported() {
|
|
174
|
+
return this.camera.supported;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Release any active camera stream and clear live timers.
|
|
178
|
+
*/
|
|
179
|
+
destroy() {
|
|
180
|
+
this.timers.forEach((id) => clearInterval(id));
|
|
181
|
+
this.timers = [];
|
|
182
|
+
this.cleanups.forEach((fn) => fn());
|
|
183
|
+
this.cleanups = [];
|
|
184
|
+
this.camera.stop();
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Render the screen for the given state.
|
|
188
|
+
*
|
|
189
|
+
* @param state
|
|
190
|
+
* @returns
|
|
191
|
+
*/
|
|
192
|
+
render(state) {
|
|
193
|
+
this.destroy();
|
|
194
|
+
this.clear(this.body);
|
|
195
|
+
this.clear(this.footer);
|
|
196
|
+
this.root.classList.remove("arkyc-handoff");
|
|
197
|
+
switch (state.step) {
|
|
198
|
+
case "welcome": return this.renderWelcome(state.handoffAvailable);
|
|
199
|
+
case "document_selection": return this.renderDocumentSelection();
|
|
200
|
+
case "front_capture": return this.renderCapture("Front of document", "environment", state.allowSkip, false, state.strictCapture);
|
|
201
|
+
case "back_capture": return this.renderCapture("Back of document", "environment", state.allowSkip, false, state.strictCapture);
|
|
202
|
+
case "selfie_capture": return this.renderCapture("Take a selfie", "user", state.allowSkip, true, state.strictCapture);
|
|
203
|
+
case "active_liveness": return this.renderActiveLiveness(state.livenessChallenges ?? [], state.allowSkip, state.requireLiveCamera);
|
|
204
|
+
case "ocr_processing": return this.renderProcessing("Reading your document…");
|
|
205
|
+
case "passive_liveness": return this.renderProcessing("Checking liveness…");
|
|
206
|
+
case "face_match": return this.renderProcessing("Matching your face…");
|
|
207
|
+
case "processing": return this.renderProcessing(state.statusLabel ?? "Finalising verification…");
|
|
208
|
+
case "result": return this.renderResult(state.decision ?? null, state.errorMessage, {
|
|
209
|
+
terminal: state.terminalNotice,
|
|
210
|
+
status: state.status
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Prepend a decorative illustration for the given screen, when one exists. */
|
|
215
|
+
appendArt(key) {
|
|
216
|
+
const art = STEP_ART[key];
|
|
217
|
+
if (art) this.body.appendChild(this.el("div", {
|
|
218
|
+
class: "arkyc-illus",
|
|
219
|
+
html: art
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
renderWelcome(handoffAvailable) {
|
|
223
|
+
this.appendArt("welcome");
|
|
224
|
+
this.body.appendChild(this.el("h2", {
|
|
225
|
+
class: "arkyc-h",
|
|
226
|
+
text: "Verify your identity"
|
|
227
|
+
}));
|
|
228
|
+
this.body.appendChild(this.el("p", {
|
|
229
|
+
class: "arkyc-p",
|
|
230
|
+
text: "You will need a government-issued ID and a moment to take a selfie. Your data is processed securely."
|
|
231
|
+
}));
|
|
232
|
+
this.footer.appendChild(this.button("Get started", () => this.handlers.onStart()));
|
|
233
|
+
if (handoffAvailable) this.footer.appendChild(this.button("Continue on your phone", () => this.handlers.onUsePhone(), "arkyc-btn-ghost"));
|
|
234
|
+
}
|
|
235
|
+
/** A neutral connecting screen shown while the widget bootstraps the session. */
|
|
236
|
+
renderLoading(label = "Loading…") {
|
|
237
|
+
this.destroy();
|
|
238
|
+
this.clear(this.body);
|
|
239
|
+
this.clear(this.footer);
|
|
240
|
+
this.root.classList.remove("arkyc-handoff");
|
|
241
|
+
this.renderProcessing(label);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* An interstitial that tells the user what the next step is, with a single
|
|
245
|
+
* Continue button — so each step starts deliberately rather than the camera
|
|
246
|
+
* springing to life unannounced.
|
|
247
|
+
*/
|
|
248
|
+
renderInstruction(step, title, body, cta, onContinue) {
|
|
249
|
+
this.destroy();
|
|
250
|
+
this.clear(this.body);
|
|
251
|
+
this.clear(this.footer);
|
|
252
|
+
this.root.classList.remove("arkyc-handoff");
|
|
253
|
+
this.appendArt(step);
|
|
254
|
+
this.body.appendChild(this.el("h2", {
|
|
255
|
+
class: "arkyc-h",
|
|
256
|
+
text: title
|
|
257
|
+
}));
|
|
258
|
+
this.body.appendChild(this.el("p", {
|
|
259
|
+
class: "arkyc-p",
|
|
260
|
+
text: body
|
|
261
|
+
}));
|
|
262
|
+
this.footer.appendChild(this.button(cta, onContinue));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Show the cross-device handoff QR: the user scans it to resume this same
|
|
266
|
+
* session on their phone. The widget then waits for the other device to finish.
|
|
267
|
+
* `allowContinueHere` offers an escape hatch to keep verifying on this device.
|
|
268
|
+
*/
|
|
269
|
+
renderHandoff(qrSvg, allowContinueHere) {
|
|
270
|
+
this.destroy();
|
|
271
|
+
this.clear(this.body);
|
|
272
|
+
this.clear(this.footer);
|
|
273
|
+
this.root.classList.add("arkyc-handoff");
|
|
274
|
+
this.body.appendChild(this.el("h2", {
|
|
275
|
+
class: "arkyc-h",
|
|
276
|
+
text: "Continue on your phone"
|
|
277
|
+
}));
|
|
278
|
+
this.body.appendChild(this.el("p", {
|
|
279
|
+
class: "arkyc-p",
|
|
280
|
+
text: "Scan this code with your phone camera to finish verifying there."
|
|
281
|
+
}));
|
|
282
|
+
this.body.appendChild(this.el("div", {
|
|
283
|
+
class: "arkyc-qr",
|
|
284
|
+
html: qrSvg
|
|
285
|
+
}));
|
|
286
|
+
const waiting = this.el("div", { class: "arkyc-handoff-wait" });
|
|
287
|
+
waiting.appendChild(this.el("div", { class: "arkyc-spinner sm" }));
|
|
288
|
+
waiting.appendChild(this.el("span", { text: "Waiting for your phone…" }));
|
|
289
|
+
this.body.appendChild(waiting);
|
|
290
|
+
if (allowContinueHere) this.footer.appendChild(this.button("Continue on this device", () => this.handlers.onContinueHere(), "arkyc-btn-ghost"));
|
|
291
|
+
}
|
|
292
|
+
renderDocumentSelection() {
|
|
293
|
+
this.body.appendChild(this.el("h2", {
|
|
294
|
+
class: "arkyc-h",
|
|
295
|
+
text: "Select your document"
|
|
296
|
+
}));
|
|
297
|
+
const country = this.el("select", {
|
|
298
|
+
class: "arkyc-btn arkyc-btn-ghost arkyc-text-center",
|
|
299
|
+
name: "country",
|
|
300
|
+
autocomplete: "country",
|
|
301
|
+
"aria-label": "Country"
|
|
302
|
+
});
|
|
303
|
+
const placeholder = this.el("option", {
|
|
304
|
+
value: "",
|
|
305
|
+
selected: "selected"
|
|
306
|
+
});
|
|
307
|
+
placeholder.textContent = "Country";
|
|
308
|
+
country.appendChild(placeholder);
|
|
309
|
+
countries.forEach(({ name, iso2, flag }) => {
|
|
310
|
+
const option = this.el("option", { value: iso2 });
|
|
311
|
+
option.textContent = `${flag} ${name}`;
|
|
312
|
+
country.appendChild(option);
|
|
313
|
+
});
|
|
314
|
+
const choices = this.el("div", { class: "arkyc-choices" });
|
|
315
|
+
choices.appendChild(country);
|
|
316
|
+
Object.keys(DOCUMENT_LABELS).forEach((type) => {
|
|
317
|
+
const btn = this.button(DOCUMENT_LABELS[type], () => this.handlers.onDocumentSelected(type, (country.value || "").trim().toUpperCase()));
|
|
318
|
+
btn.classList.add("arkyc-btn-ghost");
|
|
319
|
+
choices.appendChild(btn);
|
|
320
|
+
});
|
|
321
|
+
this.body.appendChild(choices);
|
|
322
|
+
}
|
|
323
|
+
renderCapture(title, facing, allowSkip, selfie = false, strict = false) {
|
|
324
|
+
const strictDoc = strict && !selfie;
|
|
325
|
+
this.body.appendChild(this.el("h2", {
|
|
326
|
+
class: "arkyc-h",
|
|
327
|
+
text: title
|
|
328
|
+
}));
|
|
329
|
+
this.body.appendChild(this.el("p", {
|
|
330
|
+
class: "arkyc-p",
|
|
331
|
+
text: "Position it clearly in frame, then capture."
|
|
332
|
+
}));
|
|
333
|
+
const fileInput = this.el("input", {
|
|
334
|
+
type: "file",
|
|
335
|
+
accept: "image/*",
|
|
336
|
+
class: "arkyc-hidden"
|
|
337
|
+
});
|
|
338
|
+
fileInput.addEventListener("change", () => this.handlers.onImage(Camera.fileFromInput(fileInput)));
|
|
339
|
+
this.body.appendChild(fileInput);
|
|
340
|
+
if (this.camera.supported) {
|
|
341
|
+
const video = this.el("video", { class: `arkyc-preview${selfie ? " selfie" : ""}` });
|
|
342
|
+
const face = selfie ? this.buildFaceStage(video) : null;
|
|
343
|
+
const doc = selfie ? null : this.buildDocStage(video);
|
|
344
|
+
const mount = face?.stage ?? doc.stage;
|
|
345
|
+
this.body.appendChild(mount);
|
|
346
|
+
const hint = this.el("p", { class: "arkyc-p arkyc-hint" });
|
|
347
|
+
this.body.appendChild(hint);
|
|
348
|
+
const confirmCapture = (blob) => {
|
|
349
|
+
this.destroy();
|
|
350
|
+
if (blob) {
|
|
351
|
+
const url = URL.createObjectURL(blob);
|
|
352
|
+
const still = this.el("img", {
|
|
353
|
+
class: `arkyc-preview${selfie ? " selfie" : ""}`,
|
|
354
|
+
src: url
|
|
355
|
+
});
|
|
356
|
+
mount.replaceWith(still);
|
|
357
|
+
this.cleanups.push(() => URL.revokeObjectURL(url));
|
|
358
|
+
} else mount.classList.add("arkyc-hidden");
|
|
359
|
+
this.clear(this.footer);
|
|
360
|
+
hint.textContent = "✓ Captured";
|
|
361
|
+
this.footer.appendChild(this.button("Continue", () => this.handlers.onImage(blob)));
|
|
362
|
+
const retake = this.button("Retake", () => {
|
|
363
|
+
this.destroy();
|
|
364
|
+
this.clear(this.body);
|
|
365
|
+
this.clear(this.footer);
|
|
366
|
+
this.renderCapture(title, facing, allowSkip, selfie, strict);
|
|
367
|
+
});
|
|
368
|
+
retake.classList.add("arkyc-btn-ghost");
|
|
369
|
+
this.footer.appendChild(retake);
|
|
370
|
+
};
|
|
371
|
+
const onCapture = () => void this.camera.grabFrame(video).then(confirmCapture);
|
|
372
|
+
this.camera.start(video, facing).catch(() => {
|
|
373
|
+
mount.classList.add("arkyc-hidden");
|
|
374
|
+
if (strictDoc) {
|
|
375
|
+
hint.textContent = "Camera access is required to scan your document. Please allow access and try again.";
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
fileInput.click();
|
|
379
|
+
});
|
|
380
|
+
if (selfie) this.runSelfieAutoCapture(video, hint, onCapture, face);
|
|
381
|
+
else {
|
|
382
|
+
this.runDocumentAutoCapture(video, hint, doc, strictDoc, onCapture);
|
|
383
|
+
if (!strictDoc) this.footer.appendChild(this.button("Capture", onCapture));
|
|
384
|
+
}
|
|
385
|
+
} else if (strictDoc) {
|
|
386
|
+
this.body.appendChild(this.el("div", {
|
|
387
|
+
class: "arkyc-badge err",
|
|
388
|
+
html: "!"
|
|
389
|
+
}));
|
|
390
|
+
this.body.appendChild(this.el("p", {
|
|
391
|
+
class: "arkyc-p",
|
|
392
|
+
text: "This step needs a working camera to scan your document. Please retry on a device with a camera."
|
|
393
|
+
}));
|
|
394
|
+
} else {
|
|
395
|
+
const upload = this.button("Upload photo", () => fileInput.click());
|
|
396
|
+
this.footer.appendChild(upload);
|
|
397
|
+
}
|
|
398
|
+
if (allowSkip) {
|
|
399
|
+
const skip = this.button("Skip (demo)", () => this.handlers.onImage(null));
|
|
400
|
+
skip.classList.add("arkyc-btn-ghost");
|
|
401
|
+
this.footer.appendChild(skip);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Run `cb` once the live camera feed appears (the video has frames). The
|
|
406
|
+
* detection model may still be downloading at that point, so callers use this
|
|
407
|
+
* to replace the "starting camera" hint with a neutral one.
|
|
408
|
+
*
|
|
409
|
+
* @param video
|
|
410
|
+
* @param cb
|
|
411
|
+
*/
|
|
412
|
+
onCameraLive(video, cb) {
|
|
413
|
+
if ((video.readyState ?? 0) >= 2) {
|
|
414
|
+
cb();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
video.addEventListener("playing", cb, { once: true });
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Document capture: detect a real, well-framed, in-focus document (edge
|
|
421
|
+
* projection) and auto-grab only once it's been held steady. Falls back to the
|
|
422
|
+
* brightness/glare heuristic when the detector can't run.
|
|
423
|
+
*
|
|
424
|
+
* @param video
|
|
425
|
+
* @returns
|
|
426
|
+
*/
|
|
427
|
+
runDocumentAutoCapture(video, hint, doc, strict, capture) {
|
|
428
|
+
const onNoDetector = () => {
|
|
429
|
+
if (strict) {
|
|
430
|
+
hint.textContent = "Document scanning isn’t available on this device.";
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.runDocumentBrightnessCapture(video, hint, doc, capture);
|
|
434
|
+
};
|
|
435
|
+
const analyzer = this.docAnalyzer;
|
|
436
|
+
if (!analyzer) return onNoDetector();
|
|
437
|
+
hint.textContent = "Starting camera…";
|
|
438
|
+
let cancelled = false;
|
|
439
|
+
let modelReady = false;
|
|
440
|
+
this.cleanups.push(() => {
|
|
441
|
+
cancelled = true;
|
|
442
|
+
});
|
|
443
|
+
this.onCameraLive(video, () => {
|
|
444
|
+
if (!cancelled && !modelReady) hint.textContent = "Getting ready…";
|
|
445
|
+
});
|
|
446
|
+
analyzer.ready().then((ok) => {
|
|
447
|
+
modelReady = true;
|
|
448
|
+
if (cancelled) return;
|
|
449
|
+
if (!ok) {
|
|
450
|
+
onNoDetector();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
let goodStreak = 0;
|
|
454
|
+
let fillEma = null;
|
|
455
|
+
let edgeEma = null;
|
|
456
|
+
const ema = (prev, next) => prev == null ? next : prev * .6 + next * .4;
|
|
457
|
+
const timer = setInterval(() => {
|
|
458
|
+
const sample = analyzer.analyze(video);
|
|
459
|
+
if (!sample) return;
|
|
460
|
+
fillEma = ema(fillEma, sample.fill);
|
|
461
|
+
edgeEma = ema(edgeEma, sample.edgeStrength);
|
|
462
|
+
const { ready, hint: message } = documentGuidance({
|
|
463
|
+
...sample,
|
|
464
|
+
fill: fillEma,
|
|
465
|
+
edgeStrength: edgeEma
|
|
466
|
+
}, this.docTuning);
|
|
467
|
+
hint.textContent = message;
|
|
468
|
+
doc.setFrame(sample.present ? sample.rect : null);
|
|
469
|
+
doc.setQuality(ready ? "good" : sample.present ? "wait" : "bad");
|
|
470
|
+
goodStreak = ready ? goodStreak + 1 : Math.max(0, goodStreak - 1);
|
|
471
|
+
if (goodStreak >= this.docTuning.hold) {
|
|
472
|
+
clearInterval(timer);
|
|
473
|
+
doc.setQuality("good");
|
|
474
|
+
capture();
|
|
475
|
+
}
|
|
476
|
+
}, 250);
|
|
477
|
+
this.timers.push(timer);
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Coarse fallback: brightness/glare heuristic, used when detection can't run.
|
|
482
|
+
*
|
|
483
|
+
* @param video
|
|
484
|
+
* @param hint
|
|
485
|
+
* @param doc
|
|
486
|
+
*/
|
|
487
|
+
runDocumentBrightnessCapture(video, hint, doc, capture) {
|
|
488
|
+
let goodStreak = 0;
|
|
489
|
+
const timer = setInterval(() => {
|
|
490
|
+
const quality = this.camera.sampleQuality(video);
|
|
491
|
+
if (!quality) return;
|
|
492
|
+
if (quality.tooDark) {
|
|
493
|
+
hint.textContent = "Too dark — find better lighting";
|
|
494
|
+
goodStreak = 0;
|
|
495
|
+
doc.setQuality("bad");
|
|
496
|
+
} else if (quality.glare) {
|
|
497
|
+
hint.textContent = "Reduce glare on the document";
|
|
498
|
+
goodStreak = 0;
|
|
499
|
+
doc.setQuality("bad");
|
|
500
|
+
} else {
|
|
501
|
+
hint.textContent = "Looks good — hold steady";
|
|
502
|
+
goodStreak += 1;
|
|
503
|
+
doc.setQuality("good");
|
|
504
|
+
}
|
|
505
|
+
if (goodStreak >= 5) {
|
|
506
|
+
clearInterval(timer);
|
|
507
|
+
capture();
|
|
508
|
+
}
|
|
509
|
+
}, 300);
|
|
510
|
+
this.timers.push(timer);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Selfie capture: when face detection is available, auto-grab once a centred
|
|
514
|
+
* face is held steady. Falls back to a brightness hint (manual capture) when
|
|
515
|
+
* the detector can't load (unsupported browser / offline / test host).
|
|
516
|
+
*
|
|
517
|
+
* @param video
|
|
518
|
+
* @param hint
|
|
519
|
+
* @param capture
|
|
520
|
+
* @returns
|
|
521
|
+
*/
|
|
522
|
+
runSelfieAutoCapture(video, hint, capture, face) {
|
|
523
|
+
const analyzer = this.analyzer;
|
|
524
|
+
const addManualCapture = () => this.footer.appendChild(this.button("Capture", capture));
|
|
525
|
+
if (!analyzer) {
|
|
526
|
+
hint.textContent = "Center your face, then capture";
|
|
527
|
+
addManualCapture();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
hint.textContent = "Starting camera…";
|
|
531
|
+
let cancelled = false;
|
|
532
|
+
let modelReady = false;
|
|
533
|
+
this.cleanups.push(() => {
|
|
534
|
+
cancelled = true;
|
|
535
|
+
});
|
|
536
|
+
this.onCameraLive(video, () => {
|
|
537
|
+
if (!cancelled && !modelReady) hint.textContent = "Getting ready…";
|
|
538
|
+
});
|
|
539
|
+
analyzer.ready().then((ok) => {
|
|
540
|
+
modelReady = true;
|
|
541
|
+
if (cancelled) return;
|
|
542
|
+
if (!ok) {
|
|
543
|
+
hint.textContent = "Center your face, then capture";
|
|
544
|
+
addManualCapture();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const need = 4;
|
|
548
|
+
let streak = 0;
|
|
549
|
+
const timer = setInterval(() => {
|
|
550
|
+
const sample = analyzer.analyze(video);
|
|
551
|
+
if (!sample || !sample.present) {
|
|
552
|
+
hint.textContent = "Position your face in the circle";
|
|
553
|
+
streak = 0;
|
|
554
|
+
face.setState("wait");
|
|
555
|
+
face.setProgress(0);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (isSelfieReady(sample, this.tuning)) {
|
|
559
|
+
hint.textContent = "Hold still…";
|
|
560
|
+
streak += 1;
|
|
561
|
+
face.setState("good");
|
|
562
|
+
face.setProgress(streak / need);
|
|
563
|
+
} else {
|
|
564
|
+
hint.textContent = sample.scale <= this.tuning.selfieMinScale ? "Move a little closer" : "Center your face";
|
|
565
|
+
streak = 0;
|
|
566
|
+
face.setState("wait");
|
|
567
|
+
face.setProgress(0);
|
|
568
|
+
}
|
|
569
|
+
if (streak >= need) {
|
|
570
|
+
clearInterval(timer);
|
|
571
|
+
face.setState("done");
|
|
572
|
+
face.setProgress(1);
|
|
573
|
+
capture();
|
|
574
|
+
}
|
|
575
|
+
}, 180);
|
|
576
|
+
this.timers.push(timer);
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Guided active-liveness screen: live front-camera preview, a recorded video,
|
|
581
|
+
* and a sequence of challenge prompts the user advances through. The performed
|
|
582
|
+
* sequence (the prompts shown, in order) is submitted for the driver to verify.
|
|
583
|
+
*
|
|
584
|
+
* @param video
|
|
585
|
+
* @returns
|
|
586
|
+
*/
|
|
587
|
+
renderActiveLiveness(challenges, allowSkip, requireLiveCamera) {
|
|
588
|
+
this.body.appendChild(this.el("h2", {
|
|
589
|
+
class: "arkyc-h",
|
|
590
|
+
text: "Liveness check"
|
|
591
|
+
}));
|
|
592
|
+
const prompt = this.el("p", {
|
|
593
|
+
class: "arkyc-p",
|
|
594
|
+
text: "Follow the on-screen prompts. Keep your face centred in the circle."
|
|
595
|
+
});
|
|
596
|
+
this.body.appendChild(prompt);
|
|
597
|
+
if (!this.camera.supported || !this.camera.canRecord) {
|
|
598
|
+
if (requireLiveCamera) {
|
|
599
|
+
this.body.appendChild(this.el("div", {
|
|
600
|
+
class: "arkyc-badge err",
|
|
601
|
+
html: "!"
|
|
602
|
+
}));
|
|
603
|
+
prompt.textContent = "This check needs camera access on a supported device. Please retry on a device with a working camera.";
|
|
604
|
+
if (allowSkip) {
|
|
605
|
+
const skip = this.button("Skip (demo)", () => this.handlers.onActiveLiveness(null, challenges));
|
|
606
|
+
skip.classList.add("arkyc-btn-ghost");
|
|
607
|
+
this.footer.appendChild(skip);
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const finish = this.button("I performed the steps", () => this.handlers.onActiveLiveness(null, challenges));
|
|
612
|
+
this.footer.appendChild(finish);
|
|
613
|
+
if (allowSkip) {
|
|
614
|
+
const skip = this.button("Skip (demo)", () => this.handlers.onActiveLiveness(null, challenges));
|
|
615
|
+
skip.classList.add("arkyc-btn-ghost");
|
|
616
|
+
this.footer.appendChild(skip);
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const video = this.el("video", { class: "arkyc-preview selfie" });
|
|
621
|
+
const face = this.buildFaceStage(video);
|
|
622
|
+
const dots = this.buildDots(challenges.length);
|
|
623
|
+
if (challenges.length > 1) this.body.appendChild(dots.el);
|
|
624
|
+
this.body.appendChild(face.stage);
|
|
625
|
+
let recording = null;
|
|
626
|
+
let index = 0;
|
|
627
|
+
const performed = [];
|
|
628
|
+
let detector = makeChallengeDetector(challenges[0] ?? "blink", this.tuning);
|
|
629
|
+
const showPrompt = (done = false) => {
|
|
630
|
+
const challenge = challenges[index];
|
|
631
|
+
if (!challenge) {
|
|
632
|
+
prompt.textContent = "Hold still…";
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
prompt.textContent = done ? `✓ ${CHALLENGE_LABELS[challenge]}` : `Step ${index + 1} of ${challenges.length}: ${CHALLENGE_LABELS[challenge]}`;
|
|
636
|
+
};
|
|
637
|
+
const armChallenge = () => {
|
|
638
|
+
face.setCue(challenges[index] ?? null);
|
|
639
|
+
face.setState("wait");
|
|
640
|
+
face.setProgress(0);
|
|
641
|
+
dots.setActive(index);
|
|
642
|
+
};
|
|
643
|
+
const finish = () => {
|
|
644
|
+
advance.setAttribute("disabled", "true");
|
|
645
|
+
this.camera.grabFrame(video).then((selfie) => Promise.resolve(recording?.stop() ?? Promise.resolve(null)).then((blob) => this.handlers.onActiveLiveness(blob, performed, selfie)));
|
|
646
|
+
};
|
|
647
|
+
const advanceStep = () => {
|
|
648
|
+
if (index >= challenges.length) return;
|
|
649
|
+
const current = challenges[index];
|
|
650
|
+
if (current) performed.push(current);
|
|
651
|
+
dots.markDone(index);
|
|
652
|
+
index += 1;
|
|
653
|
+
if (index >= challenges.length) {
|
|
654
|
+
finish();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
detector = makeChallengeDetector(challenges[index], this.tuning);
|
|
658
|
+
showPrompt();
|
|
659
|
+
armChallenge();
|
|
660
|
+
if (index === challenges.length - 1) advance.textContent = "Finish";
|
|
661
|
+
};
|
|
662
|
+
const advance = this.button("Next", () => advanceStep());
|
|
663
|
+
if (challenges.length <= 1) advance.textContent = "Finish";
|
|
664
|
+
let manualShown = false;
|
|
665
|
+
const showManualAdvance = () => {
|
|
666
|
+
if (manualShown) return;
|
|
667
|
+
manualShown = true;
|
|
668
|
+
this.footer.appendChild(advance);
|
|
669
|
+
};
|
|
670
|
+
this.camera.start(video, "user").then((stream) => {
|
|
671
|
+
recording = this.camera.recordStart(stream);
|
|
672
|
+
showPrompt();
|
|
673
|
+
armChallenge();
|
|
674
|
+
const analyzer = this.analyzer;
|
|
675
|
+
if (!analyzer) {
|
|
676
|
+
showManualAdvance();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
let cancelled = false;
|
|
680
|
+
this.cleanups.push(() => {
|
|
681
|
+
cancelled = true;
|
|
682
|
+
});
|
|
683
|
+
analyzer.ready().then((ok) => {
|
|
684
|
+
if (cancelled) return;
|
|
685
|
+
if (!ok) {
|
|
686
|
+
showManualAdvance();
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
let flashing = false;
|
|
690
|
+
const timer = setInterval(() => {
|
|
691
|
+
if (index >= challenges.length) {
|
|
692
|
+
clearInterval(timer);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (flashing) return;
|
|
696
|
+
const sample = analyzer.analyze(video);
|
|
697
|
+
if (!sample) return;
|
|
698
|
+
const hit = detector.feed(sample);
|
|
699
|
+
face.setProgress(detector.progress);
|
|
700
|
+
if (hit) {
|
|
701
|
+
face.setState("done");
|
|
702
|
+
showPrompt(true);
|
|
703
|
+
flashing = true;
|
|
704
|
+
const to = setTimeout(() => {
|
|
705
|
+
flashing = false;
|
|
706
|
+
advanceStep();
|
|
707
|
+
}, 650);
|
|
708
|
+
this.cleanups.push(() => clearTimeout(to));
|
|
709
|
+
} else face.setState(detector.progress > 0 ? "good" : "wait");
|
|
710
|
+
}, 160);
|
|
711
|
+
this.timers.push(timer);
|
|
712
|
+
});
|
|
713
|
+
}).catch(() => {
|
|
714
|
+
face.stage.classList.add("arkyc-hidden");
|
|
715
|
+
if (requireLiveCamera) {
|
|
716
|
+
prompt.textContent = "Camera access is required to continue. Please allow access and try again.";
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
this.handlers.onActiveLiveness(null, challenges);
|
|
720
|
+
});
|
|
721
|
+
if (allowSkip) {
|
|
722
|
+
const skip = this.button("Skip (demo)", () => this.handlers.onActiveLiveness(null, challenges));
|
|
723
|
+
skip.classList.add("arkyc-btn-ghost");
|
|
724
|
+
this.footer.appendChild(skip);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Build the circular selfie/liveness preview overlay: an SVG progress ring, a
|
|
729
|
+
* gesture-cue layer, and a success checkmark — driven via the returned handles.
|
|
730
|
+
*
|
|
731
|
+
* @param video
|
|
732
|
+
* @returns
|
|
733
|
+
*/
|
|
734
|
+
buildFaceStage(video) {
|
|
735
|
+
const stage = this.el("div", { class: "arkyc-stage" });
|
|
736
|
+
stage.setAttribute("data-state", "wait");
|
|
737
|
+
stage.appendChild(video);
|
|
738
|
+
const ring = this.el("div", {
|
|
739
|
+
class: "arkyc-ring",
|
|
740
|
+
html: "<svg viewBox=\"0 0 100 100\"><circle class=\"arkyc-ring-track\" cx=\"50\" cy=\"50\" r=\"46\"/><circle class=\"arkyc-ring-arc\" cx=\"50\" cy=\"50\" r=\"46\"/></svg>"
|
|
741
|
+
});
|
|
742
|
+
stage.appendChild(ring);
|
|
743
|
+
const cue = this.el("div", { class: "arkyc-cue" });
|
|
744
|
+
stage.appendChild(cue);
|
|
745
|
+
stage.appendChild(this.el("div", {
|
|
746
|
+
class: "arkyc-check",
|
|
747
|
+
html: CHECK_ICON
|
|
748
|
+
}));
|
|
749
|
+
const arc = ring.querySelector(".arkyc-ring-arc");
|
|
750
|
+
const circumference = 2 * Math.PI * 46;
|
|
751
|
+
if (arc) {
|
|
752
|
+
arc.style.strokeDasharray = String(circumference);
|
|
753
|
+
arc.style.strokeDashoffset = String(circumference);
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
stage,
|
|
757
|
+
setProgress: (p) => {
|
|
758
|
+
if (arc) arc.style.strokeDashoffset = String(circumference * (1 - Math.max(0, Math.min(1, p))));
|
|
759
|
+
},
|
|
760
|
+
setState: (state) => stage.setAttribute("data-state", state),
|
|
761
|
+
setCue: (challenge) => {
|
|
762
|
+
cue.className = "arkyc-cue" + (challenge ? ` show arkyc-cue-${challenge}` : "");
|
|
763
|
+
cue.innerHTML = challenge ? CUE_ICONS[challenge] : "";
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Build the liveness step-progress dots (one per challenge).
|
|
769
|
+
*
|
|
770
|
+
* @param video
|
|
771
|
+
* @param hint
|
|
772
|
+
* @param doc
|
|
773
|
+
*/
|
|
774
|
+
buildDots(count) {
|
|
775
|
+
const el = this.el("div", { class: "arkyc-dots" });
|
|
776
|
+
const dots = [];
|
|
777
|
+
for (let i = 0; i < count; i += 1) {
|
|
778
|
+
const dot = this.el("div", { class: "arkyc-dot" });
|
|
779
|
+
dots.push(dot);
|
|
780
|
+
el.appendChild(dot);
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
el,
|
|
784
|
+
setActive: (index) => dots.forEach((d, i) => d.classList.toggle("active", i === index && !d.classList.contains("done"))),
|
|
785
|
+
markDone: (index) => {
|
|
786
|
+
const d = dots[index];
|
|
787
|
+
if (d) {
|
|
788
|
+
d.classList.remove("active");
|
|
789
|
+
d.classList.add("done");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Build the document preview overlay: a fixed card-shaped alignment guide
|
|
796
|
+
* (corner brackets + border) with an animated scan line. The guide is a target,
|
|
797
|
+
* not a tracker — it stays put so the user aligns the card to it; detection only
|
|
798
|
+
* tints it for quality and brightens it once a document is in view.
|
|
799
|
+
*
|
|
800
|
+
* @param video
|
|
801
|
+
* @returns
|
|
802
|
+
*/
|
|
803
|
+
buildDocStage(video) {
|
|
804
|
+
const stage = this.el("div", { class: "arkyc-doc" });
|
|
805
|
+
stage.setAttribute("data-q", "wait");
|
|
806
|
+
stage.appendChild(video);
|
|
807
|
+
const frame = this.el("div", { class: "arkyc-doc-frame" });
|
|
808
|
+
for (const corner of [
|
|
809
|
+
"tl",
|
|
810
|
+
"tr",
|
|
811
|
+
"bl",
|
|
812
|
+
"br"
|
|
813
|
+
]) frame.appendChild(this.el("span", { class: `arkyc-corner ${corner}` }));
|
|
814
|
+
frame.appendChild(this.el("div", { class: "arkyc-scan" }));
|
|
815
|
+
stage.appendChild(frame);
|
|
816
|
+
return {
|
|
817
|
+
stage,
|
|
818
|
+
setQuality: (quality) => stage.setAttribute("data-q", quality),
|
|
819
|
+
setFrame: (rect) => stage.setAttribute("data-detected", rect ? "true" : "false")
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
renderProcessing(label) {
|
|
823
|
+
this.body.appendChild(this.el("div", { class: "arkyc-spinner" }));
|
|
824
|
+
this.body.appendChild(this.el("p", {
|
|
825
|
+
class: "arkyc-p",
|
|
826
|
+
text: label
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
renderResult(decision, errorMessage, opts = {}) {
|
|
830
|
+
if (errorMessage) {
|
|
831
|
+
this.body.appendChild(this.el("div", {
|
|
832
|
+
class: "arkyc-badge err",
|
|
833
|
+
html: "!"
|
|
834
|
+
}));
|
|
835
|
+
this.body.appendChild(this.el("h2", {
|
|
836
|
+
class: "arkyc-h",
|
|
837
|
+
text: "Something went wrong"
|
|
838
|
+
}));
|
|
839
|
+
this.body.appendChild(this.el("p", {
|
|
840
|
+
class: "arkyc-p",
|
|
841
|
+
text: errorMessage
|
|
842
|
+
}));
|
|
843
|
+
} else if (opts.terminal) {
|
|
844
|
+
const n = TERMINAL_NOTICE[opts.status ?? "expired"] ?? EXPIRED_NOTICE;
|
|
845
|
+
this.body.appendChild(this.el("div", {
|
|
846
|
+
class: `arkyc-badge ${n.cls}`,
|
|
847
|
+
text: n.icon
|
|
848
|
+
}));
|
|
849
|
+
this.body.appendChild(this.el("h2", {
|
|
850
|
+
class: "arkyc-h",
|
|
851
|
+
text: n.title
|
|
852
|
+
}));
|
|
853
|
+
this.body.appendChild(this.el("p", {
|
|
854
|
+
class: "arkyc-p",
|
|
855
|
+
text: n.copy
|
|
856
|
+
}));
|
|
857
|
+
this.footer.appendChild(this.button("Close", () => this.handlers.onClose()));
|
|
858
|
+
return;
|
|
859
|
+
} else {
|
|
860
|
+
const map = {
|
|
861
|
+
approved: {
|
|
862
|
+
cls: "ok",
|
|
863
|
+
icon: "✓",
|
|
864
|
+
title: "Verified",
|
|
865
|
+
copy: "Your identity has been verified."
|
|
866
|
+
},
|
|
867
|
+
requires_review: {
|
|
868
|
+
cls: "warn",
|
|
869
|
+
icon: "⏳",
|
|
870
|
+
title: "Under review",
|
|
871
|
+
copy: "Your verification is being reviewed. We will be in touch shortly."
|
|
872
|
+
},
|
|
873
|
+
rejected: {
|
|
874
|
+
cls: "err",
|
|
875
|
+
icon: "✕",
|
|
876
|
+
title: "Not verified",
|
|
877
|
+
copy: "We could not verify your identity. Please try again."
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
const r = map[decision ?? "requires_review"] ?? map.requires_review;
|
|
881
|
+
this.body.appendChild(this.el("div", {
|
|
882
|
+
class: `arkyc-badge ${r.cls}`,
|
|
883
|
+
text: r.icon
|
|
884
|
+
}));
|
|
885
|
+
this.body.appendChild(this.el("h2", {
|
|
886
|
+
class: "arkyc-h",
|
|
887
|
+
text: r.title
|
|
888
|
+
}));
|
|
889
|
+
this.body.appendChild(this.el("p", {
|
|
890
|
+
class: "arkyc-p",
|
|
891
|
+
text: r.copy
|
|
892
|
+
}));
|
|
893
|
+
}
|
|
894
|
+
this.footer.appendChild(this.button("Done", () => this.handlers.onAcknowledge()));
|
|
895
|
+
}
|
|
896
|
+
button(label, onClick, extraClass) {
|
|
897
|
+
const cls = extraClass ? `arkyc-btn ${extraClass}` : "arkyc-btn";
|
|
898
|
+
const btn = this.el("button", {
|
|
899
|
+
class: cls,
|
|
900
|
+
text: label
|
|
901
|
+
});
|
|
902
|
+
let fired = false;
|
|
903
|
+
btn.addEventListener("click", () => {
|
|
904
|
+
if (fired) return;
|
|
905
|
+
fired = true;
|
|
906
|
+
btn.setAttribute("disabled", "true");
|
|
907
|
+
btn.classList.add("arkyc-busy");
|
|
908
|
+
onClick();
|
|
909
|
+
});
|
|
910
|
+
return btn;
|
|
911
|
+
}
|
|
912
|
+
clear(node) {
|
|
913
|
+
while (node.firstChild) node.removeChild(node.firstChild);
|
|
914
|
+
}
|
|
915
|
+
el(tag, props = {}) {
|
|
916
|
+
const node = this.doc.createElement(tag);
|
|
917
|
+
for (const [key, value] of Object.entries(props)) {
|
|
918
|
+
if (value == null) continue;
|
|
919
|
+
if (key === "class") node.className = value;
|
|
920
|
+
else if (key === "text") node.textContent = value;
|
|
921
|
+
else if (key === "html") node.innerHTML = value;
|
|
922
|
+
else if (key === "value" || key === "src" || key === "type" || key === "accept" || key === "placeholder") node[key] = value;
|
|
923
|
+
else node.setAttribute(key, value);
|
|
924
|
+
}
|
|
925
|
+
return node;
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
//#endregion
|
|
929
|
+
export { WidgetView };
|
|
930
|
+
|
|
931
|
+
//# sourceMappingURL=ui.mjs.map
|