@naturalcycles/js-lib 14.256.0 → 14.258.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/cfg/frontend/tsconfig.json +67 -0
- package/dist/browser/adminService.d.ts +69 -0
- package/dist/browser/adminService.js +98 -0
- package/dist/browser/analytics.util.d.ts +12 -0
- package/dist/browser/analytics.util.js +59 -0
- package/dist/browser/i18n/fetchTranslationLoader.d.ts +13 -0
- package/dist/browser/i18n/fetchTranslationLoader.js +17 -0
- package/dist/browser/i18n/translation.service.d.ts +53 -0
- package/dist/browser/i18n/translation.service.js +61 -0
- package/dist/browser/imageFitter.d.ts +60 -0
- package/dist/browser/imageFitter.js +69 -0
- package/dist/browser/script.util.d.ts +14 -0
- package/dist/browser/script.util.js +50 -0
- package/dist/browser/topbar.d.ts +23 -0
- package/dist/browser/topbar.js +137 -0
- package/dist/decorators/memo.util.d.ts +2 -1
- package/dist/decorators/memo.util.js +8 -6
- package/dist/decorators/swarmSafe.decorator.d.ts +9 -0
- package/dist/decorators/swarmSafe.decorator.js +42 -0
- package/dist/deviceIdService.d.ts +65 -0
- package/dist/deviceIdService.js +109 -0
- package/dist/error/assert.d.ts +2 -1
- package/dist/error/assert.js +15 -13
- package/dist/error/error.util.js +9 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/nanoid.d.ts +7 -0
- package/dist/nanoid.js +61 -0
- package/dist/number/createDeterministicRandom.d.ts +6 -1
- package/dist/number/createDeterministicRandom.js +1 -2
- package/dist/string/hash.util.d.ts +1 -1
- package/dist/string/hash.util.js +1 -1
- package/dist/web.d.ts +6 -0
- package/dist/web.js +6 -0
- package/dist/zod/zod.util.d.ts +1 -1
- package/dist-esm/browser/adminService.js +94 -0
- package/dist-esm/browser/analytics.util.js +54 -0
- package/dist-esm/browser/i18n/fetchTranslationLoader.js +13 -0
- package/dist-esm/browser/i18n/translation.service.js +56 -0
- package/dist-esm/browser/imageFitter.js +65 -0
- package/dist-esm/browser/script.util.js +46 -0
- package/dist-esm/browser/topbar.js +134 -0
- package/dist-esm/decorators/memo.util.js +3 -1
- package/dist-esm/decorators/swarmSafe.decorator.js +38 -0
- package/dist-esm/deviceIdService.js +105 -0
- package/dist-esm/error/assert.js +3 -1
- package/dist-esm/error/error.util.js +4 -1
- package/dist-esm/index.js +9 -0
- package/dist-esm/nanoid.js +57 -0
- package/dist-esm/number/createDeterministicRandom.js +1 -2
- package/dist-esm/string/hash.util.js +1 -1
- package/dist-esm/web.js +6 -0
- package/package.json +2 -1
- package/src/browser/adminService.ts +157 -0
- package/src/browser/analytics.util.ts +68 -0
- package/src/browser/i18n/fetchTranslationLoader.ts +16 -0
- package/src/browser/i18n/translation.service.ts +102 -0
- package/src/browser/imageFitter.ts +128 -0
- package/src/browser/script.util.ts +52 -0
- package/src/browser/topbar.ts +147 -0
- package/src/datetime/localDate.ts +16 -0
- package/src/datetime/localTime.ts +39 -0
- package/src/decorators/debounce.ts +1 -0
- package/src/decorators/memo.util.ts +4 -1
- package/src/decorators/swarmSafe.decorator.ts +47 -0
- package/src/deviceIdService.ts +137 -0
- package/src/error/assert.ts +5 -11
- package/src/error/error.util.ts +4 -1
- package/src/index.ts +9 -0
- package/src/json-schema/jsonSchemaBuilder.ts +20 -0
- package/src/nanoid.ts +79 -0
- package/src/number/createDeterministicRandom.ts +7 -2
- package/src/semver.ts +2 -0
- package/src/string/hash.util.ts +1 -1
- package/src/web.ts +6 -0
- package/src/zod/zod.util.ts +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/// <reference lib="dom" preserve="true" />
|
|
2
|
+
import { isServerSide } from './env';
|
|
3
|
+
import { nanoidBrowser } from './nanoid';
|
|
4
|
+
import { hashCode } from './string/hash.util';
|
|
5
|
+
// This is in sync with the default length in Nanoid.
|
|
6
|
+
const deviceIdLength = 21;
|
|
7
|
+
/**
|
|
8
|
+
* Service to generate, maintain, persist a stable "device id".
|
|
9
|
+
*
|
|
10
|
+
* It's called "device id" and not userId/visitorId, to indicate that it only identifies a device,
|
|
11
|
+
* and has nothing to do with user identification.
|
|
12
|
+
* User might be logged in or not.
|
|
13
|
+
* User id can be the same on multiple devices.
|
|
14
|
+
* DeviceId is unique per device, same User or not.
|
|
15
|
+
*
|
|
16
|
+
* Service provides methods to deterministically select fraction of devices.
|
|
17
|
+
* For example, select 10% of devices that visit the website to be tracked by Analytics
|
|
18
|
+
* (to reduce Analytics quota usage).
|
|
19
|
+
* DeviceId persistence will ensure that recurring visits from the same device will yield the same
|
|
20
|
+
* DeviceId, and same "selection assignment" (like an assignment in an AB test).
|
|
21
|
+
*
|
|
22
|
+
* @experimental
|
|
23
|
+
*/
|
|
24
|
+
export class DeviceIdService {
|
|
25
|
+
constructor(cfg = {}) {
|
|
26
|
+
this.cfg = {
|
|
27
|
+
localStorageKey: 'deviceId',
|
|
28
|
+
debug: false,
|
|
29
|
+
...cfg,
|
|
30
|
+
};
|
|
31
|
+
this.init();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Selects this device based on "deterministic random selection", according to the defined `rate`.
|
|
35
|
+
* Rate is a floating number between 0 and 1.
|
|
36
|
+
* E.g rate of 0.1 means 10% chance of being selected.
|
|
37
|
+
*
|
|
38
|
+
* Selection is based on deviceId, which is generated random and persisted between visits.
|
|
39
|
+
* Persistence ensures that the selection (similar to an AB-test assignment) "sticks" to the device.
|
|
40
|
+
*
|
|
41
|
+
* If deviceId failed to be generated, e.g due to Device running out-of-space to save a string to localStorage,
|
|
42
|
+
* it will NOT be selected.
|
|
43
|
+
*
|
|
44
|
+
* @returns true if the device is selected.
|
|
45
|
+
*/
|
|
46
|
+
select(rate) {
|
|
47
|
+
if (!this.deviceId) {
|
|
48
|
+
this.debug(`deviceId is null, skipping selection`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const mod = Math.trunc(rate * 1000);
|
|
52
|
+
// console.log('hash: ', hashCode(this.deviceId)) // todo
|
|
53
|
+
return hashCode(this.deviceId) % 1000 < mod;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Deletes the persisted deviceId.
|
|
57
|
+
* Keeps it in the service.
|
|
58
|
+
* To remove it from the service, assign deviceIdService.deviceId = null.
|
|
59
|
+
*/
|
|
60
|
+
clearPersistence() {
|
|
61
|
+
try {
|
|
62
|
+
globalThis.localStorage.removeItem(this.cfg.localStorageKey);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.log(err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Generates a stable Device id if it wasn't previously generated on this device.
|
|
70
|
+
* Otherwise, reads a Device id from persistent storage.
|
|
71
|
+
*/
|
|
72
|
+
init() {
|
|
73
|
+
this.deviceId = null;
|
|
74
|
+
if (isServerSide())
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
this.deviceId = globalThis.localStorage.getItem(this.cfg.localStorageKey);
|
|
78
|
+
if (this.deviceId)
|
|
79
|
+
this.debug(`loaded deviceId: ${this.deviceId}`);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
console.log(err);
|
|
83
|
+
this.deviceId = null;
|
|
84
|
+
}
|
|
85
|
+
if (this.deviceId && this.deviceId.length !== deviceIdLength) {
|
|
86
|
+
console.warn(`[DeviceIdService] unexpected deviceIdLength (${this.deviceId.length}), will re-generate the id`, { deviceId: this.deviceId });
|
|
87
|
+
this.deviceId = null;
|
|
88
|
+
}
|
|
89
|
+
if (!this.deviceId) {
|
|
90
|
+
try {
|
|
91
|
+
this.deviceId = nanoidBrowser(deviceIdLength);
|
|
92
|
+
this.debug(`generated new deviceId: ${this.deviceId}`);
|
|
93
|
+
globalThis.localStorage.setItem(this.cfg.localStorageKey, this.deviceId);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.log(err);
|
|
97
|
+
this.deviceId = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
debug(...args) {
|
|
102
|
+
if (this.cfg.debug)
|
|
103
|
+
console.log('[DeviceIdService]', ...args);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist-esm/error/assert.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { _deepEquals
|
|
1
|
+
import { _deepEquals } from '../object/deepEquals';
|
|
2
|
+
import { _stringify } from '../string/stringify';
|
|
3
|
+
import { _isBackendErrorResponseObject, _isErrorObject, AssertionError } from './error.util';
|
|
2
4
|
/**
|
|
3
5
|
* Evaluates the `condition` (casts it to Boolean).
|
|
4
6
|
* Expects it to be truthy, otherwise throws AppError.
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isServerSide } from '../env';
|
|
2
|
+
import { _jsonParseIfPossible } from '../string/json.util';
|
|
3
|
+
import { _truncate, _truncateMiddle } from '../string/string.util';
|
|
4
|
+
import { _stringify } from '../string/stringify';
|
|
2
5
|
/**
|
|
3
6
|
* Useful to ensure that error in `catch (err) { ... }`
|
|
4
7
|
* is indeed an Error (and not e.g `string` or `undefined`).
|
package/dist-esm/index.js
CHANGED
|
@@ -2,6 +2,13 @@ export * from './abort';
|
|
|
2
2
|
export * from './array/array.util';
|
|
3
3
|
export * from './array/range';
|
|
4
4
|
export * from './bot';
|
|
5
|
+
export * from './browser/adminService';
|
|
6
|
+
export * from './browser/analytics.util';
|
|
7
|
+
export * from './browser/i18n/fetchTranslationLoader';
|
|
8
|
+
export * from './browser/i18n/translation.service';
|
|
9
|
+
export * from './browser/imageFitter';
|
|
10
|
+
export * from './browser/script.util';
|
|
11
|
+
export * from './browser/topbar';
|
|
5
12
|
export * from './datetime/dateInterval';
|
|
6
13
|
export * from './datetime/localDate';
|
|
7
14
|
export * from './datetime/localTime';
|
|
@@ -20,6 +27,7 @@ export * from './decorators/memoFnAsync';
|
|
|
20
27
|
export * from './decorators/retry.decorator';
|
|
21
28
|
export * from './decorators/timeout.decorator';
|
|
22
29
|
export * from './define';
|
|
30
|
+
export * from './deviceIdService';
|
|
23
31
|
export * from './enum.util';
|
|
24
32
|
export * from './env';
|
|
25
33
|
export * from './env/buildInfo';
|
|
@@ -45,6 +53,7 @@ export * from './log/commonLogger';
|
|
|
45
53
|
export * from './math/math.util';
|
|
46
54
|
export * from './math/sma';
|
|
47
55
|
export * from './math/stack.util';
|
|
56
|
+
export * from './nanoid';
|
|
48
57
|
export * from './number/createDeterministicRandom';
|
|
49
58
|
export * from './number/number.util';
|
|
50
59
|
export * from './object/deepEquals';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Vendored from https://github.com/ai/nanoid/blob/main/index.browser.js
|
|
2
|
+
// All credit to nanoid authors: https://github.com/ai/nanoid
|
|
3
|
+
// Reason for vendoring: (still) cannot import esm, and Nanoid went ESM-only since 4.0
|
|
4
|
+
/// <reference lib="dom" preserve="true" />
|
|
5
|
+
/* eslint-disable no-bitwise */
|
|
6
|
+
// "0-9a-zA-Z-_", same as base64url alphabet
|
|
7
|
+
const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
|
|
8
|
+
export function nanoidBrowser(length = 21) {
|
|
9
|
+
let id = '';
|
|
10
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(length));
|
|
11
|
+
while (length--) {
|
|
12
|
+
// Using the bitwise AND operator to "cap" the value of
|
|
13
|
+
// the random byte from 255 to 63, in that way we can make sure
|
|
14
|
+
// that the value will be a valid index for the "chars" string.
|
|
15
|
+
id += urlAlphabet[bytes[length] & 63];
|
|
16
|
+
}
|
|
17
|
+
return id;
|
|
18
|
+
}
|
|
19
|
+
const defaultRandomFunction = (bytes) => globalThis.crypto.getRandomValues(new Uint8Array(bytes));
|
|
20
|
+
export function nanoidBrowserCustomAlphabet(alphabet, length = 21) {
|
|
21
|
+
return customRandom(alphabet, length, defaultRandomFunction);
|
|
22
|
+
}
|
|
23
|
+
function customRandom(alphabet, defaultSize, getRandom) {
|
|
24
|
+
// First, a bitmask is necessary to generate the ID. The bitmask makes bytes
|
|
25
|
+
// values closer to the alphabet size. The bitmask calculates the closest
|
|
26
|
+
// `2^31 - 1` number, which exceeds the alphabet size.
|
|
27
|
+
// For example, the bitmask for the alphabet size 30 is 31 (00011111).
|
|
28
|
+
// `Math.clz32` is not used, because it is not available in browsers.
|
|
29
|
+
const mask = (2 << Math.log2(alphabet.length - 1)) - 1;
|
|
30
|
+
// Though, the bitmask solution is not perfect since the bytes exceeding
|
|
31
|
+
// the alphabet size are refused. Therefore, to reliably generate the ID,
|
|
32
|
+
// the random bytes redundancy has to be satisfied.
|
|
33
|
+
// Note: every hardware random generator call is performance expensive,
|
|
34
|
+
// because the system call for entropy collection takes a lot of time.
|
|
35
|
+
// So, to avoid additional system calls, extra bytes are requested in advance.
|
|
36
|
+
// Next, a step determines how many random bytes to generate.
|
|
37
|
+
// The number of random bytes gets decided upon the ID size, mask,
|
|
38
|
+
// alphabet size, and magic number 1.6 (using 1.6 peaks at performance
|
|
39
|
+
// according to benchmarks).
|
|
40
|
+
// `-~f => Math.ceil(f)` if f is a float
|
|
41
|
+
// `-~i => i + 1` if i is an integer
|
|
42
|
+
const step = -~((1.6 * mask * defaultSize) / alphabet.length);
|
|
43
|
+
return (size = defaultSize) => {
|
|
44
|
+
let id = '';
|
|
45
|
+
while (true) {
|
|
46
|
+
const bytes = getRandom(step);
|
|
47
|
+
// A compact alternative for `for (var i = 0; i < step; i++)`.
|
|
48
|
+
let j = step;
|
|
49
|
+
while (j--) {
|
|
50
|
+
// Adding `|| ''` refuses a random byte that exceeds the alphabet size.
|
|
51
|
+
id += alphabet[bytes[j] & mask] || '';
|
|
52
|
+
if (id.length === size)
|
|
53
|
+
return id;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Based on: https://gist.github.com/mathiasbynens/5670917
|
|
6
6
|
*/
|
|
7
|
-
export function _createDeterministicRandom() {
|
|
8
|
-
let seed = 0x2f6e2b1;
|
|
7
|
+
export function _createDeterministicRandom(seed = 0x2f6e2b1) {
|
|
9
8
|
return () => {
|
|
10
9
|
// Robert Jenkins’ 32 bit integer hash function
|
|
11
10
|
seed = (seed + 0x7ed55d16 + (seed << 12)) & 0xffffffff;
|
|
@@ -8,7 +8,7 @@ const BASE64URL = BASE62 + '-_';
|
|
|
8
8
|
*
|
|
9
9
|
* 1. Performance
|
|
10
10
|
* 2. For non-cryptographic use (where accidental collision is not the end-of-the-world)
|
|
11
|
-
* 3. Compact size (32 bits max, versus 128 in md5; presented in
|
|
11
|
+
* 3. Compact size (32 bits max, versus 128 in md5; presented in smaller number of string json-safe characters)
|
|
12
12
|
*
|
|
13
13
|
* Basically, these functions are as simple as they can be, but still "random enough" for
|
|
14
14
|
* normal non-cryptographic use cases.
|
package/dist-esm/web.js
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Implements WebStorage API by using in-memory storage.
|
|
5
5
|
* Can be useful in SSR environment or unit tests.
|
|
6
6
|
*
|
|
7
|
+
* This is how localStorage can be mocked in Node:
|
|
8
|
+
*
|
|
9
|
+
* Object.assign(globalThis, {
|
|
10
|
+
* localStorage: new InMemoryWebStorage(),
|
|
11
|
+
* })
|
|
12
|
+
*
|
|
7
13
|
* @experimental
|
|
8
14
|
*/
|
|
9
15
|
export class InMemoryWebStorage {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/js-lib",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.258.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"prepare": "husky",
|
|
6
6
|
"build": "dev-lib build-esm-cjs",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"files": [
|
|
37
37
|
"dist",
|
|
38
38
|
"dist-esm",
|
|
39
|
+
"cfg",
|
|
39
40
|
"src",
|
|
40
41
|
"!src/test",
|
|
41
42
|
"!src/**/*.test.*",
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { _Memo } from '../decorators/memo.decorator'
|
|
2
|
+
import { isServerSide } from '../env'
|
|
3
|
+
import { _stringify } from '../string/stringify'
|
|
4
|
+
import { Promisable } from '../typeFest'
|
|
5
|
+
|
|
6
|
+
export interface AdminModeCfg {
|
|
7
|
+
/**
|
|
8
|
+
* Function (predicate) to detect if needed keys are pressed.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* predicate: e => e.ctrlKey && e.key === 'L'
|
|
12
|
+
*
|
|
13
|
+
* @default
|
|
14
|
+
* Detects Ctrl+Shift+L
|
|
15
|
+
*/
|
|
16
|
+
predicate?: (e: KeyboardEvent) => boolean
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Called when RedDot is clicked. Implies that AdminMode is enabled.
|
|
20
|
+
*/
|
|
21
|
+
onRedDotClick?: () => any
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Called when AdminMode was changed.
|
|
25
|
+
*/
|
|
26
|
+
onChange?: (adminMode: boolean) => any
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Called BEFORE entering AdminMode.
|
|
30
|
+
* Serves as a predicate that can cancel entering AdminMode if false is returned.
|
|
31
|
+
* Return true to allow.
|
|
32
|
+
* Function is awaited before proceeding.
|
|
33
|
+
*/
|
|
34
|
+
beforeEnter?: () => Promisable<boolean>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Called BEFORE exiting AdminMode.
|
|
38
|
+
* Serves as a predicate that can cancel exiting AdminMode if false is returned.
|
|
39
|
+
* Return true to allow.
|
|
40
|
+
* Function is awaited before proceeding.
|
|
41
|
+
*/
|
|
42
|
+
beforeExit?: () => Promisable<boolean>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @default true
|
|
46
|
+
* If true - it will "persist" the adminMode state in LocalStorage
|
|
47
|
+
*/
|
|
48
|
+
persistToLocalStorage?: boolean
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The key for LocalStorage persistence.
|
|
52
|
+
*
|
|
53
|
+
* @default '__adminMode__'
|
|
54
|
+
*/
|
|
55
|
+
localStorageKey?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const RED_DOT_ID = '__red-dot__'
|
|
59
|
+
const NOOP = (): void => {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @experimental
|
|
63
|
+
*
|
|
64
|
+
* Allows to listen for AdminMode keypress combination (Ctrl+Shift+L by default) to toggle AdminMode,
|
|
65
|
+
* indicated by RedDot DOM element.
|
|
66
|
+
*
|
|
67
|
+
* todo: help with Authentication
|
|
68
|
+
*/
|
|
69
|
+
export class AdminService {
|
|
70
|
+
constructor(cfg?: AdminModeCfg) {
|
|
71
|
+
this.cfg = {
|
|
72
|
+
predicate: e => e.ctrlKey && e.key === 'L',
|
|
73
|
+
persistToLocalStorage: true,
|
|
74
|
+
localStorageKey: '__adminMode__',
|
|
75
|
+
onRedDotClick: NOOP,
|
|
76
|
+
onChange: NOOP,
|
|
77
|
+
beforeEnter: () => true,
|
|
78
|
+
beforeExit: () => true,
|
|
79
|
+
...cfg,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
cfg: Required<AdminModeCfg>
|
|
84
|
+
|
|
85
|
+
adminMode = false
|
|
86
|
+
|
|
87
|
+
private listening = false
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start listening to keyboard events to toggle AdminMode when detected.
|
|
91
|
+
*/
|
|
92
|
+
startListening(): void {
|
|
93
|
+
if (this.listening || isServerSide()) return
|
|
94
|
+
|
|
95
|
+
this.adminMode = !!localStorage.getItem(this.cfg.localStorageKey)
|
|
96
|
+
|
|
97
|
+
if (this.adminMode) this.toggleRedDotVisibility()
|
|
98
|
+
|
|
99
|
+
document.addEventListener('keydown', this.keydownListener.bind(this), { passive: true })
|
|
100
|
+
|
|
101
|
+
this.listening = true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
stopListening(): void {
|
|
105
|
+
if (isServerSide()) return
|
|
106
|
+
document.removeEventListener('keydown', this.keydownListener)
|
|
107
|
+
this.listening = false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async keydownListener(e: KeyboardEvent): Promise<void> {
|
|
111
|
+
// console.log(e)
|
|
112
|
+
if (!this.cfg.predicate(e)) return
|
|
113
|
+
await this.toggleRedDot()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async toggleRedDot(): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
const allow = await this.cfg[this.adminMode ? 'beforeExit' : 'beforeEnter']()
|
|
119
|
+
if (!allow) return // no change
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(err)
|
|
122
|
+
// ok to show alert to Admins, it's not user-facing
|
|
123
|
+
alert(_stringify(err))
|
|
124
|
+
return // treat as "not allowed"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.adminMode = !this.adminMode
|
|
128
|
+
|
|
129
|
+
this.toggleRedDotVisibility()
|
|
130
|
+
|
|
131
|
+
if (this.cfg.persistToLocalStorage) {
|
|
132
|
+
const { localStorageKey } = this.cfg
|
|
133
|
+
if (this.adminMode) {
|
|
134
|
+
localStorage.setItem(localStorageKey, '1')
|
|
135
|
+
} else {
|
|
136
|
+
localStorage.removeItem(localStorageKey)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.cfg.onChange(this.adminMode)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private toggleRedDotVisibility(): void {
|
|
144
|
+
this.getRedDotElement().style.display = this.adminMode ? 'block' : 'none'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@_Memo()
|
|
148
|
+
private getRedDotElement(): HTMLElement {
|
|
149
|
+
const el = document.createElement('div')
|
|
150
|
+
el.id = RED_DOT_ID
|
|
151
|
+
el.style.cssText =
|
|
152
|
+
'position:fixed;width:24px;height:24px;margin-top:-12px;background-color:red;opacity:0.5;top:50%;left:0;z-index:9999999;cursor:pointer;border-radius:0 3px 3px 0'
|
|
153
|
+
el.addEventListener('click', () => this.cfg.onRedDotClick())
|
|
154
|
+
document.body.append(el)
|
|
155
|
+
return el
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isServerSide } from '@naturalcycles/js-lib'
|
|
2
|
+
import { loadScript } from './script.util'
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
dataLayer: any[]
|
|
7
|
+
gtag: (...args: any[]) => void
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* eslint-disable unicorn/prefer-global-this */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pass enabled = false to only init window.gtag, but not load actual gtag script (e.g in dev mode).
|
|
15
|
+
*/
|
|
16
|
+
export async function loadGTag(gtagId: string, enabled = true): Promise<void> {
|
|
17
|
+
if (isServerSide()) return
|
|
18
|
+
|
|
19
|
+
window.dataLayer ||= []
|
|
20
|
+
window.gtag ||= function gtag() {
|
|
21
|
+
// biome-ignore lint/complexity/useArrowFunction: ok
|
|
22
|
+
// biome-ignore lint/style/noArguments: ok
|
|
23
|
+
window.dataLayer.push(arguments)
|
|
24
|
+
}
|
|
25
|
+
window.gtag('js', new Date())
|
|
26
|
+
window.gtag('config', gtagId)
|
|
27
|
+
|
|
28
|
+
if (!enabled) return
|
|
29
|
+
|
|
30
|
+
await loadScript(`https://www.googletagmanager.com/gtag/js?id=${gtagId}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function loadGTM(gtmId: string, enabled = true): Promise<void> {
|
|
34
|
+
if (isServerSide()) return
|
|
35
|
+
|
|
36
|
+
window.dataLayer ||= []
|
|
37
|
+
window.dataLayer.push({
|
|
38
|
+
'gtm.start': Date.now(),
|
|
39
|
+
event: 'gtm.js',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (!enabled) return
|
|
43
|
+
|
|
44
|
+
await loadScript(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadHotjar(hjid: number): void {
|
|
48
|
+
if (isServerSide()) return
|
|
49
|
+
|
|
50
|
+
/* eslint-disable */
|
|
51
|
+
// prettier-ignore
|
|
52
|
+
;
|
|
53
|
+
;((h: any, o, t, j, a?: any, r?: any) => {
|
|
54
|
+
h.hj =
|
|
55
|
+
h.hj ||
|
|
56
|
+
function hj() {
|
|
57
|
+
// biome-ignore lint/style/noArguments: ok
|
|
58
|
+
;(h.hj.q = h.hj.q || []).push(arguments)
|
|
59
|
+
}
|
|
60
|
+
h._hjSettings = { hjid, hjsv: 6 }
|
|
61
|
+
a = o.querySelectorAll('head')[0]
|
|
62
|
+
r = o.createElement('script')
|
|
63
|
+
r.async = 1
|
|
64
|
+
r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv
|
|
65
|
+
a.append(r)
|
|
66
|
+
})(window, document, 'https://static.hotjar.com/c/hotjar-', '.js?sv=')
|
|
67
|
+
/* eslint-enable */
|
|
68
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Fetcher } from '../../http/fetcher'
|
|
2
|
+
import { StringMap } from '../../types'
|
|
3
|
+
import { TranslationLoader } from './translation.service'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Use `baseUrl` to prefix your language files.
|
|
7
|
+
* Example URL structure:
|
|
8
|
+
* ${baseUrl}/${locale}.json
|
|
9
|
+
*/
|
|
10
|
+
export class FetchTranslationLoader implements TranslationLoader {
|
|
11
|
+
constructor(public fetcher: Fetcher) {}
|
|
12
|
+
|
|
13
|
+
async load(locale: string): Promise<StringMap> {
|
|
14
|
+
return await this.fetcher.get(`${locale}.json`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { pMap } from '../../promise/pMap'
|
|
2
|
+
import { StringMap } from '../../types'
|
|
3
|
+
|
|
4
|
+
export type MissingTranslationHandler = (key: string, params?: StringMap<any>) => string
|
|
5
|
+
|
|
6
|
+
export const defaultMissingTranslationHandler: MissingTranslationHandler = key => {
|
|
7
|
+
console.warn(`[tr] missing: ${key}`)
|
|
8
|
+
return `[${key}]`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TranslationServiceCfg {
|
|
12
|
+
defaultLocale: string
|
|
13
|
+
supportedLocales: string[]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* It is allowed to set it later. Will default to `defaultLocale` in that case.
|
|
17
|
+
*/
|
|
18
|
+
currentLocale?: string
|
|
19
|
+
|
|
20
|
+
translationLoader: TranslationLoader
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Defaults to `defaultMissingTranslationHandler` that returns `[${key}]` and emits console warning.
|
|
24
|
+
*/
|
|
25
|
+
missingTranslationHandler?: MissingTranslationHandler
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TranslationServiceCfgComplete extends TranslationServiceCfg {
|
|
29
|
+
missingTranslationHandler: MissingTranslationHandler // non-optional
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TranslationLoader {
|
|
33
|
+
load: (locale: string) => Promise<StringMap>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TranslationService {
|
|
37
|
+
constructor(cfg: TranslationServiceCfg, preloadedLocales: StringMap<StringMap> = {}) {
|
|
38
|
+
this.cfg = {
|
|
39
|
+
...cfg,
|
|
40
|
+
missingTranslationHandler: defaultMissingTranslationHandler,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.locales = {
|
|
44
|
+
...preloadedLocales,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.currentLocale = cfg.currentLocale || cfg.defaultLocale
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cfg: TranslationServiceCfgComplete
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cache of loaded locales
|
|
54
|
+
*/
|
|
55
|
+
locales: StringMap<StringMap>
|
|
56
|
+
|
|
57
|
+
currentLocale: string
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Manually set locale data, bypassing the TranslationLoader.
|
|
61
|
+
*/
|
|
62
|
+
setLocale(localeName: string, locale: StringMap): void {
|
|
63
|
+
this.locales[localeName] = locale
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getLocale(locale: string): StringMap | undefined {
|
|
67
|
+
return this.locales[locale]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Loads locale(s) (if not already cached) via configured TranslationLoader.
|
|
72
|
+
* Resolves promise when done (ready to be used).
|
|
73
|
+
*/
|
|
74
|
+
async loadLocale(locale: string | string[]): Promise<void> {
|
|
75
|
+
const locales = Array.isArray(locale) ? locale : [locale]
|
|
76
|
+
|
|
77
|
+
await pMap(locales, async locale => {
|
|
78
|
+
if (this.locales[locale]) return // already loaded
|
|
79
|
+
this.locales[locale] = await this.cfg.translationLoader.load(locale)
|
|
80
|
+
// console.log(`[tr] locale loaded: ${locale}`)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Will invoke `missingTranslationHandler` on missing tranlation.
|
|
86
|
+
*
|
|
87
|
+
* Does NOT do any locale loading. The locale needs to be loaded beforehand:
|
|
88
|
+
* either pre-loaded and passed to the constructor,
|
|
89
|
+
* or `await loadLocale(locale)`.
|
|
90
|
+
*/
|
|
91
|
+
translate(key: string, params?: StringMap): string {
|
|
92
|
+
return this.translateIfExists(key, params) || this.cfg.missingTranslationHandler(key, params)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Does NOT invoke `missingTranslationHandler`, returns `undefined` instead.
|
|
97
|
+
*/
|
|
98
|
+
translateIfExists(key: string, _params?: StringMap): string | undefined {
|
|
99
|
+
// todo: support params
|
|
100
|
+
return this.locales[this.currentLocale]?.[key] || this.locales[this.cfg.defaultLocale]?.[key]
|
|
101
|
+
}
|
|
102
|
+
}
|