@smileid/web-components 11.0.2 → 11.1.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.
Files changed (93) hide show
  1. package/README.md +61 -0
  2. package/dist/components/smart-camera-web/src/README.md +0 -1
  3. package/dist/esm/{DocumentCaptureScreens-CRx5ymqY.js → DocumentCaptureScreens-DHAfE8VG.js} +191 -185
  4. package/dist/esm/DocumentCaptureScreens-DHAfE8VG.js.map +1 -0
  5. package/dist/esm/{EndUserConsent-D4fd1ovG.js → EndUserConsent-DE6OBSGG.js} +65 -63
  6. package/dist/esm/EndUserConsent-DE6OBSGG.js.map +1 -0
  7. package/dist/esm/{Navigation-CTjK6tLU.js → Navigation-kjll5SgE.js} +17 -9
  8. package/dist/esm/Navigation-kjll5SgE.js.map +1 -0
  9. package/dist/esm/{SelfieCaptureScreens-DuQjKt_K.js → SelfieCaptureScreens-DaREKFi4.js} +3087 -3177
  10. package/dist/esm/SelfieCaptureScreens-DaREKFi4.js.map +1 -0
  11. package/dist/esm/{TotpConsent-CQU5jQi4.js → TotpConsent-DsMJhXD8.js} +13 -9
  12. package/dist/esm/TotpConsent-DsMJhXD8.js.map +1 -0
  13. package/dist/esm/combobox.js +20 -19
  14. package/dist/esm/combobox.js.map +1 -1
  15. package/dist/esm/document.js +1 -1
  16. package/dist/esm/end-user-consent.js +1 -1
  17. package/dist/esm/index-LJce6hoU.js +1360 -0
  18. package/dist/esm/index-LJce6hoU.js.map +1 -0
  19. package/dist/esm/localisation.js +21 -0
  20. package/dist/esm/localisation.js.map +1 -0
  21. package/dist/esm/main.js +34 -17
  22. package/dist/esm/main.js.map +1 -1
  23. package/dist/esm/navigation.js +1 -1
  24. package/dist/esm/{package-C_cKNrUu.js → package-BoRHLVSv.js} +25 -40
  25. package/dist/esm/package-BoRHLVSv.js.map +1 -0
  26. package/dist/esm/selfie.js +1 -1
  27. package/dist/esm/smart-camera-web.js +22 -18
  28. package/dist/esm/smart-camera-web.js.map +1 -1
  29. package/dist/esm/totp-consent.js +1 -1
  30. package/dist/smart-camera-web.js +136 -177
  31. package/dist/smart-camera-web.js.map +1 -1
  32. package/dist/src/components/combobox/src/index.js +424 -1
  33. package/dist/src/components/document/src/index.js +1422 -1
  34. package/dist/src/components/end-user-consent/src/index.js +1573 -1
  35. package/dist/src/components/selfie/src/index.js +1220 -1
  36. package/dist/src/components/signature-pad/src/index.js +787 -1
  37. package/dist/src/components/smart-camera-web/src/SmartCameraWeb.js +2753 -1
  38. package/dist/src/components/totp-consent/src/index.js +1292 -1
  39. package/dist/types/combobox.d.ts +2 -2
  40. package/dist/types/document.d.ts +2 -2
  41. package/dist/types/end-user-consent.d.ts +2 -2
  42. package/dist/types/locale.d.ts +19 -0
  43. package/dist/types/localisation.d.ts +21 -0
  44. package/dist/types/main.d.ts +35 -26
  45. package/dist/types/navigation.d.ts +2 -2
  46. package/dist/types/selfie.d.ts +2 -2
  47. package/dist/types/signature-pad.d.ts +2 -2
  48. package/dist/types/smart-camera-web.d.ts +2 -2
  49. package/dist/types/totp-consent.d.ts +2 -2
  50. package/lib/components/camera-permission/CameraPermission.js +8 -3
  51. package/lib/components/combobox/src/Combobox.js +4 -2
  52. package/lib/components/document/src/DocumentCaptureScreens.js +4 -3
  53. package/lib/components/document/src/DocumentCaptureScreens.stories.js +37 -13
  54. package/lib/components/document/src/document-capture/DocumentCapture.js +20 -14
  55. package/lib/components/document/src/document-capture/DocumentCapture.stories.js +11 -2
  56. package/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.js +16 -11
  57. package/lib/components/document/src/document-capture-instructions/DocumentCaptureInstructions.stories.js +14 -5
  58. package/lib/components/document/src/document-capture-review/DocumentCaptureReview.js +11 -7
  59. package/lib/components/document/src/document-capture-review/DocumentCaptureReview.stories.js +14 -5
  60. package/lib/components/end-user-consent/src/EndUserConsent.js +30 -29
  61. package/lib/components/end-user-consent/src/EndUserConsent.stories.js +12 -2
  62. package/lib/components/navigation/src/Navigation.js +15 -2
  63. package/lib/components/navigation/src/Navigation.stories.js +20 -4
  64. package/lib/components/selfie/src/SelfieCaptureScreens.js +3 -7
  65. package/lib/components/selfie/src/SelfieCaptureScreens.stories.js +16 -4
  66. package/lib/components/selfie/src/selfie-capture/SelfieCapture.js +23 -16
  67. package/lib/components/selfie/src/selfie-capture/SelfieCapture.stories.js +19 -7
  68. package/lib/components/selfie/src/selfie-capture-instructions/SelfieCaptureInstructions.js +16 -11
  69. package/lib/components/selfie/src/selfie-capture-instructions/SelfieCaptureInstructions.stories.js +14 -5
  70. package/lib/components/selfie/src/selfie-capture-review/SelfieCaptureReview.js +10 -5
  71. package/lib/components/selfie/src/selfie-capture-review/SelfieCaptureReview.stories.js +14 -5
  72. package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +2 -2
  73. package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.stories.js +47 -0
  74. package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +2 -2
  75. package/lib/components/selfie/src/smartselfie-capture/components/CaptureControls.tsx +5 -2
  76. package/lib/components/selfie/src/smartselfie-capture/hooks/useCamera.ts +4 -4
  77. package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +6 -5
  78. package/lib/components/selfie/src/smartselfie-capture/utils/alertMessages.ts +11 -9
  79. package/lib/components/selfie/src/smartselfie-capture/utils/imageCapture.ts +3 -1
  80. package/lib/components/signature-pad/package-lock.json +11 -9
  81. package/lib/components/signature-pad/package.json +1 -1
  82. package/lib/components/totp-consent/src/TotpConsent.js +8 -3
  83. package/lib/domain/camera/src/SmartCamera.js +7 -22
  84. package/lib/domain/constants/src/Constants.js +28 -0
  85. package/lib/domain/file-upload/src/SmartFileUpload.js +9 -10
  86. package/lib/domain/localisation/index.js +456 -0
  87. package/package.json +13 -7
  88. package/dist/esm/DocumentCaptureScreens-CRx5ymqY.js.map +0 -1
  89. package/dist/esm/EndUserConsent-D4fd1ovG.js.map +0 -1
  90. package/dist/esm/Navigation-CTjK6tLU.js.map +0 -1
  91. package/dist/esm/SelfieCaptureScreens-DuQjKt_K.js.map +0 -1
  92. package/dist/esm/TotpConsent-CQU5jQi4.js.map +0 -1
  93. package/dist/esm/package-C_cKNrUu.js.map +0 -1
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@smileid/signature-pad",
3
- "version": "1.4.2",
3
+ "version": "11.0.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@smileid/signature-pad",
9
- "version": "1.4.2",
9
+ "version": "11.0.2",
10
10
  "dependencies": {
11
11
  "signature_pad": "^5.0.2"
12
12
  },
