feature/IO-3701-Harness-Replacement - Implement

This commit is contained in:
Dave
2026-05-20 14:41:24 -04:00
parent 84ec68f142
commit deb2fc28ce
100 changed files with 4813 additions and 773 deletions

302
client/package-lock.json generated
View File

@@ -29,7 +29,6 @@
"@sentry/cli": "^3.3.5",
"@sentry/react": "^10.47.0",
"@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.5",
"apollo-link-logger": "^3.0.0",
@@ -3726,12 +3725,6 @@
"react": "*"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
"license": "MIT"
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
@@ -7249,63 +7242,6 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@splitsoftware/splitio": {
"version": "11.9.0",
"resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.9.0.tgz",
"integrity": "sha512-1kWFgfkV1zE6Ubq8WkLVrvxsk1TF/UY2gux8A1jUdKyBwItrKJVjS10C9dspnkJxHSfRtMl2mtxJ/8vH7XjFew==",
"license": "Apache-2.0",
"dependencies": {
"@splitsoftware/splitio-commons": "2.9.0",
"bloom-filters": "^3.0.4",
"ioredis": "^4.28.0",
"js-yaml": "^3.13.1",
"node-fetch": "^2.7.0",
"tslib": "^2.3.1",
"unfetch": "^4.2.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@splitsoftware/splitio-commons": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.9.0.tgz",
"integrity": "sha512-dfGqtiuYcWeR235NM43z3BOULTFi+hdkB1FbOHePrufWJTYBOfuBeIgPnsW3wyg+kXyGkNN49JyywZHrJtVpDA==",
"license": "Apache-2.0",
"dependencies": {
"@types/ioredis": "^4.28.0",
"tslib": "^2.3.1"
},
"peerDependencies": {
"ioredis": "^4.28.0"
},
"peerDependenciesMeta": {
"ioredis": {
"optional": true
}
}
},
"node_modules/@splitsoftware/splitio-react": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@splitsoftware/splitio-react/-/splitio-react-2.6.1.tgz",
"integrity": "sha512-9TUrvNHcN3F1VpnlT8l12OY+s/atBgpKoThixQTBP87DMlHXEtEF4/GNl+oKKdkNEymYY9lY1iR0xGatuTvi9A==",
"license": "Apache-2.0",
"dependencies": {
"@splitsoftware/splitio": "11.9.0",
"memoize-one": "^5.1.1",
"shallowequal": "^1.1.0",
"tslib": "^2.3.1"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@splitsoftware/splitio-react/node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -7619,15 +7555,6 @@
"@types/react": "*"
}
},
"node_modules/@types/ioredis": {
"version": "4.28.10",
"resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz",
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -7682,12 +7609,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/seedrandom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-3.0.8.tgz",
"integrity": "sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==",
"license": "MIT"
},
"node_modules/@types/stylis": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz",
@@ -8182,15 +8103,6 @@
"@apollo/client": "^4.0.0"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -8540,15 +8452,6 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -8613,25 +8516,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bloom-filters": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/bloom-filters/-/bloom-filters-3.0.4.tgz",
"integrity": "sha512-BdnPWo2OpYhlvuP2fRzJBdioMCkm7Zp0HCf8NJgF5Mbyqy7VQ/CnTiVWMMyq4EZCBHwj0Kq6098gW2/3RsZsrA==",
"license": "MIT",
"dependencies": {
"@types/seedrandom": "^3.0.8",
"base64-arraybuffer": "^1.0.2",
"is-buffer": "^2.0.5",
"lodash": "^4.17.21",
"long": "^5.2.0",
"reflect-metadata": "^0.1.13",
"seedrandom": "^3.0.5",
"xxhashjs": "^0.2.2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/bn.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
@@ -9184,15 +9068,6 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -9559,12 +9434,6 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/cuint": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz",
"integrity": "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@@ -9896,15 +9765,6 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz",
"integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -10734,19 +10594,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -12004,32 +11851,6 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/ioredis": {
"version": "4.30.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.30.1.tgz",
"integrity": "sha512-17Ed70njJ7wT7JZsdTVLb0j/cmwHwfQCFu+AP6jY7nFKd+CA7MBW7nX121mM64eT8S9ekAVtYYt8nGQPmm3euA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "^1.0.2",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.1",
"denque": "^1.1.0",
"lodash.defaults": "^4.2.0",
"lodash.flatten": "^4.4.0",
"lodash.isarguments": "^3.1.0",
"p-map": "^2.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -12161,29 +11982,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -12720,19 +12518,6 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "28.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
@@ -13257,24 +13042,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -14711,15 +14478,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
"integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -15949,27 +15707,6 @@
"node": ">=8"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reduce-reducers": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reduce-reducers/-/reduce-reducers-1.0.4.tgz",
@@ -16038,12 +15775,6 @@
"redux": "^5.0.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz",
"integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==",
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -16545,12 +16276,6 @@
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -17049,12 +16774,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -17062,12 +16781,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
@@ -17982,12 +17695,6 @@
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT"
},
"node_modules/unfetch": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
"integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==",
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
@@ -19481,15 +19188,6 @@
"node": ">=0.4"
}
},
"node_modules/xxhashjs": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xxhashjs/-/xxhashjs-0.2.2.tgz",
"integrity": "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==",
"license": "MIT",
"dependencies": {
"cuint": "^0.2.2"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -28,7 +28,6 @@
"@sentry/cli": "^3.3.5",
"@sentry/react": "^10.47.0",
"@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.5",
"apollo-link-logger": "^3.0.0",

View File

@@ -5593,29 +5593,6 @@ Demo: https://rawgit.com/Sphinxxxx/color-conversion/master/demo/index.html
-----------
The following NPM packages may be included in this product:
- @splitsoftware/splitio-commons@1.6.1
- @splitsoftware/splitio-react@1.7.1
These packages each contain the following license and notice below:
Copyright © 2022 Split Software, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-----------
The following NPM packages may be included in this product:
- @stripe/react-stripe-js@1.9.0

View File

@@ -1,184 +0,0 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider, Grid } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { signOutStart } from "../redux/user/user.actions";
import client from "../utils/GraphQLClient";
import App from "./App";
import getTheme from "./themeProvider";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
}
};
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
function AppContainer() {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isUltraWide = Boolean(screens.xxxl);
const theme = useMemo(() => {
const baseTheme = getTheme(isDarkMode);
return {
...baseTheme,
token: {
...(baseTheme.token || {}),
screenXXXL: 2160
},
components: {
...(baseTheme.components || {}),
Table: {
...(baseTheme.components?.Table || {}),
cellFontSizeSM: isPhone ? 12 : 13,
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
cellFontSize: isUltraWide ? 15 : 14,
cellPaddingInlineSM: isPhone ? 8 : 10,
cellPaddingInlineMD: isPhone ? 10 : 14,
cellPaddingInline: isUltraWide ? 20 : 16,
cellPaddingBlockSM: isPhone ? 8 : 10,
cellPaddingBlockMD: isPhone ? 10 : 12,
cellPaddingBlock: isUltraWide ? 14 : 12,
selectionColumnWidth: isPhone ? 44 : 52
}
}
};
}, [isDarkMode, isPhone, isUltraWide]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
const antdPagination = useMemo(
() => ({
showSizeChanger: !isPhone,
totalBoundaryShowSizeChanger: 100
}),
[isPhone]
);
const antdForm = useMemo(
() => ({
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}),
[t]
);
// Global seamless logout listener with redirect to /signin
useEffect(() => {
const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return;
// Only accept messages from the parent window
if (event.source !== window.parent) return;
const targetOrigin = event.origin || "*";
if (currentUser?.authorized !== true) {
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
return;
}
dispatch(signOutStart());
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
};
window.addEventListener("message", handleSeamlessLogout);
return () => {
window.removeEventListener("message", handleSeamlessLogout);
};
}, [dispatch, currentUser?.authorized]);
// Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => {
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
// Sync darkMode with localStorage
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) {
dispatch(setDarkMode(false));
return;
}
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) return;
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={antdInput}
locale={enLocale}
theme={theme}
form={antdForm}
table={antdTable}
pagination={antdPagination}
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
popupOverflow="viewport"
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
);
}
export default Sentry.withProfiler(AppContainer);

