From c1b1cdc807240561892f774201b874c32b7a861f Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 21 Oct 2026 17:16:52 +0100 Subject: [PATCH 1/3] feat(liveness): per-project reject floor; borderline scores go to review A failed liveness check is declined only below livenessRejectThreshold (default 0.2, a clear spoof); from the floor up it is borderline and goes to manual review. Laptop webcams often score just under 0.5 on a real person, which used to be an automatic decline. The floor may not sit above the liveness threshold; it is editable in the dashboard. A failed challenge sequence now scores 0, so it is always declined. --- apps/api/src/support/risk-settings.ts | 10 ++++++ apps/api/tests/project-thresholds.test.ts | 8 +++++ .../pages/projects/ProjectSettingsPage.tsx | 19 +++++++++++ packages/core/src/decision.ts | 6 ++-- packages/core/tests/decision.test.ts | 33 ++++++++++++++++++- packages/liveness/src/drivers/local.ts | 3 +- packages/liveness/src/drivers/mock.ts | 2 +- packages/types/src/project.ts | 9 +++++ 8 files changed, 85 insertions(+), 5 deletions(-) diff --git a/apps/api/src/support/risk-settings.ts b/apps/api/src/support/risk-settings.ts index 25080654..fbc2a6a3 100644 --- a/apps/api/src/support/risk-settings.ts +++ b/apps/api/src/support/risk-settings.ts @@ -75,6 +75,7 @@ const THRESHOLD_KEYS: (keyof VerificationThresholds)[] = [ 'documentQualityThreshold', 'ocrConfidenceThreshold', 'livenessThreshold', + 'livenessRejectThreshold', 'faceMatchThreshold', 'faceMatchRejectThreshold', 'addressThreshold', @@ -122,5 +123,14 @@ export function mergeThresholds( }) } + const livenessFloor = merged.livenessRejectThreshold ?? DEFAULT_VERIFICATION_THRESHOLDS.livenessRejectThreshold + const liveness = merged.livenessThreshold ?? DEFAULT_VERIFICATION_THRESHOLDS.livenessThreshold + + if (livenessFloor > liveness) { + throw ValidationException.withMessages({ + thresholds: [`livenessRejectThreshold (${livenessFloor}) must not be above livenessThreshold (${liveness}).`], + }) + } + return merged } diff --git a/apps/api/tests/project-thresholds.test.ts b/apps/api/tests/project-thresholds.test.ts index 9f469075..a051aaf5 100644 --- a/apps/api/tests/project-thresholds.test.ts +++ b/apps/api/tests/project-thresholds.test.ts @@ -45,6 +45,14 @@ describe('project verification thresholds', () => { expect(res.status).toBe(422) }) + it('saves a liveness reject floor, but not above the liveness threshold', async () => { + const res = await update({ livenessRejectThreshold: 0.3 }) + expect(res.status, res.text).toBe(200) + expect(await stored()).toMatchObject({ livenessRejectThreshold: 0.3 }) + + expect((await update({ livenessThreshold: 0.5, livenessRejectThreshold: 0.6 })).status).toBe(422) + }) + it('rejects a score outside 0 to 1', async () => { expect((await update({ livenessThreshold: 1.5 })).status).toBe(422) expect((await update({ livenessThreshold: 'high' })).status).toBe(422) diff --git a/apps/dashboard/src/pages/projects/ProjectSettingsPage.tsx b/apps/dashboard/src/pages/projects/ProjectSettingsPage.tsx index 1978ca9a..c6d5051e 100644 --- a/apps/dashboard/src/pages/projects/ProjectSettingsPage.tsx +++ b/apps/dashboard/src/pages/projects/ProjectSettingsPage.tsx @@ -29,6 +29,7 @@ interface FormState { documentQualityThreshold: string ocrConfidenceThreshold: string livenessThreshold: string + livenessRejectThreshold: string faceMatchThreshold: string faceMatchRejectThreshold: string addressThreshold: string @@ -58,6 +59,12 @@ const THRESHOLD_FIELDS: { key: ScoreThreshold; label: string; description: strin label: 'Liveness', description: 'Selfies scoring below this as a live person go to review.', }, + { + key: 'livenessRejectThreshold', + label: 'Liveness reject floor', + description: + 'Selfies that fail below this are declined, and those between this and Liveness go to a reviewer. Laptop webcams often score just under 0.5.', + }, { key: 'faceMatchThreshold', label: 'Face match', @@ -116,6 +123,9 @@ function formFromProject(project: Project): FormState { documentQualityThreshold: String(thresholds.documentQualityThreshold), ocrConfidenceThreshold: String(thresholds.ocrConfidenceThreshold), livenessThreshold: String(thresholds.livenessThreshold), + livenessRejectThreshold: String( + thresholds.livenessRejectThreshold ?? DEFAULT_VERIFICATION_THRESHOLDS.livenessRejectThreshold, + ), faceMatchThreshold: String(thresholds.faceMatchThreshold), faceMatchRejectThreshold: thresholds.faceMatchRejectThreshold != null ? String(thresholds.faceMatchRejectThreshold) : '', @@ -216,8 +226,17 @@ function ProjectSettingsForm({ project }: { project: Project }) { // The reject floor can't sit above the face-match threshold (blank = its default). const faceMatchInput = Number(form.faceMatchThreshold.trim() || DEFAULT_VERIFICATION_THRESHOLDS.faceMatchThreshold) const floorMax = Number.isFinite(faceMatchInput) ? Math.min(1, Math.max(0, faceMatchInput)) : 1 + // The liveness floor can't sit above the liveness threshold either. + const livenessInput = Number(form.livenessThreshold.trim() || DEFAULT_VERIFICATION_THRESHOLDS.livenessThreshold) + const livenessFloorMax = Number.isFinite(livenessInput) ? Math.min(1, Math.max(0, livenessInput)) : 1 + const thresholdErrors: Record = { ...Object.fromEntries(THRESHOLD_FIELDS.map(({ key }) => [key, thresholdError(form[key])])), + livenessRejectThreshold: thresholdError( + form.livenessRejectThreshold, + livenessFloorMax, + `Keep this at or below Liveness (${livenessFloorMax}).`, + ), faceMatchRejectThreshold: thresholdError( form.faceMatchRejectThreshold, floorMax, diff --git a/packages/core/src/decision.ts b/packages/core/src/decision.ts index 4d1c7fe6..67354b5c 100644 --- a/packages/core/src/decision.ts +++ b/packages/core/src/decision.ts @@ -98,7 +98,9 @@ export class DecisionEngine { if (input.document.expired) { return verdict('rejected', 'DOCUMENT_EXPIRED') } - if (!input.liveness.passed) { + // Only a clear spoof is declined; a failed check scoring at or above the floor + // is borderline and goes to a reviewer below. + if (!input.liveness.passed && input.liveness.score < t.livenessRejectThreshold) { return verdict('rejected', 'LIVENESS_FAILED') } @@ -132,7 +134,7 @@ export class DecisionEngine { if (input.document.ocrConfidence < t.ocrConfidenceThreshold) { return verdict('requires_review', 'OCR_LOW_CONFIDENCE') } - if (input.liveness.score < t.livenessThreshold) { + if (!input.liveness.passed || input.liveness.score < t.livenessThreshold) { return verdict('requires_review', 'LIVENESS_LOW_CONFIDENCE') } if (input.faceMatch.similarityScore < t.faceMatchThreshold) { diff --git a/packages/core/tests/decision.test.ts b/packages/core/tests/decision.test.ts index d74dc930..ea1aef2d 100644 --- a/packages/core/tests/decision.test.ts +++ b/packages/core/tests/decision.test.ts @@ -36,13 +36,32 @@ describe('DecisionEngine.decide', () => { reason: 'DOCUMENT_EXPIRED', }, { - name: 'rejects failed liveness', + name: 'rejects failed liveness below the reject floor (a clear spoof)', mutate: (i) => { i.liveness.passed = false + i.liveness.score = 0.1 }, decision: 'rejected', reason: 'LIVENESS_FAILED', }, + { + name: 'reviews failed liveness at or above the reject floor (borderline)', + mutate: (i) => { + i.liveness.passed = false + i.liveness.score = 0.46 + }, + decision: 'requires_review', + reason: 'LIVENESS_LOW_CONFIDENCE', + }, + { + name: 'reviews a failed liveness check even when its score is high', + mutate: (i) => { + i.liveness.passed = false + i.liveness.score = 0.95 + }, + decision: 'requires_review', + reason: 'LIVENESS_LOW_CONFIDENCE', + }, { name: 'rejects failed face match', mutate: (i) => { @@ -169,6 +188,7 @@ describe('DecisionEngine.decide', () => { it('orders reject reasons: expired before liveness before face match', () => { const input = goodInput() input.liveness.passed = false + input.liveness.score = 0 input.faceMatch.passed = false expect(DecisionEngine.decide(input).reason).toBe('LIVENESS_FAILED') }) @@ -231,6 +251,7 @@ describe('DecisionEngine โ€” face-match reject floor', () => { it('keeps hard rejections ahead of the face-match floor', () => { const input = idToSelfie() input.liveness.passed = false + input.liveness.score = 0 expect(DecisionEngine.decide(input, { faceMatchRejectThreshold: 0.3 }).reason).toBe('LIVENESS_FAILED') }) @@ -269,9 +290,19 @@ describe('risk signals', () => { expect(DecisionEngine.decide(input)).toMatchObject({ decision: 'rejected', reason: 'SANCTIONS_MATCH' }) input.liveness.passed = false + input.liveness.score = 0 expect(DecisionEngine.decide(input).reason).toBe('LIVENESS_FAILED') }) + it('honours a per-project liveness reject floor', () => { + const input = goodInput() + input.liveness.passed = false + input.liveness.score = 0.3 + + expect(DecisionEngine.decide(input).reason).toBe('LIVENESS_LOW_CONFIDENCE') + expect(DecisionEngine.decide(input, { livenessRejectThreshold: 0.5 }).reason).toBe('LIVENESS_FAILED') + }) + it('ignores info signals and orders by signal precedence', () => { expect(DecisionEngine.decide({ ...goodInput(), signals: [signal('velocity_exceeded', 'info')] }).reason).toBe( 'AUTO_APPROVED', diff --git a/packages/liveness/src/drivers/local.ts b/packages/liveness/src/drivers/local.ts index 7a262780..52aa25ac 100644 --- a/packages/liveness/src/drivers/local.ts +++ b/packages/liveness/src/drivers/local.ts @@ -79,7 +79,8 @@ export class LocalLivenessDriver implements LivenessDriver { const [cx, cy] = [face.box[0] + face.box[2] / 2, face.box[1] + face.box[3] / 2] const faceNotCentered = Math.abs(cx / image.width - 0.5) > 0.3 || Math.abs(cy / image.height - 0.5) > 0.3 - const score = sequenceOk ? realProbability : Math.min(realProbability, 0.3) + // A failed challenge sequence is no liveness at all: score 0, always declined. + const score = sequenceOk ? realProbability : 0 return this.result( realProbability >= 0.5 && sequenceOk, diff --git a/packages/liveness/src/drivers/mock.ts b/packages/liveness/src/drivers/mock.ts index a59fc4cf..9a2ee4ab 100644 --- a/packages/liveness/src/drivers/mock.ts +++ b/packages/liveness/src/drivers/mock.ts @@ -24,7 +24,7 @@ export class MockLivenessDriver implements LivenessDriver { return { passed, - score: sequenceOk ? score : Math.min(score, 0.3), + score: sequenceOk ? score : 0, spoofSignals: { screenReplay: active && !sequenceOk, printedPhoto: false, diff --git a/packages/types/src/project.ts b/packages/types/src/project.ts index b17a22f9..92ffd99b 100644 --- a/packages/types/src/project.ts +++ b/packages/types/src/project.ts @@ -18,6 +18,14 @@ export interface VerificationThresholds { documentQualityThreshold: number ocrConfidenceThreshold: number livenessThreshold: number + /** + * Liveness score below which a failed check is declined outright. A failed + * check scoring from here up is borderline (a laptop webcam's noise can look + * like a screen to the anti-spoofing model) and goes to manual review instead. + * At or below `livenessThreshold`. A failed challenge sequence scores 0, so it + * is always declined. + */ + livenessRejectThreshold: number faceMatchThreshold: number /** * Face-match similarity below which a session is rejected outright. Unset (the @@ -39,6 +47,7 @@ export const DEFAULT_VERIFICATION_THRESHOLDS: VerificationThresholds = { documentQualityThreshold: 0.75, ocrConfidenceThreshold: 0.8, livenessThreshold: 0.85, + livenessRejectThreshold: 0.2, faceMatchThreshold: 0.75, addressThreshold: 0.6, } From 25ee20e56f12982def2328825afcce3bd9122499 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 21 Oct 2026 17:16:52 +0100 Subject: [PATCH 2/3] feat(widget): searchable country screen with the full country list The country step is a search box over a scrollable list, matching names from the start of any word and ignoring accents, or a two-letter code, with the preselected country on top. The list was missing about 50 issuing countries and territories (Cote d'Ivoire, Palestine, Kosovo, Taiwan, Hong Kong, ...); it is regenerated from the ISO 3166-1 regions plus Kosovo, alphabetically. --- packages/widget/src/countries.ts | 1215 ++++++-------------------- packages/widget/src/theme.css | 29 + packages/widget/src/ui.ts | 96 +- packages/widget/tests/widget.test.ts | 72 +- 4 files changed, 411 insertions(+), 1001 deletions(-) diff --git a/packages/widget/src/countries.ts b/packages/widget/src/countries.ts index 93ff0ce6..971eeee0 100644 --- a/packages/widget/src/countries.ts +++ b/packages/widget/src/countries.ts @@ -1,962 +1,257 @@ +/** + * Countries and territories that issue identity documents: ISO 3166-1 alpha-2 + * codes (plus Kosovo, XK) with English names, alphabetical. Generated from the + * runtime's CLDR region names; flags are the codes' regional-indicator pairs. + */ export const countries = [ - { - name: 'United States', - iso2: 'US', - flag: '๐Ÿ‡บ๐Ÿ‡ธ', - }, - { - name: 'Canada', - iso2: 'CA', - flag: '๐Ÿ‡จ๐Ÿ‡ฆ', - }, - { - name: 'Brazil', - iso2: 'BR', - flag: '๐Ÿ‡ง๐Ÿ‡ท', - }, - { - name: 'United Kingdom', - iso2: 'GB', - flag: '๐Ÿ‡ฌ๐Ÿ‡ง', - }, - { - name: 'Germany', - iso2: 'DE', - flag: '๐Ÿ‡ฉ๐Ÿ‡ช', - }, - { - name: 'France', - iso2: 'FR', - flag: '๐Ÿ‡ซ๐Ÿ‡ท', - }, - { - name: 'China', - iso2: 'CN', - flag: '๐Ÿ‡จ๐Ÿ‡ณ', - }, - { - name: 'Japan', - iso2: 'JP', - flag: '๐Ÿ‡ฏ๐Ÿ‡ต', - }, - { - name: 'India', - iso2: 'IN', - flag: '๐Ÿ‡ฎ๐Ÿ‡ณ', - }, - { - name: 'Australia', - iso2: 'AU', - flag: '๐Ÿ‡ฆ๐Ÿ‡บ', - }, - { - name: 'South Africa', - iso2: 'ZA', - flag: '๐Ÿ‡ฟ๐Ÿ‡ฆ', - }, - { - name: 'Russia', - iso2: 'RU', - flag: '๐Ÿ‡ท๐Ÿ‡บ', - }, - { - name: 'Saudi Arabia', - iso2: 'SA', - flag: '๐Ÿ‡ธ๐Ÿ‡ฆ', - }, - { - name: 'Afghanistan', - iso2: 'AF', - flag: '๐Ÿ‡ฆ๐Ÿ‡ซ', - }, - { - name: 'Albania', - iso2: 'AL', - flag: '๐Ÿ‡ฆ๐Ÿ‡ฑ', - }, - { - name: 'Algeria', - iso2: 'DZ', - flag: '๐Ÿ‡ฉ๐Ÿ‡ฟ', - }, - { - name: 'Andorra', - iso2: 'AD', - flag: '๐Ÿ‡ฆ๐Ÿ‡ฉ', - }, - { - name: 'Angola', - iso2: 'AO', - flag: '๐Ÿ‡ฆ๐Ÿ‡ด', - }, - { - name: 'Antigua and Barbuda', - iso2: 'AG', - flag: '๐Ÿ‡ฆ๐Ÿ‡ฌ', - }, - { - name: 'Argentina', - iso2: 'AR', - flag: '๐Ÿ‡ฆ๐Ÿ‡ท', - }, - { - name: 'Armenia', - iso2: 'AM', - flag: '๐Ÿ‡ฆ๐Ÿ‡ฒ', - }, - { - name: 'Austria', - iso2: 'AT', - flag: '๐Ÿ‡ฆ๐Ÿ‡น', - }, - { - name: 'Azerbaijan', - iso2: 'AZ', - flag: '๐Ÿ‡ฆ๐Ÿ‡ฟ', - }, - { - name: 'Bahamas', - iso2: 'BS', - flag: '๐Ÿ‡ง๐Ÿ‡ธ', - }, - { - name: 'Bahrain', - iso2: 'BH', - flag: '๐Ÿ‡ง๐Ÿ‡ญ', - }, - { - name: 'Bangladesh', - iso2: 'BD', - flag: '๐Ÿ‡ง๐Ÿ‡ฉ', - }, - { - name: 'Barbados', - iso2: 'BB', - flag: '๐Ÿ‡ง๐Ÿ‡ง', - }, - { - name: 'Belarus', - iso2: 'BY', - flag: '๐Ÿ‡ง๐Ÿ‡พ', - }, - { - name: 'Belgium', - iso2: 'BE', - flag: '๐Ÿ‡ง๐Ÿ‡ช', - }, - { - name: 'Belize', - iso2: 'BZ', - flag: '๐Ÿ‡ง๐Ÿ‡ฟ', - }, - { - name: 'Benin', - iso2: 'BJ', - flag: '๐Ÿ‡ง๐Ÿ‡ฏ', - }, - { - name: 'Bhutan', - iso2: 'BT', - flag: '๐Ÿ‡ง๐Ÿ‡น', - }, - { - name: 'Bolivia', - iso2: 'BO', - flag: '๐Ÿ‡ง๐Ÿ‡ด', - }, - { - name: 'Bosnia and Herzegovina', - iso2: 'BA', - flag: '๐Ÿ‡ง๐Ÿ‡ฆ', - }, - { - name: 'Botswana', - iso2: 'BW', - flag: '๐Ÿ‡ง๐Ÿ‡ผ', - }, - { - name: 'Brunei', - iso2: 'BN', - flag: '๐Ÿ‡ง๐Ÿ‡ณ', - }, - { - name: 'Bulgaria', - iso2: 'BG', - flag: '๐Ÿ‡ง๐Ÿ‡ฌ', - }, - { - name: 'Burkina Faso', - iso2: 'BF', - flag: '๐Ÿ‡ง๐Ÿ‡ซ', - }, - { - name: 'Burundi', - iso2: 'BI', - flag: '๐Ÿ‡ง๐Ÿ‡ฎ', - }, - { - name: 'Cabo Verde', - iso2: 'CV', - flag: '๐Ÿ‡จ๐Ÿ‡ป', - }, - { - name: 'Cambodia', - iso2: 'KH', - flag: '๐Ÿ‡ฐ๐Ÿ‡ญ', - }, - { - name: 'Cameroon', - iso2: 'CM', - flag: '๐Ÿ‡จ๐Ÿ‡ฒ', - }, - { - name: 'Central African Republic', - iso2: 'CF', - flag: '๐Ÿ‡จ๐Ÿ‡ซ', - }, - { - name: 'Chad', - iso2: 'TD', - flag: '๐Ÿ‡น๐Ÿ‡ฉ', - }, - { - name: 'Chile', - iso2: 'CL', - flag: '๐Ÿ‡จ๐Ÿ‡ฑ', - }, - { - name: 'Colombia', - iso2: 'CO', - flag: '๐Ÿ‡จ๐Ÿ‡ด', - }, - { - name: 'Comoros', - iso2: 'KM', - flag: '๐Ÿ‡ฐ๐Ÿ‡ฒ', - }, - { - name: 'Congo (Congo-Brazzaville)', - iso2: 'CG', - flag: '๐Ÿ‡จ๐Ÿ‡ฌ', - }, - { - name: 'Congo (Congo-Kinshasa)', - iso2: 'CD', - flag: '๐Ÿ‡จ๐Ÿ‡ฉ', - }, - { - name: 'Costa Rica', - iso2: 'CR', - flag: '๐Ÿ‡จ๐Ÿ‡ท', - }, - { - name: 'Croatia', - iso2: 'HR', - flag: '๐Ÿ‡ญ๐Ÿ‡ท', - }, - { - name: 'Cuba', - iso2: 'CU', - flag: '๐Ÿ‡จ๐Ÿ‡บ', - }, - { - name: 'Cyprus', - iso2: 'CY', - flag: '๐Ÿ‡จ๐Ÿ‡พ', - }, - { - name: 'Czech Republic', - iso2: 'CZ', - flag: '๐Ÿ‡จ๐Ÿ‡ฟ', - }, - { - name: 'Denmark', - iso2: 'DK', - flag: '๐Ÿ‡ฉ๐Ÿ‡ฐ', - }, - { - name: 'Djibouti', - iso2: 'DJ', - flag: '๐Ÿ‡ฉ๐Ÿ‡ฏ', - }, - { - name: 'Dominica', - iso2: 'DM', - flag: '๐Ÿ‡ฉ๐Ÿ‡ฒ', - }, - { - name: 'Dominican Republic', - iso2: 'DO', - flag: '๐Ÿ‡ฉ๐Ÿ‡ด', - }, - { - name: 'Ecuador', - iso2: 'EC', - flag: '๐Ÿ‡ช๐Ÿ‡จ', - }, - { - name: 'Egypt', - iso2: 'EG', - flag: '๐Ÿ‡ช๐Ÿ‡ฌ', - }, - { - name: 'El Salvador', - iso2: 'SV', - flag: '๐Ÿ‡ธ๐Ÿ‡ป', - }, - { - name: 'Equatorial Guinea', - iso2: 'GQ', - flag: '๐Ÿ‡ฌ๐Ÿ‡ถ', - }, - { - name: 'Eritrea', - iso2: 'ER', - flag: '๐Ÿ‡ช๐Ÿ‡ท', - }, - { - name: 'Estonia', - iso2: 'EE', - flag: '๐Ÿ‡ช๐Ÿ‡ช', - }, - { - name: 'Eswatini', - iso2: 'SZ', - flag: '๐Ÿ‡ธ๐Ÿ‡ฟ', - }, - { - name: 'Ethiopia', - iso2: 'ET', - flag: '๐Ÿ‡ช๐Ÿ‡น', - }, - { - name: 'Fiji', - iso2: 'FJ', - flag: '๐Ÿ‡ซ๐Ÿ‡ฏ', - }, - { - name: 'Finland', - iso2: 'FI', - flag: '๐Ÿ‡ซ๐Ÿ‡ฎ', - }, - { - name: 'Gabon', - iso2: 'GA', - flag: '๐Ÿ‡ฌ๐Ÿ‡ฆ', - }, - { - name: 'Gambia', - iso2: 'GM', - flag: '๐Ÿ‡ฌ๐Ÿ‡ฒ', - }, - { - name: 'Georgia', - iso2: 'GE', - flag: '๐Ÿ‡ฌ๐Ÿ‡ช', - }, - { - name: 'Ghana', - iso2: 'GH', - flag: '๐Ÿ‡ฌ๐Ÿ‡ญ', - }, - { - name: 'Greece', - iso2: 'GR', - flag: '๐Ÿ‡ฌ๐Ÿ‡ท', - }, - { - name: 'Grenada', - iso2: 'GD', - flag: '๐Ÿ‡ฌ๐Ÿ‡ฉ', - }, - { - name: 'Guatemala', - iso2: 'GT', - flag: '๐Ÿ‡ฌ๐Ÿ‡น', - }, - { - name: 'Guinea', - iso2: 'GN', - flag: '๐Ÿ‡ฌ๐Ÿ‡ณ', - }, - { - name: 'Guinea-Bissau', - iso2: 'GW', - flag: '๐Ÿ‡ฌ๐Ÿ‡ผ', - }, - { - name: 'Guyana', - iso2: 'GY', - flag: '๐Ÿ‡ฌ๐Ÿ‡พ', - }, - { - name: 'Haiti', - iso2: 'HT', - flag: '๐Ÿ‡ญ๐Ÿ‡น', - }, - { - name: 'Honduras', - iso2: 'HN', - flag: '๐Ÿ‡ญ๐Ÿ‡ณ', - }, - { - name: 'Hungary', - iso2: 'HU', - flag: '๐Ÿ‡ญ๐Ÿ‡บ', - }, - { - name: 'Iceland', - iso2: 'IS', - flag: '๐Ÿ‡ฎ๐Ÿ‡ธ', - }, - { - name: 'Indonesia', - iso2: 'ID', - flag: '๐Ÿ‡ฎ๐Ÿ‡ฉ', - }, - { - name: 'Iran', - iso2: 'IR', - flag: '๐Ÿ‡ฎ๐Ÿ‡ท', - }, - { - name: 'Iraq', - iso2: 'IQ', - flag: '๐Ÿ‡ฎ๐Ÿ‡ถ', - }, - { - name: 'Ireland', - iso2: 'IE', - flag: '๐Ÿ‡ฎ๐Ÿ‡ช', - }, - { - name: 'Israel', - iso2: 'IL', - flag: '๐Ÿ‡ฎ๐Ÿ‡ฑ', - }, - { - name: 'Italy', - iso2: 'IT', - flag: '๐Ÿ‡ฎ๐Ÿ‡น', - }, - { - name: 'Jamaica', - iso2: 'JM', - flag: '๐Ÿ‡ฏ๐Ÿ‡ฒ', - }, - { - name: 'Jordan', - iso2: 'JO', - flag: '๐Ÿ‡ฏ๐Ÿ‡ด', - }, - { - name: 'Kazakhstan', - iso2: 'KZ', - flag: '๐Ÿ‡ฐ๐Ÿ‡ฟ', - }, - { - name: 'Kenya', - iso2: 'KE', - flag: '๐Ÿ‡ฐ๐Ÿ‡ช', - }, - { - name: 'Kiribati', - iso2: 'KI', - flag: '๐Ÿ‡ฐ๐Ÿ‡ฎ', - }, - { - name: 'Kuwait', - iso2: 'KW', - flag: '๐Ÿ‡ฐ๐Ÿ‡ผ', - }, - { - name: 'Kyrgyzstan', - iso2: 'KG', - flag: '๐Ÿ‡ฐ๐Ÿ‡ฌ', - }, - { - name: 'Laos', - iso2: 'LA', - flag: '๐Ÿ‡ฑ๐Ÿ‡ฆ', - }, - { - name: 'Latvia', - iso2: 'LV', - flag: '๐Ÿ‡ฑ๐Ÿ‡ป', - }, - { - name: 'Lebanon', - iso2: 'LB', - flag: '๐Ÿ‡ฑ๐Ÿ‡ง', - }, - { - name: 'Lesotho', - iso2: 'LS', - flag: '๐Ÿ‡ฑ๐Ÿ‡ธ', - }, - { - name: 'Liberia', - iso2: 'LR', - flag: '๐Ÿ‡ฑ๐Ÿ‡ท', - }, - { - name: 'Libya', - iso2: 'LY', - flag: '๐Ÿ‡ฑ๐Ÿ‡พ', - }, - { - name: 'Liechtenstein', - iso2: 'LI', - flag: '๐Ÿ‡ฑ๐Ÿ‡ฎ', - }, - { - name: 'Lithuania', - iso2: 'LT', - flag: '๐Ÿ‡ฑ๐Ÿ‡น', - }, - { - name: 'Luxembourg', - iso2: 'LU', - flag: '๐Ÿ‡ฑ๐Ÿ‡บ', - }, - { - name: 'Madagascar', - iso2: 'MG', - flag: '๐Ÿ‡ฒ๐Ÿ‡ฌ', - }, - { - name: 'Malawi', - iso2: 'MW', - flag: '๐Ÿ‡ฒ๐Ÿ‡ผ', - }, - { - name: 'Malaysia', - iso2: 'MY', - flag: '๐Ÿ‡ฒ๐Ÿ‡พ', - }, - { - name: 'Maldives', - iso2: 'MV', - flag: '๐Ÿ‡ฒ๐Ÿ‡ป', - }, - { - name: 'Mali', - iso2: 'ML', - flag: '๐Ÿ‡ฒ๐Ÿ‡ฑ', - }, - { - name: 'Malta', - iso2: 'MT', - flag: '๐Ÿ‡ฒ๐Ÿ‡น', - }, - { - name: 'Marshall Islands', - iso2: 'MH', - flag: '๐Ÿ‡ฒ๐Ÿ‡ญ', - }, - { - name: 'Mauritania', - iso2: 'MR', - flag: '๐Ÿ‡ฒ๐Ÿ‡ท', - }, - { - name: 'Mauritius', - iso2: 'MU', - flag: '๐Ÿ‡ฒ๐Ÿ‡บ', - }, - { - name: 'Mexico', - iso2: 'MX', - flag: '๐Ÿ‡ฒ๐Ÿ‡ฝ', - }, - { - name: 'Micronesia', - iso2: 'FM', - flag: '๐Ÿ‡ซ๐Ÿ‡ฒ', - }, - { - name: 'Moldova', - iso2: 'MD', - flag: '๐Ÿ‡ฒ๐Ÿ‡ฉ', - }, - { - name: 'Monaco', - iso2: 'MC', - flag: '๐Ÿ‡ฒ๐Ÿ‡จ', - }, - { - name: 'Mongolia', - iso2: 'MN', - flag: '๐Ÿ‡ฒ๐Ÿ‡ณ', - }, - { - name: 'Montenegro', - iso2: 'ME', - flag: '๐Ÿ‡ฒ๐Ÿ‡ช', - }, - { - name: 'Morocco', - iso2: 'MA', - flag: '๐Ÿ‡ฒ๐Ÿ‡ฆ', - }, - { - name: 'Mozambique', - iso2: 'MZ', - flag: '๐Ÿ‡ฒ๐Ÿ‡ฟ', - }, - { - name: 'Myanmar', - iso2: 'MM', - flag: '๐Ÿ‡ฒ๐Ÿ‡ฒ', - }, - { - name: 'Namibia', - iso2: 'NA', - flag: '๐Ÿ‡ณ๐Ÿ‡ฆ', - }, - { - name: 'Nauru', - iso2: 'NR', - flag: '๐Ÿ‡ณ๐Ÿ‡ท', - }, - { - name: 'Nepal', - iso2: 'NP', - flag: '๐Ÿ‡ณ๐Ÿ‡ต', - }, - { - name: 'Netherlands', - iso2: 'NL', - flag: '๐Ÿ‡ณ๐Ÿ‡ฑ', - }, - { - name: 'New Zealand', - iso2: 'NZ', - flag: '๐Ÿ‡ณ๐Ÿ‡ฟ', - }, - { - name: 'Nicaragua', - iso2: 'NI', - flag: '๐Ÿ‡ณ๐Ÿ‡ฎ', - }, - { - name: 'Niger', - iso2: 'NE', - flag: '๐Ÿ‡ณ๐Ÿ‡ช', - }, - { - name: 'Nigeria', - iso2: 'NG', - flag: '๐Ÿ‡ณ๐Ÿ‡ฌ', - }, - { - name: 'North Korea', - iso2: 'KP', - flag: '๐Ÿ‡ฐ๐Ÿ‡ต', - }, - { - name: 'North Macedonia', - iso2: 'MK', - flag: '๐Ÿ‡ฒ๐Ÿ‡ฐ', - }, - { - name: 'Norway', - iso2: 'NO', - flag: '๐Ÿ‡ณ๐Ÿ‡ด', - }, - { - name: 'Oman', - iso2: 'OM', - flag: '๐Ÿ‡ด๐Ÿ‡ฒ', - }, - { - name: 'Pakistan', - iso2: 'PK', - flag: '๐Ÿ‡ต๐Ÿ‡ฐ', - }, - { - name: 'Palau', - iso2: 'PW', - flag: '๐Ÿ‡ต๐Ÿ‡ผ', - }, - { - name: 'Panama', - iso2: 'PA', - flag: '๐Ÿ‡ต๐Ÿ‡ฆ', - }, - { - name: 'Papua New Guinea', - iso2: 'PG', - flag: '๐Ÿ‡ต๐Ÿ‡ฌ', - }, - { - name: 'Paraguay', - iso2: 'PY', - flag: '๐Ÿ‡ต๐Ÿ‡พ', - }, - { - name: 'Peru', - iso2: 'PE', - flag: '๐Ÿ‡ต๐Ÿ‡ช', - }, - { - name: 'Philippines', - iso2: 'PH', - flag: '๐Ÿ‡ต๐Ÿ‡ญ', - }, - { - name: 'Poland', - iso2: 'PL', - flag: '๐Ÿ‡ต๐Ÿ‡ฑ', - }, - { - name: 'Portugal', - iso2: 'PT', - flag: '๐Ÿ‡ต๐Ÿ‡น', - }, - { - name: 'Qatar', - iso2: 'QA', - flag: '๐Ÿ‡ถ๐Ÿ‡ฆ', - }, - { - name: 'Romania', - iso2: 'RO', - flag: '๐Ÿ‡ท๐Ÿ‡ด', - }, - { - name: 'Rwanda', - iso2: 'RW', - flag: '๐Ÿ‡ท๐Ÿ‡ผ', - }, - { - name: 'Saint Kitts and Nevis', - iso2: 'KN', - flag: '๐Ÿ‡ฐ๐Ÿ‡ณ', - }, - { - name: 'Saint Lucia', - iso2: 'LC', - flag: '๐Ÿ‡ฑ๐Ÿ‡จ', - }, - { - name: 'Saint Vincent and the Grenadines', - iso2: 'VC', - flag: '๐Ÿ‡ป๐Ÿ‡จ', - }, - { - name: 'Samoa', - iso2: 'WS', - flag: '๐Ÿ‡ผ๐Ÿ‡ธ', - }, - { - name: 'San Marino', - iso2: 'SM', - flag: '๐Ÿ‡ธ๐Ÿ‡ฒ', - }, - { - name: 'Sao Tome and Principe', - iso2: 'ST', - flag: '๐Ÿ‡ธ๐Ÿ‡น', - }, - { - name: 'Senegal', - iso2: 'SN', - flag: '๐Ÿ‡ธ๐Ÿ‡ณ', - }, - { - name: 'Serbia', - iso2: 'RS', - flag: '๐Ÿ‡ท๐Ÿ‡ธ', - }, - { - name: 'Seychelles', - iso2: 'SC', - flag: '๐Ÿ‡ธ๐Ÿ‡จ', - }, - { - name: 'Sierra Leone', - iso2: 'SL', - flag: '๐Ÿ‡ธ๐Ÿ‡ฑ', - }, - { - name: 'Singapore', - iso2: 'SG', - flag: '๐Ÿ‡ธ๐Ÿ‡ฌ', - }, - { - name: 'Slovakia', - iso2: 'SK', - flag: '๐Ÿ‡ธ๐Ÿ‡ฐ', - }, - { - name: 'Slovenia', - iso2: 'SI', - flag: '๐Ÿ‡ธ๐Ÿ‡ฎ', - }, - { - name: 'Solomon Islands', - iso2: 'SB', - flag: '๐Ÿ‡ธ๐Ÿ‡ง', - }, - { - name: 'Somalia', - iso2: 'SO', - flag: '๐Ÿ‡ธ๐Ÿ‡ด', - }, - { - name: 'South Korea', - iso2: 'KR', - flag: '๐Ÿ‡ฐ๐Ÿ‡ท', - }, - { - name: 'South Sudan', - iso2: 'SS', - flag: '๐Ÿ‡ธ๐Ÿ‡ธ', - }, - { - name: 'Spain', - iso2: 'ES', - flag: '๐Ÿ‡ช๐Ÿ‡ธ', - }, - { - name: 'Sri Lanka', - iso2: 'LK', - flag: '๐Ÿ‡ฑ๐Ÿ‡ฐ', - }, - { - name: 'Sudan', - iso2: 'SD', - flag: '๐Ÿ‡ธ๐Ÿ‡ฉ', - }, - { - name: 'Suriname', - iso2: 'SR', - flag: '๐Ÿ‡ธ๐Ÿ‡ท', - }, - { - name: 'Sweden', - iso2: 'SE', - flag: '๐Ÿ‡ธ๐Ÿ‡ช', - }, - { - name: 'Switzerland', - iso2: 'CH', - flag: '๐Ÿ‡จ๐Ÿ‡ญ', - }, - { - name: 'Syria', - iso2: 'SY', - flag: '๐Ÿ‡ธ๐Ÿ‡พ', - }, - { - name: 'Tajikistan', - iso2: 'TJ', - flag: '๐Ÿ‡น๐Ÿ‡ฏ', - }, - { - name: 'Tanzania', - iso2: 'TZ', - flag: '๐Ÿ‡น๐Ÿ‡ฟ', - }, - { - name: 'Thailand', - iso2: 'TH', - flag: '๐Ÿ‡น๐Ÿ‡ญ', - }, - { - name: 'Togo', - iso2: 'TG', - flag: '๐Ÿ‡น๐Ÿ‡ฌ', - }, - { - name: 'Tonga', - iso2: 'TO', - flag: '๐Ÿ‡น๐Ÿ‡ด', - }, - { - name: 'Trinidad and Tobago', - iso2: 'TT', - flag: '๐Ÿ‡น๐Ÿ‡น', - }, - { - name: 'Tunisia', - iso2: 'TN', - flag: '๐Ÿ‡น๐Ÿ‡ณ', - }, - { - name: 'Turkey', - iso2: 'TR', - flag: '๐Ÿ‡น๐Ÿ‡ท', - }, - { - name: 'Turkmenistan', - iso2: 'TM', - flag: '๐Ÿ‡น๐Ÿ‡ฒ', - }, - { - name: 'Tuvalu', - iso2: 'TV', - flag: '๐Ÿ‡น๐Ÿ‡ป', - }, - { - name: 'Uganda', - iso2: 'UG', - flag: '๐Ÿ‡บ๐Ÿ‡ฌ', - }, - { - name: 'Ukraine', - iso2: 'UA', - flag: '๐Ÿ‡บ๐Ÿ‡ฆ', - }, - { - name: 'United Arab Emirates', - iso2: 'AE', - flag: '๐Ÿ‡ฆ๐Ÿ‡ช', - }, - { - name: 'Uruguay', - iso2: 'UY', - flag: '๐Ÿ‡บ๐Ÿ‡พ', - }, - { - name: 'Uzbekistan', - iso2: 'UZ', - flag: '๐Ÿ‡บ๐Ÿ‡ฟ', - }, - { - name: 'Vanuatu', - iso2: 'VU', - flag: '๐Ÿ‡ป๐Ÿ‡บ', - }, - { - name: 'Venezuela', - iso2: 'VE', - flag: '๐Ÿ‡ป๐Ÿ‡ช', - }, - { - name: 'Vietnam', - iso2: 'VN', - flag: '๐Ÿ‡ป๐Ÿ‡ณ', - }, - { - name: 'Yemen', - iso2: 'YE', - flag: '๐Ÿ‡พ๐Ÿ‡ช', - }, - { - name: 'Zambia', - iso2: 'ZM', - flag: '๐Ÿ‡ฟ๐Ÿ‡ฒ', - }, - { - name: 'Zimbabwe', - iso2: 'ZW', - flag: '๐Ÿ‡ฟ๐Ÿ‡ผ', - }, - { - name: 'Timor-Leste', - iso2: 'TL', - flag: '๐Ÿ‡น๐Ÿ‡ฑ', - }, + { name: 'Afghanistan', iso2: 'AF', flag: '๐Ÿ‡ฆ๐Ÿ‡ซ' }, + { name: 'ร…land Islands', iso2: 'AX', flag: '๐Ÿ‡ฆ๐Ÿ‡ฝ' }, + { name: 'Albania', iso2: 'AL', flag: '๐Ÿ‡ฆ๐Ÿ‡ฑ' }, + { name: 'Algeria', iso2: 'DZ', flag: '๐Ÿ‡ฉ๐Ÿ‡ฟ' }, + { name: 'American Samoa', iso2: 'AS', flag: '๐Ÿ‡ฆ๐Ÿ‡ธ' }, + { name: 'Andorra', iso2: 'AD', flag: '๐Ÿ‡ฆ๐Ÿ‡ฉ' }, + { name: 'Angola', iso2: 'AO', flag: '๐Ÿ‡ฆ๐Ÿ‡ด' }, + { name: 'Anguilla', iso2: 'AI', flag: '๐Ÿ‡ฆ๐Ÿ‡ฎ' }, + { name: 'Antarctica', iso2: 'AQ', flag: '๐Ÿ‡ฆ๐Ÿ‡ถ' }, + { name: 'Antigua & Barbuda', iso2: 'AG', flag: '๐Ÿ‡ฆ๐Ÿ‡ฌ' }, + { name: 'Argentina', iso2: 'AR', flag: '๐Ÿ‡ฆ๐Ÿ‡ท' }, + { name: 'Armenia', iso2: 'AM', flag: '๐Ÿ‡ฆ๐Ÿ‡ฒ' }, + { name: 'Aruba', iso2: 'AW', flag: '๐Ÿ‡ฆ๐Ÿ‡ผ' }, + { name: 'Australia', iso2: 'AU', flag: '๐Ÿ‡ฆ๐Ÿ‡บ' }, + { name: 'Austria', iso2: 'AT', flag: '๐Ÿ‡ฆ๐Ÿ‡น' }, + { name: 'Azerbaijan', iso2: 'AZ', flag: '๐Ÿ‡ฆ๐Ÿ‡ฟ' }, + { name: 'Bahamas', iso2: 'BS', flag: '๐Ÿ‡ง๐Ÿ‡ธ' }, + { name: 'Bahrain', iso2: 'BH', flag: '๐Ÿ‡ง๐Ÿ‡ญ' }, + { name: 'Bangladesh', iso2: 'BD', flag: '๐Ÿ‡ง๐Ÿ‡ฉ' }, + { name: 'Barbados', iso2: 'BB', flag: '๐Ÿ‡ง๐Ÿ‡ง' }, + { name: 'Belarus', iso2: 'BY', flag: '๐Ÿ‡ง๐Ÿ‡พ' }, + { name: 'Belgium', iso2: 'BE', flag: '๐Ÿ‡ง๐Ÿ‡ช' }, + { name: 'Belize', iso2: 'BZ', flag: '๐Ÿ‡ง๐Ÿ‡ฟ' }, + { name: 'Benin', iso2: 'BJ', flag: '๐Ÿ‡ง๐Ÿ‡ฏ' }, + { name: 'Bermuda', iso2: 'BM', flag: '๐Ÿ‡ง๐Ÿ‡ฒ' }, + { name: 'Bhutan', iso2: 'BT', flag: '๐Ÿ‡ง๐Ÿ‡น' }, + { name: 'Bolivia', iso2: 'BO', flag: '๐Ÿ‡ง๐Ÿ‡ด' }, + { name: 'Bosnia & Herzegovina', iso2: 'BA', flag: '๐Ÿ‡ง๐Ÿ‡ฆ' }, + { name: 'Botswana', iso2: 'BW', flag: '๐Ÿ‡ง๐Ÿ‡ผ' }, + { name: 'Bouvet Island', iso2: 'BV', flag: '๐Ÿ‡ง๐Ÿ‡ป' }, + { name: 'Brazil', iso2: 'BR', flag: '๐Ÿ‡ง๐Ÿ‡ท' }, + { name: 'British Indian Ocean Territory', iso2: 'IO', flag: '๐Ÿ‡ฎ๐Ÿ‡ด' }, + { name: 'British Virgin Islands', iso2: 'VG', flag: '๐Ÿ‡ป๐Ÿ‡ฌ' }, + { name: 'Brunei', iso2: 'BN', flag: '๐Ÿ‡ง๐Ÿ‡ณ' }, + { name: 'Bulgaria', iso2: 'BG', flag: '๐Ÿ‡ง๐Ÿ‡ฌ' }, + { name: 'Burkina Faso', iso2: 'BF', flag: '๐Ÿ‡ง๐Ÿ‡ซ' }, + { name: 'Burundi', iso2: 'BI', flag: '๐Ÿ‡ง๐Ÿ‡ฎ' }, + { name: 'Cambodia', iso2: 'KH', flag: '๐Ÿ‡ฐ๐Ÿ‡ญ' }, + { name: 'Cameroon', iso2: 'CM', flag: '๐Ÿ‡จ๐Ÿ‡ฒ' }, + { name: 'Canada', iso2: 'CA', flag: '๐Ÿ‡จ๐Ÿ‡ฆ' }, + { name: 'Cape Verde', iso2: 'CV', flag: '๐Ÿ‡จ๐Ÿ‡ป' }, + { name: 'Caribbean Netherlands', iso2: 'BQ', flag: '๐Ÿ‡ง๐Ÿ‡ถ' }, + { name: 'Cayman Islands', iso2: 'KY', flag: '๐Ÿ‡ฐ๐Ÿ‡พ' }, + { name: 'Central African Republic', iso2: 'CF', flag: '๐Ÿ‡จ๐Ÿ‡ซ' }, + { name: 'Chad', iso2: 'TD', flag: '๐Ÿ‡น๐Ÿ‡ฉ' }, + { name: 'Chile', iso2: 'CL', flag: '๐Ÿ‡จ๐Ÿ‡ฑ' }, + { name: 'China', iso2: 'CN', flag: '๐Ÿ‡จ๐Ÿ‡ณ' }, + { name: 'Christmas Island', iso2: 'CX', flag: '๐Ÿ‡จ๐Ÿ‡ฝ' }, + { name: 'Cocos (Keeling) Islands', iso2: 'CC', flag: '๐Ÿ‡จ๐Ÿ‡จ' }, + { name: 'Colombia', iso2: 'CO', flag: '๐Ÿ‡จ๐Ÿ‡ด' }, + { name: 'Comoros', iso2: 'KM', flag: '๐Ÿ‡ฐ๐Ÿ‡ฒ' }, + { name: 'Congo - Brazzaville', iso2: 'CG', flag: '๐Ÿ‡จ๐Ÿ‡ฌ' }, + { name: 'Congo - Kinshasa', iso2: 'CD', flag: '๐Ÿ‡จ๐Ÿ‡ฉ' }, + { name: 'Cook Islands', iso2: 'CK', flag: '๐Ÿ‡จ๐Ÿ‡ฐ' }, + { name: 'Costa Rica', iso2: 'CR', flag: '๐Ÿ‡จ๐Ÿ‡ท' }, + { name: 'Cรดte dโ€™Ivoire', iso2: 'CI', flag: '๐Ÿ‡จ๐Ÿ‡ฎ' }, + { name: 'Croatia', iso2: 'HR', flag: '๐Ÿ‡ญ๐Ÿ‡ท' }, + { name: 'Cuba', iso2: 'CU', flag: '๐Ÿ‡จ๐Ÿ‡บ' }, + { name: 'Curaรงao', iso2: 'CW', flag: '๐Ÿ‡จ๐Ÿ‡ผ' }, + { name: 'Cyprus', iso2: 'CY', flag: '๐Ÿ‡จ๐Ÿ‡พ' }, + { name: 'Czechia', iso2: 'CZ', flag: '๐Ÿ‡จ๐Ÿ‡ฟ' }, + { name: 'Denmark', iso2: 'DK', flag: '๐Ÿ‡ฉ๐Ÿ‡ฐ' }, + { name: 'Djibouti', iso2: 'DJ', flag: '๐Ÿ‡ฉ๐Ÿ‡ฏ' }, + { name: 'Dominica', iso2: 'DM', flag: '๐Ÿ‡ฉ๐Ÿ‡ฒ' }, + { name: 'Dominican Republic', iso2: 'DO', flag: '๐Ÿ‡ฉ๐Ÿ‡ด' }, + { name: 'Ecuador', iso2: 'EC', flag: '๐Ÿ‡ช๐Ÿ‡จ' }, + { name: 'Egypt', iso2: 'EG', flag: '๐Ÿ‡ช๐Ÿ‡ฌ' }, + { name: 'El Salvador', iso2: 'SV', flag: '๐Ÿ‡ธ๐Ÿ‡ป' }, + { name: 'Equatorial Guinea', iso2: 'GQ', flag: '๐Ÿ‡ฌ๐Ÿ‡ถ' }, + { name: 'Eritrea', iso2: 'ER', flag: '๐Ÿ‡ช๐Ÿ‡ท' }, + { name: 'Estonia', iso2: 'EE', flag: '๐Ÿ‡ช๐Ÿ‡ช' }, + { name: 'Eswatini', iso2: 'SZ', flag: '๐Ÿ‡ธ๐Ÿ‡ฟ' }, + { name: 'Ethiopia', iso2: 'ET', flag: '๐Ÿ‡ช๐Ÿ‡น' }, + { name: 'Falkland Islands', iso2: 'FK', flag: '๐Ÿ‡ซ๐Ÿ‡ฐ' }, + { name: 'Faroe Islands', iso2: 'FO', flag: '๐Ÿ‡ซ๐Ÿ‡ด' }, + { name: 'Fiji', iso2: 'FJ', flag: '๐Ÿ‡ซ๐Ÿ‡ฏ' }, + { name: 'Finland', iso2: 'FI', flag: '๐Ÿ‡ซ๐Ÿ‡ฎ' }, + { name: 'France', iso2: 'FR', flag: '๐Ÿ‡ซ๐Ÿ‡ท' }, + { name: 'French Guiana', iso2: 'GF', flag: '๐Ÿ‡ฌ๐Ÿ‡ซ' }, + { name: 'French Polynesia', iso2: 'PF', flag: '๐Ÿ‡ต๐Ÿ‡ซ' }, + { name: 'French Southern Territories', iso2: 'TF', flag: '๐Ÿ‡น๐Ÿ‡ซ' }, + { name: 'Gabon', iso2: 'GA', flag: '๐Ÿ‡ฌ๐Ÿ‡ฆ' }, + { name: 'Gambia', iso2: 'GM', flag: '๐Ÿ‡ฌ๐Ÿ‡ฒ' }, + { name: 'Georgia', iso2: 'GE', flag: '๐Ÿ‡ฌ๐Ÿ‡ช' }, + { name: 'Germany', iso2: 'DE', flag: '๐Ÿ‡ฉ๐Ÿ‡ช' }, + { name: 'Ghana', iso2: 'GH', flag: '๐Ÿ‡ฌ๐Ÿ‡ญ' }, + { name: 'Gibraltar', iso2: 'GI', flag: '๐Ÿ‡ฌ๐Ÿ‡ฎ' }, + { name: 'Greece', iso2: 'GR', flag: '๐Ÿ‡ฌ๐Ÿ‡ท' }, + { name: 'Greenland', iso2: 'GL', flag: '๐Ÿ‡ฌ๐Ÿ‡ฑ' }, + { name: 'Grenada', iso2: 'GD', flag: '๐Ÿ‡ฌ๐Ÿ‡ฉ' }, + { name: 'Guadeloupe', iso2: 'GP', flag: '๐Ÿ‡ฌ๐Ÿ‡ต' }, + { name: 'Guam', iso2: 'GU', flag: '๐Ÿ‡ฌ๐Ÿ‡บ' }, + { name: 'Guatemala', iso2: 'GT', flag: '๐Ÿ‡ฌ๐Ÿ‡น' }, + { name: 'Guernsey', iso2: 'GG', flag: '๐Ÿ‡ฌ๐Ÿ‡ฌ' }, + { name: 'Guinea', iso2: 'GN', flag: '๐Ÿ‡ฌ๐Ÿ‡ณ' }, + { name: 'Guinea-Bissau', iso2: 'GW', flag: '๐Ÿ‡ฌ๐Ÿ‡ผ' }, + { name: 'Guyana', iso2: 'GY', flag: '๐Ÿ‡ฌ๐Ÿ‡พ' }, + { name: 'Haiti', iso2: 'HT', flag: '๐Ÿ‡ญ๐Ÿ‡น' }, + { name: 'Heard & McDonald Islands', iso2: 'HM', flag: '๐Ÿ‡ญ๐Ÿ‡ฒ' }, + { name: 'Honduras', iso2: 'HN', flag: '๐Ÿ‡ญ๐Ÿ‡ณ' }, + { name: 'Hong Kong SAR China', iso2: 'HK', flag: '๐Ÿ‡ญ๐Ÿ‡ฐ' }, + { name: 'Hungary', iso2: 'HU', flag: '๐Ÿ‡ญ๐Ÿ‡บ' }, + { name: 'Iceland', iso2: 'IS', flag: '๐Ÿ‡ฎ๐Ÿ‡ธ' }, + { name: 'India', iso2: 'IN', flag: '๐Ÿ‡ฎ๐Ÿ‡ณ' }, + { name: 'Indonesia', iso2: 'ID', flag: '๐Ÿ‡ฎ๐Ÿ‡ฉ' }, + { name: 'Iran', iso2: 'IR', flag: '๐Ÿ‡ฎ๐Ÿ‡ท' }, + { name: 'Iraq', iso2: 'IQ', flag: '๐Ÿ‡ฎ๐Ÿ‡ถ' }, + { name: 'Ireland', iso2: 'IE', flag: '๐Ÿ‡ฎ๐Ÿ‡ช' }, + { name: 'Isle of Man', iso2: 'IM', flag: '๐Ÿ‡ฎ๐Ÿ‡ฒ' }, + { name: 'Israel', iso2: 'IL', flag: '๐Ÿ‡ฎ๐Ÿ‡ฑ' }, + { name: 'Italy', iso2: 'IT', flag: '๐Ÿ‡ฎ๐Ÿ‡น' }, + { name: 'Jamaica', iso2: 'JM', flag: '๐Ÿ‡ฏ๐Ÿ‡ฒ' }, + { name: 'Japan', iso2: 'JP', flag: '๐Ÿ‡ฏ๐Ÿ‡ต' }, + { name: 'Jersey', iso2: 'JE', flag: '๐Ÿ‡ฏ๐Ÿ‡ช' }, + { name: 'Jordan', iso2: 'JO', flag: '๐Ÿ‡ฏ๐Ÿ‡ด' }, + { name: 'Kazakhstan', iso2: 'KZ', flag: '๐Ÿ‡ฐ๐Ÿ‡ฟ' }, + { name: 'Kenya', iso2: 'KE', flag: '๐Ÿ‡ฐ๐Ÿ‡ช' }, + { name: 'Kiribati', iso2: 'KI', flag: '๐Ÿ‡ฐ๐Ÿ‡ฎ' }, + { name: 'Kosovo', iso2: 'XK', flag: '๐Ÿ‡ฝ๐Ÿ‡ฐ' }, + { name: 'Kuwait', iso2: 'KW', flag: '๐Ÿ‡ฐ๐Ÿ‡ผ' }, + { name: 'Kyrgyzstan', iso2: 'KG', flag: '๐Ÿ‡ฐ๐Ÿ‡ฌ' }, + { name: 'Laos', iso2: 'LA', flag: '๐Ÿ‡ฑ๐Ÿ‡ฆ' }, + { name: 'Latvia', iso2: 'LV', flag: '๐Ÿ‡ฑ๐Ÿ‡ป' }, + { name: 'Lebanon', iso2: 'LB', flag: '๐Ÿ‡ฑ๐Ÿ‡ง' }, + { name: 'Lesotho', iso2: 'LS', flag: '๐Ÿ‡ฑ๐Ÿ‡ธ' }, + { name: 'Liberia', iso2: 'LR', flag: '๐Ÿ‡ฑ๐Ÿ‡ท' }, + { name: 'Libya', iso2: 'LY', flag: '๐Ÿ‡ฑ๐Ÿ‡พ' }, + { name: 'Liechtenstein', iso2: 'LI', flag: '๐Ÿ‡ฑ๐Ÿ‡ฎ' }, + { name: 'Lithuania', iso2: 'LT', flag: '๐Ÿ‡ฑ๐Ÿ‡น' }, + { name: 'Luxembourg', iso2: 'LU', flag: '๐Ÿ‡ฑ๐Ÿ‡บ' }, + { name: 'Macao SAR China', iso2: 'MO', flag: '๐Ÿ‡ฒ๐Ÿ‡ด' }, + { name: 'Madagascar', iso2: 'MG', flag: '๐Ÿ‡ฒ๐Ÿ‡ฌ' }, + { name: 'Malawi', iso2: 'MW', flag: '๐Ÿ‡ฒ๐Ÿ‡ผ' }, + { name: 'Malaysia', iso2: 'MY', flag: '๐Ÿ‡ฒ๐Ÿ‡พ' }, + { name: 'Maldives', iso2: 'MV', flag: '๐Ÿ‡ฒ๐Ÿ‡ป' }, + { name: 'Mali', iso2: 'ML', flag: '๐Ÿ‡ฒ๐Ÿ‡ฑ' }, + { name: 'Malta', iso2: 'MT', flag: '๐Ÿ‡ฒ๐Ÿ‡น' }, + { name: 'Marshall Islands', iso2: 'MH', flag: '๐Ÿ‡ฒ๐Ÿ‡ญ' }, + { name: 'Martinique', iso2: 'MQ', flag: '๐Ÿ‡ฒ๐Ÿ‡ถ' }, + { name: 'Mauritania', iso2: 'MR', flag: '๐Ÿ‡ฒ๐Ÿ‡ท' }, + { name: 'Mauritius', iso2: 'MU', flag: '๐Ÿ‡ฒ๐Ÿ‡บ' }, + { name: 'Mayotte', iso2: 'YT', flag: '๐Ÿ‡พ๐Ÿ‡น' }, + { name: 'Mexico', iso2: 'MX', flag: '๐Ÿ‡ฒ๐Ÿ‡ฝ' }, + { name: 'Micronesia', iso2: 'FM', flag: '๐Ÿ‡ซ๐Ÿ‡ฒ' }, + { name: 'Moldova', iso2: 'MD', flag: '๐Ÿ‡ฒ๐Ÿ‡ฉ' }, + { name: 'Monaco', iso2: 'MC', flag: '๐Ÿ‡ฒ๐Ÿ‡จ' }, + { name: 'Mongolia', iso2: 'MN', flag: '๐Ÿ‡ฒ๐Ÿ‡ณ' }, + { name: 'Montenegro', iso2: 'ME', flag: '๐Ÿ‡ฒ๐Ÿ‡ช' }, + { name: 'Montserrat', iso2: 'MS', flag: '๐Ÿ‡ฒ๐Ÿ‡ธ' }, + { name: 'Morocco', iso2: 'MA', flag: '๐Ÿ‡ฒ๐Ÿ‡ฆ' }, + { name: 'Mozambique', iso2: 'MZ', flag: '๐Ÿ‡ฒ๐Ÿ‡ฟ' }, + { name: 'Myanmar (Burma)', iso2: 'MM', flag: '๐Ÿ‡ฒ๐Ÿ‡ฒ' }, + { name: 'Namibia', iso2: 'NA', flag: '๐Ÿ‡ณ๐Ÿ‡ฆ' }, + { name: 'Nauru', iso2: 'NR', flag: '๐Ÿ‡ณ๐Ÿ‡ท' }, + { name: 'Nepal', iso2: 'NP', flag: '๐Ÿ‡ณ๐Ÿ‡ต' }, + { name: 'Netherlands', iso2: 'NL', flag: '๐Ÿ‡ณ๐Ÿ‡ฑ' }, + { name: 'New Caledonia', iso2: 'NC', flag: '๐Ÿ‡ณ๐Ÿ‡จ' }, + { name: 'New Zealand', iso2: 'NZ', flag: '๐Ÿ‡ณ๐Ÿ‡ฟ' }, + { name: 'Nicaragua', iso2: 'NI', flag: '๐Ÿ‡ณ๐Ÿ‡ฎ' }, + { name: 'Niger', iso2: 'NE', flag: '๐Ÿ‡ณ๐Ÿ‡ช' }, + { name: 'Nigeria', iso2: 'NG', flag: '๐Ÿ‡ณ๐Ÿ‡ฌ' }, + { name: 'Niue', iso2: 'NU', flag: '๐Ÿ‡ณ๐Ÿ‡บ' }, + { name: 'Norfolk Island', iso2: 'NF', flag: '๐Ÿ‡ณ๐Ÿ‡ซ' }, + { name: 'North Korea', iso2: 'KP', flag: '๐Ÿ‡ฐ๐Ÿ‡ต' }, + { name: 'North Macedonia', iso2: 'MK', flag: '๐Ÿ‡ฒ๐Ÿ‡ฐ' }, + { name: 'Northern Mariana Islands', iso2: 'MP', flag: '๐Ÿ‡ฒ๐Ÿ‡ต' }, + { name: 'Norway', iso2: 'NO', flag: '๐Ÿ‡ณ๐Ÿ‡ด' }, + { name: 'Oman', iso2: 'OM', flag: '๐Ÿ‡ด๐Ÿ‡ฒ' }, + { name: 'Pakistan', iso2: 'PK', flag: '๐Ÿ‡ต๐Ÿ‡ฐ' }, + { name: 'Palau', iso2: 'PW', flag: '๐Ÿ‡ต๐Ÿ‡ผ' }, + { name: 'Palestinian Territories', iso2: 'PS', flag: '๐Ÿ‡ต๐Ÿ‡ธ' }, + { name: 'Panama', iso2: 'PA', flag: '๐Ÿ‡ต๐Ÿ‡ฆ' }, + { name: 'Papua New Guinea', iso2: 'PG', flag: '๐Ÿ‡ต๐Ÿ‡ฌ' }, + { name: 'Paraguay', iso2: 'PY', flag: '๐Ÿ‡ต๐Ÿ‡พ' }, + { name: 'Peru', iso2: 'PE', flag: '๐Ÿ‡ต๐Ÿ‡ช' }, + { name: 'Philippines', iso2: 'PH', flag: '๐Ÿ‡ต๐Ÿ‡ญ' }, + { name: 'Pitcairn Islands', iso2: 'PN', flag: '๐Ÿ‡ต๐Ÿ‡ณ' }, + { name: 'Poland', iso2: 'PL', flag: '๐Ÿ‡ต๐Ÿ‡ฑ' }, + { name: 'Portugal', iso2: 'PT', flag: '๐Ÿ‡ต๐Ÿ‡น' }, + { name: 'Puerto Rico', iso2: 'PR', flag: '๐Ÿ‡ต๐Ÿ‡ท' }, + { name: 'Qatar', iso2: 'QA', flag: '๐Ÿ‡ถ๐Ÿ‡ฆ' }, + { name: 'Rรฉunion', iso2: 'RE', flag: '๐Ÿ‡ท๐Ÿ‡ช' }, + { name: 'Romania', iso2: 'RO', flag: '๐Ÿ‡ท๐Ÿ‡ด' }, + { name: 'Russia', iso2: 'RU', flag: '๐Ÿ‡ท๐Ÿ‡บ' }, + { name: 'Rwanda', iso2: 'RW', flag: '๐Ÿ‡ท๐Ÿ‡ผ' }, + { name: 'Samoa', iso2: 'WS', flag: '๐Ÿ‡ผ๐Ÿ‡ธ' }, + { name: 'San Marino', iso2: 'SM', flag: '๐Ÿ‡ธ๐Ÿ‡ฒ' }, + { name: 'Sรฃo Tomรฉ & Prรญncipe', iso2: 'ST', flag: '๐Ÿ‡ธ๐Ÿ‡น' }, + { name: 'Saudi Arabia', iso2: 'SA', flag: '๐Ÿ‡ธ๐Ÿ‡ฆ' }, + { name: 'Senegal', iso2: 'SN', flag: '๐Ÿ‡ธ๐Ÿ‡ณ' }, + { name: 'Serbia', iso2: 'RS', flag: '๐Ÿ‡ท๐Ÿ‡ธ' }, + { name: 'Seychelles', iso2: 'SC', flag: '๐Ÿ‡ธ๐Ÿ‡จ' }, + { name: 'Sierra Leone', iso2: 'SL', flag: '๐Ÿ‡ธ๐Ÿ‡ฑ' }, + { name: 'Singapore', iso2: 'SG', flag: '๐Ÿ‡ธ๐Ÿ‡ฌ' }, + { name: 'Sint Maarten', iso2: 'SX', flag: '๐Ÿ‡ธ๐Ÿ‡ฝ' }, + { name: 'Slovakia', iso2: 'SK', flag: '๐Ÿ‡ธ๐Ÿ‡ฐ' }, + { name: 'Slovenia', iso2: 'SI', flag: '๐Ÿ‡ธ๐Ÿ‡ฎ' }, + { name: 'Solomon Islands', iso2: 'SB', flag: '๐Ÿ‡ธ๐Ÿ‡ง' }, + { name: 'Somalia', iso2: 'SO', flag: '๐Ÿ‡ธ๐Ÿ‡ด' }, + { name: 'South Africa', iso2: 'ZA', flag: '๐Ÿ‡ฟ๐Ÿ‡ฆ' }, + { name: 'South Georgia & South Sandwich Islands', iso2: 'GS', flag: '๐Ÿ‡ฌ๐Ÿ‡ธ' }, + { name: 'South Korea', iso2: 'KR', flag: '๐Ÿ‡ฐ๐Ÿ‡ท' }, + { name: 'South Sudan', iso2: 'SS', flag: '๐Ÿ‡ธ๐Ÿ‡ธ' }, + { name: 'Spain', iso2: 'ES', flag: '๐Ÿ‡ช๐Ÿ‡ธ' }, + { name: 'Sri Lanka', iso2: 'LK', flag: '๐Ÿ‡ฑ๐Ÿ‡ฐ' }, + { name: 'St. Barthรฉlemy', iso2: 'BL', flag: '๐Ÿ‡ง๐Ÿ‡ฑ' }, + { name: 'St. Helena', iso2: 'SH', flag: '๐Ÿ‡ธ๐Ÿ‡ญ' }, + { name: 'St. Kitts & Nevis', iso2: 'KN', flag: '๐Ÿ‡ฐ๐Ÿ‡ณ' }, + { name: 'St. Lucia', iso2: 'LC', flag: '๐Ÿ‡ฑ๐Ÿ‡จ' }, + { name: 'St. Martin', iso2: 'MF', flag: '๐Ÿ‡ฒ๐Ÿ‡ซ' }, + { name: 'St. Pierre & Miquelon', iso2: 'PM', flag: '๐Ÿ‡ต๐Ÿ‡ฒ' }, + { name: 'St. Vincent & Grenadines', iso2: 'VC', flag: '๐Ÿ‡ป๐Ÿ‡จ' }, + { name: 'Sudan', iso2: 'SD', flag: '๐Ÿ‡ธ๐Ÿ‡ฉ' }, + { name: 'Suriname', iso2: 'SR', flag: '๐Ÿ‡ธ๐Ÿ‡ท' }, + { name: 'Svalbard & Jan Mayen', iso2: 'SJ', flag: '๐Ÿ‡ธ๐Ÿ‡ฏ' }, + { name: 'Sweden', iso2: 'SE', flag: '๐Ÿ‡ธ๐Ÿ‡ช' }, + { name: 'Switzerland', iso2: 'CH', flag: '๐Ÿ‡จ๐Ÿ‡ญ' }, + { name: 'Syria', iso2: 'SY', flag: '๐Ÿ‡ธ๐Ÿ‡พ' }, + { name: 'Taiwan', iso2: 'TW', flag: '๐Ÿ‡น๐Ÿ‡ผ' }, + { name: 'Tajikistan', iso2: 'TJ', flag: '๐Ÿ‡น๐Ÿ‡ฏ' }, + { name: 'Tanzania', iso2: 'TZ', flag: '๐Ÿ‡น๐Ÿ‡ฟ' }, + { name: 'Thailand', iso2: 'TH', flag: '๐Ÿ‡น๐Ÿ‡ญ' }, + { name: 'Timor-Leste', iso2: 'TL', flag: '๐Ÿ‡น๐Ÿ‡ฑ' }, + { name: 'Togo', iso2: 'TG', flag: '๐Ÿ‡น๐Ÿ‡ฌ' }, + { name: 'Tokelau', iso2: 'TK', flag: '๐Ÿ‡น๐Ÿ‡ฐ' }, + { name: 'Tonga', iso2: 'TO', flag: '๐Ÿ‡น๐Ÿ‡ด' }, + { name: 'Trinidad & Tobago', iso2: 'TT', flag: '๐Ÿ‡น๐Ÿ‡น' }, + { name: 'Tunisia', iso2: 'TN', flag: '๐Ÿ‡น๐Ÿ‡ณ' }, + { name: 'Tรผrkiye', iso2: 'TR', flag: '๐Ÿ‡น๐Ÿ‡ท' }, + { name: 'Turkmenistan', iso2: 'TM', flag: '๐Ÿ‡น๐Ÿ‡ฒ' }, + { name: 'Turks & Caicos Islands', iso2: 'TC', flag: '๐Ÿ‡น๐Ÿ‡จ' }, + { name: 'Tuvalu', iso2: 'TV', flag: '๐Ÿ‡น๐Ÿ‡ป' }, + { name: 'U.S. Outlying Islands', iso2: 'UM', flag: '๐Ÿ‡บ๐Ÿ‡ฒ' }, + { name: 'U.S. Virgin Islands', iso2: 'VI', flag: '๐Ÿ‡ป๐Ÿ‡ฎ' }, + { name: 'Uganda', iso2: 'UG', flag: '๐Ÿ‡บ๐Ÿ‡ฌ' }, + { name: 'Ukraine', iso2: 'UA', flag: '๐Ÿ‡บ๐Ÿ‡ฆ' }, + { name: 'United Arab Emirates', iso2: 'AE', flag: '๐Ÿ‡ฆ๐Ÿ‡ช' }, + { name: 'United Kingdom', iso2: 'GB', flag: '๐Ÿ‡ฌ๐Ÿ‡ง' }, + { name: 'United States', iso2: 'US', flag: '๐Ÿ‡บ๐Ÿ‡ธ' }, + { name: 'Uruguay', iso2: 'UY', flag: '๐Ÿ‡บ๐Ÿ‡พ' }, + { name: 'Uzbekistan', iso2: 'UZ', flag: '๐Ÿ‡บ๐Ÿ‡ฟ' }, + { name: 'Vanuatu', iso2: 'VU', flag: '๐Ÿ‡ป๐Ÿ‡บ' }, + { name: 'Vatican City', iso2: 'VA', flag: '๐Ÿ‡ป๐Ÿ‡ฆ' }, + { name: 'Venezuela', iso2: 'VE', flag: '๐Ÿ‡ป๐Ÿ‡ช' }, + { name: 'Vietnam', iso2: 'VN', flag: '๐Ÿ‡ป๐Ÿ‡ณ' }, + { name: 'Wallis & Futuna', iso2: 'WF', flag: '๐Ÿ‡ผ๐Ÿ‡ซ' }, + { name: 'Western Sahara', iso2: 'EH', flag: '๐Ÿ‡ช๐Ÿ‡ญ' }, + { name: 'Yemen', iso2: 'YE', flag: '๐Ÿ‡พ๐Ÿ‡ช' }, + { name: 'Zambia', iso2: 'ZM', flag: '๐Ÿ‡ฟ๐Ÿ‡ฒ' }, + { name: 'Zimbabwe', iso2: 'ZW', flag: '๐Ÿ‡ฟ๐Ÿ‡ผ' }, ] as const diff --git a/packages/widget/src/theme.css b/packages/widget/src/theme.css index cb2c4299..11a4c1fb 100644 --- a/packages/widget/src/theme.css +++ b/packages/widget/src/theme.css @@ -272,6 +272,35 @@ select.arkyc-addr-input { max-width: 340px; } +.arkyc-country-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 260px; + overflow-y: auto; +} + +.arkyc-country { + padding: 9px 11px; + font-size: 14px; + text-align: left; + color: var(--arkyc-fg); + background: var(--arkyc-bg); + border: 1px solid var(--arkyc-border); + border-radius: var(--arkyc-radius); + cursor: pointer; +} + +.arkyc-country:hover { + border-color: color-mix(in srgb, var(--arkyc-primary) 45%, var(--arkyc-border)); +} + +.arkyc-country.arkyc-selected { + font-weight: 600; + border-color: var(--arkyc-primary); + background: color-mix(in srgb, var(--arkyc-primary) 10%, var(--arkyc-bg)); +} + .arkyc-preview { width: 100%; max-width: 360px; diff --git a/packages/widget/src/ui.ts b/packages/widget/src/ui.ts index 4e4bf63e..7ded4a1c 100644 --- a/packages/widget/src/ui.ts +++ b/packages/widget/src/ui.ts @@ -204,6 +204,26 @@ interface StepDots { * so it can run under a fake DOM in tests. The view owns capture-screen camera * mechanics and raises only high-level events to the controller. */ +/** Lower-cased, accents removed: so "cote" finds "Cรดte d'Ivoire". */ +function foldText(text: string): string { + return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim() +} + +/** + * A search matches a country from the start of its name or of any word in it + * ("korea" finds South Korea, "gha" finds Ghana but not Afghanistan), or by its + * exact two-letter code. + */ +function countryMatches(name: string, iso2: string, query: string): boolean { + const folded = foldText(name) + + return ( + folded.startsWith(query) || + folded.split(/[^\p{L}\p{N}]+/u).some((word) => word.startsWith(query)) || + iso2.toLowerCase() === query + ) +} + /** Consecutive well-framed samples before active liveness takes its still. */ const FRAMING_HOLD = 4 @@ -457,21 +477,71 @@ export class WidgetView { * layouts the server tries first. */ private renderCountrySelection(state: ViewState): void { - const select = this.dom.field('Choose a country', { - class: 'arkyc-btn arkyc-btn-ghost arkyc-text-center', - name: 'country', - autocomplete: 'country', - choices: countries.map(({ name, iso2, flag }) => ({ value: iso2, label: `${flag} ${name}` })), - }) - const suggested = state.suggestedCountry - if (suggested && countries.some((c) => c.iso2 === suggested)) select.value = suggested + const suggested = countries.some((c) => c.iso2 === state.suggestedCountry) ? state.suggestedCountry! : null + let chosen: string | null = suggested - const chosen = () => (select.value || '').trim().toUpperCase() const proceed = this.button('Continue', () => { - if (chosen()) this.handlers.onCountrySelected(chosen()) + if (chosen) this.handlers.onCountrySelected(chosen) + }) + const sync = () => (chosen ? proceed.removeAttribute?.('disabled') : proceed.setAttribute('disabled', 'true')) + + const search = this.dom.h('input', { + class: 'arkyc-addr-input', + type: 'search', + name: 'country-search', + placeholder: 'Search countries', + autocomplete: 'off', + 'aria-label': 'Search countries', }) - const sync = () => (chosen() ? proceed.removeAttribute?.('disabled') : proceed.setAttribute('disabled', 'true')) - select.addEventListener('change', sync) + + const list = this.dom.h('div', { + class: 'arkyc-country-list', + role: 'listbox', + 'aria-label': 'Countries', + }) + + const renderList = () => { + this.clear(list) + const query = foldText(search.value) + const matches = countries.filter(({ name, iso2 }) => !query || countryMatches(name, iso2, query)) + + // With no search, keep the chosen country on top so a preselection is visible. + if (!query && chosen) matches.sort((a, b) => Number(b.iso2 === chosen) - Number(a.iso2 === chosen)) + + if (!matches.length) { + list.appendChild( + this.dom.h('p', { + class: 'arkyc-p', + text: 'No country matches your search.', + }), + ) + return + } + + for (const { name, iso2, flag } of matches) { + const selected = iso2 === chosen + list.appendChild( + this.dom.h('button', { + type: 'button', + class: selected ? 'arkyc-country arkyc-selected' : 'arkyc-country', + role: 'option', + 'aria-selected': String(selected), + 'data-country': iso2, + text: `${flag} ${name}`, + on: { + click: () => { + chosen = iso2 + renderList() + sync() + }, + }, + }), + ) + } + } + + search.addEventListener('input', renderList) + renderList() sync() this.dom.append(this.body, [ @@ -480,7 +550,7 @@ export class WidgetView { class: 'arkyc-p', text: "Pick the country that issued the ID you'll use.", }), - this.dom.h('div', { class: 'arkyc-choices' }, [select]), + this.dom.h('div', { class: 'arkyc-choices' }, [search, list]), ]) this.footer.appendChild(proceed) } diff --git a/packages/widget/tests/widget.test.ts b/packages/widget/tests/widget.test.ts index 1ebed9ed..410b6e90 100644 --- a/packages/widget/tests/widget.test.ts +++ b/packages/widget/tests/widget.test.ts @@ -917,15 +917,32 @@ describe('ArkycWidget launchers', () => { }) }) -describe('country screen', () => { - it('asks for the issuing country on its own screen before the document type', async () => { - const { controller, fetchMock } = makeController({ defaultCountry: null }) - const el = controller.element as unknown as FakeEl +/** Type into the country search box. */ +function searchCountries(root: FakeEl, query: string): void { + const search = findByAttr(root, 'name', 'country-search')! + search.value = query + ;(search.listeners.input ?? []).forEach((fn) => fn()) +} - controller.start() +/** The country codes currently listed, in order. */ +function listedCountries(root: FakeEl): string[] { + const list = findByClass(root, 'arkyc-country-list')! + return list.children.map((child) => child.attributes['data-country']).filter(Boolean) as string[] +} + +describe('country screen', () => { + const openCountryScreen = async (over: Parameters[0] = {}) => { + const made = makeController(over) + const el = made.controller.element as unknown as FakeEl + made.controller.start() await flush() clickText(el, 'Get started') await flush() + return { ...made, el } + } + + it('asks for the issuing country on its own screen before the document type', async () => { + const { el, fetchMock } = await openCountryScreen({ defaultCountry: null }) // Nothing preselected (no defaultCountry, no browser language): Continue is disabled. expect(find(el, 'Choose your country')).toBeTruthy() @@ -933,9 +950,9 @@ describe('country screen', () => { const continueBtn = find(el, 'Continue')! expect(continueBtn.attributes.disabled).toBe('true') - const select = findByAttr(el, 'name', 'country')! - select.value = 'GH' - ;(select.listeners.change ?? []).forEach((fn) => fn()) + searchCountries(el, 'gha') + expect(listedCountries(el)).toEqual(['GH']) + clickText(el, '๐Ÿ‡ฌ๐Ÿ‡ญ Ghana') expect(continueBtn.attributes.disabled).toBeUndefined() continueBtn.click() await flush() @@ -951,33 +968,32 @@ describe('country screen', () => { await flush() const front = fetchMock.mock.calls.find(([url]) => String(url).includes('document/front')) - expect((front?.[1] as RequestInit).body).toBeTruthy() expect(((front?.[1] as RequestInit).body as FormData).get('country')).toBe('GH') }) - it('preselects defaultCountry, then the region of the browser language', async () => { - const configured = makeController({ defaultCountry: 'ng' }) - configured.controller.start() - await flush() - clickText(configured.controller.element as unknown as FakeEl, 'Get started') - await flush() - expect(findByAttr(configured.controller.element as unknown as FakeEl, 'name', 'country')!.value).toBe('NG') + it('filters by name ignoring accents, or by country code, and says when nothing matches', async () => { + const { el } = await openCountryScreen({ defaultCountry: null }) - const fromLanguage = makeController({ defaultCountry: null, nav: { language: 'en-KE' } as Navigator }) - fromLanguage.controller.start() - await flush() - clickText(fromLanguage.controller.element as unknown as FakeEl, 'Get started') - await flush() - expect(findByAttr(fromLanguage.controller.element as unknown as FakeEl, 'name', 'country')!.value).toBe('KE') + searchCountries(el, 'cote') + expect(listedCountries(el)).toContain('CI') + searchCountries(el, 'ng') + expect(listedCountries(el)).toContain('NG') + searchCountries(el, 'zzzz') + expect(listedCountries(el)).toEqual([]) + expect(find(el, 'No country matches your search.')).toBeTruthy() + }) + + it('preselects defaultCountry at the top of the list, then the region of the browser language', async () => { + const configured = await openCountryScreen({ defaultCountry: 'ng' }) + expect(listedCountries(configured.el)[0]).toBe('NG') + expect(findByClass(configured.el, 'arkyc-selected')!.attributes['data-country']).toBe('NG') + + const fromLanguage = await openCountryScreen({ defaultCountry: null, nav: { language: 'en-KE' } as Navigator }) + expect(findByClass(fromLanguage.el, 'arkyc-selected')!.attributes['data-country']).toBe('KE') }) it('goes back to the country screen from the document screen', async () => { - const { controller } = makeController() - const el = controller.element as unknown as FakeEl - controller.start() - await flush() - clickText(el, 'Get started') - await flush() + const { el } = await openCountryScreen() clickText(el, 'Continue') await flush() clickText(el, 'Change country') From a5e159aedf158169777d502a82ad571ebff83525 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 21 Oct 2026 17:16:52 +0100 Subject: [PATCH 3/3] docs(changelog): 2.4.0 --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f679480b..c68eccb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to Arkyc are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.4.0] - 2026-10-21 + +### Added + +- **Liveness reject floor** (`livenessRejectThreshold`, per project, default + `0.2`, dashboard Settings โ†’ Verification thresholds). A failed liveness check + is declined only when it scores below the floor (a clear spoof); a failed check + scoring from the floor up is borderline and goes to manual review. Laptop + webcams often score just under 0.5 on a real person, which used to be an + automatic decline. It may not sit above the liveness threshold. A failed + challenge sequence now scores 0, so it is always declined. +- **Searchable country screen.** The widget's country step is now a search box + over a scrollable list, matching names from the start of any word and ignoring + accents ("cote" finds Cรดte d'Ivoire), or a two-letter code. The preselected + country sits at the top, highlighted. + +### Fixed + +- **The widget's country list was missing about 50 issuing countries and + territories** (Cรดte d'Ivoire, Palestine, Kosovo, Taiwan, Hong Kong, Puerto + Rico and others). It now lists every ISO 3166-1 country plus Kosovo, + alphabetically. + ## [2.3.5] - 2026-10-21 ### Fixed