@@ -16,7 +16,7 @@
16
16
  "eslint-config-prettier": "^9.1.0",
17
17
  "eslint-plugin-cypress": "^3.3.0",
18
18
  "eslint-plugin-import": "^2.29.1",
19
- "eslint-plugin-jest": "^28.6.0",
19
+ "eslint-plugin-jest": "^28.8.3",
20
20
  "eslint-plugin-prettier": "^5.2.1",
21
21
  "prettier": "^3.3.3"
22
22
  }
@@ -1099,10 +1099,11 @@
1099
1099
  }
1100
1100
  },
1101
1101
  "node_modules/eslint-plugin-jest": {
1102
- "version": "28.8.0",
1103
- "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.0.tgz",
1104
- "integrity": "sha512-Tubj1hooFxCl52G4qQu0edzV/+EZzPUeN8p2NnW5uu4fbDs+Yo7+qDVDc4/oG3FbCqEBmu/OC3LSsyiU22oghw==",
1102
+ "version": "28.14.0",
1103
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.14.0.tgz",
1104
+ "integrity": "sha512-P9s/qXSMTpRTerE2FQ0qJet2gKbcGyFTPAJipoKxmWqR6uuFqIqk8FuEfg5yBieOezVrEfAMZrEwJ6yEp+1MFQ==",
1105
1105
  "dev": true,
1106
+ "license": "MIT",
1106
1107
  "dependencies": {
1107
1108
  "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0"
1108
1109
  },
@@ -1964,10 +1965,11 @@
1964
1965
  "dev": true
1965
1966
  },