View File

@@ -1,6 +1,6 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { FeatureFlagProvider, useFeatureFlagClient } from "../feature-flags/splitio-react-replacement";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
@@ -16,23 +16,21 @@ import client from "../utils/GraphQLClient";
import App from "./App";
import getTheme from "./themeProvider";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
}
};
function SplitClientProvider({ children }) {
function FeatureFlagClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
const featureFlagClient = useFeatureFlagClient({ key: imexshopid || "anon" });
useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
if (import.meta.env.DEV && featureFlagClient && imexshopid) {
console.log(`Feature flag client initialized with key: ${imexshopid}, isReady: ${featureFlagClient.isReady}`);
}
}, [splitClient, imexshopid]);
}, [featureFlagClient, imexshopid]);
return children;
}
@@ -124,11 +122,11 @@ function AppContainer() {
<ApolloProvider client={client}>
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<FeatureFlagProvider config={config}>
<FeatureFlagClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</FeatureFlagClientProvider>
</FeatureFlagProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>

View File

@@ -1,184 +0,0 @@
import { ApolloProvider } from "@apollo/client/react";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider, Grid } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { signOutStart } from "../redux/user/user.actions";
import client from "../utils/GraphQLClient";
import App from "./App";
import getTheme from "./themeProvider";
// Base Split configuration
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon"
}
};
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
function AppContainer() {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const screens = Grid.useBreakpoint();
const isPhone = !screens.md;
const isUltraWide = Boolean(screens.xxxl);
const theme = useMemo(() => {
const baseTheme = getTheme(isDarkMode);
return {
...baseTheme,
token: {
...(baseTheme.token || {}),
screenXXXL: 2160
},
components: {
...(baseTheme.components || {}),
Table: {
...(baseTheme.components?.Table || {}),
cellFontSizeSM: isPhone ? 12 : 13,
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
cellFontSize: isUltraWide ? 15 : 14,
cellPaddingInlineSM: isPhone ? 8 : 10,
cellPaddingInlineMD: isPhone ? 10 : 14,
cellPaddingInline: isUltraWide ? 20 : 16,
cellPaddingBlockSM: isPhone ? 8 : 10,
cellPaddingBlockMD: isPhone ? 10 : 12,
cellPaddingBlock: isUltraWide ? 14 : 12,
selectionColumnWidth: isPhone ? 44 : 52
}
}
};
}, [isDarkMode, isPhone, isUltraWide]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
const antdPagination = useMemo(
() => ({
showSizeChanger: !isPhone,
totalBoundaryShowSizeChanger: 100
}),
[isPhone]
);
const antdForm = useMemo(
() => ({
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}),
[t]
);
// Global seamless logout listener with redirect to /signin
useEffect(() => {
const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return;
// Only accept messages from the parent window
if (event.source !== window.parent) return;
const targetOrigin = event.origin || "*";
if (currentUser?.authorized !== true) {
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
return;
}
dispatch(signOutStart());
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
};
window.addEventListener("message", handleSeamlessLogout);
return () => {
window.removeEventListener("message", handleSeamlessLogout);
};
}, [dispatch, currentUser?.authorized]);
// Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => {
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
// Sync darkMode with localStorage
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) {
dispatch(setDarkMode(false));
return;
}
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode
useEffect(() => {
const uid = currentUser?.uid;
if (!uid) return;
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={antdInput}
locale={enLocale}
theme={theme}
form={antdForm}
table={antdTable}
pagination={antdPagination}
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
popupOverflow="viewport"
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
);
}
export default Sentry.withProfiler(AppContainer);