1966
1967
  "node_modules/js-yaml": {
1967
- "version": "4.1.0",
1968
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
1969
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
1968
+ "version": "4.1.1",
1969
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
1970
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
1970
1971
  "dev": true,
1972
+ "license": "MIT",
1971
1973
  "dependencies": {
1972
1974
  "argparse": "^2.0.1"
1973
1975
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smileid/signature-pad",
3
- "version": "11.0.2",
3
+ "version": "11.1.0",
4
4
  "private": "true",
5
5
  "exports": {
6
6
  ".": "./index.js"
@@ -1,4 +1,5 @@
1
1
  import validate from 'validate.js';
2
+ import { getDirection } from '../../../domain/localisation';
2
3
 
3
4
  function postData(url, data) {
4
5
  return fetch(url, {
@@ -284,7 +285,7 @@ function markup() {
284
285
  }
285
286
  </style>
286
287
 
287
- <div class='flow center' id='id-entry'>
288
+ <div class='flow center' id='id-entry' dir='${this.direction}'>
288
289
  <div class="nav">
289
290
  <div class="back-wrapper">
290
291
  <button type='button' data-type='icon' id="back-button" class="back-button">
@@ -331,7 +332,7 @@ function markup() {
331
332
  </form>
332
333
  </div>
333
334
 
334
- <div hidden class='flow center' id='select-mode'>
335
+ <div hidden class='flow center' id='select-mode' dir='${this.direction}'>
335
336
  <div class="nav">
336
337
  <div class="back-wrapper">
337
338
  <button type='button' data-type='icon' id="back-to-entry-button" class="back-button">
@@ -459,7 +460,7 @@ function markup() {
459
460
  </form>
460
461
  </div>
461
462
 
462
- <div hidden class='flow center' id='otp-verification'>
463
+ <div hidden class='flow center' id='otp-verification' dir='${this.direction}'>
463
464
  <div class="nav justify-right">
464
465
  <button data-type='icon' type='button' class='close-iframe'>
465
466
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none">
@@ -899,6 +900,10 @@ class TotpConsent extends HTMLElement {
899
900
  return this.getAttribute('theme-color') || '#001096';
900
901
  }
901
902
 
903
+ get direction() {
904
+ return this.getAttribute('dir') || getDirection() || 'ltr';
905
+ }
906
+
902
907
  get hideBack() {
903
908
  return this.hasAttribute('hide-back');
904
909
  }
@@ -1,3 +1,5 @@
1
+ import { t } from '../../localisation';
2
+
1
3
  class SmartCamera {
2
4
  static stream = null;
3
5
 
@@ -74,32 +76,15 @@ class SmartCamera {
74
76
  switch (e.name) {
75
77
  case 'NotAllowedError':
76
78
  case 'SecurityError':
77
- return `
78
- Looks like camera access was not granted, or was blocked by a browser
79
- level setting / extension. Please follow the prompt from the URL bar,
80
- or extensions, and enable access.
81
- You may need to refresh to start all over again
82
- `;
79
+ return t('camera.error.notAllowed');
83
80
  case 'AbortError':
84
- return `
85
- Oops! Something happened, and we lost access to your stream.
86
- Please refresh to start all over again
87
- `;
81
+ return t('camera.error.abort');
88
82
  case 'NotReadableError':
89
- return `
90
- There seems to be a problem with your device's camera, or its connection.
91
- Please check this, and when resolved, try again. Or try another device.
92
- `;
83
+ return t('camera.error.notReadable');
93
84
  case 'NotFoundError':
94
- return `
95
- We are unable to find a video stream.
96
- You may need to refresh to start all over again
97
- `;
85
+ return t('camera.error.notFound');
98
86
  case 'TypeError':
99
- return `
100
- This site is insecure, and as such cannot have access to your camera.
101
- Try to navigate to a secure version of this page, or contact the owner.
102
- `;
87
+ return t('camera.error.insecure');
103
88
  default:
104
89
  return e.message;
105
90
  }
@@ -23,5 +23,33 @@ export const IMAGE_TYPE = {
23
23
  };
24
24
 
25
25
  export const DEFAULT_NO_OF_LIVENESS_FRAMES = 8;
26
+
27
+ /**
28
+ * JPEG compression quality for captured images (selfies, liveness frames, ID documents).
29
+ *
30
+ * Value: 0.92 (92% quality)
31
+ *
32
+ * This value is optimized for identity verification and biometric matching:
33
+ * - Preserves fine facial details needed for accurate facial recognition
34
+ * - Maintains skin tone gradients without JPEG blocking artifacts
35
+ * - Ensures ID document text remains readable for OCR processing
36
+ * - Provides minimal compression artifacts that could affect liveness detection
37
+ *
38
+ * Quality comparison:
39
+ * - 1.0: No compression, very large files
40
+ * - 0.95: Virtually lossless, larger files
41
+ * - 0.92: Good quality, reasonable file size
42
+ * - 0.85: Visible artifacts in gradients and skin tones
43
+ * - 0.80: Noticeable blocking artifacts that hurt matching accuracy
44
+ *
45
+ * WARNING: DO NOT LOWER THIS VALUE BELOW 0.92
46
+ * Reducing JPEG quality can negatively impact downstream systems:
47
+ * - Facial recognition matching accuracy may decrease
48
+ * - Liveness detection anti-spoofing checks may fail
49
+ * - ID document OCR text extraction may produce errors
50
+ * - Overall verification success rates may drop
51
+ *
52
+ */
53
+ export const JPEG_QUALITY = 0.92;
26
54
  export const PORTRAIT_ID_PREVIEW_WIDTH = 396;
27
55
  export const PORTRAIT_ID_PREVIEW_HEIGHT = 527;
@@ -1,3 +1,5 @@
1
+ import { t, tHtml } from '../../localisation';
2
+
1
3
  class SmartFileUpload {
2
4
  static memoryLimit = 10240000;
3
5
 
@@ -27,11 +29,7 @@ class SmartFileUpload {
27
29
  resolve(e.target.result);
28
30
  };
29
31
  reader.onerror = () => {
30
- reject(
31
- new Error(
32
- 'An error occurred reading the file. Please check the file, and try again',
33
- ),
34
- );
32
+ reject(new Error(t('fileUpload.error.readingFile')));
35
33
  };
36
34
  reader.readAsDataURL(file);
37
35
  });
@@ -39,20 +37,21 @@ class SmartFileUpload {
39
37
 
40
38
  static async retrieve(files) {
41
39
  if (files.length > 1) {
42
- throw new Error('Only one file upload is permitted at a time');
40
+ throw new Error(t('fileUpload.error.multipleFiles'));
43
41
  }
44
42
 
45
43
  const file = files[0];
46
44
 
47
45
  if (!SmartFileUpload.supportedTypes.includes(file.type)) {
48
- throw new Error(
49
- 'Unsupported file format. Please ensure that you are providing a JPG or PNG image',
50
- );
46
+ throw new Error(t('fileUpload.error.unsupportedFormat'));
51
47
  }
52
48
 
53
49
  if (file.size > SmartFileUpload.memoryLimit) {
54
50
  throw new Error(
55
- `${file.name} is too large. Please ensure that the file is less than ${SmartFileUpload.getHumanSize(SmartFileUpload.memoryLimit)}.`,
51
+ tHtml('fileUpload.error.fileTooLarge', {
52
+ filename: file.name,
53
+ size: SmartFileUpload.getHumanSize(SmartFileUpload.memoryLimit),
54
+ }),
56
55
  );
57
56
  }
58
57
 
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Minimal runtime i18n module for SmileID web components.
3
+ * Provides simple locale registration, loading, and translation lookup.
4
+ */
5
+
6
+ import merge from 'lodash/merge';
7
+
8
+ // Bundle supported locales for offline/instant switching
9
+ import arLocale from '../../../locales/ar-EG.json';
10
+ import enLocale from '../../../locales/en-GB.json';
11
+ import frLocale from '../../../locales/fr-FR.json';
12
+
13
+ // Locale alias mapping for short codes
14
+ const LOCALE_ALIASES = {
15
+ ar: 'ar-EG',
16
+ en: 'en-GB',
17
+ fr: 'fr-FR',
18
+ };
19
+
20
+ /**
21
+ * Resolve locale alias to full locale code.
22
+ * @param {string} lang - Language code (e.g., 'en', 'ar', 'en-GB')
23
+ * @returns {string} Resolved locale code
24
+ */
25
+ function resolveLocale(lang) {
26
+ return LOCALE_ALIASES[lang] || lang;
27
+ }
28
+
29
+ const DEFAULT_LOCALE = 'en-GB';
30
+ const FETCH_TIMEOUT_MS = 5000;
31
+
32
+ let currentLocale = DEFAULT_LOCALE;
33
+ const locales = {
34
+ 'ar-EG': arLocale,
35
+ 'en-GB': enLocale,
36
+ 'fr-FR': frLocale,
37
+ };
38
+
39
+ /**
40
+ * Register a locale object (in-memory).
41
+ * @param {string} lang - Language code (e.g., 'en-GB', 'ar-EG')
42
+ * @param {object} data - Locale translation object
43
+ */
44
+ export function registerLocale(lang, data) {
45
+ locales[lang] = data;
46
+ }
47
+
48
+ /**
49
+ * Deep merge source object into target object.
50
+ * Recursively merges nested objects while preserving non-overridden values.
51
+ * Uses lodash merge under the hood but returns a new object (does not mutate inputs).
52
+ * @param {object} target - Base object to merge into
53
+ * @param {object} source - Object with values to merge/override
54
+ * @returns {object} New merged object (does not mutate inputs)
55
+ */
56
+ export function deepMerge(target, source) {
57
+ if (!source || typeof source !== 'object') {
58
+ return target;
59
+ }
60
+
61
+ if (!target || typeof target !== 'object') {
62
+ return source;
63
+ }
64
+
65
+ // Use lodash merge with empty object as first arg to avoid mutating target
66
+ return merge({}, target, source);
67
+ }
68
+
69
+ /**
70
+ * Required translation keys for a complete locale.
71
+ * These are the minimum keys needed for the SDK to function properly.
72
+ */
73
+ const REQUIRED_LOCALE_KEYS = [
74
+ 'direction',
75
+ 'common.back',
76
+ 'common.close',
77
+ 'common.continue',
78
+ 'common.cancel',
79
+ 'camera.permission.description',
80
+ 'camera.permission.requestButton',
81
+ 'camera.error.notAllowed',
82
+ 'selfie.instructions.title',
83
+ 'selfie.capture.button.takeSelfie',
84
+ 'selfie.review.title',
85
+ 'selfie.review.acceptButton',
86
+ 'selfie.review.retakeButton',
87
+ 'document.capture.captureButton',
88
+ 'document.review.acceptButton',
89
+ 'document.review.retakeButton',
90
+ ];
91
+
92
+ /**
93
+ * Fetch with timeout wrapper.
94
+ * @param {string} url - URL to fetch
95
+ * @param {number} timeout - Timeout in milliseconds
96
+ * @returns {Promise<Response>} Fetch response
97
+ */
98
+ async function fetchWithTimeout(url, timeout = FETCH_TIMEOUT_MS) {
99
+ const controller = new AbortController();
100
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
101
+
102
+ try {
103
+ const response = await fetch(url, { signal: controller.signal });
104
+ return response;
105
+ } finally {
106
+ clearTimeout(timeoutId);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Register a locale by URL (fetch and cache).
112
+ * @param {string} lang - Language code
113
+ * @param {string} url - URL to locale JSON file
114
+ * @returns {Promise<object>} Loaded locale data
115
+ */
116
+ export async function registerLocaleUrl(lang, url) {
117
+ try {
118
+ const response = await fetchWithTimeout(url);
119
+ if (!response.ok) {
120
+ throw new Error(`Failed to load locale from ${url}: ${response.status}`);
121
+ }
122
+ const data = await response.json();
123
+ registerLocale(lang, data);
124
+ return data;
125
+ } catch (error) {
126
+ const errorMessage =
127
+ error.name === 'AbortError'
128
+ ? `Timeout loading locale '${lang}' from ${url}`
129
+ : `Error loading locale '${lang}' from ${url}: ${error.message}`;
130
+ console.error(errorMessage);
131
+
132
+ // Fallback to default locale if available
133
+ if (lang !== DEFAULT_LOCALE && locales[DEFAULT_LOCALE]) {
134
+ console.warn(`Falling back to default locale '${DEFAULT_LOCALE}'`);
135
+ return locales[DEFAULT_LOCALE];
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Load a locale (supports both inline registration and URL fetch).
143
+ * @param {string} lang - Language code
144
+ * @param {string} [url] - Optional URL to fetch locale from
145
+ * @returns {Promise<object>} Loaded locale data
146
+ */
147
+ export async function loadLocale(lang, url) {
148
+ const resolvedLang = resolveLocale(lang);
149
+
150
+ // Return cached locale if available
151
+ if (locales[resolvedLang]) {
152
+ return locales[resolvedLang];
153
+ }
154
+
155
+ // Fetch from URL if provided
156
+ if (url) {
157
+ return registerLocaleUrl(lang, url);
158
+ }
159
+
160
+ // No URL provided and locale not cached - fallback to default
161
+ console.warn(
162
+ `Locale '${lang}' not found and no URL provided, using default '${DEFAULT_LOCALE}'`,
163
+ );
164
+ return locales[DEFAULT_LOCALE] || {};
165
+ }
166
+
167
+ /**
168
+ * Helper to get nested value from object using dot notation.
169
+ * @param {object} obj - Object to traverse
170
+ * @param {string} key - Dot-separated key path
171
+ * @returns {string|undefined} Value at path or undefined
172
+ */
173
+ function getNestedValue(obj, key) {
174
+ if (!obj) return undefined;
175
+
176
+ const value = key.split('.').reduce((acc, k) => {
177
+ if (acc && typeof acc === 'object' && k in acc) {
178
+ return acc[k];
179
+ }
180
+ return undefined;
181
+ }, obj);
182
+
183
+ return typeof value === 'string' ? value : undefined;
184
+ }
185
+
186
+ /**
187
+ * Validate that a locale has all required translation keys.
188
+ * @param {object} locale - Locale data to validate
189
+ * @returns {{ missingKeys: string[], valid: boolean }} Validation result
190
+ */
191
+ export function validateLocale(locale) {
192
+ if (!locale || typeof locale !== 'object') {
193
+ return { missingKeys: REQUIRED_LOCALE_KEYS, valid: false };
194
+ }
195
+
196
+ const missingKeys = REQUIRED_LOCALE_KEYS.filter((key) => {
197
+ if (key === 'direction') {
198
+ return !locale.direction;
199
+ }
200
+ return !getNestedValue(locale, key);
201
+ });
202
+
203
+ return {
204
+ missingKeys,
205
+ valid: missingKeys.length === 0,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Get translation for a key.
211
+ * Supports nested keys with dot notation (e.g., 'camera.permission.description').
212
+ * Fallback chain: current locale → default locale (English) → raw key.
213
+ * @param {string} key - Translation key
214
+ * @returns {string} Translated string or fallback
215
+ */
216
+ export function t(key) {
217
+ // Try current locale first
218
+ const resolvedLocale = resolveLocale(currentLocale);
219
+ const currentLocaleData = locales[resolvedLocale];
220
+ const value = getNestedValue(currentLocaleData, key);
221
+ if (value) {
222
+ return value;
223
+ }
224
+
225
+ // Fallback to default locale if different from current
226
+ const resolvedDefault = resolveLocale(DEFAULT_LOCALE);
227
+ if (resolvedLocale !== resolvedDefault) {
228
+ const defaultValue = getNestedValue(locales[resolvedDefault], key);
229
+ if (defaultValue) {
230
+ return defaultValue;
231
+ }
232
+ }
233
+
234
+ // Final fallback: return the key itself
235
+ console.warn(`Translation key '${key}' not found in any locale`);
236
+ return key;
237
+ }
238
+
239
+ /**
240
+ * Alias for t() function.
241
+ * @see t
242
+ */
243
+ export const translate = t;
244
+
245
+ /**
246
+ * HTML entity map for escaping special characters.
247
+ */
248
+ const HTML_ENTITIES = {
249
+ '"': '&quot;',
250
+ '&': '&amp;',
251
+ "'": '&#39;',
252
+ '<': '&lt;',
253
+ '>': '&gt;',
254
+ };
255
+
256
+ /**
257
+ * Escape HTML special characters to prevent XSS.
258
+ * @param {string} str - String to escape
259
+ * @returns {string} Escaped string
260
+ */
261
+ export function escapeHtml(str) {
262
+ return str.replace(/[&<>"']/g, (char) => HTML_ENTITIES[char]);
263
+ }
264
+
265
+ /**
266
+ * Get translation with HTML interpolation for styled placeholders.
267
+ * Placeholders in format {{key}} will be replaced with provided values.
268
+ * Values can be plain strings or objects with {value, className} for styled spans.
269
+ * @param {string} key - Translation key
270
+ * @param {Object} params - Interpolation parameters
271
+ * @returns {string} Translated string with interpolations
272
+ * @example
273
+ * // Plain interpolation
274
+ * tHtml('greeting', { name: 'John' }) // "Hello, John"
275
+ *
276
+ * // Styled interpolation
277
+ * tHtml('consent.accessRequest', {
278
+ * partnerName: { value: 'Acme', className: 'theme' }
279
+ * }) // "<span class="theme">Acme</span> wants to access..."
280
+ */
281
+ export function tHtml(key, params = {}) {
282
+ let text = t(key);
283
+
284
+ Object.keys(params).forEach((paramKey) => {
285
+ const paramValue = params[paramKey];
286
+ const placeholder = `{{${paramKey}}}`;
287
+
288
+ if (paramValue && typeof paramValue === 'object' && 'value' in paramValue) {
289
+ // Styled interpolation: { value: 'text', className: 'theme' }
290
+ const escapedValue = escapeHtml(String(paramValue.value || ''));
291
+ const className = escapeHtml(String(paramValue.className || ''));
292
+ text = text
293
+ .split(placeholder)
294
+ .join(
295
+ className
296
+ ? `<span class="${className}">${escapedValue}</span>`
297
+ : escapedValue,
298
+ );
299
+ } else {
300
+ // Plain text interpolation
301
+ text = text.split(placeholder).join(escapeHtml(String(paramValue)));
302
+ }
303
+ });
304
+
305
+ return text;
306
+ }
307
+
308
+ /**
309
+ * Alias for tHtml() function.
310
+ * @see tHtml
311
+ */
312
+ export const translateHtml = tHtml;
313
+
314
+ /**
315
+ * Set the current locale.
316
+ * If the locale is not registered, it will attempt to load it from the provided URL.
317
+ * Applies RTL direction to document element if direction is specified in locale.
318
+ * @param {string} lang - Language code
319
+ * @param {Object} [options] - Configuration options
320
+ * @param {string} [options.url] - URL to fetch locale from if not registered
321
+ * @param {Object} [options.translation] - Complete locale data object (legacy)
322
+ * @param {Object} [options.locales] - Locale data keyed by language code (new API)
323
+ * @param {boolean} [options.validate] - Whether to validate locale completeness
324
+ * @returns {Promise<boolean>} Whether locale was successfully set
325
+ */
326
+ export async function setCurrentLocale(
327
+ lang,
328
+ { url, translation, locales: customLocales, validate = false } = {},
329
+ ) {
330
+ currentLocale = resolveLocale(lang);
331
+ // Step 1: Process custom locales (new API - keyed by language code)
332
+ if (customLocales && typeof customLocales === 'object') {
333
+ Object.entries(customLocales).forEach(([localeKey, localeData]) => {
334
+ if (!localeData || typeof localeData !== 'object') {
335
+ return;
336
+ }
337
+
338
+ const resolvedKey = resolveLocale(localeKey);
339
+
340
+ if (locales[resolvedKey]) {
341
+ // Deep merge into existing bundled locale
342
+ locales[resolvedKey] = deepMerge(locales[resolvedKey], localeData);
343
+ } else {
344
+ // Register as new locale
345
+ registerLocale(resolvedKey, localeData);
346
+
347
+ // Add alias if short code provided
348
+ if (localeKey !== resolvedKey) {
349
+ LOCALE_ALIASES[localeKey] = resolvedKey;
350
+ }
351
+ }
352
+ });
353
+ }
354
+
355
+ // Step 2: Handle legacy translation option (for backward compatibility)
356
+ if (!locales[currentLocale]) {
357
+ if (translation) {
358
+ registerLocale(currentLocale, translation);
359
+ } else if (url) {
360
+ try {
361
+ await loadLocale(currentLocale, url);
362
+ } catch (error) {
363
+ console.error(
364
+ `Failed to load locale '${lang}', keeping current locale '${currentLocale}'`,
365
+ );
366
+ return false;
367
+ }
368
+ } else {
369
+ console.warn(`Locale '${lang}' not registered and no URL provided`);
370
+ return false;
371
+ }
372
+ }
373
+
374
+ // Step 3: Validate locale completeness if requested
375
+ if (validate && locales[currentLocale]) {
376
+ const validation = validateLocale(locales[currentLocale]);
377
+ if (!validation.valid) {
378
+ console.warn(
379
+ `Locale '${lang}' is missing required keys:`,
380
+ validation.missingKeys,
381
+ );
382
+ }
383
+ }
384
+
385
+ // Step 4: Apply RTL/LTR direction if specified in locale data
386
+ const locale = locales[currentLocale];
387
+ if (locale && locale.direction && document?.documentElement) {
388
+ document.documentElement.dir = locale.direction;
389
+ }
390
+
391
+ return true;
392
+ }
393
+
394
+ /**
395
+ * Get the current locale.
396
+ * @returns {string} Current language code
397
+ */
398
+ export function getCurrentLocale() {
399
+ return currentLocale;
400
+ }
401
+
402
+ /**
403
+ * Get the default locale code.
404
+ * @returns {string} Default language code
405
+ */
406
+ export function getDefaultLocale() {
407
+ return DEFAULT_LOCALE;
408
+ }
409
+
410
+ /**
411
+ * Check if a locale is registered.
412
+ * @param {string} lang - Language code
413
+ * @returns {boolean} Whether locale is registered
414
+ */
415
+ export function hasLocale(lang) {
416
+ return lang in locales;
417
+ }
418
+
419
+ /**
420
+ * Set document direction based on locale direction property.
421
+ * @param {string} lang - Language code
422
+ */
423
+ export function setDocumentDir(lang) {
424
+ const locale = locales[lang];
425
+ if (locale && locale.direction) {
426
+ document.documentElement.dir = locale.direction;
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Get the text direction for the current locale.
432
+ * @returns {string} Direction ('ltr' or 'rtl'), defaults to 'ltr'
433
+ */
434
+ export function getDirection() {
435
+ const locale = locales[currentLocale];
436
+ return locale?.direction || 'ltr';
437
+ }
438
+
439
+ export default {
440
+ deepMerge,
441
+ escapeHtml,
442
+ getCurrentLocale,
443
+ getDefaultLocale,
444
+ getDirection,
445
+ hasLocale,
446
+ loadLocale,
447
+ registerLocale,
448
+ registerLocaleUrl,
449
+ setCurrentLocale,
450
+ setDocumentDir,
451
+ t,
452
+ tHtml,
453
+ translate,
454
+ translateHtml,
455
+ validateLocale,
456
+ };