View File

@@ -1,4 +1,4 @@
import { useSplitClient } from "@splitsoftware/splitio-react";
import { useSplitClient } from "../feature-flags/splitio-react-replacement";
import { Button, Result } from "antd";
import LogRocket from "logrocket";
import { lazy, Suspense, useEffect, useState } from "react";
@@ -225,13 +225,22 @@ export function App({
path="/parts/*"
element={
<ErrorBoundary>
<PrivateRoute isAuthorized={currentUser.authorized} />
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
}
>
<Route path="*" element={<SimplifiedPartsPageContainer />} />
</Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
<Route
path="/edit/*"
element={
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
}
>
<Route path="*" element={<DocumentEditorContainer />} />
</Route>
</Routes>

View File

@@ -1,5 +1,5 @@
import { useApolloClient, useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
import _ from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";

View File

@@ -1,6 +1,6 @@
import Icon, { UploadOutlined } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import { useLazyQuery, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";

View File

@@ -1,5 +1,5 @@
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
import { useRef } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -8,7 +8,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import GlobalSearch from "../global-search/global-search.component";
import GlobalSearchOs from "../global-search/global-search-os.component";
import "./breadcrumbs.styles.scss";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
breadcrumbs: selectBreadcrumbs,

View File

@@ -1,6 +1,6 @@
import { PictureFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Badge, Popover } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,

View File

@@ -26,7 +26,7 @@ import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import { DMS_MAP } from "../../utils/dmsUtils";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
/**
* CDK-like DMS post form:

View File

@@ -9,7 +9,7 @@ import AlertComponent from "../alert/alert.component";
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
const mapStateToProps = createStructuredSelector({

View File

@@ -5,6 +5,7 @@ import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx";
import { useTreatment } from "../../feature-flags/splitio-react-replacement.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
@@ -16,6 +17,12 @@ const mapStateToProps = createStructuredSelector({
export function GlobalFooter({ isPartsEntry }) {
const { t } = useTranslation();
const testFlagTreatment = useTreatment({ name: "TEST_FLAG" });
const testFlagEnabled = testFlagTreatment === "on";
const testFlagIndicator = testFlagEnabled ? (
<div style={{ fontWeight: 600, marginTop: 4 }}>Test Feature Flag Enabled</div>
) : null;
if (isPartsEntry) {
return (
@@ -38,6 +45,7 @@ export function GlobalFooter({ isPartsEntry }) {
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices
</Link>
{testFlagIndicator}
</div>
</Footer>
);
@@ -74,6 +82,7 @@ export function GlobalFooter({ isPartsEntry }) {
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
Disclaimer & Notices
</Link>
{testFlagIndicator}
</div>
</Footer>
);

View File

@@ -2,7 +2,7 @@
import { BellFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";

View File

@@ -1,5 +1,5 @@
import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Popconfirm } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -30,7 +30,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
// import AllocationsAssignmentContainer from "../allocations-assignment/allocations-assignment.container";
// import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container";
// import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import _ from "lodash";
import { FaTasks } from "react-icons/fa";
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, Input, InputNumber, Modal, Select, Switch } from "antd";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import Axios from "axios";
import Dinero from "dinero.js";
import { useState } from "react";

View File

@@ -16,7 +16,7 @@ import DataLabel from "../data-label/data-label.component";
import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component";
import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -4,7 +4,7 @@ import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../gra
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
import { useIsEmployee } from "../../utils/useIsEmployee.js";

View File

@@ -1,6 +1,6 @@
import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react";
import { gql } from "@apollo/client";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Col, Row } from "antd";
import Axios from "axios";
import _ from "lodash";

View File

@@ -6,7 +6,7 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";

View File

@@ -1,6 +1,6 @@
import { DownCircleFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";

View File

@@ -6,7 +6,7 @@ import LaborAllocationsTableComponent from "../labor-allocations-table/labor-all
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly,

View File

@@ -6,7 +6,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
//import yauzl from "yauzl";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";

View File

@@ -7,7 +7,7 @@ import JobDocuments from "./jobs-documents-gallery.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -1,5 +1,5 @@
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";

View File

@@ -19,7 +19,7 @@ import { TemplateList } from "../../utils/TemplateConstants";
import AlertComponent from "../alert/alert.component";
import PartsOrderModalComponent from "./parts-order-modal.component";
import axios from "axios";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import _ from "lodash";
import { UPDATE_JOB } from "../../graphql/jobs.queries";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, Input, Radio, Select } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, Row, Space, Tooltip, Typography } from "antd";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Card, Col, Input, Row, Space, Typography } from "antd";
import _ from "lodash";
import { useState } from "react";

View File

@@ -11,7 +11,7 @@ import {
import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({

View File

@@ -5,7 +5,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
technician: selectTechnician,

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries";
import { UPDATE_SHOP } from "../../graphql/bodyshop.queries";
import ProductionListColumns from "../production-list-columns/production-list-columns.data";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { isFunction } from "lodash";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";

View File

@@ -1,6 +1,6 @@
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-layout";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
import _ from "lodash";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

View File

@@ -9,7 +9,7 @@ import {
} from "../../graphql/jobs.queries";
import ProductionListTable from "./production-list-table.component";
import _ from "lodash";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {

View File

@@ -1,5 +1,5 @@
import { useLazyQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd";
import _ from "lodash";
import { useState } from "react";

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import queryString from "query-string";

View File

@@ -33,7 +33,7 @@ vi.mock("@apollo/client/react", async () => {
};
});
vi.mock("@splitsoftware/splitio-react", () => ({
vi.mock("../../feature-flags/splitio-react-replacement", () => ({
useTreatmentsWithConfig: () => ({
treatments: {
Enhanced_Payroll: {

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Tabs } from "antd";
import queryString from "query-string";
import { useRef } from "react";

View File

@@ -4,7 +4,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -1,4 +1,4 @@
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Form, InputNumber } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";

View File

@@ -1,5 +1,5 @@
import { DeleteFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -16,7 +16,7 @@ import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./sh
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -7,7 +7,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import JobSearchSelect from "../job-search-select/job-search-select.component";
import JobsDetailLaborContainer from "../jobs-detail-labor/jobs-detail-labor.container";
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,

View File

@@ -12,7 +12,7 @@ import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import TechJobPrintTickets from "../tech-job-print-tickets/tech-job-print-tickets.component";
import TechClockInComponent from "./tech-job-clock-in-form.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({

View File

@@ -14,7 +14,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-out-delete.component";
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";

View File

@@ -11,7 +11,7 @@ import { createStructuredSelector } from "reselect";
import { techLogout } from "../../redux/tech/tech.actions";
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { BsKanban } from "react-icons/bs";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { setModalContext } from "../../redux/modals/modals.actions";

View File

@@ -1,5 +1,5 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Card, Checkbox, Space } from "antd";
import ResponsiveTable from "../responsive-table/responsive-table.component";
import { useMemo, useState } from "react";

View File

@@ -1,5 +1,5 @@
import { useLazyQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,6 +1,6 @@
import { PageHeader } from "@ant-design/pro-layout";
import { useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Form, Modal, Space } from "antd";
import { useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import { useTranslation } from "react-i18next";

View File

@@ -14,7 +14,7 @@ import {
} from "../../graphql/notifications.queries.js";
import { useMutation } from "@apollo/client/react";
import { useTranslation } from "react-i18next";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { FEATURE_FLAGS_CHANGED_EVENT, useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
const LIMIT = INITIAL_NOTIFICATIONS;
@@ -280,6 +280,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
}
};
const handleFeatureFlagsChanged = (payload) => {
window.dispatchEvent(new CustomEvent(FEATURE_FLAGS_CHANGED_EVENT, { detail: payload }));
};
const syncCurrentTokenToSocket = async () => {
try {
if (!auth.currentUser || !bodyshop?.id) return;
@@ -574,6 +578,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
socketInstance.on("notification", handleNotification);
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
socketInstance.on(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
socketInstance.on("token-updated", handleTokenUpdated);
if (tokenSyncIntervalRef.current) {

View File

@@ -0,0 +1,71 @@
# Feature Flags
The app imports feature-flag hooks from `src/feature-flags/splitio-react-replacement.jsx`. That module keeps the old
Split-shaped component and hook API intact while removing the runtime dependency on Split.
Code should import this local module directly. We no longer rely on a Vite alias for the old Split package.
## Current storage contract
The compatibility layer reads the active shop from Redux, then fetches DB-backed assignments from:
```text
GET /feature-flags/bodyshops/:bodyshopId
```
That endpoint verifies the Firebase user can access the bodyshop through Hasura permissions, then returns cached Redis
data when present or refreshes from `feature_flags` + `bodyshop_feature_flags`.
On successful backend responses, the client stores the last-known flag payload in browser `localStorage` for the active
bodyshop. If the backend cannot be reached later, the client uses that bodyshop-scoped browser cache for up to 24 hours.
If there is no browser cache, unknown flags resolve to `"off"`.
Recommended backend payload shape:
```json
{
"flags": {
"Enhanced_Payroll": {
"treatment": "on",
"config": null,
"activeDate": null,
"deactiveDate": null
},
"Demo_Feature": {
"treatment": "on",
"config": null,
"activeDate": "2026-06-01T13:00:00-04:00",
"deactiveDate": "2026-06-05T17:00:00-04:00"
}
}
}
```
Supported values:
- `true`, `"true"`, `1`, `"on"` -> treatment `"on"`
- `false`, `"false"`, `0`, `"off"` -> treatment `"off"`
- ISO-ish future date strings -> `"on"` until the date passes
- `{ "treatment": "on" | "off" | "control" | "any-custom-treatment", "config": ... }`
- Scheduled demo windows using `activeDate` and `deactiveDate`
Unknown flags default to `"off"`.
## Backend registry
Canonical feature flag definitions live in the Hasura-backed `feature_flags` table and are exposed to the admin panel
through `GET /adm/feature-flags`.
Per-shop assignments live in `bodyshop_feature_flags`. The admin panel reads them through
`GET /adm/bodyshops/:bodyshopId/feature-flags` and saves them through `POST /adm/updateshop`.
Hasura invalidates the Redis cache through `/feature-flags/cache/invalidate` when `bodyshop_feature_flags` or
`feature_flags` changes. Assignment changes clear the affected shop cache for the current cache version; definition
changes increment a global feature flag cache version so old per-shop cache entries become invisible and expire by TTL.
The backend also emits `feature-flags-changed` over the existing Socket.IO connection. `SocketProvider` bridges that
socket message to a browser event, and `SplitFactoryProvider` refetches flags when the event is global or matches the
active bodyshop. This keeps already-open tabs in sync with admin edits and Hasura-triggered invalidation.
For manual frontend testing, the global footer displays `Test Feature Flag Enabled` when `TEST_FLAG` resolves to
the `on` treatment.

View File

@@ -0,0 +1,411 @@
import axios from "axios";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { selectBodyshop } from "../redux/user/user.selectors";
const FeatureFlagContext = createContext({
config: {},
factory: null,
flags: {},
isReady: true,
source: "local"
});
const OFF_TREATMENT = Object.freeze({ treatment: "off", config: null });
const LOCAL_STORAGE_PREFIX = "bodyshop-feature-flags";
const LOCAL_STORAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
const FEATURE_FLAGS_REFRESH_DEBOUNCE_MS = 150;
const MAX_SCHEDULE_REFRESH_DELAY_MS = 2_147_483_647;
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const hasSchedule = (value) => value.activeDate != null || value.deactiveDate != null;
export const FEATURE_FLAGS_CHANGED_EVENT = "feature-flags-changed";
/**
* Parses optional schedule timestamps into comparable epoch milliseconds.
*/
const parseDate = (value) => {
if (value == null || value === "") return null;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
};
/**
* Determines whether a scheduled feature flag assignment is active at the current time.
*/
const isWithinSchedule = (value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return true;
const now = Date.now();
const startsAt = parseDate(value.activeDate);
const endsAt = parseDate(value.deactiveDate);
if (startsAt != null && now < startsAt) return false;
if (endsAt != null && now >= endsAt) return false;
return true;
};
/**
* Normalizes backend config values into the object/string/null shape Split hooks expect.
*/
const normalizeConfig = (config) => {
if (config == null || config === "") return null;
if (typeof config === "string") {
try {
return JSON.parse(config);
} catch {
return config;
}
}
return config;
};
/**
* Converts legacy boolean-ish values and custom treatment strings into a stable treatment value.
*/
const normalizeTreatment = (value) => {
if (typeof value === "boolean") return value ? "on" : "off";
if (typeof value === "number") return value > 0 ? "on" : "off";
if (typeof value === "string") {
const normalized = value.trim();
const lowered = normalized.toLowerCase();
if (lowered === "true") return "on";
if (lowered === "false") return "off";
if (lowered === "on" || lowered === "off" || lowered === "control") return lowered;
const dateValue = Date.parse(normalized);
if (!Number.isNaN(dateValue)) return dateValue > Date.now() ? "on" : "off";
return normalized;
}
return value ? "on" : "off";
};
/**
* Converts any supported backend flag value into a Split-compatible treatment/config pair.
*/
const normalizeFlagValue = (value) => {
if (value == null) return OFF_TREATMENT;
if (typeof value === "object" && !Array.isArray(value)) {
if (!isWithinSchedule(value)) return OFF_TREATMENT;
if (hasOwn(value, "treatment")) {
return {
treatment: normalizeTreatment(value.treatment),
config: normalizeConfig(value.config)
};
}
if (hasOwn(value, "enabled")) {
return {
treatment: normalizeTreatment(value.enabled),
config: normalizeConfig(value.config)
};
}
if (hasSchedule(value)) {
return {
treatment: "on",
config: normalizeConfig(value.config)
};
}
}
return {
treatment: normalizeTreatment(value),
config: null
};
};
/**
* Checks whether a socket/browser feature-flag change event applies to the active bodyshop.
*/
const isFeatureFlagChangeRelevant = (detail, bodyshopId) => {
if (!detail || detail.scope === "global") return true;
if (!bodyshopId) return false;
return String(detail.bodyshopId) === String(bodyshopId);
};
/**
* Finds the next scheduled flag boundary that should force a local re-render.
*/
const getNextScheduleRefreshDelay = (flags = {}, now = Date.now()) => {
const nextTimestamp = Object.values(flags).reduce((next, value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return next;
const timestamps = [parseDate(value.activeDate), parseDate(value.deactiveDate)].filter(
(timestamp) => timestamp != null && timestamp > now
);
if (!timestamps.length) return next;
const candidate = Math.min(...timestamps);
return next == null ? candidate : Math.min(next, candidate);
}, null);
if (nextTimestamp == null) return null;
return Math.min(Math.max(nextTimestamp - now + 50, 0), MAX_SCHEDULE_REFRESH_DELAY_MS);
};
/**
* Checks whether browser localStorage can be used in the current runtime.
*/
const isBrowserStorageAvailable = () => typeof window !== "undefined" && window.localStorage;
/**
* Builds the browser cache key for one bodyshop's feature flags.
*/
const getLocalStorageKey = (bodyshopId) => `${LOCAL_STORAGE_PREFIX}:${bodyshopId}`;
/**
* Reads a bodyshop-scoped last-known-good flag payload from browser storage.
*/
const readCachedFeatureFlags = (bodyshopId, now = Date.now()) => {
if (!bodyshopId || !isBrowserStorageAvailable()) return null;
try {
const rawValue = window.localStorage.getItem(getLocalStorageKey(bodyshopId));
if (!rawValue) return null;
const parsed = JSON.parse(rawValue);
if (!parsed?.flags || typeof parsed.flags !== "object" || Array.isArray(parsed.flags)) return null;
const cachedAt = Date.parse(parsed.cachedAt);
if (!parsed.cachedAt || Number.isNaN(cachedAt) || now - cachedAt > LOCAL_STORAGE_MAX_AGE_MS) return null;
return parsed.flags;
} catch {
return null;
}
};
/**
* Persists a successful backend flag payload for short-term browser fallback.
*/
const writeCachedFeatureFlags = (bodyshopId, flags) => {
if (!bodyshopId || !flags || !isBrowserStorageAvailable()) return;
try {
window.localStorage.setItem(
getLocalStorageKey(bodyshopId),
JSON.stringify({
cachedAt: new Date().toISOString(),
flags
})
);
} catch {
// localStorage may be unavailable, full, or blocked. Runtime flags still work without the browser cache.
}
};
/**
* Builds the local client object that mimics the Split client surface used by the app.
*/
const createFeatureFlagClient = ({ bodyshop, key, backendFlags }) => {
const attributes = {};
const getTreatmentWithConfig = (name) => normalizeFlagValue(backendFlags?.[name]);
return {
client: null,
isReady: true,
isReadyFromCache: true,
key: key || bodyshop?.imexshopid || "anon",
getTreatment: (name) => getTreatmentWithConfig(name).treatment,
getTreatmentWithConfig,
getTreatments: (names = []) =>
names.reduce((acc, name) => {
acc[name] = getTreatmentWithConfig(name).treatment;
return acc;
}, {}),
getTreatmentsWithConfig: (names = []) =>
names.reduce((acc, name) => {
acc[name] = getTreatmentWithConfig(name);
return acc;
}, {}),
setAttribute: (name, value) => {
attributes[name] = value;
return true;
},
setAttributes: (values = {}) => {
Object.assign(attributes, values);
return true;
},
getAttribute: (name) => attributes[name],
getAttributes: () => ({ ...attributes }),
ready: () => Promise.resolve(),
on: () => {},
off: () => {},
destroy: () => {}
};
};
/**
* Provides database-backed feature flags through a Split-shaped React context.
*/
export function SplitFactoryProvider({ children, config, factory }) {
const bodyshop = useSelector(selectBodyshop);
const [state, setState] = useState({ flags: {}, isReady: true, source: "local" });
const loadIdRef = useRef(0);
const refreshTimerRef = useRef(null);
const loadFeatureFlags = useCallback(async () => {
const loadId = (loadIdRef.current += 1);
if (!bodyshop?.id) {
setState({ flags: {}, isReady: true, source: "local" });
return;
}
setState((current) => ({ ...current, isReady: false }));
try {
const { data } = await axios.get(`/feature-flags/bodyshops/${bodyshop.id}`);
if (loadId !== loadIdRef.current) return;
const flags = data.flags || {};
writeCachedFeatureFlags(bodyshop.id, flags);
setState({
flags,
isReady: true,
source: data.source || "database"
});
} catch (error) {
if (loadId !== loadIdRef.current) return;
const cachedFlags = readCachedFeatureFlags(bodyshop.id);
console.warn("Feature flags backend fetch failed; falling back to last-known browser cache.", error);
setState({
flags: cachedFlags || {},
isReady: true,
source: cachedFlags ? "browser-cache" : "local"
});
}
}, [bodyshop?.id]);
useEffect(() => {
loadFeatureFlags();
return () => {
loadIdRef.current += 1;
};
}, [loadFeatureFlags]);
useEffect(() => {
if (!bodyshop?.id) return undefined;
const handleFeatureFlagsChanged = (event) => {
if (!isFeatureFlagChangeRelevant(event.detail, bodyshop.id)) return;
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
loadFeatureFlags();
}, FEATURE_FLAGS_REFRESH_DEBOUNCE_MS);
};
window.addEventListener(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
return () => {
window.removeEventListener(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
};
}, [bodyshop?.id, loadFeatureFlags]);
useEffect(() => {
const delay = getNextScheduleRefreshDelay(state.flags);
if (delay == null) return undefined;
const timer = setTimeout(() => {
setState((current) => ({ ...current, flags: { ...current.flags } }));
}, delay);
return () => {
clearTimeout(timer);
};
}, [state.flags]);
const value = useMemo(
() => ({ config, factory, flags: state.flags, isReady: state.isReady, source: state.source }),
[config, factory, state.flags, state.isReady, state.source]
);
return <FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>;
}
/**
* Returns a Split-compatible client backed by the local feature flag context.
*/
export function useSplitClient(options = {}) {
const bodyshop = useSelector(selectBodyshop);
const context = useContext(FeatureFlagContext);
const client = useMemo(() => {
const nextClient = createFeatureFlagClient({
bodyshop,
key: options.key,
backendFlags: context.flags
});
nextClient.client = nextClient;
nextClient.isReady = context.isReady;
nextClient.isReadyFromCache = context.source === "redis" || context.source === "browser-cache";
return nextClient;
}, [bodyshop, options.key, context.flags, context.isReady, context.source]);
return client;
}
/**
* Returns treatment/config pairs for several feature flags.
*/
export function useTreatmentsWithConfig({ names = [] } = {}) {
const client = useSplitClient();
return useMemo(
() => ({
treatments: client.getTreatmentsWithConfig(names),
isReady: client.isReady,
isReadyFromCache: client.isReadyFromCache,
lastUpdate: Date.now()
}),
[client, names]
);
}
/**
* Returns only the treatment string for one feature flag.
*/
export function useTreatment({ name } = {}) {
const client = useSplitClient();
return client.getTreatment(name);
}
/**
* Returns the treatment/config pair for one feature flag.
*/
export function useTreatmentWithConfig({ name } = {}) {
const client = useSplitClient();
return client.getTreatmentWithConfig(name);
}
export const FeatureFlagProvider = SplitFactoryProvider;
export const useFeatureFlagClient = useSplitClient;
export const SplitContext = FeatureFlagContext;
export const useSplitContext = () => useContext(FeatureFlagContext);
export const __featureFlagTesting = {
createFeatureFlagClient,
getNextScheduleRefreshDelay,
getLocalStorageKey,
isFeatureFlagChangeRelevant,
normalizeFlagValue,
readCachedFeatureFlags,
writeCachedFeatureFlags
};

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __featureFlagTesting } from "./splitio-react-replacement";
const {
createFeatureFlagClient,
getNextScheduleRefreshDelay,
getLocalStorageKey,
isFeatureFlagChangeRelevant,
normalizeFlagValue,
readCachedFeatureFlags,
writeCachedFeatureFlags
} = __featureFlagTesting;
beforeEach(() => {
window.localStorage.clear();
vi.useRealTimers();
});
describe("splitio-react-replacement feature flag normalization", () => {
it("returns off for unknown or null values", () => {
expect(normalizeFlagValue(null)).toEqual({ treatment: "off", config: null });
expect(normalizeFlagValue(undefined)).toEqual({ treatment: "off", config: null });
});
it("normalizes primitive values into Split-like treatments", () => {
expect(normalizeFlagValue(true)).toEqual({ treatment: "on", config: null });
expect(normalizeFlagValue(false)).toEqual({ treatment: "off", config: null });
expect(normalizeFlagValue(1)).toEqual({ treatment: "on", config: null });
expect(normalizeFlagValue(0)).toEqual({ treatment: "off", config: null });
expect(normalizeFlagValue("true")).toEqual({ treatment: "on", config: null });
expect(normalizeFlagValue("false")).toEqual({ treatment: "off", config: null });
expect(normalizeFlagValue("variant-a")).toEqual({ treatment: "variant-a", config: null });
});
it("preserves custom treatments and parses JSON config strings", () => {
expect(
normalizeFlagValue({
treatment: "demo",
config: "{\"limit\":25}"
})
).toEqual({
treatment: "demo",
config: { limit: 25 }
});
});
it("respects activeDate and deactiveDate windows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
expect(
normalizeFlagValue({
treatment: "on",
activeDate: "2026-05-19T14:59:00.000Z",
deactiveDate: "2026-05-19T15:01:00.000Z"
})
).toEqual({ treatment: "on", config: null });
expect(
normalizeFlagValue({
treatment: "on",
activeDate: "2026-05-19T15:01:00.000Z"
})
).toEqual({ treatment: "off", config: null });
expect(
normalizeFlagValue({
treatment: "on",
deactiveDate: "2026-05-19T15:00:00.000Z"
})
).toEqual({ treatment: "off", config: null });
vi.useRealTimers();
});
});
describe("splitio-react-replacement feature flag client", () => {
it("uses backend flags", () => {
const client = createFeatureFlagClient({
bodyshop: {
imexshopid: "APPLE"
},
backendFlags: {
Enhanced_Payroll: { treatment: "on" }
}
});
expect(client.getTreatment("Enhanced_Payroll")).toBe("on");
});
it("ignores old bodyshop feature JSON fallback values", () => {
const client = createFeatureFlagClient({
bodyshop: {
imexshopid: "APPLE",
features: {
featureFlags: {
Enhanced_Payroll: { treatment: "on" }
}
}
},
backendFlags: {}
});
expect(client.getTreatment("Enhanced_Payroll")).toBe("off");
});
it("returns off for flags that are not present in any source", () => {
const client = createFeatureFlagClient({
bodyshop: { imexshopid: "APPLE", features: {} },
backendFlags: {}
});
expect(client.getTreatment("Missing_Flag")).toBe("off");
});
it("uses a bodyshop-scoped browser cache key", () => {
expect(getLocalStorageKey("shop-1")).toBe("bodyshop-feature-flags:shop-1");
});
it("stores and reads last-known backend flags from browser storage", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
writeCachedFeatureFlags("shop-1", {
Enhanced_Payroll: { treatment: "on", config: null }
});
expect(readCachedFeatureFlags("shop-1")).toEqual({
Enhanced_Payroll: { treatment: "on", config: null }
});
});
it("ignores expired browser cached flags", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
writeCachedFeatureFlags("shop-1", {
Enhanced_Payroll: { treatment: "on", config: null }
});
expect(readCachedFeatureFlags("shop-1", Date.parse("2026-05-20T15:00:01.000Z"))).toBeNull();
});
});
describe("splitio-react-replacement live refresh helpers", () => {
it("matches global and bodyshop-scoped socket changes", () => {
expect(isFeatureFlagChangeRelevant({ scope: "global" }, "shop-1")).toBe(true);
expect(isFeatureFlagChangeRelevant({ bodyshopId: "shop-1", scope: "bodyshop" }, "shop-1")).toBe(true);
expect(isFeatureFlagChangeRelevant({ bodyshopId: "shop-2", scope: "bodyshop" }, "shop-1")).toBe(false);
});
it("finds the next active/deactive date boundary that needs a refresh", () => {
const now = Date.parse("2026-05-19T15:00:00.000Z");
expect(
getNextScheduleRefreshDelay(
{
Demo: { treatment: "on", activeDate: "2026-05-19T15:05:00.000Z" },
Expiring: { treatment: "on", deactiveDate: "2026-05-19T15:02:00.000Z" },
Expired: { treatment: "on", deactiveDate: "2026-05-19T14:59:00.000Z" }
},
now
)
).toBe(120050);
});
});

View File

@@ -6,7 +6,7 @@ import { createStructuredSelector } from "reselect";
import queryString from "query-string";
import { useQuery } from "@apollo/client/react";
import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useParams } from "react-router-dom";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { some } from "lodash";
import axios from "axios";
import AlertComponent from "../../components/alert/alert.component";

View File

@@ -22,7 +22,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
// import { useNavigate } from 'react-router-dom';
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import Dinero from "dinero.js";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";

View File

@@ -2,7 +2,7 @@ import ProductionBoardKanbanContainer from "../../components/production-board-ka
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser

View File

@@ -3,7 +3,7 @@ import ProductionListTable from "../../components/production-list-table/producti
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop

View File

@@ -13,7 +13,7 @@ import { GET_UNACCEPTED_PARTS_DISPATCH } from "../../graphql/parts-dispatch.quer
import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { alphaSort } from "../../utils/sorters";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser

View File

@@ -4,7 +4,7 @@ import JobsDocumentsContainer from "../../components/jobs-documents-gallery/jobs
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import JobsDocumentsLocalGallery from "../../components/jobs-documents-local-gallery/jobs-documents-local-gallery.container";

View File

@@ -18,7 +18,7 @@ import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/appli
import TimeTicketsCommit from "../../components/time-tickets-commit/time-tickets-commit.component";
import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wrapper.component";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
import { selectBodyshop } from "../../redux/user/user.selectors";
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";

View File

@@ -236,7 +236,7 @@ export default defineConfig(({ command, mode }) => {
redux: ["redux"],
lodash: ["lodash"],
"@sentry/react": ["@sentry/react"],
"@splitsoftware/splitio-react": ["@splitsoftware/splitio-react"],
"feature-flags": ["src/feature-flags/splitio-react-replacement.jsx"],
logrocket: ["logrocket"],
firebase: [
"@firebase/analytics",