diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 312163d4f..de252c393 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -108,6 +108,13 @@
"vitest": "^3.2.4"
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.22",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.22.tgz",
+ "integrity": "sha512-QviHW7uL3M3oQ5b5z+6AqDe+ZzJ3XeLLKNaD+XbuRIMkeAZ/FsL7zIle0V+YR5bllZDL4s1i+NYx8wNGNpnQTg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
@@ -180,9 +187,9 @@
}
},
"node_modules/@asamuzakjp/dom-selector": {
- "version": "6.7.3",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.3.tgz",
- "integrity": "sha512-kiGFeY+Hxf5KbPpjRLf+ffWbkos1aGo8MBfd91oxS3O57RgU3XhZrt/6UzoVF9VMpWbC3v87SRc9jxGrc9qHtQ==",
+ "version": "6.7.4",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz",
+ "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -456,9 +463,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.0.14",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz",
- "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==",
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz",
+ "integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==",
"dev": true,
"funding": [
{
@@ -473,9 +480,6 @@
"license": "MIT-0",
"engines": {
"node": ">=18"
- },
- "peerDependencies": {
- "postcss": "^8.4"
}
},
"node_modules/@csstools/css-tokenizer": {
@@ -933,9 +937,9 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
- "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz",
+ "integrity": "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -951,9 +955,9 @@
"optional": true
},
"node_modules/@emnapi/runtime": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz",
- "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz",
+ "integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1131,9 +1135,9 @@
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
- "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
@@ -1148,9 +1152,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz",
- "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
@@ -1165,9 +1169,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz",
- "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
@@ -1182,9 +1186,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz",
- "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
@@ -1199,9 +1203,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz",
- "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
@@ -1216,9 +1220,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz",
- "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
@@ -1233,9 +1237,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz",
- "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
@@ -1250,9 +1254,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz",
- "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
@@ -1267,9 +1271,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz",
- "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
@@ -1284,9 +1288,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz",
- "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
@@ -1301,9 +1305,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz",
- "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
@@ -1318,9 +1322,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz",
- "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
@@ -1335,9 +1339,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz",
- "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
@@ -1352,9 +1356,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz",
- "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
@@ -1369,9 +1373,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz",
- "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
@@ -1386,9 +1390,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz",
- "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
@@ -1403,9 +1407,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz",
- "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
@@ -1420,9 +1424,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz",
- "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
@@ -1437,9 +1441,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz",
- "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
@@ -1454,9 +1458,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz",
- "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
@@ -1471,9 +1475,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz",
- "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
@@ -1488,9 +1492,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz",
- "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
@@ -1505,9 +1509,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz",
- "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
@@ -1522,9 +1526,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz",
- "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
@@ -1539,9 +1543,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz",
- "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
@@ -1556,9 +1560,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz",
- "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
@@ -1641,22 +1645,22 @@
}
},
"node_modules/@eslint/config-helpers": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
- "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.16.0"
+ "@eslint/core": "^0.17.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
- "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1738,9 +1742,9 @@
}
},
"node_modules/@eslint/js": {
- "version": "9.38.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
- "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
+ "version": "9.39.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
+ "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1761,13 +1765,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
- "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.16.0",
+ "@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
@@ -1880,9 +1884,9 @@
}
},
"node_modules/@iconify-json/material-symbols": {
- "version": "1.2.43",
- "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.43.tgz",
- "integrity": "sha512-Atn0KzZkk96vZuSA//BEzwd9we8FUbifNIWxS9+ngPbWsZEKv1WAe+Gxle8BQjvnfIpewCP1opIeHKMxcNEmPw==",
+ "version": "1.2.44",
+ "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.44.tgz",
+ "integrity": "sha512-NAJjhswaK9FxBeIzFFsNygws7wHtmAkBWhF4YEwn1NZIMbA+LNITqhUiq6sP5mOdKQqnoritFTlQaZ47a5BgBg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2111,9 +2115,9 @@
"license": "MIT"
},
"node_modules/@mantine/core": {
- "version": "8.3.5",
- "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
- "integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
+ "integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.16",
@@ -2124,46 +2128,46 @@
"type-fest": "^4.41.0"
},
"peerDependencies": {
- "@mantine/hooks": "8.3.5",
+ "@mantine/hooks": "8.3.6",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dates": {
- "version": "8.3.5",
- "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.5.tgz",
- "integrity": "sha512-LkIdC4eWPNQFv1BU1c52U3Z3RuA3yU1asvTgMEIQ/MdJsGK8GePwpgMH/jKQ8ba/AW9NfksdvtOJ6uIqPwjCkg==",
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.6.tgz",
+ "integrity": "sha512-lSi1zvyL86SKeePH0J3vOjAR7ZIVNOrZm6ja7jAH6IBdcpQOKH8TXbrcAi5okEStvmvkne7pVaGu0VkdE8KnAw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
- "@mantine/core": "8.3.5",
- "@mantine/hooks": "8.3.5",
+ "@mantine/core": "8.3.6",
+ "@mantine/hooks": "8.3.6",
"dayjs": ">=1.0.0",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/dropzone": {
- "version": "8.3.5",
- "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.3.5.tgz",
- "integrity": "sha512-8eRNoEYQzUOav8Te58osGRt2vbqJO3ZORXgKALY+FhB0YGRCZYmS/gQ2T66SpyrpovY6k6OVi1ScM3wRaJrxUg==",
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-8.3.6.tgz",
+ "integrity": "sha512-zgHEoO4z4hjDMMuVapwGoMahkp3lZvaht1bYc0e1hUMVN3FQNRWBrmczL9a3CG21a1cbUdwr8cbHIxOBFVAR+Q==",
"license": "MIT",
"dependencies": {
"react-dropzone": "14.3.8"
},
"peerDependencies": {
- "@mantine/core": "8.3.5",
- "@mantine/hooks": "8.3.5",
+ "@mantine/core": "8.3.6",
+ "@mantine/hooks": "8.3.6",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/hooks": {
- "version": "8.3.5",
- "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
- "integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
+ "integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
@@ -2181,9 +2185,9 @@
}
},
"node_modules/@maxim_mazurok/gapi.client.drive-v3": {
- "version": "0.1.20251021",
- "resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20251021.tgz",
- "integrity": "sha512-bfjWWFpDm+Y5PcssNb2JzrxgtmEKfva+HTngPmsHinBH9JC7qe9OuODUtWcwKyUJE1d0DendLjOhD9m7GDLX2A==",
+ "version": "0.1.20251104",
+ "resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20251104.tgz",
+ "integrity": "sha512-Z2D5Ce5Q32ev2deqmb7RgPp8c+AqrTylRw8iONLlb/iN/jgtGoKTUjgumlD8Vo+etJxcOWVjze+d00I3mggc/g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2192,9 +2196,9 @@
}
},
"node_modules/@mui/core-downloads-tracker": {
- "version": "7.3.4",
- "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.4.tgz",
- "integrity": "sha512-BIktMapG3r4iXwIhYNpvk97ZfYWTreBBQTWjQKbNbzI64+ULHfYavQEX2w99aSWHS58DvXESWIgbD9adKcUOBw==",
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.5.tgz",
+ "integrity": "sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -2202,9 +2206,9 @@
}
},
"node_modules/@mui/icons-material": {
- "version": "7.3.4",
- "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.4.tgz",
- "integrity": "sha512-9n6Xcq7molXWYb680N2Qx+FRW8oT6j/LXF5PZFH3ph9X/Rct0B/BlLAsFI7iL9ySI6LVLuQIVtrLiPT82R7OZw==",
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.5.tgz",
+ "integrity": "sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
@@ -2217,7 +2221,7 @@
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
- "@mui/material": "^7.3.4",
+ "@mui/material": "^7.3.5",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
@@ -2228,22 +2232,22 @@
}
},
"node_modules/@mui/material": {
- "version": "7.3.4",
- "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
- "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
+ "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
- "@mui/core-downloads-tracker": "^7.3.4",
- "@mui/system": "^7.3.3",
- "@mui/types": "^7.4.7",
- "@mui/utils": "^7.3.3",
+ "@mui/core-downloads-tracker": "^7.3.5",
+ "@mui/system": "^7.3.5",
+ "@mui/types": "^7.4.8",
+ "@mui/utils": "^7.3.5",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1",
- "react-is": "^19.1.1",
+ "react-is": "^19.2.0",
"react-transition-group": "^4.4.5"
},
"engines": {
@@ -2256,7 +2260,7 @@
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
- "@mui/material-pigment-css": "^7.3.3",
+ "@mui/material-pigment-css": "^7.3.5",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2277,13 +2281,13 @@
}
},
"node_modules/@mui/private-theming": {
- "version": "7.3.3",
- "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.3.tgz",
- "integrity": "sha512-OJM+9nj5JIyPUvsZ5ZjaeC9PfktmK+W5YaVLToLR8L0lB/DGmv1gcKE43ssNLSvpoW71Hct0necfade6+kW3zQ==",
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.5.tgz",
+ "integrity": "sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
- "@mui/utils": "^7.3.3",
+ "@mui/utils": "^7.3.5",
"prop-types": "^15.8.1"
},
"engines": {
@@ -2304,9 +2308,9 @@
}
},
"node_modules/@mui/styled-engine": {
- "version": "7.3.3",
- "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.3.tgz",
- "integrity": "sha512-CmFxvRJIBCEaWdilhXMw/5wFJ1+FT9f3xt+m2pPXhHPeVIbBg9MnMvNSJjdALvnQJMPw8jLhrUtXmN7QAZV2fw==",
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.5.tgz",
+ "integrity": "sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
@@ -2338,16 +2342,16 @@
}
},
"node_modules/@mui/system": {
- "version": "7.3.3",
- "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz",
- "integrity": "sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==",
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.5.tgz",
+ "integrity": "sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
- "@mui/private-theming": "^7.3.3",
- "@mui/styled-engine": "^7.3.3",
- "@mui/types": "^7.4.7",
- "@mui/utils": "^7.3.3",
+ "@mui/private-theming": "^7.3.5",
+ "@mui/styled-engine": "^7.3.5",
+ "@mui/types": "^7.4.8",
+ "@mui/utils": "^7.3.5",
"clsx": "^2.1.1",
"csstype": "^3.1.3",
"prop-types": "^15.8.1"
@@ -2378,9 +2382,9 @@
}
},
"node_modules/@mui/types": {
- "version": "7.4.7",
- "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.7.tgz",
- "integrity": "sha512-8vVje9rdEr1rY8oIkYgP+Su5Kwl6ik7O3jQ0wl78JGSmiZhRHV+vkjooGdKD8pbtZbutXFVTWQYshu2b3sG9zw==",
+ "version": "7.4.8",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.8.tgz",
+ "integrity": "sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
@@ -2395,17 +2399,17 @@
}
},
"node_modules/@mui/utils": {
- "version": "7.3.3",
- "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.3.tgz",
- "integrity": "sha512-kwNAUh7bLZ7mRz9JZ+6qfRnnxbE4Zuc+RzXnhSpRSxjTlSTj7b4JxRLXpG+MVtPVtqks5k/XC8No1Vs3x4Z2gg==",
+ "version": "7.3.5",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.5.tgz",
+ "integrity": "sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
- "@mui/types": "^7.4.7",
+ "@mui/types": "^7.4.8",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
- "react-is": "^19.1.1"
+ "react-is": "^19.2.0"
},
"engines": {
"node": ">=14.0.0"
@@ -2715,15 +2719,18 @@
}
},
"node_modules/@posthog/core": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.4.0.tgz",
- "integrity": "sha512-jmW8/I//YOHAfjzokqas+Qtc2T57Ux8d2uIJu7FLcMGxywckHsl6od59CD18jtUzKToQdjQhV6Y3429qj+KeNw==",
- "license": "MIT"
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.5.2.tgz",
+ "integrity": "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.6"
+ }
},
"node_modules/@puppeteer/browsers": {
- "version": "2.10.12",
- "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.12.tgz",
- "integrity": "sha512-mP9iLFZwH+FapKJLeA7/fLqOlSUwYpMwjR1P5J23qd4e7qGJwecJccJqHYrjw33jmIZYV4dtiTHPD/J+1e7cEw==",
+ "version": "2.10.13",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz",
+ "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2794,9 +2801,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.43",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
- "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
+ "version": "1.0.0-beta.46",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.46.tgz",
+ "integrity": "sha512-xMNwJo/pHkEP/mhNVnW+zUiJDle6/hxrwO0mfSJuEVRbBfgrJFuUSRoZx/nYUw5pCjrysl9OkNXCkAdih8GCnA==",
"dev": true,
"license": "MIT"
},
@@ -3131,9 +3138,9 @@
"license": "MIT"
},
"node_modules/@sindresorhus/is": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.0.tgz",
- "integrity": "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.1.1.tgz",
+ "integrity": "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -3285,15 +3292,15 @@
}
},
"node_modules/@swc/core": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
- "integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.0.tgz",
+ "integrity": "sha512-8SnJV+JV0rYbfSiEiUvYOmf62E7QwsEG+aZueqSlKoxFt0pw333+bgZSQXGUV6etXU88nxur0afVMaINujBMSw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
- "@swc/types": "^0.1.24"
+ "@swc/types": "^0.1.25"
},
"engines": {
"node": ">=10"
@@ -3303,16 +3310,16 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
- "@swc/core-darwin-arm64": "1.13.5",
- "@swc/core-darwin-x64": "1.13.5",
- "@swc/core-linux-arm-gnueabihf": "1.13.5",
- "@swc/core-linux-arm64-gnu": "1.13.5",
- "@swc/core-linux-arm64-musl": "1.13.5",
- "@swc/core-linux-x64-gnu": "1.13.5",
- "@swc/core-linux-x64-musl": "1.13.5",
- "@swc/core-win32-arm64-msvc": "1.13.5",
- "@swc/core-win32-ia32-msvc": "1.13.5",
- "@swc/core-win32-x64-msvc": "1.13.5"
+ "@swc/core-darwin-arm64": "1.15.0",
+ "@swc/core-darwin-x64": "1.15.0",
+ "@swc/core-linux-arm-gnueabihf": "1.15.0",
+ "@swc/core-linux-arm64-gnu": "1.15.0",
+ "@swc/core-linux-arm64-musl": "1.15.0",
+ "@swc/core-linux-x64-gnu": "1.15.0",
+ "@swc/core-linux-x64-musl": "1.15.0",
+ "@swc/core-win32-arm64-msvc": "1.15.0",
+ "@swc/core-win32-ia32-msvc": "1.15.0",
+ "@swc/core-win32-x64-msvc": "1.15.0"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -3324,9 +3331,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.5.tgz",
- "integrity": "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.0.tgz",
+ "integrity": "sha512-TBKWkbnShnEjlIbO4/gfsrIgAqHBVqgPWLbWmPdZ80bF393yJcLgkrb7bZEnJs6FCbSSuGwZv2rx1jDR2zo6YA==",
"cpu": [
"arm64"
],
@@ -3341,9 +3348,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.5.tgz",
- "integrity": "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.0.tgz",
+ "integrity": "sha512-f5JKL1v1H56CIZc1pVn4RGPOfnWqPwmuHdpf4wesvXunF1Bx85YgcspW5YxwqG5J9g3nPU610UFuExJXVUzOiQ==",
"cpu": [
"x64"
],
@@ -3358,9 +3365,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.5.tgz",
- "integrity": "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.0.tgz",
+ "integrity": "sha512-duK6nG+WyuunnfsfiTUQdzC9Fk8cyDLqT9zyXvY2i2YgDu5+BH5W6wM5O4mDNCU5MocyB/SuF5YDF7XySnowiQ==",
"cpu": [
"arm"
],
@@ -3375,9 +3382,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.5.tgz",
- "integrity": "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.0.tgz",
+ "integrity": "sha512-ITe9iDtTRXM98B91rvyPP6qDVbhUBnmA/j4UxrHlMQ0RlwpqTjfZYZkD0uclOxSZ6qIrOj/X5CaoJlDUuQ0+Cw==",
"cpu": [
"arm64"
],
@@ -3392,9 +3399,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.5.tgz",
- "integrity": "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.0.tgz",
+ "integrity": "sha512-Q5ldc2bzriuzYEoAuqJ9Vr3FyZhakk5hiwDbniZ8tlEXpbjBhbOleGf9/gkhLaouDnkNUEazFW9mtqwUTRdh7Q==",
"cpu": [
"arm64"
],
@@ -3409,9 +3416,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.5.tgz",
- "integrity": "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.0.tgz",
+ "integrity": "sha512-pY4is+jEpOxlYCSnI+7N8Oxbap9TmTz5YT84tUvRTlOlTBwFAUlWFCX0FRwWJlsfP0TxbqhIe8dNNzlsEmJbXQ==",
"cpu": [
"x64"
],
@@ -3426,9 +3433,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.5.tgz",
- "integrity": "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.0.tgz",
+ "integrity": "sha512-zYEt5eT8y8RUpoe7t5pjpoOdGu+/gSTExj8PV86efhj6ugB3bPlj3Y85ogdW3WMVXr4NvwqvzdaYGCZfXzSyVg==",
"cpu": [
"x64"
],
@@ -3443,9 +3450,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.5.tgz",
- "integrity": "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.0.tgz",
+ "integrity": "sha512-zC1rmOgFH5v2BCbByOazEqs0aRNpTdLRchDExfcCfgKgeaD+IdpUOqp7i3VG1YzkcnbuZjMlXfM0ugpt+CddoA==",
"cpu": [
"arm64"
],
@@ -3460,9 +3467,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.5.tgz",
- "integrity": "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.0.tgz",
+ "integrity": "sha512-7t9U9KwMwQblkdJIH+zX1V4q1o3o41i0HNO+VlnAHT5o+5qHJ963PHKJ/pX3P2UlZnBCY465orJuflAN4rAP9A==",
"cpu": [
"ia32"
],
@@ -3477,9 +3484,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.5.tgz",
- "integrity": "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==",
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.0.tgz",
+ "integrity": "sha512-VE0Zod5vcs8iMLT64m5QS1DlTMXJFI/qSgtMDRx8rtZrnjt6/9NW8XUaiPJuRu8GluEO1hmHoyf1qlbY19gGSQ==",
"cpu": [
"x64"
],
@@ -3523,47 +3530,47 @@
}
},
"node_modules/@tailwindcss/node": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
- "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
+ "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.1",
"lightningcss": "1.30.2",
- "magic-string": "^0.30.19",
+ "magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
- "tailwindcss": "4.1.16"
+ "tailwindcss": "4.1.17"
}
},
"node_modules/@tailwindcss/oxide": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
- "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
+ "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.1.16",
- "@tailwindcss/oxide-darwin-arm64": "4.1.16",
- "@tailwindcss/oxide-darwin-x64": "4.1.16",
- "@tailwindcss/oxide-freebsd-x64": "4.1.16",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
- "@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
- "@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
- "@tailwindcss/oxide-linux-x64-musl": "4.1.16",
- "@tailwindcss/oxide-wasm32-wasi": "4.1.16",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
- "@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
+ "@tailwindcss/oxide-android-arm64": "4.1.17",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.17",
+ "@tailwindcss/oxide-darwin-x64": "4.1.17",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.17",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.17",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.17",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
- "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
+ "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
"cpu": [
"arm64"
],
@@ -3577,9 +3584,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
- "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
+ "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
"cpu": [
"arm64"
],
@@ -3593,9 +3600,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
- "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
+ "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
"cpu": [
"x64"
],
@@ -3609,9 +3616,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
- "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
+ "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
"cpu": [
"x64"
],
@@ -3625,9 +3632,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
- "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
+ "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
"cpu": [
"arm"
],
@@ -3641,9 +3648,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
- "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
+ "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
"cpu": [
"arm64"
],
@@ -3657,9 +3664,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
- "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
+ "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
"cpu": [
"arm64"
],
@@ -3673,9 +3680,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
- "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
+ "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
"cpu": [
"x64"
],
@@ -3689,9 +3696,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
- "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
+ "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
"cpu": [
"x64"
],
@@ -3705,9 +3712,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
- "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
+ "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -3722,8 +3729,8 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "^1.5.0",
- "@emnapi/runtime": "^1.5.0",
+ "@emnapi/core": "^1.6.0",
+ "@emnapi/runtime": "^1.6.0",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.7",
"@tybys/wasm-util": "^0.10.1",
@@ -3734,9 +3741,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
- "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
+ "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
"cpu": [
"arm64"
],
@@ -3750,9 +3757,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
- "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
+ "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
"cpu": [
"x64"
],
@@ -3766,16 +3773,16 @@
}
},
"node_modules/@tailwindcss/postcss": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz",
- "integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz",
+ "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==",
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
- "@tailwindcss/node": "4.1.16",
- "@tailwindcss/oxide": "4.1.16",
+ "@tailwindcss/node": "4.1.17",
+ "@tailwindcss/oxide": "4.1.17",
"postcss": "^8.4.41",
- "tailwindcss": "4.1.16"
+ "tailwindcss": "4.1.17"
}
},
"node_modules/@tanstack/react-virtual": {
@@ -3816,9 +3823,9 @@
}
},
"node_modules/@tauri-apps/cli": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.1.tgz",
- "integrity": "sha512-kKi2/WWsNXKoMdatBl4xrT7e1Ce27JvsetBVfWuIb6D3ep/Y0WO5SIr70yarXOSWam8NyDur4ipzjZkg6m7VDg==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.3.tgz",
+ "integrity": "sha512-BQ7iLUXTQcyG1PpzLWeVSmBCedYDpnA/6Cm/kRFGtqjTf/eVUlyYO5S2ee07tLum3nWwDBWTGFZeruO8yEukfA==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -3832,23 +3839,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
- "@tauri-apps/cli-darwin-arm64": "2.9.1",
- "@tauri-apps/cli-darwin-x64": "2.9.1",
- "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.1",
- "@tauri-apps/cli-linux-arm64-gnu": "2.9.1",
- "@tauri-apps/cli-linux-arm64-musl": "2.9.1",
- "@tauri-apps/cli-linux-riscv64-gnu": "2.9.1",
- "@tauri-apps/cli-linux-x64-gnu": "2.9.1",
- "@tauri-apps/cli-linux-x64-musl": "2.9.1",
- "@tauri-apps/cli-win32-arm64-msvc": "2.9.1",
- "@tauri-apps/cli-win32-ia32-msvc": "2.9.1",
- "@tauri-apps/cli-win32-x64-msvc": "2.9.1"
+ "@tauri-apps/cli-darwin-arm64": "2.9.3",
+ "@tauri-apps/cli-darwin-x64": "2.9.3",
+ "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.3",
+ "@tauri-apps/cli-linux-arm64-gnu": "2.9.3",
+ "@tauri-apps/cli-linux-arm64-musl": "2.9.3",
+ "@tauri-apps/cli-linux-riscv64-gnu": "2.9.3",
+ "@tauri-apps/cli-linux-x64-gnu": "2.9.3",
+ "@tauri-apps/cli-linux-x64-musl": "2.9.3",
+ "@tauri-apps/cli-win32-arm64-msvc": "2.9.3",
+ "@tauri-apps/cli-win32-ia32-msvc": "2.9.3",
+ "@tauri-apps/cli-win32-x64-msvc": "2.9.3"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.1.tgz",
- "integrity": "sha512-sdwhtsE/6njD0AjgfYEj1JyxZH4SBmCJSXpRm6Ph5fQeuZD6MyjzjdVOrrtFguyREVQ7xn0Ujkwvbo01ULthNg==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.3.tgz",
+ "integrity": "sha512-W8FQXZXQmQ0Fmj9UJXNrm2mLdIaLLriKVY7o/FzmizyIKTPIvHjfZALTNybbpTQRbJvKoGHLrW1DNzAWVDWJYg==",
"cpu": [
"arm64"
],
@@ -3863,9 +3870,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.1.tgz",
- "integrity": "sha512-c86g+67wTdI4TUCD7CaSd/13+oYuLQxVST4ZNJ5C+6i1kdnU3Us1L68N9MvbDLDQGJc9eo0pvuK6sCWkee+BzA==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.3.tgz",
+ "integrity": "sha512-zDwu40rlshijt3TU6aRvzPUyVpapsx1sNfOlreDMTaMelQLHl6YoQzSRpLHYwrHrhimxyX2uDqnKIiuGel0Lhg==",
"cpu": [
"x64"
],
@@ -3880,9 +3887,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.1.tgz",
- "integrity": "sha512-IrB3gFQmueQKJjjisOcMktW/Gh6gxgqYO419doA3YZ7yIV5rbE8ZW52Q3I4AO+SlFEyVYer5kpi066p0JBlLGw==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.3.tgz",
+ "integrity": "sha512-+Oc2OfcTRwYtW93VJqd/HOk77buORwC9IToj/qsEvM7bTMq6Kda4alpZprzwrCHYANSw+zD8PgjJdljTpe4p+g==",
"cpu": [
"arm"
],
@@ -3897,9 +3904,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.1.tgz",
- "integrity": "sha512-Ke7TyXvu6HbWSkmVkFbbH19D3cLsd117YtXP/u9NIvSpYwKeFtnbpirrIUfPm44Q+PZFZ2Hvg8X9qoUiAK0zKw==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.3.tgz",
+ "integrity": "sha512-59GqU/J1n9wFyAtleoQOaU0oVIo+kwQynEw4meFDoKRXszKGor6lTsbsS3r0QKLSPbc0o/yYGJhqqCtkYjb/eg==",
"cpu": [
"arm64"
],
@@ -3914,9 +3921,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.1.tgz",
- "integrity": "sha512-sGvy75sv55oeMulR5ArwPD28DsDQxqTzLhXCrpU9/nbFg/JImmI7k994YE9fr3V0qE3Cjk5gjLldRNv7I9sjwQ==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.3.tgz",
+ "integrity": "sha512-fzvG+jEn5/iYGNH6Z2IRMheYFC4pJdXa19BR9fFm6Bdn2cuajRLDKdUcEME/DCtwqclphXtFZTrT4oezY5vI/A==",
"cpu": [
"arm64"
],
@@ -3931,9 +3938,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.1.tgz",
- "integrity": "sha512-tEKbJydV3BdIxpAx8aGHW6VDg1xW4LlQuRD/QeFZdZNTreHJpMbJEcdvAcI+Hg6vgQpVpaoEldR9W4F6dYSLqQ==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.3.tgz",
+ "integrity": "sha512-qV8DZXI/fZwawk6T3Th1g6smiNC2KeQTk7XFgKvqZ6btC01z3UTsQmNGvI602zwm3Ld1TBZb4+rEWu2QmQimmw==",
"cpu": [
"riscv64"
],
@@ -3948,9 +3955,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.1.tgz",
- "integrity": "sha512-mg5msXHagtHpyCVWgI01M26JeSrgE/otWyGdYcuTwyRYZYEJRTbcNt7hscOkdNlPBe7isScW7PVKbxmAjJJl4g==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.3.tgz",
+ "integrity": "sha512-tquyEONCNRfqEBWEe4eAHnxFN5yY5lFkCuD4w79XLIovUxVftQ684+xLp7zkhntkt4y20SMj2AgJa/+MOlx4Kg==",
"cpu": [
"x64"
],
@@ -3965,9 +3972,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.1.tgz",
- "integrity": "sha512-lFZEXkpDreUe3zKilvnMsrnKP9gwQudaEjDnOz/GMzbzNceIuPfFZz0cR/ky1Aoq4eSvZonPKHhROq4owz4fzg==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.3.tgz",
+ "integrity": "sha512-v2cBIB/6ji8DL+aiL5QUykU3ZO8OoJGyx50/qv2HQVzkf85KdaYSis3D/oVRemN/pcDz+vyCnnL3XnzFnDl4JQ==",
"cpu": [
"x64"
],
@@ -3982,9 +3989,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.1.tgz",
- "integrity": "sha512-ejc5RAp/Lm1Aj0EQHaT+Wdt5PHfdgQV5hIDV00MV6HNbIb5W4ZUFxMDaRkAg65gl9MvY2fH396riePW3RoKXDw==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.3.tgz",
+ "integrity": "sha512-ZGvBy7nvrHPbE0HeKp/ioaiw8bNgAHxWnb7JRZ4/G0A+oFj0SeSFxl9k5uU6FKnM7bHM23Gd1oeaDex9g5Fceg==",
"cpu": [
"arm64"
],
@@ -3999,9 +4006,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.1.tgz",
- "integrity": "sha512-fSATtJDc0fNjVB6ystyi8NbwhNFk8i8E05h6KrsC8Fio5eaJIJvPCbC9pdrPl6kkxN1X7fj25ErBbgfqgcK8Fg==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.3.tgz",
+ "integrity": "sha512-UsgIwOnpCoY9NK9/65QiwgmWVIE80LE7SwRYVblGtmlY9RYfsYvpbItwsovA/AcHMTiO+OCvS/q9yLeqS3m6Sg==",
"cpu": [
"ia32"
],
@@ -4016,9 +4023,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.1.tgz",
- "integrity": "sha512-/JHlOzpUDhjBOO9w167bcYxfJbcMQv7ykS/Y07xjtcga8np0rzUzVGWYmLMH7orKcDMC7wjhheEW1x8cbGma/Q==",
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.3.tgz",
+ "integrity": "sha512-fmw7NrrHE5m49idCvJAx9T9bsupjdJ0a3p3DPCNCZRGANU6R1tA1L+KTlVuUtdAldX2NqU/9UPo2SCslYKgJHQ==",
"cpu": [
"x64"
],
@@ -4353,9 +4360,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "24.9.1",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
- "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
+ "version": "24.10.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz",
+ "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -4428,17 +4435,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz",
- "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz",
+ "integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.46.2",
- "@typescript-eslint/type-utils": "8.46.2",
- "@typescript-eslint/utils": "8.46.2",
- "@typescript-eslint/visitor-keys": "8.46.2",
+ "@typescript-eslint/scope-manager": "8.46.3",
+ "@typescript-eslint/type-utils": "8.46.3",
+ "@typescript-eslint/utils": "8.46.3",
+ "@typescript-eslint/visitor-keys": "8.46.3",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@@ -4452,22 +4459,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.46.2",
+ "@typescript-eslint/parser": "^8.46.3",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz",
- "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.3.tgz",
+ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.46.2",
- "@typescript-eslint/types": "8.46.2",
- "@typescript-eslint/typescript-estree": "8.46.2",
- "@typescript-eslint/visitor-keys": "8.46.2",
+ "@typescript-eslint/scope-manager": "8.46.3",
+ "@typescript-eslint/types": "8.46.3",
+ "@typescript-eslint/typescript-estree": "8.46.3",
+ "@typescript-eslint/visitor-keys": "8.46.3",
"debug": "^4.3.4"
},
"engines": {
@@ -4483,14 +4490,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz",
- "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.3.tgz",
+ "integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.46.2",
- "@typescript-eslint/types": "^8.46.2",
+ "@typescript-eslint/tsconfig-utils": "^8.46.3",
+ "@typescript-eslint/types": "^8.46.3",
"debug": "^4.3.4"
},
"engines": {
@@ -4505,14 +4512,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz",
- "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.3.tgz",
+ "integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.2",
- "@typescript-eslint/visitor-keys": "8.46.2"
+ "@typescript-eslint/types": "8.46.3",
+ "@typescript-eslint/visitor-keys": "8.46.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4523,9 +4530,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz",
- "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.3.tgz",
+ "integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4540,15 +4547,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz",
- "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.3.tgz",
+ "integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.2",
- "@typescript-eslint/typescript-estree": "8.46.2",
- "@typescript-eslint/utils": "8.46.2",
+ "@typescript-eslint/types": "8.46.3",
+ "@typescript-eslint/typescript-estree": "8.46.3",
+ "@typescript-eslint/utils": "8.46.3",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@@ -4565,9 +4572,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz",
- "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.3.tgz",
+ "integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4579,16 +4586,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz",
- "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.3.tgz",
+ "integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.46.2",
- "@typescript-eslint/tsconfig-utils": "8.46.2",
- "@typescript-eslint/types": "8.46.2",
- "@typescript-eslint/visitor-keys": "8.46.2",
+ "@typescript-eslint/project-service": "8.46.3",
+ "@typescript-eslint/tsconfig-utils": "8.46.3",
+ "@typescript-eslint/types": "8.46.3",
+ "@typescript-eslint/visitor-keys": "8.46.3",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -4608,16 +4615,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz",
- "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.3.tgz",
+ "integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.46.2",
- "@typescript-eslint/types": "8.46.2",
- "@typescript-eslint/typescript-estree": "8.46.2"
+ "@typescript-eslint/scope-manager": "8.46.3",
+ "@typescript-eslint/types": "8.46.3",
+ "@typescript-eslint/typescript-estree": "8.46.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4632,13 +4639,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz",
- "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.3.tgz",
+ "integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.46.2",
+ "@typescript-eslint/types": "8.46.3",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -4932,13 +4939,13 @@
]
},
"node_modules/@vitejs/plugin-react-swc": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.0.tgz",
- "integrity": "sha512-/tesahXD1qpkGC6FzMoFOJj0RyZdw9xLELOL+6jbElwmWfwOnIVy+IfpY+o9JfD9PKaR/Eyb6DNrvbXpuvA+8Q==",
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.1.tgz",
+ "integrity": "sha512-SIZ/XxeS2naLw4L2vVvpTyujM2OY+Rf+y6nWETqfoBrZpI3SFdyNJof3nQ8HbLhXJ1Eh9e9c0JGYC8GYPhLkCw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@rolldown/pluginutils": "1.0.0-beta.43",
+ "@rolldown/pluginutils": "1.0.0-beta.46",
"@swc/core": "^1.13.5"
},
"engines": {
@@ -5098,13 +5105,13 @@
}
},
"node_modules/@vue/compiler-core": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
- "integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
+ "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.4",
- "@vue/shared": "3.5.22",
+ "@babel/parser": "^7.28.5",
+ "@vue/shared": "3.5.24",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
@@ -5129,28 +5136,28 @@
"license": "MIT"
},
"node_modules/@vue/compiler-dom": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
- "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
+ "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-core": "3.5.22",
- "@vue/shared": "3.5.22"
+ "@vue/compiler-core": "3.5.24",
+ "@vue/shared": "3.5.24"
}
},
"node_modules/@vue/compiler-sfc": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
- "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
+ "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.4",
- "@vue/compiler-core": "3.5.22",
- "@vue/compiler-dom": "3.5.22",
- "@vue/compiler-ssr": "3.5.22",
- "@vue/shared": "3.5.22",
+ "@babel/parser": "^7.28.5",
+ "@vue/compiler-core": "3.5.24",
+ "@vue/compiler-dom": "3.5.24",
+ "@vue/compiler-ssr": "3.5.24",
+ "@vue/shared": "3.5.24",
"estree-walker": "^2.0.2",
- "magic-string": "^0.30.19",
+ "magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
@@ -5162,67 +5169,67 @@
"license": "MIT"
},
"node_modules/@vue/compiler-ssr": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
- "integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
+ "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-dom": "3.5.22",
- "@vue/shared": "3.5.22"
+ "@vue/compiler-dom": "3.5.24",
+ "@vue/shared": "3.5.24"
}
},
"node_modules/@vue/reactivity": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
- "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
+ "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
- "@vue/shared": "3.5.22"
+ "@vue/shared": "3.5.24"
}
},
"node_modules/@vue/runtime-core": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
- "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
+ "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
- "@vue/reactivity": "3.5.22",
- "@vue/shared": "3.5.22"
+ "@vue/reactivity": "3.5.24",
+ "@vue/shared": "3.5.24"
}
},
"node_modules/@vue/runtime-dom": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
- "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
+ "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
- "@vue/reactivity": "3.5.22",
- "@vue/runtime-core": "3.5.22",
- "@vue/shared": "3.5.22",
+ "@vue/reactivity": "3.5.24",
+ "@vue/runtime-core": "3.5.24",
+ "@vue/shared": "3.5.24",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
- "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
+ "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
- "@vue/compiler-ssr": "3.5.22",
- "@vue/shared": "3.5.22"
+ "@vue/compiler-ssr": "3.5.24",
+ "@vue/shared": "3.5.24"
},
"peerDependencies": {
- "vue": "3.5.22"
+ "vue": "3.5.24"
}
},
"node_modules/@vue/shared": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
- "integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
+ "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
"license": "MIT"
},
"node_modules/abbrev": {
@@ -5629,9 +5636,9 @@
}
},
"node_modules/axios": {
- "version": "1.13.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz",
- "integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==",
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -5687,9 +5694,9 @@
"license": "MIT"
},
"node_modules/bare-events": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.1.tgz",
- "integrity": "sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==",
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
+ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"dev": true,
"license": "Apache-2.0",
"peerDependencies": {
@@ -5773,9 +5780,9 @@
}
},
"node_modules/bare-url": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.1.tgz",
- "integrity": "sha512-v2yl0TnaZTdEnelkKtXZGnotiV6qATBlnNuUMrHl6v9Lmmrh9mw9RYyImPU7/4RahumSwQS1k2oKXcRfXcbjJw==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",
+ "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
@@ -5805,9 +5812,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.8.20",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz",
- "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==",
+ "version": "2.8.25",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz",
+ "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -5970,6 +5977,18 @@
"node": "*"
}
},
+ "node_modules/byte-counter": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz",
+ "integrity": "sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -5990,9 +6009,9 @@
}
},
"node_modules/cacheable-request": {
- "version": "13.0.12",
- "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.12.tgz",
- "integrity": "sha512-qqK/etGeI/9DV5yRkO50ApDTjip9UXPml1NHYJksUAw15yMLOf8VUO1/8bu4P8birOCqR+hYQ/nh1Lezc8sZrA==",
+ "version": "13.0.13",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.13.tgz",
+ "integrity": "sha512-/5/6xDTN/AZHOn93MNaU7HUSzOL/YJWOwa5hV84ib1mPUtQB/ZTg5bn57UGfdNwXGhgQfcPpJDy+eGiNgGJI1w==",
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "^4.0.4",
@@ -6100,9 +6119,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001751",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
- "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
+ "version": "1.0.30001754",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
+ "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"funding": [
{
"type": "opencollective",
@@ -6447,7 +6466,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -6493,9 +6511,9 @@
}
},
"node_modules/cssstyle": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz",
- "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==",
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.2.tgz",
+ "integrity": "sha512-zDMqXh8Vs1CdRYZQ2M633m/SFgcjlu8RB8b/1h82i+6vpArF507NSYIWJHGlJaTWoS+imcnctmEz43txhbVkOw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6592,9 +6610,9 @@
}
},
"node_modules/dayjs": {
- "version": "1.11.18",
- "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
- "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
+ "version": "1.11.19",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
+ "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT",
"peer": true
},
@@ -6962,9 +6980,9 @@
}
},
"node_modules/devtools-protocol": {
- "version": "0.0.1508733",
- "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
- "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
+ "version": "0.0.1521046",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
+ "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause"
},
@@ -7031,9 +7049,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.241",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz",
- "integrity": "sha512-ILMvKX/ZV5WIJzzdtuHg8xquk2y0BOGlFOxBVwTpbiXqWIH0hamG45ddU4R3PQ0gYu+xgo0vdHXHli9sHIGb4w==",
+ "version": "1.5.248",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.248.tgz",
+ "integrity": "sha512-zsur2yunphlyAO4gIubdJEXCK6KOVvtpiuDfCIqbM9FjcnMYiyn0ICa3hWfPr0nc41zcLWobgy1iL7VvoOyA2Q==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -7260,9 +7278,9 @@
}
},
"node_modules/esbuild": {
- "version": "0.25.11",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
- "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -7273,32 +7291,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.11",
- "@esbuild/android-arm": "0.25.11",
- "@esbuild/android-arm64": "0.25.11",
- "@esbuild/android-x64": "0.25.11",
- "@esbuild/darwin-arm64": "0.25.11",
- "@esbuild/darwin-x64": "0.25.11",
- "@esbuild/freebsd-arm64": "0.25.11",
- "@esbuild/freebsd-x64": "0.25.11",
- "@esbuild/linux-arm": "0.25.11",
- "@esbuild/linux-arm64": "0.25.11",
- "@esbuild/linux-ia32": "0.25.11",
- "@esbuild/linux-loong64": "0.25.11",
- "@esbuild/linux-mips64el": "0.25.11",
- "@esbuild/linux-ppc64": "0.25.11",
- "@esbuild/linux-riscv64": "0.25.11",
- "@esbuild/linux-s390x": "0.25.11",
- "@esbuild/linux-x64": "0.25.11",
- "@esbuild/netbsd-arm64": "0.25.11",
- "@esbuild/netbsd-x64": "0.25.11",
- "@esbuild/openbsd-arm64": "0.25.11",
- "@esbuild/openbsd-x64": "0.25.11",
- "@esbuild/openharmony-arm64": "0.25.11",
- "@esbuild/sunos-x64": "0.25.11",
- "@esbuild/win32-arm64": "0.25.11",
- "@esbuild/win32-ia32": "0.25.11",
- "@esbuild/win32-x64": "0.25.11"
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escalade": {
@@ -7356,20 +7374,20 @@
}
},
"node_modules/eslint": {
- "version": "9.38.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
- "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
+ "version": "9.39.1",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
+ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.1",
- "@eslint/config-helpers": "^0.4.1",
- "@eslint/core": "^0.16.0",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
- "@eslint/js": "9.38.0",
- "@eslint/plugin-kit": "^0.4.0",
+ "@eslint/js": "9.39.1",
+ "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -7759,9 +7777,9 @@
}
},
"node_modules/esrap": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.1.tgz",
- "integrity": "sha512-ebTT9B6lOtZGMgJ3o5r12wBacHctG7oEWazIda8UlPfA3HD/Wrv8FdXoVo73vzdpwCxNyXjPauyN2bbJzMkB9A==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
+ "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -8525,9 +8543,9 @@
}
},
"node_modules/globals": {
- "version": "16.4.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
- "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
+ "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -8589,13 +8607,14 @@
}
},
"node_modules/got": {
- "version": "14.6.1",
- "resolved": "https://registry.npmjs.org/got/-/got-14.6.1.tgz",
- "integrity": "sha512-56lZOw904LHKr6KdKN0Zbgz9Lw6cpEAAqZcS+0iY4D27caHoLiFT0EGCbrX9ZKYvt+I2lGl3a8eeDNSbmhyjkQ==",
+ "version": "14.6.2",
+ "resolved": "https://registry.npmjs.org/got/-/got-14.6.2.tgz",
+ "integrity": "sha512-bnhvxegqufyxHAmzwCZSscjGLVpw6/NzTXOk2tQVu/b9Q9FeMAgLabYulXEQRwP04UYltnkcZwvBq14fsdqvyw==",
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^7.0.1",
"@szmarczak/http-timer": "^5.0.1",
+ "byte-counter": "^0.1.0",
"cacheable-lookup": "^7.0.0",
"cacheable-request": "^13.0.12",
"decompress-response": "^10.0.0",
@@ -8835,9 +8854,9 @@
}
},
"node_modules/i18next": {
- "version": "25.6.0",
- "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.0.tgz",
- "integrity": "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==",
+ "version": "25.6.1",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.1.tgz",
+ "integrity": "sha512-yUWvdXtalZztmKrKw3yz/AvSP3yKyqIkVPx/wyvoYy9lkLmwzItLxp0iHZLG5hfVQ539Jor4XLO+U+NHIXg7pw==",
"funding": [
{
"type": "individual",
@@ -9553,7 +9572,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -9655,14 +9673,15 @@
}
},
"node_modules/jsdom": {
- "version": "27.0.1",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz",
- "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
+ "version": "27.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz",
+ "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@asamuzakjp/dom-selector": "^6.7.2",
- "cssstyle": "^5.3.1",
+ "@acemir/cssom": "^0.9.19",
+ "@asamuzakjp/dom-selector": "^6.7.3",
+ "cssstyle": "^5.3.2",
"data-urls": "^6.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^4.0.0",
@@ -9670,7 +9689,6 @@
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"parse5": "^8.0.0",
- "rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0",
@@ -9683,7 +9701,7 @@
"xml-name-validator": "^5.0.0"
},
"engines": {
- "node": ">=20"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
@@ -10742,9 +10760,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.26",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
- "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"license": "MIT"
},
"node_modules/node-source-walk": {
@@ -11231,7 +11249,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -11306,15 +11323,15 @@
}
},
"node_modules/pdfjs-dist": {
- "version": "5.4.296",
- "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
- "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
+ "version": "5.4.394",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz",
+ "integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
- "@napi-rs/canvas": "^0.1.80"
+ "@napi-rs/canvas": "^0.1.81"
}
},
"node_modules/pend": {
@@ -11692,12 +11709,12 @@
}
},
"node_modules/posthog-js": {
- "version": "1.281.0",
- "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.281.0.tgz",
- "integrity": "sha512-t3sAlgVozpU1W1ppiF5zLG6eBRPUs0hmtxN8R1V7P0qZFmnECshAAk2cBxCsxEanadT3iUpS8Z7crBytATqWQQ==",
+ "version": "1.289.0",
+ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.289.0.tgz",
+ "integrity": "sha512-fv1ClV/yqwub5zs3QbrO7Jqi50StGaUAcCE5xwmlsnMxh3GgwMHip7waRk34T/9LhAI7rLXT7ykGOrIEULANjA==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
- "@posthog/core": "1.4.0",
+ "@posthog/core": "1.5.2",
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
@@ -11922,18 +11939,18 @@
}
},
"node_modules/puppeteer": {
- "version": "24.26.1",
- "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.26.1.tgz",
- "integrity": "sha512-3RG2UqclzMFolM2fS4bN8t5/EjZ0VwEoAGVxG8PMGeprjLzj+x0U4auH7MQ4B6ftW+u1JUnTTN8ab4ABPdl4mA==",
+ "version": "24.29.1",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.29.1.tgz",
+ "integrity": "sha512-pX05JV1mMP+1N0vP3I4DOVwjMdpihv2LxQTtSfw6CUm5F0ZFLUFE/LSZ4yUWHYaM3C11Hdu+sgn7uY7teq5MYw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@puppeteer/browsers": "2.10.12",
+ "@puppeteer/browsers": "2.10.13",
"chromium-bidi": "10.5.1",
"cosmiconfig": "^9.0.0",
- "devtools-protocol": "0.0.1508733",
- "puppeteer-core": "24.26.1",
+ "devtools-protocol": "0.0.1521046",
+ "puppeteer-core": "24.29.1",
"typed-query-selector": "^2.12.0"
},
"bin": {
@@ -11944,16 +11961,16 @@
}
},
"node_modules/puppeteer-core": {
- "version": "24.26.1",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.26.1.tgz",
- "integrity": "sha512-YHZdo3chJ5b9pTYVnuDuoI3UX/tWJFJyRZvkLbThGy6XeHWC+0KI8iN0UMCkvde5l/YOk3huiVZ/PvwgSbwdrA==",
+ "version": "24.29.1",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.29.1.tgz",
+ "integrity": "sha512-ErJ9qKCK+bdLvBa7QVSQTBSPm8KZbl1yC/WvhrZ0ut27hDf2QBzjDsn1IukzE1i1KtZ7NYGETOV4W1beoo9izA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@puppeteer/browsers": "2.10.12",
+ "@puppeteer/browsers": "2.10.13",
"chromium-bidi": "10.5.1",
"debug": "^4.4.3",
- "devtools-protocol": "0.0.1508733",
+ "devtools-protocol": "0.0.1521046",
"typed-query-selector": "^2.12.0",
"webdriver-bidi-protocol": "0.3.8",
"ws": "^8.18.3"
@@ -12276,9 +12293,9 @@
"license": "0BSD"
},
"node_modules/react-router": {
- "version": "7.9.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",
- "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==",
+ "version": "7.9.5",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
+ "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -12298,12 +12315,12 @@
}
},
"node_modules/react-router-dom": {
- "version": "7.9.4",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz",
- "integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==",
+ "version": "7.9.5",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz",
+ "integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==",
"license": "MIT",
"dependencies": {
- "react-router": "7.9.4"
+ "react-router": "7.9.5"
},
"engines": {
"node": ">=20.0.0"
@@ -12718,13 +12735,6 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/rrweb-cssom": {
- "version": "0.8.0",
- "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
- "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -12954,7 +12964,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -12967,7 +12976,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13584,9 +13592,9 @@
}
},
"node_modules/svelte": {
- "version": "5.43.0",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.0.tgz",
- "integrity": "sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==",
+ "version": "5.43.4",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.4.tgz",
+ "integrity": "sha512-tPNp21nDWB0PSHE+VrTvEy9cFtDp2Q+ATxQoFomISEVdikZ1QZ69UqBPz/LlT+Oc8/LYS/COYwDQZrmZEUr+JQ==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -13661,9 +13669,9 @@
}
},
"node_modules/tailwindcss": {
- "version": "4.1.16",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
- "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
+ "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"license": "MIT"
},
"node_modules/tapable": {
@@ -13773,11 +13781,14 @@
"license": "MIT"
},
"node_modules/tinyexec": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
- "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
},
"node_modules/tinyglobby": {
"version": "0.2.15",
@@ -14130,16 +14141,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.46.2",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz",
- "integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==",
+ "version": "8.46.3",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.3.tgz",
+ "integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.46.2",
- "@typescript-eslint/parser": "8.46.2",
- "@typescript-eslint/typescript-estree": "8.46.2",
- "@typescript-eslint/utils": "8.46.2"
+ "@typescript-eslint/eslint-plugin": "8.46.3",
+ "@typescript-eslint/parser": "8.46.3",
+ "@typescript-eslint/typescript-estree": "8.46.3",
+ "@typescript-eslint/utils": "8.46.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14401,9 +14412,9 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "7.1.12",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
- "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
+ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14667,17 +14678,17 @@
}
},
"node_modules/vue": {
- "version": "3.5.22",
- "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
- "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
+ "version": "3.5.24",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
+ "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"license": "MIT",
"peer": true,
"dependencies": {
- "@vue/compiler-dom": "3.5.22",
- "@vue/compiler-sfc": "3.5.22",
- "@vue/runtime-dom": "3.5.22",
- "@vue/server-renderer": "3.5.22",
- "@vue/shared": "3.5.22"
+ "@vue/compiler-dom": "3.5.24",
+ "@vue/compiler-sfc": "3.5.24",
+ "@vue/runtime-dom": "3.5.24",
+ "@vue/server-renderer": "3.5.24",
+ "@vue/shared": "3.5.24"
},
"peerDependencies": {
"typescript": "*"
@@ -14785,7 +14796,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
diff --git a/frontend/public/branding/StirlingLogo.svg b/frontend/public/branding/StirlingLogo.svg
new file mode 100644
index 000000000..db1f03e00
--- /dev/null
+++ b/frontend/public/branding/StirlingLogo.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/branding/StirlingLogoLegacy.svg b/frontend/public/branding/StirlingLogoLegacy.svg
new file mode 100644
index 000000000..29a85fbef
--- /dev/null
+++ b/frontend/public/branding/StirlingLogoLegacy.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/branding/StirlingPDFLogoNoTextLightHC.svg b/frontend/public/branding/StirlingPDFLogoNoTextLightHC.svg
new file mode 100644
index 000000000..a909d6016
--- /dev/null
+++ b/frontend/public/branding/StirlingPDFLogoNoTextLightHC.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index bf287c732..29317af86 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -294,7 +294,8 @@
"learnMore": "Learn more",
"enable": "Enable analytics",
"disable": "Disable analytics",
- "settings": "You can change the settings for analytics in the config/settings.yml file"
+ "settings": "You can change the settings for analytics in the config/settings.yml file",
+ "privacyAssurance": "We do not track any personal information or the contents of your files."
},
"navbar": {
"favorite": "Favorites",
@@ -5123,6 +5124,10 @@
"maybeLater": "Maybe Later",
"dontShowAgain": "Don't Show Again"
},
+ "welcomeSlide": {
+ "title": "Welcome to Stirling",
+ "body": "Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - Edit Text ."
+ },
"allTools": "This is the Tools panel, where you can browse and select from all available PDF tools.",
"selectCropTool": "Let's select the Crop tool to demonstrate how to use one of the tools.",
"toolInterface": "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
@@ -5145,7 +5150,39 @@
"next": "Next",
"finish": "Finish",
"startTour": "Start Tour",
- "startTourDescription": "Take a guided tour of Stirling PDF's key features"
+ "startTourDescription": "Take a guided tour of Stirling PDF's key features",
+ "buttons": {
+ "next": "Next →",
+ "back": "Back",
+ "skipForNow": "Skip for now",
+ "download": "Download →",
+ "showMeAround": "Show me around",
+ "skipTheTour": "Skip the tour"
+ },
+ "serverLicense": {
+ "skip": "Skip for now",
+ "seePlans": "See Plans →",
+ "upgrade": "Upgrade now →",
+ "freeTitle": "Server License",
+ "overLimitTitle": "Server License Needed",
+ "overLimitBody": "Our licensing permits up to {{freeTierLimit}} users for free per server. You have {{overLimitUserCopy}} Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - unlimited seats , PDF text editing, and full admin control for $99/server/mo.",
+ "freeBody": "Our Open-Core licensing permits up to {{freeTierLimit}} users for free per server. To scale uninterrupted and get early access to our new PDF text editing tool , we recommend the Stirling Server plan - full editing and unlimited seats for $99/server/mo."
+ },
+ "desktopInstall": {
+ "title": "Download",
+ "titleWithOs": "Download for {{osLabel}}",
+ "body": "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer."
+ },
+ "planOverview": {
+ "adminTitle": "Admin Overview",
+ "userTitle": "Plan Overview",
+ "adminBodyLoginEnabled": "As an admin, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge.",
+ "adminBodyLoginDisabled": "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge.",
+ "userBody": "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use."
+ },
+ "securityCheck": {
+ "message": "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
+ }
},
"adminOnboarding": {
"welcome": "Welcome to the Admin Tour ! Let's explore the powerful enterprise features and settings available to system administrators.",
@@ -5174,7 +5211,10 @@
"role": "Role",
"team": "Team",
"status": "Status",
- "actions": "Actions",
+ "actions": {
+ "label": "Actions",
+ "upgrade": "Upgrade"
+ },
"noMembersFound": "No members found",
"active": "Active",
"disabled": "Disabled",
@@ -5286,7 +5326,8 @@
"slotsAvailable": "{{count}} user slot(s) available",
"noSlotsAvailable": "No slots available",
"currentUsage": "Currently using {{current}} of {{max}} user licences"
- }
+ },
+ "loginRequired": "Enable login mode first"
},
"teams": {
"title": "Teams",
@@ -5440,6 +5481,12 @@
"api": "API Access",
"priority": "Priority Support",
"customPricing": "Custom Pricing"
+ },
+ "licenseWarning": {
+ "title": "Free self-hosted limit reached",
+ "body": "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly.",
+ "overLimit": "more than {{limit}}",
+ "cta": "See plans"
}
},
"subscription": {
@@ -5481,7 +5528,11 @@
"title": "Upgrade to Server Plan",
"message": "Get the most out of Stirling PDF with unlimited users and advanced features",
"upgradeButton": "Upgrade Now",
- "dismiss": "Dismiss banner"
+ "dismiss": "Dismiss banner",
+ "attentionTitle": "This server needs admin attention",
+ "attentionBody": "Your admin needs to sign in to see more info. Please contact them immediately.",
+ "attentionBodyAdmin": "Review the license requirements to keep this server compliant.",
+ "seeInfo": "See info"
},
"payment": {
"preparing": "Preparing your checkout...",
diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx
index 96c44bfa2..3d313cd68 100644
--- a/frontend/src/core/components/AppProviders.tsx
+++ b/frontend/src/core/components/AppProviders.tsx
@@ -17,6 +17,7 @@ import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContex
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
+import { CookieConsentProvider } from "@app/contexts/CookieConsentContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
@@ -57,35 +58,37 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css
new file mode 100644
index 000000000..63a9e7977
--- /dev/null
+++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css
@@ -0,0 +1,277 @@
+.heroWrapper {
+ position: relative;
+ width: 100%;
+ height: 220px;
+ overflow: hidden;
+}
+
+.heroLogo {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ animation: heroLogoEnter 0.25s ease forwards;
+ z-index: 20;
+}
+
+.heroLogoCircle {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ background: #ffffff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
+ animation: heroLogoScale 0.25s ease forwards;
+}
+
+.heroLogoCircle img {
+ width: 60px;
+ height: 60px;
+ animation: heroLogoRotate 0.25s ease forwards;
+ transform-origin: center;
+}
+
+.standaloneIcon {
+ width: 96px;
+ height: 96px;
+ object-fit: contain;
+ animation: heroLogoScale 0.25s ease forwards;
+}
+
+.securitySlideContent {
+ display: flex;
+ justify-content: center;
+ margin-top: 12px;
+}
+
+.securityCard {
+ width: 100%;
+ max-width: 480px;
+ background: var(--bg-surface, #ffffff);
+ border-radius: 16px;
+ padding: 20px;
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.securityAlertRow {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ font-weight: 500;
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--onboarding-body, #1f2937);
+}
+
+.heroIconsContainer {
+ display: flex;
+ gap: 32px;
+ align-items: flex-start;
+ justify-content: center;
+ animation: heroLogoEnter 0.25s ease forwards;
+ position: relative;
+ top: 1rem;
+}
+
+.iconWrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+
+.iconButton {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ transition: transform 0.2s ease, opacity 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ border-radius: 12px;
+ padding: 4px;
+ outline: none;
+}
+
+.iconButton:focus {
+ outline: none;
+}
+
+.iconButton:focus-visible {
+ outline: none;
+}
+
+.iconButton:hover {
+ transform: scale(1.05);
+ opacity: 0.9;
+}
+
+.iconButton:active {
+ transform: scale(0.95);
+}
+
+.iconButtonSelected {
+ background: rgba(255, 255, 255, 0.1);
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4);
+}
+
+.downloadIcon {
+ width: 96px;
+ height: 96px;
+ object-fit: contain;
+ animation: heroLogoScale 0.25s ease forwards;
+}
+
+.iconLabel {
+ font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
+ font-size: 14px;
+ font-weight: 500;
+ color: rgba(255, 255, 255, 0.9);
+ text-align: center;
+ animation: heroLogoEnter 0.25s ease forwards;
+}
+
+.title {
+ text-align: center;
+ opacity: 0;
+ transform: translateX(24px);
+ animation: bodySlideIn 0.25s ease forwards;
+}
+
+.bodyCopy {
+ opacity: 0;
+ transform: translateX(24px);
+ animation: bodySlideIn 0.25s ease forwards;
+}
+
+@keyframes heroLogoEnter {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes heroLogoScale {
+ from {
+ transform: scale(0.6);
+ }
+ to {
+ transform: scale(1);
+ }
+}
+
+@keyframes heroLogoRotate {
+ from {
+ transform: rotate(-90deg) scale(0.9);
+ }
+ to {
+ transform: rotate(0deg) scale(1);
+ }
+}
+
+@keyframes bodySlideIn {
+ from {
+ opacity: 0;
+ transform: translateX(24px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+/* Dev overlay styles */
+.devOverlay {
+ position: absolute;
+ bottom: 8px;
+ left: 10px;
+ display: flex;
+ gap: 6px;
+ z-index: 10;
+}
+
+.devButton {
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ cursor: pointer;
+ opacity: 0.8;
+ border: 1px solid rgba(255, 255, 255, 0.4);
+ background: rgba(0, 0, 0, 0.35);
+ color: #fff;
+ box-shadow: none;
+}
+
+.devButtonActive {
+ opacity: 1;
+ border: 1px solid rgba(255, 255, 255, 0.9);
+ background: rgba(255, 255, 255, 0.9);
+ color: #1F2933;
+ box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
+}
+
+/* Modal content styles */
+.modalContent {
+ background: var(--bg-surface);
+ position: relative;
+}
+
+.modalBody {
+ padding: 24px;
+}
+
+/* Title styles */
+.titleText {
+ font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
+ font-weight: 600;
+ font-size: 22px;
+ color: var(--onboarding-title);
+}
+
+/* Body text styles */
+.bodyText {
+ font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
+ font-size: 16px;
+ color: var(--onboarding-body);
+ line-height: 1.5;
+}
+
+.bodyCopyInner {
+ color: inherit;
+}
+
+/* Button margin */
+.buttonContainer {
+ margin-top: 8px;
+}
+
+/* Welcome slide V2 badge */
+.welcomeTitleContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+}
+
+.v2Badge {
+ background: #DBEFFF;
+ color: #2A4BFF;
+ padding: 4px 12px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+/* Icon styles */
+.heroIcon {
+ color: #000000;
+}
diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts
new file mode 100644
index 000000000..ec742c143
--- /dev/null
+++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/flowResolver.ts
@@ -0,0 +1,33 @@
+import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig';
+
+export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin';
+
+export interface FlowConfig {
+ type: FlowType;
+ ids: SlideId[];
+}
+
+export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig {
+ if (!enableLogin) {
+ return selfReportedAdmin
+ ? {
+ type: 'no-login-admin',
+ ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin],
+ }
+ : {
+ type: 'no-login',
+ ids: FLOW_SEQUENCES.noLoginBase,
+ };
+ }
+
+ return isAdmin
+ ? {
+ type: 'login-admin',
+ ids: FLOW_SEQUENCES.loginAdmin,
+ }
+ : {
+ type: 'login-user',
+ ids: FLOW_SEQUENCES.loginUser,
+ };
+}
+
diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx
new file mode 100644
index 000000000..0ab33cfed
--- /dev/null
+++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/index.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import { Modal, Stack } from '@mantine/core';
+import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
+import LocalIcon from '@app/components/shared/LocalIcon';
+import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
+import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
+import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
+import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
+import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
+import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
+import { BASE_PATH } from '@app/constants/app';
+import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
+
+export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
+ const flow = useInitialOnboardingState(props);
+
+ if (!flow) {
+ return null;
+ }
+
+ const {
+ state,
+ totalSteps,
+ currentSlide,
+ slideDefinition,
+ licenseNotice,
+ flowState,
+ closeAndMarkSeen,
+ handleButtonAction,
+ } = flow;
+
+ const renderHero = () => {
+ if (slideDefinition.hero.type === 'dual-icon') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {slideDefinition.hero.type === 'rocket' && (
+
+ )}
+ {slideDefinition.hero.type === 'shield' && (
+
+ )}
+ {slideDefinition.hero.type === 'diamond' &&
}
+ {slideDefinition.hero.type === 'logo' && (
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+ {currentSlide.title}
+
+
+
+
+ {currentSlide.body}
+
+
+
+
+
+
+
+ {renderButtons({
+ slideDefinition,
+ licenseNotice,
+ flowState,
+ onAction: handleButtonAction,
+ })}
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx
new file mode 100644
index 000000000..4325d9b2f
--- /dev/null
+++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx
@@ -0,0 +1,109 @@
+import React from 'react';
+import { Button, Group, ActionIcon } from '@mantine/core';
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
+import { useTranslation } from 'react-i18next';
+import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
+import type { LicenseNotice } from '@app/types/types';
+import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
+
+interface RenderButtonsProps {
+ slideDefinition: {
+ buttons: ButtonDefinition[];
+ id: string;
+ };
+ licenseNotice: LicenseNotice;
+ flowState: FlowState;
+ onAction: (action: ButtonAction) => void;
+}
+
+export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
+ const { t } = useTranslation();
+ const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
+ const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
+
+ const buttonStyles = (variant: ButtonDefinition['variant']) =>
+ variant === 'primary'
+ ? {
+ root: {
+ background: 'var(--onboarding-primary-button-bg)',
+ color: 'var(--onboarding-primary-button-text)',
+ },
+ }
+ : {
+ root: {
+ background: 'var(--onboarding-secondary-button-bg)',
+ border: '1px solid var(--onboarding-secondary-button-border)',
+ color: 'var(--onboarding-secondary-button-text)',
+ },
+ };
+
+ const resolveButtonLabel = (button: ButtonDefinition) => {
+ // Special case: override "See Plans" with "Upgrade now" when over limit
+ if (
+ button.type === 'button' &&
+ slideDefinition.id === 'server-license' &&
+ button.action === 'see-plans' &&
+ licenseNotice.isOverLimit
+ ) {
+ return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
+ }
+
+ // Translate the label (it's a translation key)
+ const label = button.label ?? '';
+ if (!label) return '';
+
+ // Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
+ const fallback = label.split('.').pop() || label;
+ return t(label, fallback);
+ };
+
+ const renderButton = (button: ButtonDefinition) => {
+ const disabled = button.disabledWhen?.(flowState) ?? false;
+
+ if (button.type === 'icon') {
+ return (
+ onAction(button.action)}
+ radius="md"
+ size={40}
+ disabled={disabled}
+ styles={{
+ root: {
+ background: 'var(--onboarding-secondary-button-bg)',
+ border: '1px solid var(--onboarding-secondary-button-border)',
+ color: 'var(--onboarding-secondary-button-text)',
+ },
+ }}
+ >
+ {button.icon === 'chevron-left' && }
+
+ );
+ }
+
+ const variant = button.variant ?? 'secondary';
+ const label = resolveButtonLabel(button);
+
+ return (
+ onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
+ {label}
+
+ );
+ };
+
+ if (leftButtons.length === 0) {
+ return {rightButtons.map(renderButton)} ;
+ }
+
+ if (rightButtons.length === 0) {
+ return {leftButtons.map(renderButton)} ;
+ }
+
+ return (
+
+ {leftButtons.map(renderButton)}
+ {rightButtons.map(renderButton)}
+
+ );
+}
+
diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts
new file mode 100644
index 000000000..d8bc96a1c
--- /dev/null
+++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts
@@ -0,0 +1,21 @@
+import type { LicenseNotice } from '@app/types/types';
+
+export interface InitialOnboardingModalProps {
+ opened: boolean;
+ onClose: () => void;
+ onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
+ onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
+}
+
+export interface OnboardingState {
+ step: number;
+ selectedRole: 'admin' | 'user' | null;
+ selfReportedAdmin: boolean;
+}
+
+export const DEFAULT_STATE: OnboardingState = {
+ step: 0,
+ selectedRole: null,
+ selfReportedAdmin: false,
+};
+
diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts
new file mode 100644
index 000000000..066a777cc
--- /dev/null
+++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts
@@ -0,0 +1,381 @@
+import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
+import { usePreferences } from '@app/contexts/PreferencesContext';
+import { useOnboarding } from '@app/contexts/OnboardingContext';
+import { useOs } from '@app/hooks/useOs';
+import { useNavigate } from 'react-router-dom';
+import {
+ SLIDE_DEFINITIONS,
+ type ButtonAction,
+ type FlowState,
+ type SlideId,
+} from '@app/components/onboarding/onboardingFlowConfig';
+import type { LicenseNotice } from '@app/types/types';
+import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
+import { useServerExperience } from '@app/hooks/useServerExperience';
+import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
+import { DOWNLOAD_URLS } from '@app/constants/downloads';
+
+interface UseInitialOnboardingStateResult {
+ state: OnboardingState;
+ totalSteps: number;
+ slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
+ currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
+ licenseNotice: LicenseNotice;
+ flowState: FlowState;
+ closeAndMarkSeen: () => void;
+ handleButtonAction: (action: ButtonAction) => void;
+}
+
+export function useInitialOnboardingState({
+ opened,
+ onClose,
+ onRequestServerLicense,
+ onLicenseNoticeUpdate,
+}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
+ const { preferences, updatePreference } = usePreferences();
+ const { startTour } = useOnboarding();
+ const {
+ loginEnabled: loginEnabledFromServer,
+ configIsAdmin,
+ totalUsers: serverTotalUsers,
+ userCountResolved: serverUserCountResolved,
+ freeTierLimit,
+ hasPaidLicense,
+ scenarioKey,
+ setSelfReportedAdmin,
+ isNewServer,
+ } = useServerExperience();
+ const osType = useOs();
+ const navigate = useNavigate();
+ const selectedDownloadUrlRef = useRef('');
+
+ const [state, setState] = useState(DEFAULT_STATE);
+
+ const resetState = useCallback(() => {
+ setState(DEFAULT_STATE);
+ }, []);
+
+ useEffect(() => {
+ if (!opened) {
+ resetState();
+ }
+ }, [opened, resetState]);
+
+ const handleRoleSelect = useCallback(
+ (role: 'admin' | 'user' | null) => {
+ const isAdminSelection = role === 'admin';
+ setState((prev) => ({
+ ...prev,
+ selectedRole: role,
+ selfReportedAdmin: isAdminSelection,
+ }));
+
+ if (typeof window !== 'undefined') {
+ if (isAdminSelection) {
+ window.localStorage.setItem('stirling-self-reported-admin', 'true');
+ } else {
+ window.localStorage.removeItem('stirling-self-reported-admin');
+ }
+ }
+
+ setSelfReportedAdmin(isAdminSelection);
+ },
+ [setSelfReportedAdmin],
+ );
+
+ const closeAndMarkSeen = useCallback(() => {
+ if (!preferences.hasSeenIntroOnboarding) {
+ updatePreference('hasSeenIntroOnboarding', true);
+ }
+ onClose();
+ }, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
+
+ const isAdmin = configIsAdmin;
+ const enableLogin = loginEnabledFromServer;
+
+ const effectiveEnableLogin = enableLogin;
+ const effectiveIsAdmin = isAdmin;
+ const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
+
+ useEffect(() => {
+ if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
+ handleRoleSelect('admin');
+ }
+ }, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
+
+ const shouldUseServerCount =
+ (effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
+ const licenseUserCountFromServer =
+ shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
+
+ const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
+
+ const os = useMemo(() => {
+ switch (osType) {
+ case 'windows':
+ return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
+ case 'mac-apple':
+ return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
+ case 'mac-intel':
+ return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
+ case 'linux-x64':
+ case 'linux-arm64':
+ return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
+ default:
+ return { label: '', url: '' };
+ }
+ }, [osType]);
+
+ const osOptions = useMemo(() => {
+ const options = [
+ { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
+ { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
+ { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
+ { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
+ ];
+ return options.filter(opt => opt.url);
+ }, []);
+
+ const resolvedFlow = useMemo(
+ () => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
+ [effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
+ );
+ const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
+ const flowSlideIds = useMemo(
+ () =>
+ shouldSkipSecurityCheck
+ ? resolvedFlow.ids.filter((id) => id !== 'security-check')
+ : resolvedFlow.ids,
+ [resolvedFlow.ids, shouldSkipSecurityCheck],
+ );
+ const flowType = resolvedFlow.type;
+ const totalSteps = flowSlideIds.length;
+ const maxIndex = Math.max(totalSteps - 1, 0);
+
+ useEffect(() => {
+ if (state.step >= flowSlideIds.length) {
+ setState((prev) => ({
+ ...prev,
+ step: Math.max(flowSlideIds.length - 1, 0),
+ }));
+ }
+ }, [flowSlideIds.length, state.step]);
+
+ const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
+ const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
+
+ if (!slideDefinition) {
+ return null;
+ }
+
+ const scenarioProvidesInfo =
+ scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
+ const scenarioIndicatesAdmin = scenarioProvidesInfo
+ ? scenarioKey!.includes('admin')
+ : state.selfReportedAdmin || effectiveIsAdmin;
+ const scenarioIndicatesOverLimit = scenarioProvidesInfo
+ ? scenarioKey!.includes('over-limit')
+ : effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
+ const scenarioRequiresLicense =
+ scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
+
+ const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
+
+ const licenseNotice = useMemo(
+ () => ({
+ totalUsers: effectiveLicenseUserCount,
+ freeTierLimit,
+ isOverLimit: scenarioIndicatesOverLimit,
+ requiresLicense: shouldShowServerLicenseInfo,
+ }),
+ [
+ effectiveLicenseUserCount,
+ freeTierLimit,
+ scenarioIndicatesOverLimit,
+ shouldShowServerLicenseInfo,
+ ],
+ );
+
+ const requestServerLicenseIfNeeded = useCallback(
+ (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
+ if (!shouldShowServerLicenseInfo) {
+ return;
+ }
+ onRequestServerLicense?.(options);
+ },
+ [onRequestServerLicense, shouldShowServerLicenseInfo],
+ );
+
+ useEffect(() => {
+ onLicenseNoticeUpdate?.(licenseNotice);
+ }, [licenseNotice, onLicenseNoticeUpdate]);
+
+ // Initialize ref with default URL
+ useEffect(() => {
+ if (!selectedDownloadUrlRef.current && os.url) {
+ selectedDownloadUrlRef.current = os.url;
+ }
+ }, [os.url]);
+
+ const handleDownloadUrlChange = useCallback((url: string) => {
+ selectedDownloadUrlRef.current = url;
+ }, []);
+
+ const currentSlide = slideDefinition.createSlide({
+ osLabel: os.label,
+ osUrl: os.url,
+ osOptions,
+ onDownloadUrlChange: handleDownloadUrlChange,
+ selectedRole: state.selectedRole,
+ onRoleSelect: handleRoleSelect,
+ licenseNotice,
+ loginEnabled: effectiveEnableLogin,
+ });
+
+ const goNext = useCallback(() => {
+ setState((prev) => ({
+ ...prev,
+ step: Math.min(prev.step + 1, maxIndex),
+ }));
+ }, [maxIndex]);
+
+ const goPrev = useCallback(() => {
+ setState((prev) => ({
+ ...prev,
+ step: Math.max(prev.step - 1, 0),
+ }));
+ }, []);
+
+ const launchTour = useCallback(
+ (mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
+ if (options?.closeOnboardingSlides) {
+ closeAndMarkSeen();
+ }
+
+ startTour(mode, {
+ source: 'initial-onboarding-modal',
+ metadata: {
+ hasCompletedOnboarding: preferences.hasCompletedOnboarding,
+ toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
+ selfReportedAdmin: state.selfReportedAdmin,
+ },
+ });
+ },
+ [closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
+ );
+
+ const handleButtonAction = useCallback(
+ (action: ButtonAction) => {
+ const currentSlideIdLocal = currentSlideId;
+ const shouldAutoLaunchLoginUserTour =
+ flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
+
+ switch (action) {
+ case 'next':
+ if (shouldAutoLaunchLoginUserTour) {
+ launchTour('tools', { closeOnboardingSlides: true });
+ return;
+ }
+ goNext();
+ return;
+ case 'prev':
+ goPrev();
+ return;
+ case 'close':
+ closeAndMarkSeen();
+ return;
+ case 'download-selected': {
+ const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
+ if (downloadUrl) {
+ window.open(downloadUrl, '_blank', 'noopener');
+ }
+ if (shouldAutoLaunchLoginUserTour) {
+ launchTour('tools', { closeOnboardingSlides: true });
+ return;
+ }
+ goNext();
+ return;
+ }
+ case 'complete-close':
+ updatePreference('hasCompletedOnboarding', true);
+ closeAndMarkSeen();
+ return;
+ case 'security-next':
+ if (!state.selectedRole) {
+ return;
+ }
+ if (state.selectedRole === 'admin') {
+ goNext();
+ } else {
+ launchTour('tools', { closeOnboardingSlides: true });
+ }
+ return;
+ case 'launch-admin':
+ requestServerLicenseIfNeeded({
+ deferUntilTourComplete: true,
+ selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
+ });
+ launchTour('admin', { closeOnboardingSlides: true });
+ return;
+ case 'launch-tools':
+ launchTour('tools', { closeOnboardingSlides: true });
+ return;
+ case 'launch-auto': {
+ const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
+ if (launchMode === 'admin') {
+ requestServerLicenseIfNeeded({
+ deferUntilTourComplete: true,
+ selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
+ });
+ }
+ launchTour(launchMode, { closeOnboardingSlides: true });
+ return;
+ }
+ case 'skip-to-license':
+ updatePreference('hasCompletedOnboarding', true);
+ requestServerLicenseIfNeeded({
+ deferUntilTourComplete: false,
+ selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
+ });
+ closeAndMarkSeen();
+ return;
+ case 'see-plans':
+ closeAndMarkSeen();
+ navigate('/settings/adminPlan');
+ return;
+ default:
+ return;
+ }
+ },
+ [
+ closeAndMarkSeen,
+ currentSlide,
+ effectiveIsAdmin,
+ flowType,
+ goNext,
+ goPrev,
+ launchTour,
+ navigate,
+ requestServerLicenseIfNeeded,
+ onRequestServerLicense,
+ os.url,
+ state.selectedRole,
+ state.selfReportedAdmin,
+ updatePreference,
+ ],
+ );
+
+ const flowState: FlowState = { selectedRole: state.selectedRole };
+
+ return {
+ state,
+ totalSteps,
+ slideDefinition,
+ currentSlide,
+ licenseNotice,
+ flowState,
+ closeAndMarkSeen,
+ handleButtonAction,
+ };
+}
+
diff --git a/frontend/src/core/components/onboarding/OnboardingStepper.tsx b/frontend/src/core/components/onboarding/OnboardingStepper.tsx
new file mode 100644
index 000000000..ec6767d8a
--- /dev/null
+++ b/frontend/src/core/components/onboarding/OnboardingStepper.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+
+interface OnboardingStepperProps {
+ totalSteps: number;
+ activeStep: number; // 0-indexed
+ className?: string;
+}
+
+/**
+ * Renders a progress indicator where the active step is a pill and others are dots.
+ * Colors come from theme.css variables.
+ */
+export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
+ const items = Array.from({ length: totalSteps }, (_, index) => index);
+
+ return (
+
+ {items.map((index) => {
+ const isActive = index === activeStep;
+ const baseStyles: React.CSSProperties = {
+ background: isActive
+ ? 'var(--onboarding-step-active)'
+ : 'var(--onboarding-step-inactive)',
+ };
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export default OnboardingStepper;
+
+
diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx
index e06fa41d5..b044fd3e5 100644
--- a/frontend/src/core/components/onboarding/OnboardingTour.tsx
+++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx
@@ -1,105 +1,29 @@
-import React, { useMemo } from "react";
-import { TourProvider, useTour, type StepType } from '@reactour/tour';
-import { useOnboarding } from '@app/contexts/OnboardingContext';
+import React, { useEffect, useMemo } from "react";
+import { TourProvider, type StepType } from '@reactour/tour';
import { useTranslation } from 'react-i18next';
import { CloseButton, ActionIcon } from '@mantine/core';
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
+import CheckIcon from '@mui/icons-material/Check';
+import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
+import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal';
+import '@app/components/onboarding/OnboardingTour.css';
+import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
-import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
+import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow';
+import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
+import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
+import { removeAllGlows } from '@app/components/onboarding/tourGlow';
+import TourContent from '@app/components/onboarding/TourContent';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
-import CheckIcon from '@mui/icons-material/Check';
-import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
import '@app/components/onboarding/OnboardingTour.css';
import i18n from "@app/i18n";
-// Enum case order defines order steps will appear
-enum TourStep {
- ALL_TOOLS,
- SELECT_CROP_TOOL,
- TOOL_INTERFACE,
- FILES_BUTTON,
- FILE_SOURCES,
- WORKBENCH,
- VIEW_SWITCHER,
- VIEWER,
- PAGE_EDITOR,
- ACTIVE_FILES,
- FILE_CHECKBOX,
- SELECT_CONTROLS,
- CROP_SETTINGS,
- RUN_BUTTON,
- RESULTS,
- FILE_REPLACEMENT,
- PIN_BUTTON,
- WRAP_UP,
-}
-
-enum AdminTourStep {
- WELCOME,
- CONFIG_BUTTON,
- SETTINGS_OVERVIEW,
- TEAMS_AND_USERS,
- SYSTEM_CUSTOMIZATION,
- DATABASE_SECTION,
- CONNECTIONS_SECTION,
- ADMIN_TOOLS,
- WRAP_UP,
-}
-
-function TourContent() {
- const { isOpen } = useOnboarding();
- const { setIsOpen, setCurrentStep } = useTour();
- const previousIsOpenRef = React.useRef(isOpen);
-
- // Sync tour open state with context and reset to step 0 when reopening
- React.useEffect(() => {
- const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
- previousIsOpenRef.current = isOpen;
-
- if (wasClosedNowOpen) {
- // Tour is being opened (Help button pressed), reset to first step
- setCurrentStep(0);
- }
- setIsOpen(isOpen);
- }, [isOpen, setIsOpen, setCurrentStep]);
-
- return null;
-}
-
export default function OnboardingTour() {
const { t } = useTranslation();
- const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
+ const flow = useOnboardingFlow();
const { openFilesModal, closeFilesModal } = useFilesModalContext();
- const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
-
- // Helper to add glow to multiple elements
- const addGlowToElements = (selectors: string[]) => {
- selectors.forEach(selector => {
- const element = document.querySelector(selector);
- if (element) {
- if (selector === '[data-tour="settings-content-area"]') {
- element.classList.add('tour-content-glow');
- } else {
- element.classList.add('tour-nav-glow');
- }
- }
- });
- };
-
- // Helper to remove all glows
- const removeAllGlows = () => {
- document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow'));
- document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow'));
- };
-
- // Cleanup glows when tour closes
- React.useEffect(() => {
- if (!isOpen) {
- removeAllGlows();
- }
- return () => removeAllGlows();
- }, [isOpen]);
const {
saveWorkbenchState,
restoreWorkbenchState,
@@ -122,258 +46,78 @@ export default function OnboardingTour() {
scrollNavToSection,
} = useAdminTourOrchestration();
- // Define steps as object keyed by enum - TypeScript ensures all keys are present
- const stepsConfig: Record = useMemo(() => ({
- [TourStep.ALL_TOOLS]: {
- selector: '[data-tour="tool-panel"]',
- content: t('onboarding.allTools', 'This is the Tools panel, where you can browse and select from all available PDF tools.'),
- position: 'center',
- padding: 0,
- action: () => {
- saveWorkbenchState();
- closeFilesModal();
- backToAllTools();
- },
- },
- [TourStep.SELECT_CROP_TOOL]: {
- selector: '[data-tour="tool-button-crop"]',
- content: t('onboarding.selectCropTool', "Let's select the Crop tool to demonstrate how to use one of the tools."),
- position: 'right',
- padding: 0,
- actionAfter: () => selectCropTool(),
- },
- [TourStep.TOOL_INTERFACE]: {
- selector: '[data-tour="tool-panel"]',
- content: t('onboarding.toolInterface', "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
- position: 'center',
- padding: 0,
- },
- [TourStep.FILES_BUTTON]: {
- selector: '[data-tour="files-button"]',
- content: t('onboarding.filesButton', "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on."),
- position: 'right',
- padding: 10,
- action: () => openFilesModal(),
- },
- [TourStep.FILE_SOURCES]: {
- selector: '[data-tour="file-sources"]',
- content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
- position: 'right',
- padding: 0,
- actionAfter: () => {
- loadSampleFile();
- closeFilesModal();
- }
- },
- [TourStep.WORKBENCH]: {
- selector: '[data-tour="workbench"]',
- content: t('onboarding.workbench', 'This is the Workbench - the main area where you view and edit your PDFs.'),
- position: 'center',
- padding: 0,
- },
- [TourStep.VIEW_SWITCHER]: {
- selector: '[data-tour="view-switcher"]',
- content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
- position: 'bottom',
- padding: 0,
- },
- [TourStep.VIEWER]: {
- selector: '[data-tour="workbench"]',
- content: t('onboarding.viewer', "The Viewer lets you read and annotate your PDFs."),
- position: 'center',
- padding: 0,
- action: () => switchToViewer(),
- },
- [TourStep.PAGE_EDITOR]: {
- selector: '[data-tour="workbench"]',
- content: t('onboarding.pageEditor', "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
- position: 'center',
- padding: 0,
- action: () => switchToPageEditor(),
- },
- [TourStep.ACTIVE_FILES]: {
- selector: '[data-tour="workbench"]',
- content: t('onboarding.activeFiles', "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
- position: 'center',
- padding: 0,
- action: () => switchToActiveFiles(),
- },
- [TourStep.FILE_CHECKBOX]: {
- selector: '[data-tour="file-card-checkbox"]',
- content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
- position: 'top',
- padding: 10,
- },
- [TourStep.SELECT_CONTROLS]: {
- selector: '[data-tour="right-rail-controls"]',
- highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
- content: t('onboarding.selectControls', "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
- position: 'left',
- padding: 5,
- action: () => selectFirstFile(),
- },
- [TourStep.CROP_SETTINGS]: {
- selector: '[data-tour="crop-settings"]',
- content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."),
- position: 'left',
- padding: 10,
- action: () => modifyCropSettings(),
- },
- [TourStep.RUN_BUTTON]: {
- selector: '[data-tour="run-button"]',
- content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
- position: 'top',
- padding: 10,
- actionAfter: () => executeTool(),
- },
- [TourStep.RESULTS]: {
- selector: '[data-tour="tool-panel"]',
- content: t('onboarding.results', "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
- position: 'center',
- padding: 0,
- },
- [TourStep.FILE_REPLACEMENT]: {
- selector: '[data-tour="file-card-checkbox"]',
- content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
- position: 'left',
- padding: 10,
- },
- [TourStep.PIN_BUTTON]: {
- selector: '[data-tour="file-card-pin"]',
- content: t('onboarding.pinButton', "You can use the Pin button if you'd rather your files stay active after running tools on them."),
- position: 'left',
- padding: 10,
- action: () => pinFile(),
- },
- [TourStep.WRAP_UP]: {
- selector: '[data-tour="help-button"]',
- content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again."),
- position: 'right',
- padding: 10,
- },
- }), [t]);
+ const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
- // Define admin tour steps
- const adminStepsConfig: Record = useMemo(() => ({
- [AdminTourStep.WELCOME]: {
- selector: '[data-tour="config-button"]',
- content: t('adminOnboarding.welcome', "Welcome to the Admin Tour ! Let's explore the powerful enterprise features and settings available to system administrators."),
- position: 'right',
- padding: 10,
- action: () => {
- saveAdminState();
- },
- },
- [AdminTourStep.CONFIG_BUTTON]: {
- selector: '[data-tour="config-button"]',
- content: t('adminOnboarding.configButton', "Click the Config button to access all system settings and administrative controls."),
- position: 'right',
- padding: 10,
- actionAfter: () => {
- openConfigModal();
- },
- },
- [AdminTourStep.SETTINGS_OVERVIEW]: {
- selector: '.modal-nav',
- content: t('adminOnboarding.settingsOverview', "This is the Settings Panel . Admin settings are organised by category for easy navigation."),
- position: 'right',
- padding: 0,
- action: () => {
- removeAllGlows();
- },
- },
- [AdminTourStep.TEAMS_AND_USERS]: {
- selector: '[data-tour="admin-people-nav"]',
- highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
- content: t('adminOnboarding.teamsAndUsers', "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
- position: 'right',
- padding: 10,
- action: () => {
- removeAllGlows();
- navigateToSection('people');
- setTimeout(() => {
- addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
- }, 100);
- },
- },
- [AdminTourStep.SYSTEM_CUSTOMIZATION]: {
- selector: '[data-tour="admin-adminGeneral-nav"]',
- highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
- content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users."),
- position: 'right',
- padding: 10,
- action: () => {
- removeAllGlows();
- navigateToSection('adminGeneral');
- setTimeout(() => {
- addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
- }, 100);
- },
- },
- [AdminTourStep.DATABASE_SECTION]: {
- selector: '[data-tour="admin-adminDatabase-nav"]',
- highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
- content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."),
- position: 'right',
- padding: 10,
- action: () => {
- removeAllGlows();
- navigateToSection('adminDatabase');
- setTimeout(() => {
- addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
- }, 100);
- },
- },
- [AdminTourStep.CONNECTIONS_SECTION]: {
- selector: '[data-tour="admin-adminConnections-nav"]',
- highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
- content: t('adminOnboarding.connectionsSection', "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
- position: 'right',
- padding: 10,
- action: () => {
- removeAllGlows();
- navigateToSection('adminConnections');
- setTimeout(() => {
- addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
- }, 100);
- },
- actionAfter: async () => {
- // Scroll for the NEXT step before it shows
- await scrollNavToSection('adminAudit');
- },
- },
- [AdminTourStep.ADMIN_TOOLS]: {
- selector: '[data-tour="admin-adminAudit-nav"]',
- highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
- content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."),
- position: 'right',
- padding: 10,
- action: () => {
- // Just navigate, scroll already happened in previous step
- removeAllGlows();
- navigateToSection('adminAudit');
- setTimeout(() => {
- addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
- }, 100);
- },
- },
- [AdminTourStep.WRAP_UP]: {
- selector: '[data-tour="help-button"]',
- content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."),
- position: 'right',
- padding: 10,
- action: () => {
- removeAllGlows();
- },
- },
- }), [t]);
+ useEffect(() => {
+ if (!flow.isTourOpen) {
+ removeAllGlows();
+ }
+ return () => removeAllGlows();
+ }, [flow.isTourOpen]);
- // Select steps based on tour type
- const steps = tourType === 'admin'
- ? Object.values(adminStepsConfig)
- : Object.values(stepsConfig);
+ const userStepsConfig = useMemo(
+ () =>
+ createUserStepsConfig({
+ t,
+ actions: {
+ saveWorkbenchState,
+ closeFilesModal,
+ backToAllTools,
+ selectCropTool,
+ loadSampleFile,
+ switchToViewer,
+ switchToPageEditor,
+ switchToActiveFiles,
+ selectFirstFile,
+ pinFile,
+ modifyCropSettings,
+ executeTool,
+ openFilesModal,
+ },
+ }),
+ [
+ t,
+ backToAllTools,
+ closeFilesModal,
+ executeTool,
+ loadSampleFile,
+ modifyCropSettings,
+ openFilesModal,
+ pinFile,
+ saveWorkbenchState,
+ selectCropTool,
+ selectFirstFile,
+ switchToActiveFiles,
+ switchToPageEditor,
+ switchToViewer,
+ ],
+ );
- const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
+ const adminStepsConfig = useMemo(
+ () =>
+ createAdminStepsConfig({
+ t,
+ actions: {
+ saveAdminState,
+ openConfigModal,
+ navigateToSection,
+ scrollNavToSection,
+ },
+ }),
+ [navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
+ );
+
+ const steps = useMemo(() => {
+ const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
+ return Object.values(config);
+ }, [adminStepsConfig, flow.tourType, userStepsConfig]);
+
+ const advanceTour = ({
+ setCurrentStep,
+ currentStep,
+ steps,
+ setIsOpen,
+ }: {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
@@ -381,12 +125,12 @@ export default function OnboardingTour() {
}) => {
if (steps && currentStep === steps.length - 1) {
setIsOpen(false);
- if (tourType === 'admin') {
+ if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
- completeTour();
+ flow.handleTourCompletion();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
@@ -394,34 +138,22 @@ export default function OnboardingTour() {
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
setIsOpen(false);
- if (tourType === 'admin') {
+ if (flow.tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
- completeTour();
+ flow.handleTourCompletion();
};
return (
<>
- {
- setShowWelcomeModal(false);
- startTour();
- }}
- onMaybeLater={() => {
- setShowWelcomeModal(false);
- }}
- onDontShowAgain={() => {
- setShowWelcomeModal(false);
- completeTour();
- }}
- />
+
+
{
@@ -429,13 +161,10 @@ export default function OnboardingTour() {
advanceTour(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
- // Handle right arrow key to advance tour
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
e.preventDefault();
advanceTour(clickProps);
- }
- // Handle escape key to close tour
- else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
+ } else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
e.preventDefault();
handleCloseTour(clickProps);
}
@@ -487,22 +216,16 @@ export default function OnboardingTour() {
}}
components={{
Close: ({ onClick }) => (
-
+
),
- Content: ({ content } : {content: string}) => (
-
+ Content: ({ content }: { content: string }) => (
+
),
}}
>
+
>
);
}
diff --git a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx
new file mode 100644
index 000000000..9c53628cb
--- /dev/null
+++ b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx
@@ -0,0 +1,118 @@
+import React from 'react';
+import { Modal, Button, Group, Stack } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
+import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
+import { LicenseNotice } from '@app/types/types';
+import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
+import { BASE_PATH } from '@app/constants/app';
+import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
+
+interface ServerLicenseModalProps {
+ opened: boolean;
+ onClose: () => void;
+ onSeePlans?: () => void;
+ licenseNotice: LicenseNotice;
+}
+
+export default function ServerLicenseModal({
+ opened,
+ onClose,
+ onSeePlans,
+ licenseNotice,
+}: ServerLicenseModalProps) {
+ const { t } = useTranslation();
+ const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
+ const primaryLabel = licenseNotice.isOverLimit
+ ? t('onboarding.serverLicense.upgrade', 'Upgrade now →')
+ : t('onboarding.serverLicense.seePlans', 'See Plans →');
+ const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now');
+
+ const handleSeePlans = () => {
+ onSeePlans?.();
+ onClose();
+ };
+
+ const secondaryStyles = {
+ root: {
+ background: 'var(--onboarding-secondary-button-bg)',
+ border: '1px solid var(--onboarding-secondary-button-border)',
+ color: 'var(--onboarding-secondary-button-text)',
+ },
+ };
+
+ const primaryStyles = {
+ root: {
+ background: 'var(--onboarding-primary-button-bg)',
+ color: 'var(--onboarding-primary-button-text)',
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {slide.title}
+
+
+ {slide.body}
+
+
+
+ {secondaryLabel}
+
+
+ {primaryLabel}
+
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/core/components/onboarding/TourContent.tsx b/frontend/src/core/components/onboarding/TourContent.tsx
new file mode 100644
index 000000000..c0f787ec9
--- /dev/null
+++ b/frontend/src/core/components/onboarding/TourContent.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { useTour } from '@reactour/tour';
+import { useOnboarding } from '@app/contexts/OnboardingContext';
+
+export default function TourContent() {
+ const { isOpen } = useOnboarding();
+ const { setIsOpen, setCurrentStep } = useTour();
+ const previousIsOpenRef = React.useRef(isOpen);
+
+ React.useEffect(() => {
+ const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
+ previousIsOpenRef.current = isOpen;
+
+ if (wasClosedNowOpen) {
+ setCurrentStep(0);
+ }
+ setIsOpen(isOpen);
+ }, [isOpen, setIsOpen, setCurrentStep]);
+
+ return null;
+}
+
diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts
new file mode 100644
index 000000000..5eb993cc9
--- /dev/null
+++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts
@@ -0,0 +1,133 @@
+import type { StepType } from '@reactour/tour';
+import type { TFunction } from 'i18next';
+import { AdminTourStep } from '@app/components/onboarding/tourSteps';
+import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
+
+interface AdminStepActions {
+ saveAdminState: () => void;
+ openConfigModal: () => void;
+ navigateToSection: (section: string) => void;
+ scrollNavToSection: (section: string) => Promise | void;
+}
+
+interface CreateAdminStepsConfigArgs {
+ t: TFunction;
+ actions: AdminStepActions;
+}
+
+export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record {
+ const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
+
+ return {
+ [AdminTourStep.WELCOME]: {
+ selector: '[data-tour="config-button"]',
+ content: t('adminOnboarding.welcome', "Welcome to the Admin Tour ! Let's explore the powerful enterprise features and settings available to system administrators."),
+ position: 'right',
+ padding: 10,
+ action: () => {
+ saveAdminState();
+ },
+ },
+ [AdminTourStep.CONFIG_BUTTON]: {
+ selector: '[data-tour="config-button"]',
+ content: t('adminOnboarding.configButton', "Click the Config button to access all system settings and administrative controls."),
+ position: 'right',
+ padding: 10,
+ actionAfter: () => {
+ openConfigModal();
+ },
+ },
+ [AdminTourStep.SETTINGS_OVERVIEW]: {
+ selector: '.modal-nav',
+ content: t('adminOnboarding.settingsOverview', "This is the Settings Panel . Admin settings are organised by category for easy navigation."),
+ position: 'right',
+ padding: 0,
+ action: () => {
+ removeAllGlows();
+ },
+ },
+ [AdminTourStep.TEAMS_AND_USERS]: {
+ selector: '[data-tour="admin-people-nav"]',
+ highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
+ content: t('adminOnboarding.teamsAndUsers', "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
+ position: 'right',
+ padding: 10,
+ action: () => {
+ removeAllGlows();
+ navigateToSection('people');
+ setTimeout(() => {
+ addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
+ }, 100);
+ },
+ },
+ [AdminTourStep.SYSTEM_CUSTOMIZATION]: {
+ selector: '[data-tour="admin-adminGeneral-nav"]',
+ highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
+ content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users."),
+ position: 'right',
+ padding: 10,
+ action: () => {
+ removeAllGlows();
+ navigateToSection('adminGeneral');
+ setTimeout(() => {
+ addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
+ }, 100);
+ },
+ },
+ [AdminTourStep.DATABASE_SECTION]: {
+ selector: '[data-tour="admin-adminDatabase-nav"]',
+ highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
+ content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."),
+ position: 'right',
+ padding: 10,
+ action: () => {
+ removeAllGlows();
+ navigateToSection('adminDatabase');
+ setTimeout(() => {
+ addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
+ }, 100);
+ },
+ },
+ [AdminTourStep.CONNECTIONS_SECTION]: {
+ selector: '[data-tour="admin-adminConnections-nav"]',
+ highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
+ content: t('adminOnboarding.connectionsSection', "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
+ position: 'right',
+ padding: 10,
+ action: () => {
+ removeAllGlows();
+ navigateToSection('adminConnections');
+ setTimeout(() => {
+ addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
+ }, 100);
+ },
+ actionAfter: async () => {
+ await scrollNavToSection('adminAudit');
+ },
+ },
+ [AdminTourStep.ADMIN_TOOLS]: {
+ selector: '[data-tour="admin-adminAudit-nav"]',
+ highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
+ content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."),
+ position: 'right',
+ padding: 10,
+ action: () => {
+ removeAllGlows();
+ navigateToSection('adminAudit');
+ setTimeout(() => {
+ addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
+ }, 100);
+ },
+ },
+ [AdminTourStep.WRAP_UP]: {
+ selector: '[data-tour="help-button"]',
+ content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."),
+ position: 'right',
+ padding: 10,
+ action: () => {
+ removeAllGlows();
+ },
+ },
+ };
+}
+
diff --git a/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts
new file mode 100644
index 000000000..18dec59fc
--- /dev/null
+++ b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts
@@ -0,0 +1,324 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { usePreferences } from '@app/contexts/PreferencesContext';
+import { useAppConfig } from '@app/contexts/AppConfigContext';
+import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
+import { useOnboarding } from '@app/contexts/OnboardingContext';
+import type { LicenseNotice } from '@app/types/types';
+import { useNavigate } from 'react-router-dom';
+import {
+ ONBOARDING_SESSION_BLOCK_KEY,
+ ONBOARDING_SESSION_EVENT,
+ SERVER_LICENSE_REQUEST_EVENT,
+ type ServerLicenseRequestPayload,
+} from '@app/constants/events';
+import { useServerExperience } from '@app/hooks/useServerExperience';
+
+interface InitialModalHandlers {
+ opened: boolean;
+ onLicenseNoticeUpdate: (notice: LicenseNotice) => void;
+ onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
+ onClose: () => void;
+}
+
+interface ServerLicenseModalHandlers {
+ opened: boolean;
+ licenseNotice: LicenseNotice;
+ onClose: () => void;
+ onSeePlans: () => void;
+}
+
+export function useOnboardingFlow() {
+ const { preferences, updatePreference } = usePreferences();
+ const { config } = useAppConfig();
+ const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext();
+ const { completeTour, tourType, isOpen } = useOnboarding();
+
+ const shouldShowIntro = !preferences.hasSeenIntroOnboarding;
+ const isAdminUser = !!config?.isAdmin;
+ const { hasPaidLicense } = useServerExperience();
+
+ const [licenseNotice, setLicenseNotice] = useState({
+ totalUsers: null,
+ freeTierLimit: 5,
+ isOverLimit: false,
+ requiresLicense: false,
+ });
+ const [cookieBannerPending, setCookieBannerPending] = useState(false);
+ const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle');
+ const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null);
+ const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false);
+ const [hasShownServerLicense, setHasShownServerLicense] = useState(false);
+ const [toolPromptCompleted, setToolPromptCompleted] = useState(
+ preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode,
+ );
+ const introWasOpenRef = useRef(false);
+ const navigate = useNavigate();
+ const onboardingSessionMarkedRef = useRef(false);
+
+ const handleInitialModalClose = useCallback(() => {
+ if (!preferences.hasSeenIntroOnboarding) {
+ updatePreference('hasSeenIntroOnboarding', true);
+ }
+ }, [preferences.hasSeenIntroOnboarding, updatePreference]);
+
+ const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => {
+ setLicenseNotice(notice);
+ }, []);
+
+ const handleToolPromptComplete = useCallback(() => {
+ setToolPromptCompleted(true);
+ }, []);
+
+ const maybeShowCookieBanner = useCallback(() => {
+ if (preferences.hasSeenCookieBanner) {
+ return;
+ }
+
+ if (!isCookieConsentReady || isServerLicenseOpen || serverLicenseIntent !== 'idle' || !toolPromptCompleted) {
+ setCookieBannerPending(true);
+ return;
+ }
+
+ setCookieBannerPending(false);
+ showCookieConsent();
+ updatePreference('hasSeenCookieBanner', true);
+ }, [
+ isCookieConsentReady,
+ isServerLicenseOpen,
+ preferences.hasSeenCookieBanner,
+ serverLicenseIntent,
+ showCookieConsent,
+ toolPromptCompleted,
+ updatePreference,
+ ]);
+
+ const requestServerLicense = useCallback(
+ ({
+ deferUntilTourComplete = false,
+ selfReportedAdmin = false,
+ }: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => {
+ const qualifies = isAdminUser || selfReportedAdmin;
+ if (!qualifies) {
+ return;
+ }
+ if (hasPaidLicense || !licenseNotice.requiresLicense) {
+ return;
+ }
+ setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
+ setServerLicenseIntent((prev) => {
+ if (prev === 'pending') {
+ return prev;
+ }
+ if (prev === 'deferred') {
+ return deferUntilTourComplete ? prev : 'pending';
+ }
+ if (prev === 'idle') {
+ return deferUntilTourComplete ? 'deferred' : 'pending';
+ }
+ return prev;
+ });
+ },
+ [hasPaidLicense, isAdminUser, licenseNotice.requiresLicense],
+ );
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const handleServerLicenseRequested = (event: Event) => {
+ const { detail } = event as CustomEvent;
+
+ if (detail?.licenseNotice) {
+ setLicenseNotice((prev) => ({
+ ...prev,
+ ...detail.licenseNotice,
+ totalUsers:
+ detail.licenseNotice?.totalUsers ?? prev.totalUsers,
+ freeTierLimit:
+ detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit,
+ isOverLimit:
+ detail.licenseNotice?.isOverLimit ?? prev.isOverLimit,
+ requiresLicense:
+ detail.licenseNotice?.requiresLicense ?? prev.requiresLicense,
+ }));
+ }
+
+ requestServerLicense({
+ deferUntilTourComplete: detail?.deferUntilTourComplete ?? false,
+ selfReportedAdmin: detail?.selfReportedAdmin ?? false,
+ });
+ };
+
+ window.addEventListener(
+ SERVER_LICENSE_REQUEST_EVENT,
+ handleServerLicenseRequested as EventListener,
+ );
+
+ return () => {
+ window.removeEventListener(
+ SERVER_LICENSE_REQUEST_EVENT,
+ handleServerLicenseRequested as EventListener,
+ );
+ };
+ }, [requestServerLicense]);
+
+ useEffect(() => {
+ if (
+ cookieBannerPending &&
+ isCookieConsentReady &&
+ serverLicenseIntent === 'idle' &&
+ !isServerLicenseOpen &&
+ toolPromptCompleted
+ ) {
+ maybeShowCookieBanner();
+ }
+ }, [
+ cookieBannerPending,
+ isCookieConsentReady,
+ isServerLicenseOpen,
+ serverLicenseIntent,
+ toolPromptCompleted,
+ maybeShowCookieBanner,
+ ]);
+
+ useEffect(() => {
+ const isEligibleAdmin =
+ isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
+ if (
+ introWasOpenRef.current &&
+ !shouldShowIntro &&
+ isEligibleAdmin &&
+ toolPromptCompleted &&
+ !hasShownServerLicense &&
+ licenseNotice.requiresLicense &&
+ serverLicenseIntent === 'idle'
+ ) {
+ if (!serverLicenseSource) {
+ setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
+ }
+ setServerLicenseIntent('pending');
+ }
+ introWasOpenRef.current = shouldShowIntro;
+ }, [
+ hasShownServerLicense,
+ isAdminUser,
+ serverLicenseIntent,
+ shouldShowIntro,
+ serverLicenseSource,
+ toolPromptCompleted,
+ licenseNotice.requiresLicense,
+ ]);
+
+ useEffect(() => {
+ const isEligibleAdmin =
+ isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
+ if (
+ serverLicenseIntent !== 'idle' &&
+ !shouldShowIntro &&
+ !isOpen &&
+ !isServerLicenseOpen &&
+ isEligibleAdmin &&
+ toolPromptCompleted &&
+ licenseNotice.requiresLicense
+ ) {
+ setIsServerLicenseOpen(true);
+ setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
+ }
+ }, [
+ isAdminUser,
+ isOpen,
+ isServerLicenseOpen,
+ serverLicenseIntent,
+ shouldShowIntro,
+ serverLicenseSource,
+ toolPromptCompleted,
+ licenseNotice.requiresLicense,
+ ]);
+
+ const handleServerLicenseClose = useCallback(() => {
+ setIsServerLicenseOpen(false);
+ setHasShownServerLicense(true);
+ setServerLicenseIntent('idle');
+ setServerLicenseSource(null);
+ maybeShowCookieBanner();
+ }, [maybeShowCookieBanner]);
+
+ useEffect(() => {
+ if (onboardingSessionMarkedRef.current) {
+ return;
+ }
+ if (typeof window === 'undefined') {
+ return;
+ }
+ if (shouldShowIntro || isOpen) {
+ onboardingSessionMarkedRef.current = true;
+ window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true');
+ window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
+ }
+ }, [isOpen, shouldShowIntro]);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ if (!shouldShowIntro && !isOpen) {
+ window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
+ window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
+ }
+ }, [isOpen, shouldShowIntro]);
+
+ const handleServerLicenseSeePlans = useCallback(() => {
+ handleServerLicenseClose();
+ navigate('/settings/adminPlan');
+ }, [handleServerLicenseClose, navigate]);
+
+ const handleTourCompletion = useCallback(() => {
+ completeTour();
+ if (serverLicenseIntent === 'deferred') {
+ setServerLicenseIntent('pending');
+ } else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) {
+ setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported'));
+ setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending'));
+ }
+ maybeShowCookieBanner();
+ }, [
+ completeTour,
+ isAdminUser,
+ maybeShowCookieBanner,
+ serverLicenseIntent,
+ serverLicenseSource,
+ tourType,
+ ]);
+
+ const initialModalProps: InitialModalHandlers = useMemo(
+ () => ({
+ opened: shouldShowIntro,
+ onLicenseNoticeUpdate: handleLicenseNoticeUpdate,
+ onRequestServerLicense: requestServerLicense,
+ onClose: handleInitialModalClose,
+ }),
+ [handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro],
+ );
+
+ const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo(
+ () => ({
+ opened: isServerLicenseOpen,
+ licenseNotice,
+ onClose: handleServerLicenseClose,
+ onSeePlans: handleServerLicenseSeePlans,
+ }),
+ [handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice],
+ );
+
+ return {
+ tourType,
+ isTourOpen: isOpen,
+ maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
+ initialModalProps,
+ handleToolPromptComplete,
+ serverLicenseModalProps,
+ handleTourCompletion,
+ };
+}
+
diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts
new file mode 100644
index 000000000..f9c08eb60
--- /dev/null
+++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts
@@ -0,0 +1,207 @@
+import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
+import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
+import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
+import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
+import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
+import { SlideConfig, LicenseNotice } from '@app/types/types';
+
+export type SlideId =
+ | 'welcome'
+ | 'desktop-install'
+ | 'security-check'
+ | 'admin-overview'
+ | 'server-license';
+
+export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
+
+export type ButtonAction =
+ | 'next'
+ | 'prev'
+ | 'close'
+ | 'complete-close'
+ | 'download-selected'
+ | 'security-next'
+ | 'launch-admin'
+ | 'launch-tools'
+ | 'launch-auto'
+ | 'see-plans'
+ | 'skip-to-license';
+
+export interface FlowState {
+ selectedRole: 'admin' | 'user' | null;
+}
+
+export interface OSOption {
+ label: string;
+ url: string;
+ value: string;
+}
+
+export interface SlideFactoryParams {
+ osLabel: string;
+ osUrl: string;
+ osOptions?: OSOption[];
+ onDownloadUrlChange?: (url: string) => void;
+ selectedRole: 'admin' | 'user' | null;
+ onRoleSelect: (role: 'admin' | 'user' | null) => void;
+ licenseNotice?: LicenseNotice;
+ loginEnabled?: boolean;
+}
+
+export interface HeroDefinition {
+ type: HeroType;
+}
+
+export interface ButtonDefinition {
+ key: string;
+ type: 'button' | 'icon';
+ label?: string;
+ icon?: 'chevron-left';
+ variant?: 'primary' | 'secondary' | 'default';
+ group: 'left' | 'right';
+ action: ButtonAction;
+ disabledWhen?: (state: FlowState) => boolean;
+}
+
+export interface SlideDefinition {
+ id: SlideId;
+ createSlide: (params: SlideFactoryParams) => SlideConfig;
+ hero: HeroDefinition;
+ buttons: ButtonDefinition[];
+}
+
+export const SLIDE_DEFINITIONS: Record = {
+ 'welcome': {
+ id: 'welcome',
+ createSlide: () => WelcomeSlide(),
+ hero: { type: 'rocket' },
+ buttons: [
+ {
+ key: 'welcome-next',
+ type: 'button',
+ label: 'onboarding.buttons.next',
+ variant: 'primary',
+ group: 'right',
+ action: 'next',
+ },
+ ],
+ },
+ 'desktop-install': {
+ id: 'desktop-install',
+ createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
+ hero: { type: 'dual-icon' },
+ buttons: [
+ {
+ key: 'desktop-back',
+ type: 'icon',
+ icon: 'chevron-left',
+ group: 'left',
+ action: 'prev',
+ },
+ {
+ key: 'desktop-skip',
+ type: 'button',
+ label: 'onboarding.buttons.skipForNow',
+ variant: 'secondary',
+ group: 'left',
+ action: 'next',
+ },
+ {
+ key: 'desktop-download',
+ type: 'button',
+ label: 'onboarding.buttons.download',
+ variant: 'primary',
+ group: 'right',
+ action: 'download-selected',
+ },
+ ],
+ },
+ 'security-check': {
+ id: 'security-check',
+ createSlide: ({ selectedRole, onRoleSelect }) =>
+ SecurityCheckSlide({ selectedRole, onRoleSelect }),
+ hero: { type: 'shield' },
+ buttons: [
+ {
+ key: 'security-back',
+ type: 'button',
+ label: 'onboarding.buttons.back',
+ variant: 'secondary',
+ group: 'left',
+ action: 'prev',
+ },
+ {
+ key: 'security-next',
+ type: 'button',
+ label: 'onboarding.buttons.next',
+ variant: 'primary',
+ group: 'right',
+ action: 'security-next',
+ disabledWhen: (state) => !state.selectedRole,
+ },
+ ],
+ },
+ 'admin-overview': {
+ id: 'admin-overview',
+ createSlide: ({ licenseNotice, loginEnabled }) =>
+ PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
+ hero: { type: 'diamond' },
+ buttons: [
+ {
+ key: 'admin-back',
+ type: 'icon',
+ icon: 'chevron-left',
+ group: 'left',
+ action: 'prev',
+ },
+ {
+ key: 'admin-show',
+ type: 'button',
+ label: 'onboarding.buttons.showMeAround',
+ variant: 'primary',
+ group: 'right',
+ action: 'launch-admin',
+ },
+ {
+ key: 'admin-skip',
+ type: 'button',
+ label: 'onboarding.buttons.skipTheTour',
+ variant: 'secondary',
+ group: 'left',
+ action: 'skip-to-license',
+ },
+ ],
+ },
+ 'server-license': {
+ id: 'server-license',
+ createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
+ hero: { type: 'logo' },
+ buttons: [
+ {
+ key: 'license-close',
+ type: 'button',
+ label: 'onboarding.buttons.skipForNow',
+ variant: 'secondary',
+ group: 'left',
+ action: 'close',
+ },
+ {
+ key: 'license-see-plans',
+ type: 'button',
+ label: 'onboarding.serverLicense.seePlans',
+ variant: 'primary',
+ group: 'right',
+ action: 'see-plans',
+ },
+ ],
+ },
+};
+
+export const FLOW_SEQUENCES = {
+ loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[],
+ loginUser: ['welcome', 'desktop-install'] as SlideId[],
+ noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[],
+ noLoginAdmin: ['admin-overview'] as SlideId[],
+};
+
+
diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css
new file mode 100644
index 000000000..5e5799ec2
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css
@@ -0,0 +1,70 @@
+.hero {
+ position: relative;
+ width: 100%;
+ height: 220px;
+ overflow: hidden;
+ border-radius: 0;
+}
+
+.gradientLayer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-size: 180% 180%;
+ opacity: 0;
+ transition: opacity 0.8s ease-in-out;
+ z-index: 1;
+}
+
+.gradientLayerActive {
+ opacity: 1;
+ animation: gradientShift 18s ease-in-out infinite alternate;
+ z-index: 2;
+}
+
+.gradientLayerPrev {
+ opacity: 1;
+ z-index: 3;
+ transition: opacity 0.8s ease-in-out;
+}
+
+.gradientLayerPrevFadeOut {
+ opacity: 0;
+ z-index: 3;
+ transition: opacity 0.8s ease-in-out;
+}
+
+.circle {
+ position: absolute;
+ border-radius: 50%;
+ pointer-events: none;
+ box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
+ animation-name: circleSway;
+ animation-timing-function: ease-in-out;
+ animation-iteration-count: infinite;
+ animation-direction: alternate;
+ animation-duration: var(--circle-duration, 15s);
+ animation-delay: var(--circle-delay, 0s);
+ will-change: transform;
+ z-index: 10;
+}
+
+@keyframes gradientShift {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 100% {
+ background-position: 100% 50%;
+ }
+}
+
+@keyframes circleSway {
+ 0% {
+ transform: translate3d(0, 0, 0);
+ }
+ 100% {
+ transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
+ }
+}
diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx
new file mode 100644
index 000000000..289b07b6c
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css';
+import { AnimatedSlideBackgroundProps } from '@app/types/types';
+
+type CircleStyles = React.CSSProperties & {
+ '--circle-move-x'?: string;
+ '--circle-move-y'?: string;
+ '--circle-duration'?: string;
+ '--circle-delay'?: string;
+};
+
+interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
+ isActive: boolean;
+ slideKey: string;
+}
+
+export default function AnimatedSlideBackground({
+ gradientStops,
+ circles,
+ isActive,
+}: AnimatedSlideBackgroundComponentProps) {
+ const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
+ const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
+ const [isTransitioning, setIsTransitioning] = React.useState(false);
+ const isFirstMount = React.useRef(true);
+
+ React.useEffect(() => {
+ // Skip transition on first mount
+ if (isFirstMount.current) {
+ isFirstMount.current = false;
+ setCurrentGradient(gradientStops);
+ return;
+ }
+
+ // Only transition if gradient actually changed
+ if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
+ // Store previous gradient and start transition
+ setPrevGradient(currentGradient);
+ setIsTransitioning(true);
+
+ // Update to new gradient (will fade in)
+ setCurrentGradient(gradientStops);
+ }
+ }, [gradientStops]);
+
+ const currentGradientStyle = React.useMemo(
+ () => ({
+ backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
+ }),
+ [currentGradient],
+ );
+
+ const prevGradientStyle = prevGradient
+ ? {
+ backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`,
+ }
+ : null;
+
+ return (
+
+ {prevGradientStyle && isTransitioning && (
+
{
+ setPrevGradient(null);
+ setIsTransitioning(false);
+ }}
+ />
+ )}
+
+ {circles.map((circle, index) => {
+ const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
+
+ const moveX = position === 'bottom-left' ? amplitude : -amplitude;
+ const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6;
+
+ const circleStyle: CircleStyles = {
+ width: size,
+ height: size,
+ background: color,
+ opacity: opacity ?? 0.9,
+ filter: blur ? `blur(${blur}px)` : undefined,
+ '--circle-move-x': `${moveX}px`,
+ '--circle-move-y': `${moveY}px`,
+ '--circle-duration': `${duration}s`,
+ '--circle-delay': `${delay}s`,
+ };
+
+ const defaultOffset = -size / 2;
+ const offsetX = circle.offsetX ?? 0;
+ const offsetY = circle.offsetY ?? 0;
+
+ if (position === 'bottom-left') {
+ circleStyle.left = `${defaultOffset + offsetX}px`;
+ circleStyle.bottom = `${defaultOffset + offsetY}px`;
+ } else {
+ circleStyle.right = `${defaultOffset + offsetX}px`;
+ circleStyle.top = `${defaultOffset + offsetY}px`;
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx
new file mode 100644
index 000000000..9cebfbb9c
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { SlideConfig } from '@app/types/types';
+import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
+import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle';
+
+export type { OSOption };
+
+interface DesktopInstallSlideProps {
+ osLabel: string;
+ osUrl: string;
+ osOptions?: OSOption[];
+ onDownloadUrlChange?: (url: string) => void;
+}
+
+const DesktopInstallBody = () => {
+ const { t } = useTranslation();
+
+ return (
+
+ {t(
+ 'onboarding.desktopInstall.body',
+ 'Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.',
+ )}
+
+ );
+};
+
+export default function DesktopInstallSlide({
+ osLabel,
+ osUrl,
+ osOptions = [],
+ onDownloadUrlChange,
+}: DesktopInstallSlideProps): SlideConfig {
+
+ return {
+ key: 'desktop-install',
+ title: (
+
+ ),
+ body:
,
+ downloadUrl: osUrl,
+ background: {
+ gradientStops: ['#2563EB', '#0EA5E9'],
+ circles: UNIFIED_CIRCLE_CONFIG,
+ },
+ };
+}
+
diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx
new file mode 100644
index 000000000..ac42b518b
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Menu, ActionIcon } from '@mantine/core';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+
+export interface OSOption {
+ label: string;
+ url: string;
+ value: string;
+}
+
+interface DesktopInstallTitleProps {
+ osLabel: string;
+ osUrl: string;
+ osOptions: OSOption[];
+ onDownloadUrlChange?: (url: string) => void;
+}
+
+export const DesktopInstallTitle: React.FC
= ({
+ osLabel,
+ osUrl,
+ osOptions,
+ onDownloadUrlChange
+}) => {
+ const { t } = useTranslation();
+ const [selectedOsUrl, setSelectedOsUrl] = React.useState(osUrl);
+
+ React.useEffect(() => {
+ setSelectedOsUrl(osUrl);
+ }, [osUrl]);
+
+ const handleOsSelect = React.useCallback((option: OSOption) => {
+ setSelectedOsUrl(option.url);
+ onDownloadUrlChange?.(option.url);
+ }, [onDownloadUrlChange]);
+
+ const currentOsOption = osOptions.find(opt => opt.url === selectedOsUrl) ||
+ (osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl });
+
+ const displayLabel = currentOsOption.label || osLabel;
+ const title = displayLabel
+ ? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel })
+ : t('onboarding.desktopInstall.title', 'Download');
+
+ // If only one option or no options, don't show dropdown
+ if (osOptions.length <= 1) {
+ return {title}
;
+ }
+
+ return (
+
+
{title}
+
+
+
+
+
+
+
+ {osOptions.map((option) => {
+ const isSelected = option.url === selectedOsUrl;
+ return (
+ handleOsSelect(option)}
+ style={{
+ backgroundColor: isSelected
+ ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
+ : 'transparent',
+ color: isSelected
+ ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
+ : 'inherit',
+ }}
+ >
+ {option.label}
+
+ );
+ })}
+
+
+
+ );
+};
+
diff --git a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx
new file mode 100644
index 000000000..3b8d4bfb0
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+import { SlideConfig, LicenseNotice } from '@app/types/types';
+import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
+
+interface PlanOverviewSlideProps {
+ isAdmin: boolean;
+ licenseNotice?: LicenseNotice;
+ loginEnabled?: boolean;
+}
+
+const DEFAULT_FREE_TIER_LIMIT = 5;
+
+const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
+ const { t } = useTranslation();
+ return (
+ <>
+ {isAdmin
+ ? t('onboarding.planOverview.adminTitle', 'Admin Overview')
+ : t('onboarding.planOverview.userTitle', 'Plan Overview')}
+ >
+ );
+};
+
+const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({
+ freeTierLimit,
+ loginEnabled,
+}) => {
+ const adminBodyKey = loginEnabled
+ ? 'onboarding.planOverview.adminBodyLoginEnabled'
+ : 'onboarding.planOverview.adminBodyLoginDisabled';
+
+ const defaultValue = loginEnabled
+ ? 'As an admin, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge.'
+ : 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge.';
+
+ return (
+ }}
+ defaults={defaultValue}
+ />
+ );
+};
+
+const UserOverviewBody: React.FC = () => {
+ const { t } = useTranslation();
+ return (
+
+ {t(
+ 'onboarding.planOverview.userBody',
+ "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.",
+ )}
+
+ );
+};
+
+const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
+ isAdmin,
+ freeTierLimit,
+ loginEnabled,
+}) =>
+ isAdmin ? : ;
+
+export default function PlanOverviewSlide({
+ isAdmin,
+ licenseNotice,
+ loginEnabled = false,
+}: PlanOverviewSlideProps): SlideConfig {
+ const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
+
+ return {
+ key: isAdmin ? 'admin-overview' : 'plan-overview',
+ title: ,
+ body: ,
+ background: {
+ gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
+ circles: UNIFIED_CIRCLE_CONFIG,
+ },
+ };
+}
+
diff --git a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx
new file mode 100644
index 000000000..0efb2f591
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { Select } from '@mantine/core';
+import { SlideConfig } from '@app/types/types';
+import LocalIcon from '@app/components/shared/LocalIcon';
+import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
+import i18n from '@app/i18n';
+import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
+
+interface SecurityCheckSlideProps {
+ selectedRole: 'admin' | 'user' | null;
+ onRoleSelect: (role: 'admin' | 'user' | null) => void;
+}
+
+export default function SecurityCheckSlide({
+ selectedRole,
+ onRoleSelect,
+}: SecurityCheckSlideProps): SlideConfig {
+ return {
+ key: 'security-check',
+ title: 'Security Check',
+ body: (
+
+
+
+
+ {i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')}
+
+
+
onRoleSelect((value as 'admin' | 'user') ?? null)}
+ comboboxProps={{ withinPortal: true, zIndex: 5000 }}
+ styles={{
+ input: {
+ height: 48,
+ fontSize: 15,
+ },
+ }}
+ />
+
+
+ ),
+ background: {
+ gradientStops: ['#5B21B6', '#2563EB'],
+ circles: UNIFIED_CIRCLE_CONFIG,
+ },
+ };
+}
+
+
diff --git a/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx b/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx
new file mode 100644
index 000000000..f118d2ac0
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { Trans } from 'react-i18next';
+import { SlideConfig, LicenseNotice } from '@app/types/types';
+import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
+import i18n from '@app/i18n';
+
+interface ServerLicenseSlideProps {
+ licenseNotice?: LicenseNotice;
+}
+
+const DEFAULT_FREE_TIER_LIMIT = 5;
+
+export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
+ const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
+ const totalUsers = licenseNotice?.totalUsers ?? null;
+ const isOverLimit = licenseNotice?.isOverLimit ?? false;
+ const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
+ const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
+ const title = isOverLimit
+ ? i18n.t('onboarding.serverLicense.overLimitTitle', 'Server License Needed')
+ : i18n.t('onboarding.serverLicense.freeTitle', 'Server License');
+ const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
+
+ const overLimitBody = (
+ ,
+ }}
+ defaults="Our licensing permits up to {{freeTierLimit}} users for free per server. You have {{overLimitUserCopy}} Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - unlimited seats , PDF text editing, and full admin control for $99/server/mo."
+ />
+ );
+
+ const freeBody = (
+ ,
+ }}
+ defaults="Our Open-Core licensing permits up to {{freeTierLimit}} users for free per server. To scale uninterrupted and get early access to our new PDF text editing tool , we recommend the Stirling Server plan - full editing and unlimited seats for $99/server/mo."
+ />
+ );
+
+ const body = isOverLimit ? overLimitBody : freeBody;
+
+ return {
+ key,
+ title,
+ body,
+ background: {
+ gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
+ circles: UNIFIED_CIRCLE_CONFIG,
+ },
+ };
+}
+
+
diff --git a/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx b/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx
new file mode 100644
index 000000000..eb9160eba
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { useTranslation, Trans } from 'react-i18next';
+import { SlideConfig } from '@app/types/types';
+import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
+import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
+
+function WelcomeSlideTitle() {
+ const { t } = useTranslation();
+
+ return (
+
+ {t('onboarding.welcomeSlide.title', 'Welcome to Stirling')}
+ V2
+
+ );
+}
+
+const WelcomeSlideBody = () => (
+
+ }}
+ defaults="Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - Edit Text ."
+ />
+
+);
+
+export default function WelcomeSlide(): SlideConfig {
+ return {
+ key: 'welcome',
+ title: ,
+ body: ,
+ background: {
+ gradientStops: ['#7C3AED', '#EC4899'],
+ circles: UNIFIED_CIRCLE_CONFIG,
+ },
+ };
+}
+
diff --git a/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts b/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts
new file mode 100644
index 000000000..8ba3c0fbb
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts
@@ -0,0 +1,30 @@
+import { AnimatedCircleConfig } from '@app/types/types';
+
+/**
+ * Unified circle background configuration used across all onboarding slides.
+ * Only gradient colors change between slides, creating smooth transitions.
+ */
+export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
+ {
+ position: 'bottom-left',
+ size: 270,
+ color: 'rgba(255, 255, 255, 0.25)',
+ opacity: 0.9,
+ amplitude: 24,
+ duration: 4.5,
+ offsetX: 18,
+ offsetY: 14,
+ },
+ {
+ position: 'top-right',
+ size: 300,
+ color: 'rgba(255, 255, 255, 0.2)',
+ opacity: 0.9,
+ amplitude: 28,
+ duration: 4.5,
+ delay: 0.5,
+ offsetX: 24,
+ offsetY: 18,
+ },
+];
+
diff --git a/frontend/src/core/components/onboarding/tourGlow.ts b/frontend/src/core/components/onboarding/tourGlow.ts
new file mode 100644
index 000000000..a043f7c01
--- /dev/null
+++ b/frontend/src/core/components/onboarding/tourGlow.ts
@@ -0,0 +1,18 @@
+export const addGlowToElements = (selectors: string[]) => {
+ selectors.forEach((selector) => {
+ const element = document.querySelector(selector);
+ if (element) {
+ if (selector === '[data-tour="settings-content-area"]') {
+ element.classList.add('tour-content-glow');
+ } else {
+ element.classList.add('tour-nav-glow');
+ }
+ }
+ });
+};
+
+export const removeAllGlows = () => {
+ document.querySelectorAll('.tour-content-glow').forEach((el) => el.classList.remove('tour-content-glow'));
+ document.querySelectorAll('.tour-nav-glow').forEach((el) => el.classList.remove('tour-nav-glow'));
+};
+
diff --git a/frontend/src/core/components/onboarding/tourSteps.ts b/frontend/src/core/components/onboarding/tourSteps.ts
new file mode 100644
index 000000000..a3f7b6cfc
--- /dev/null
+++ b/frontend/src/core/components/onboarding/tourSteps.ts
@@ -0,0 +1,33 @@
+export enum TourStep {
+ ALL_TOOLS,
+ SELECT_CROP_TOOL,
+ TOOL_INTERFACE,
+ FILES_BUTTON,
+ FILE_SOURCES,
+ WORKBENCH,
+ VIEW_SWITCHER,
+ VIEWER,
+ PAGE_EDITOR,
+ ACTIVE_FILES,
+ FILE_CHECKBOX,
+ SELECT_CONTROLS,
+ CROP_SETTINGS,
+ RUN_BUTTON,
+ RESULTS,
+ FILE_REPLACEMENT,
+ PIN_BUTTON,
+ WRAP_UP,
+}
+
+export enum AdminTourStep {
+ WELCOME,
+ CONFIG_BUTTON,
+ SETTINGS_OVERVIEW,
+ TEAMS_AND_USERS,
+ SYSTEM_CUSTOMIZATION,
+ DATABASE_SECTION,
+ CONNECTIONS_SECTION,
+ ADMIN_TOOLS,
+ WRAP_UP,
+}
+
diff --git a/frontend/src/core/components/onboarding/userStepsConfig.ts b/frontend/src/core/components/onboarding/userStepsConfig.ts
new file mode 100644
index 000000000..bd914210f
--- /dev/null
+++ b/frontend/src/core/components/onboarding/userStepsConfig.ts
@@ -0,0 +1,173 @@
+import type { StepType } from '@reactour/tour';
+import type { TFunction } from 'i18next';
+import { TourStep } from '@app/components/onboarding/tourSteps';
+
+interface UserStepActions {
+ saveWorkbenchState: () => void;
+ closeFilesModal: () => void;
+ backToAllTools: () => void;
+ selectCropTool: () => void;
+ loadSampleFile: () => void;
+ switchToViewer: () => void;
+ switchToPageEditor: () => void;
+ switchToActiveFiles: () => void;
+ selectFirstFile: () => void;
+ pinFile: () => void;
+ modifyCropSettings: () => void;
+ executeTool: () => void;
+ openFilesModal: () => void;
+}
+
+interface CreateUserStepsConfigArgs {
+ t: TFunction;
+ actions: UserStepActions;
+}
+
+export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record {
+ const {
+ saveWorkbenchState,
+ closeFilesModal,
+ backToAllTools,
+ selectCropTool,
+ loadSampleFile,
+ switchToViewer,
+ switchToPageEditor,
+ switchToActiveFiles,
+ selectFirstFile,
+ pinFile,
+ modifyCropSettings,
+ executeTool,
+ openFilesModal,
+ } = actions;
+
+ return {
+ [TourStep.ALL_TOOLS]: {
+ selector: '[data-tour="tool-panel"]',
+ content: t('onboarding.allTools', 'This is the Tools panel, where you can browse and select from all available PDF tools.'),
+ position: 'center',
+ padding: 0,
+ action: () => {
+ saveWorkbenchState();
+ closeFilesModal();
+ backToAllTools();
+ },
+ },
+ [TourStep.SELECT_CROP_TOOL]: {
+ selector: '[data-tour="tool-button-crop"]',
+ content: t('onboarding.selectCropTool', "Let's select the Crop tool to demonstrate how to use one of the tools."),
+ position: 'right',
+ padding: 0,
+ actionAfter: () => selectCropTool(),
+ },
+ [TourStep.TOOL_INTERFACE]: {
+ selector: '[data-tour="tool-panel"]',
+ content: t('onboarding.toolInterface', "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
+ position: 'center',
+ padding: 0,
+ },
+ [TourStep.FILES_BUTTON]: {
+ selector: '[data-tour="files-button"]',
+ content: t('onboarding.filesButton', "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on."),
+ position: 'right',
+ padding: 10,
+ action: () => openFilesModal(),
+ },
+ [TourStep.FILE_SOURCES]: {
+ selector: '[data-tour="file-sources"]',
+ content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
+ position: 'right',
+ padding: 0,
+ actionAfter: () => {
+ loadSampleFile();
+ closeFilesModal();
+ },
+ },
+ [TourStep.WORKBENCH]: {
+ selector: '[data-tour="workbench"]',
+ content: t('onboarding.workbench', 'This is the Workbench - the main area where you view and edit your PDFs.'),
+ position: 'center',
+ padding: 0,
+ },
+ [TourStep.VIEW_SWITCHER]: {
+ selector: '[data-tour="view-switcher"]',
+ content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
+ position: 'bottom',
+ padding: 0,
+ },
+ [TourStep.VIEWER]: {
+ selector: '[data-tour="workbench"]',
+ content: t('onboarding.viewer', "The Viewer lets you read and annotate your PDFs."),
+ position: 'center',
+ padding: 0,
+ action: () => switchToViewer(),
+ },
+ [TourStep.PAGE_EDITOR]: {
+ selector: '[data-tour="workbench"]',
+ content: t('onboarding.pageEditor', "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
+ position: 'center',
+ padding: 0,
+ action: () => switchToPageEditor(),
+ },
+ [TourStep.ACTIVE_FILES]: {
+ selector: '[data-tour="workbench"]',
+ content: t('onboarding.activeFiles', "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
+ position: 'center',
+ padding: 0,
+ action: () => switchToActiveFiles(),
+ },
+ [TourStep.FILE_CHECKBOX]: {
+ selector: '[data-tour="file-card-checkbox"]',
+ content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
+ position: 'top',
+ padding: 10,
+ },
+ [TourStep.SELECT_CONTROLS]: {
+ selector: '[data-tour="right-rail-controls"]',
+ highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
+ content: t('onboarding.selectControls', "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
+ position: 'left',
+ padding: 5,
+ action: () => selectFirstFile(),
+ },
+ [TourStep.CROP_SETTINGS]: {
+ selector: '[data-tour="crop-settings"]',
+ content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."),
+ position: 'left',
+ padding: 10,
+ action: () => modifyCropSettings(),
+ },
+ [TourStep.RUN_BUTTON]: {
+ selector: '[data-tour="run-button"]',
+ content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
+ position: 'top',
+ padding: 10,
+ actionAfter: () => executeTool(),
+ },
+ [TourStep.RESULTS]: {
+ selector: '[data-tour="tool-panel"]',
+ content: t('onboarding.results', "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
+ position: 'center',
+ padding: 0,
+ },
+ [TourStep.FILE_REPLACEMENT]: {
+ selector: '[data-tour="file-card-checkbox"]',
+ content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
+ position: 'left',
+ padding: 10,
+ },
+ [TourStep.PIN_BUTTON]: {
+ selector: '[data-tour="file-card-pin"]',
+ content: t('onboarding.pinButton', "You can use the Pin button if you'd rather your files stay active after running tools on them."),
+ position: 'left',
+ padding: 10,
+ action: () => pinFile(),
+ },
+ [TourStep.WRAP_UP]: {
+ selector: '[data-tour="help-button"]',
+ content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again."),
+ position: 'right',
+ padding: 10,
+ },
+ };
+}
+
diff --git a/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx b/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx
index 7a8974a3a..f72d28452 100644
--- a/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx
+++ b/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx
@@ -1,4 +1,13 @@
-import { Modal, Stack, Button, Text, Title, Anchor } from '@mantine/core';
+import {
+ Modal,
+ Stack,
+ Button,
+ Text,
+ Title,
+ Anchor,
+ useMantineTheme,
+ useComputedColorScheme,
+} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
@@ -15,6 +24,21 @@ export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnal
const { refetch } = useAppConfig();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const theme = useMantineTheme();
+ const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
+ const isDark = computedColorScheme === 'dark';
+ const privacyHighlightStyles = {
+ color: isDark ? '#FFFFFF' : theme.colors.blue[7],
+ padding: `${theme.spacing.xs} ${theme.spacing.sm}`,
+ borderRadius: theme.radius.md,
+ fontWeight: 700,
+ textAlign: 'center' as const,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: theme.spacing.xs,
+ letterSpacing: 0.3,
+ };
const handleChoice = async (enableAnalytics: boolean) => {
setLoading(true);
@@ -60,7 +84,10 @@ export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnal
{t('analytics.title', 'Do you want make Stirling PDF better?')}
- {t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.')}
+ {t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')}
+
+
+ • {t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')} •
diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx
index e7ddf0f0e..fe351c0ae 100644
--- a/frontend/src/core/components/shared/AppConfigModal.tsx
+++ b/frontend/src/core/components/shared/AppConfigModal.tsx
@@ -1,5 +1,5 @@
import React, { useMemo, useState, useEffect } from 'react';
-import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
+import { Modal, Text, ActionIcon, Tooltip, Group } from '@mantine/core';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
@@ -8,6 +8,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
+import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
interface AppConfigModalProps {
opened: boolean;
@@ -20,6 +21,7 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => {
const navigate = useNavigate();
const location = useLocation();
const { config } = useAppConfig();
+ const licenseAlert = useLicenseAlert();
// Extract section from URL path (e.g., /settings/people -> people)
const getSectionFromPath = (pathname: string): NavKey | null => {
@@ -64,8 +66,8 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => {
headerBorder: 'var(--modal-header-border)',
}), []);
- // Get isAdmin, runningEE, and loginEnabled from app config
- const isAdmin = config?.isAdmin ?? false;
+ // Get isAdmin and runningEE from app config
+ const isAdmin = true // config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
const loginEnabled = config?.enableLogin ?? false;
@@ -138,6 +140,10 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => {
const isDisabled = item.disabled ?? false;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
+ const showPlanWarning =
+ item.key === 'adminPlan' &&
+ licenseAlert.active &&
+ licenseAlert.audience === 'admin';
const navItemContent = (
= ({ opened, onClose }) => {
>
{!isMobile && (
-
- {item.label}
-
+
+
+ {item.label}
+
+ {showPlanWarning && (
+
+ )}
+
)}
);
diff --git a/frontend/src/core/components/shared/Footer.tsx b/frontend/src/core/components/shared/Footer.tsx
index 52ee4165b..07b5213ba 100644
--- a/frontend/src/core/components/shared/Footer.tsx
+++ b/frontend/src/core/components/shared/Footer.tsx
@@ -1,6 +1,6 @@
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
-import { useCookieConsent } from '@app/hooks/useCookieConsent';
+import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
interface FooterProps {
privacyPolicy?: string;
@@ -20,7 +20,7 @@ export default function Footer({
analyticsEnabled = false
}: FooterProps) {
const { t } = useTranslation();
- const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
+ const { showCookiePreferences } = useCookieConsentContext();
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;
diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx
index f29406c2e..f64cf6dd2 100644
--- a/frontend/src/core/components/shared/InfoBanner.tsx
+++ b/frontend/src/core/components/shared/InfoBanner.tsx
@@ -1,16 +1,55 @@
-import React from 'react';
-import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
+import React, { ReactNode } from 'react';
+import { Paper, Group, Text, Button, ActionIcon, Stack } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
+type InfoBannerTone = 'info' | 'warning';
+
+const toneStyles: Record<
+ InfoBannerTone,
+ {
+ background: string;
+ border: string;
+ text: string;
+ icon: string;
+ buttonColor: string;
+ }
+> = {
+ info: {
+ background: 'var(--mantine-color-blue-0)',
+ border: 'var(--mantine-color-blue-2)',
+ text: 'var(--mantine-color-blue-9)',
+ icon: 'var(--mantine-color-blue-6)',
+ buttonColor: 'blue',
+ },
+ warning: {
+ background: 'var(--mantine-color-orange-0)',
+ border: 'var(--mantine-color-orange-3)',
+ text: 'var(--mantine-color-orange-9)',
+ icon: 'var(--mantine-color-orange-7)',
+ buttonColor: 'orange',
+ },
+};
+
interface InfoBannerProps {
icon: string;
- message: string;
- buttonText: string;
+ title?: ReactNode;
+ message: ReactNode;
+ buttonText?: string;
buttonIcon?: string;
- onButtonClick: () => void;
- onDismiss: () => void;
+ onButtonClick?: () => void;
+ onDismiss?: () => void;
+ dismissible?: boolean;
loading?: boolean;
show?: boolean;
+ tone?: InfoBannerTone;
+ background?: string;
+ borderColor?: string;
+ textColor?: string;
+ iconColor?: string;
+ buttonColor?: string;
+ buttonVariant?: 'light' | 'filled' | 'white' | 'outline' | 'subtle';
+ minHeight?: number | string;
+ closeIconColor?: string;
}
/**
@@ -18,55 +57,97 @@ interface InfoBannerProps {
*/
export const InfoBanner: React.FC = ({
icon,
+ title,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
+ dismissible = true,
loading = false,
show = true,
+ tone = 'info',
+ background,
+ borderColor,
+ textColor,
+ iconColor,
+ buttonColor,
+ buttonVariant = 'light',
+ minHeight = 56,
+ closeIconColor,
}) => {
if (!show) {
return null;
}
+ const toneStyle = toneStyles[tone] ?? toneStyles.info;
+ const handleDismiss = () => {
+ onDismiss?.();
+ };
+
return (
-
-
-
- {message}
-
- }
- style={{ flexShrink: 0 }}
- >
- {buttonText}
-
+
+
+
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {message}
+
+
+
+
+ {buttonText && onButtonClick && (
+ }
+ >
+ {buttonText}
+
+ )}
+ {dismissible && (
+
+
+
+ )}
+
-
-
-
);
};
diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx
index af42d040d..38e67ce1f 100644
--- a/frontend/src/core/components/shared/QuickAccessBar.tsx
+++ b/frontend/src/core/components/shared/QuickAccessBar.tsx
@@ -1,5 +1,5 @@
import React, { useState, useRef, forwardRef, useEffect } from "react";
-import { ActionIcon, Stack, Divider, Menu } from "@mantine/core";
+import { ActionIcon, Stack, Divider, Menu, Indicator } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
+import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import {
isNavButtonActive,
@@ -34,6 +35,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const { startTour } = useOnboarding();
+ const licenseAlert = useLicenseAlert();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState('tools');
const scrollableRef = useRef(null);
@@ -314,9 +316,27 @@ const QuickAccessBar = forwardRef((_, ref) => {
);
}
+ const buttonNode = renderNavButton(buttonConfig, index);
+ const shouldShowSettingsBadge =
+ buttonConfig.id === 'config' &&
+ licenseAlert.active &&
+ licenseAlert.audience === 'admin';
+
return (
- {renderNavButton(buttonConfig, index)}
+ {shouldShowSettingsBadge ? (
+
+ {buttonNode}
+
+ ) : (
+ buttonNode
+ )}
);
})}
diff --git a/frontend/src/core/components/tools/ToolPanelModePrompt.tsx b/frontend/src/core/components/tools/ToolPanelModePrompt.tsx
index af0eb11bd..f9e3c2e44 100644
--- a/frontend/src/core/components/tools/ToolPanelModePrompt.tsx
+++ b/frontend/src/core/components/tools/ToolPanelModePrompt.tsx
@@ -3,16 +3,31 @@ import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { usePreferences } from '@app/contexts/PreferencesContext';
+import { useOnboarding } from '@app/contexts/OnboardingContext';
import '@app/components/tools/ToolPanelModePrompt.css';
-import type { ToolPanelMode } from '@app/constants/toolPanel';
+import { type ToolPanelMode } from '@app/constants/toolPanel';
+import { useAppConfig } from '@app/contexts/AppConfigContext';
-const ToolPanelModePrompt = () => {
+interface ToolPanelModePromptProps {
+ onComplete?: () => void;
+}
+
+const ToolPanelModePrompt = ({ onComplete }: ToolPanelModePromptProps = {}) => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const { preferences, updatePreference } = usePreferences();
+ const {
+ startTour,
+ startAfterToolModeSelection,
+ setStartAfterToolModeSelection,
+ pendingTourRequest,
+ } = useOnboarding();
const [opened, setOpened] = useState(false);
+ const { config } = useAppConfig();
+ const isAdmin = !!config?.isAdmin;
- const shouldShowPrompt = !preferences.toolPanelModePromptSeen;
+ // Only show after the new 3-slide onboarding has been completed
+ const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding;
useEffect(() => {
if (shouldShowPrompt) {
@@ -20,16 +35,56 @@ const ToolPanelModePrompt = () => {
}
}, [shouldShowPrompt]);
+ const resolveRequestedTourType = (): 'admin' | 'tools' => {
+ if (pendingTourRequest?.type) {
+ return pendingTourRequest.type;
+ }
+ if (pendingTourRequest?.metadata && 'selfReportedAdmin' in pendingTourRequest.metadata) {
+ return pendingTourRequest.metadata.selfReportedAdmin ? 'admin' : 'tools';
+ }
+ return isAdmin ? 'admin' : 'tools';
+ };
+
+ const resumeDeferredTour = (context?: { selection?: ToolPanelMode; dismissed?: boolean }) => {
+ if (!startAfterToolModeSelection) {
+ return;
+ }
+ setStartAfterToolModeSelection(false);
+ const targetType = resolveRequestedTourType();
+ startTour(targetType, {
+ skipToolPromptRequirement: true,
+ source: 'tool-panel-mode-prompt',
+ metadata: {
+ ...pendingTourRequest?.metadata,
+ resumedFromToolPrompt: true,
+ ...(context?.selection ? { selection: context.selection } : {}),
+ ...(context?.dismissed ? { dismissed: true } : {}),
+ },
+ });
+ };
+
const handleSelect = (mode: ToolPanelMode) => {
setToolPanelMode(mode);
updatePreference('defaultToolPanelMode', mode);
updatePreference('toolPanelModePromptSeen', true);
+ updatePreference('hasSelectedToolPanelMode', true);
setOpened(false);
+
+ resumeDeferredTour({ selection: mode });
+ onComplete?.();
};
const handleDismiss = () => {
+ const defaultMode: ToolPanelMode = 'sidebar';
+ if (toolPanelMode !== defaultMode) {
+ setToolPanelMode(defaultMode);
+ updatePreference('defaultToolPanelMode', defaultMode);
+ }
+ updatePreference('hasSelectedToolPanelMode', true);
updatePreference('toolPanelModePromptSeen', true);
setOpened(false);
+ resumeDeferredTour({ dismissed: true });
+ onComplete?.();
};
return (
diff --git a/frontend/src/core/constants/downloads.ts b/frontend/src/core/constants/downloads.ts
new file mode 100644
index 000000000..5eee10a28
--- /dev/null
+++ b/frontend/src/core/constants/downloads.ts
@@ -0,0 +1,10 @@
+// Centralized download URLs for Stirling PDF desktop installers
+export const DOWNLOAD_URLS = {
+ WINDOWS: 'https://files.stirlingpdf.com/win-installer.exe',
+ MAC_APPLE_SILICON: 'https://files.stirlingpdf.com/mac-installer.dmg',
+ MAC_INTEL: 'https://files.stirlingpdf.com/mac-x86_64-installer.dmg',
+ LINUX_DOCS: 'https://docs.stirlingpdf.com/Installation/Unix%20Installation/',
+} as const;
+
+export const DOWNLOAD_BASE_URL = 'https://files.stirlingpdf.com/';
+
diff --git a/frontend/src/core/constants/events.ts b/frontend/src/core/constants/events.ts
new file mode 100644
index 000000000..c8adc9938
--- /dev/null
+++ b/frontend/src/core/constants/events.ts
@@ -0,0 +1,27 @@
+import type { LicenseNotice } from '@app/types/types';
+
+export const ONBOARDING_SESSION_BLOCK_KEY = 'stirling-onboarding-session-active';
+export const ONBOARDING_SESSION_EVENT = 'stirling:onboarding-session-started';
+export const SERVER_LICENSE_REQUEST_EVENT = 'stirling:server-license-requested';
+export const UPGRADE_BANNER_TEST_EVENT = 'stirling:upgrade-banner-test';
+export const UPGRADE_BANNER_ALERT_EVENT = 'stirling:upgrade-banner-alert';
+
+export interface ServerLicenseRequestPayload {
+ licenseNotice?: Partial;
+ deferUntilTourComplete?: boolean;
+ selfReportedAdmin?: boolean;
+}
+
+export type UpgradeBannerTestScenario = 'friendly' | 'urgent-admin' | 'urgent-user' | null;
+
+export interface UpgradeBannerTestPayload {
+ scenario: UpgradeBannerTestScenario;
+}
+
+export interface UpgradeBannerAlertPayload {
+ active: boolean;
+ audience?: 'admin' | 'user';
+ totalUsers?: number | null;
+ freeTierLimit?: number;
+}
+
diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx
index c543e5700..73bc99847 100644
--- a/frontend/src/core/contexts/AppConfigContext.tsx
+++ b/frontend/src/core/contexts/AppConfigContext.tsx
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
+import { getSimulatedAppConfig } from '@app/testing/serverExperienceSimulations';
/**
* Sleep utility for delays
@@ -44,6 +45,8 @@ export interface AppConfig {
activeSecurity?: boolean;
dependenciesReady?: boolean;
error?: string;
+ isNewServer?: boolean;
+ isNewUser?: boolean;
}
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
@@ -106,6 +109,15 @@ export const AppConfigProvider: React.FC = ({
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
+ const testConfig = getSimulatedAppConfig();
+ if (testConfig) {
+ setConfig(testConfig);
+ setFetchCount((prev) => prev + 1);
+ setHasResolvedConfig(true);
+ setLoading(false);
+ return;
+ }
+
if (attempt > 0) {
const delay = initialDelay * Math.pow(2, attempt - 1);
console.log(`[AppConfig] Retry attempt ${attempt}/${maxRetries} after ${delay}ms delay...`);
diff --git a/frontend/src/core/contexts/CookieConsentContext.tsx b/frontend/src/core/contexts/CookieConsentContext.tsx
new file mode 100644
index 000000000..530f99a2a
--- /dev/null
+++ b/frontend/src/core/contexts/CookieConsentContext.tsx
@@ -0,0 +1,45 @@
+import React, { createContext, useContext, useMemo } from 'react';
+import { useCookieConsent } from '@app/hooks/useCookieConsent';
+import { useAppConfig } from '@app/contexts/AppConfigContext';
+
+interface CookieConsentContextValue {
+ isReady: boolean;
+ showCookieConsent: () => void;
+ showCookiePreferences: () => void;
+ hasResponded: boolean;
+}
+
+const CookieConsentContext = createContext(undefined);
+
+export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const { config } = useAppConfig();
+ const analyticsEnabled = config ? config.enableAnalytics !== false : false;
+ const {
+ showCookieConsent,
+ showCookiePreferences,
+ isInitialized,
+ hasResponded,
+ } = useCookieConsent({ analyticsEnabled });
+
+ const value = useMemo(() => ({
+ isReady: analyticsEnabled && isInitialized,
+ showCookieConsent,
+ showCookiePreferences,
+ hasResponded,
+ }), [analyticsEnabled, hasResponded, isInitialized, showCookieConsent, showCookiePreferences]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useCookieConsentContext = (): CookieConsentContextValue => {
+ const context = useContext(CookieConsentContext);
+ if (!context) {
+ throw new Error('useCookieConsentContext must be used within a CookieConsentProvider');
+ }
+ return context;
+};
+
diff --git a/frontend/src/core/contexts/OnboardingContext.tsx b/frontend/src/core/contexts/OnboardingContext.tsx
index 817f3dd5a..3599af5c7 100644
--- a/frontend/src/core/contexts/OnboardingContext.tsx
+++ b/frontend/src/core/contexts/OnboardingContext.tsx
@@ -1,45 +1,107 @@
-import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import React, { createContext, useContext, useState, useCallback } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
-import { useShouldShowWelcomeModal } from '@app/hooks/useShouldShowWelcomeModal';
export type TourType = 'tools' | 'admin';
+export interface StartTourOptions {
+ source?: string;
+ skipToolPromptRequirement?: boolean;
+ metadata?: Record;
+}
+
+interface PendingTourRequest {
+ type: TourType;
+ source?: string;
+ metadata?: Record;
+ requestedAt: number;
+}
+
interface OnboardingContextValue {
isOpen: boolean;
currentStep: number;
tourType: TourType;
setCurrentStep: (step: number) => void;
- startTour: (type?: TourType) => void;
+ startTour: (type?: TourType, options?: StartTourOptions) => void;
closeTour: () => void;
completeTour: () => void;
resetTour: (type?: TourType) => void;
- showWelcomeModal: boolean;
- setShowWelcomeModal: (show: boolean) => void;
+ startAfterToolModeSelection: boolean;
+ setStartAfterToolModeSelection: (value: boolean) => void;
+ pendingTourRequest: PendingTourRequest | null;
+ clearPendingTourRequest: () => void;
}
const OnboardingContext = createContext(undefined);
export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- const { updatePreference } = usePreferences();
+ const { preferences, updatePreference } = usePreferences();
const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [tourType, setTourType] = useState('tools');
- const [showWelcomeModal, setShowWelcomeModal] = useState(false);
- const shouldShow = useShouldShowWelcomeModal();
+ const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
+ const [pendingTourRequest, setPendingTourRequest] = useState(null);
- // Auto-show welcome modal for first-time users
- useEffect(() => {
- if (shouldShow) {
- setShowWelcomeModal(true);
- }
- }, [shouldShow]);
-
- const startTour = useCallback((type: TourType = 'tools') => {
+ const openTour = useCallback((type: TourType = 'tools') => {
setTourType(type);
setCurrentStep(0);
setIsOpen(true);
}, []);
+ const startTour = useCallback(
+ (type: TourType = 'tools', options?: StartTourOptions) => {
+ const requestedType = type ?? 'tools';
+ const source = options?.source ?? 'unspecified';
+ const metadata = options?.metadata;
+ const skipToolPromptRequirement = options?.skipToolPromptRequirement ?? false;
+ const toolPromptSeen = preferences.toolPanelModePromptSeen;
+ const hasSelectedToolPanelMode = preferences.hasSelectedToolPanelMode;
+ const hasToolPreference = toolPromptSeen || hasSelectedToolPanelMode;
+ const shouldDefer = !skipToolPromptRequirement && !hasToolPreference;
+
+ console.log('[onboarding] startTour invoked', {
+ requestedType,
+ source,
+ toolPromptSeen,
+ hasSelectedToolPanelMode,
+ shouldDefer,
+ hasPendingTourRequest: !!pendingTourRequest,
+ metadata,
+ });
+
+ if (shouldDefer) {
+ setPendingTourRequest({
+ type: requestedType,
+ source,
+ metadata,
+ requestedAt: Date.now(),
+ });
+ setStartAfterToolModeSelection(true);
+ console.log('[onboarding] deferring tour launch until tool panel mode selection completes', {
+ requestedType,
+ source,
+ });
+ return;
+ }
+
+ if (pendingTourRequest) {
+ console.log('[onboarding] clearing previous pending tour request before starting new tour', {
+ previousRequest: pendingTourRequest,
+ newType: requestedType,
+ source,
+ });
+ }
+
+ setPendingTourRequest(null);
+ setStartAfterToolModeSelection(false);
+ console.log('[onboarding] starting tour', {
+ requestedType,
+ source,
+ });
+ openTour(requestedType);
+ },
+ [openTour, pendingTourRequest, preferences.toolPanelModePromptSeen, preferences.hasSelectedToolPanelMode],
+ );
+
const closeTour = useCallback(() => {
setIsOpen(false);
}, []);
@@ -56,6 +118,16 @@ export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ ch
setIsOpen(true);
}, [updatePreference]);
+ const clearPendingTourRequest = useCallback(() => {
+ if (pendingTourRequest) {
+ console.log('[onboarding] clearing pending tour request manually', {
+ pendingTourRequest,
+ });
+ }
+ setPendingTourRequest(null);
+ setStartAfterToolModeSelection(false);
+ }, [pendingTourRequest]);
+
return (
= ({ ch
closeTour,
completeTour,
resetTour,
- showWelcomeModal,
- setShowWelcomeModal,
+ startAfterToolModeSelection,
+ setStartAfterToolModeSelection,
+ pendingTourRequest,
+ clearPendingTourRequest,
}}
>
{children}
diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx
index f60c28785..9717b7f68 100644
--- a/frontend/src/core/contexts/ToolWorkflowContext.tsx
+++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx
@@ -146,6 +146,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const setToolPanelMode = useCallback((mode: ToolPanelMode) => {
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode });
updatePreference('defaultToolPanelMode', mode);
+ updatePreference('hasSelectedToolPanelMode', true);
}, [updatePreference]);
diff --git a/frontend/src/core/hooks/useCookieConsent.ts b/frontend/src/core/hooks/useCookieConsent.ts
index 5c437d717..58b7683c6 100644
--- a/frontend/src/core/hooks/useCookieConsent.ts
+++ b/frontend/src/core/hooks/useCookieConsent.ts
@@ -10,6 +10,7 @@ declare global {
show: (show?: boolean) => void;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
+ validConsent?: () => boolean;
};
}
}
@@ -19,26 +20,50 @@ interface CookieConsentConfig {
}
export const useCookieConsent = ({
- analyticsEnabled = false
+ analyticsEnabled = false,
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [isInitialized, setIsInitialized] = useState(false);
+ const [hasRespondedInternal, setHasRespondedInternal] = useState(false);
useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const markResponded = () => setHasRespondedInternal(true);
+ const removeConsentListeners = () => {
+ window.removeEventListener('cc:onFirstConsent', markResponded);
+ window.removeEventListener('cc:onConsent', markResponded);
+ window.removeEventListener('cc:onChange', markResponded);
+ };
+
+ window.addEventListener('cc:onFirstConsent', markResponded);
+ window.addEventListener('cc:onConsent', markResponded);
+ window.addEventListener('cc:onChange', markResponded);
+
+ if (analyticsEnabled) {
+ setHasRespondedInternal(window.CookieConsent?.validConsent?.() ?? false);
+ }
+
if (!analyticsEnabled) {
console.log('Cookie consent not enabled - analyticsEnabled is false');
- return;
+ setHasRespondedInternal(false);
+ return () => {
+ removeConsentListeners();
+ };
}
// Prevent double initialization
if (window.CookieConsent) {
setIsInitialized(true);
- // Force show the modal if it exists but isn't visible
- setTimeout(() => {
- window.CookieConsent?.show();
- }, 100);
- return;
+ if (window.CookieConsent.validConsent?.()) {
+ markResponded();
+ }
+ return () => {
+ removeConsentListeners();
+ };
}
// Load the cookie consent CSS files first
@@ -116,7 +141,7 @@ export const useCookieConsent = ({
// Initialize cookie consent with full configuration
try {
window.CookieConsent.run({
- autoShow: true,
+ autoShow: false,
hideFromBots: false,
guiOptions: {
consentModal: {
@@ -202,18 +227,21 @@ export const useCookieConsent = ({
}
}
}
- }
+ },
+ onFirstConsent: markResponded,
+ onConsent: markResponded,
+ onChange: markResponded,
});
- // Force show after initialization
- setTimeout(() => {
- window.CookieConsent?.show();
- }, 200);
-
} catch (error) {
console.error('Error initializing CookieConsent:', error);
}
- setIsInitialized(true);
+ if (window.CookieConsent?.validConsent?.()) {
+ markResponded();
+ } else {
+ setHasRespondedInternal(false);
+ }
+ setIsInitialized(true);
}, 100); // Small delay to ensure DOM is ready
};
@@ -224,6 +252,8 @@ export const useCookieConsent = ({
document.head.appendChild(script);
return () => {
+ // Cleanup event listeners
+ removeConsentListeners();
// Cleanup script and CSS when component unmounts
if (document.head.contains(script)) {
document.head.removeChild(script);
@@ -237,11 +267,17 @@ export const useCookieConsent = ({
};
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
- const showCookiePreferences = () => {
+ const showCookieConsent = useCallback(() => {
+ if (isInitialized && window.CookieConsent) {
+ window.CookieConsent?.show();
+ }
+ }, [isInitialized]);
+
+ const showCookiePreferences = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show(true);
}
- };
+ }, [isInitialized]);
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
@@ -250,8 +286,13 @@ export const useCookieConsent = ({
return window.CookieConsent.acceptedService(service, category);
}, []);
+ const effectiveHasResponded = analyticsEnabled ? hasRespondedInternal : true;
+
return {
+ showCookieConsent,
showCookiePreferences,
- isServiceAccepted
+ isServiceAccepted,
+ isInitialized,
+ hasResponded: effectiveHasResponded,
};
};
diff --git a/frontend/src/core/hooks/useLicenseAlert.ts b/frontend/src/core/hooks/useLicenseAlert.ts
new file mode 100644
index 000000000..a9035ef5e
--- /dev/null
+++ b/frontend/src/core/hooks/useLicenseAlert.ts
@@ -0,0 +1,53 @@
+import { useEffect, useState } from 'react';
+import {
+ UPGRADE_BANNER_ALERT_EVENT,
+ type UpgradeBannerAlertPayload,
+} from '@app/constants/events';
+
+export interface LicenseAlertState {
+ active: boolean;
+ audience: 'admin' | 'user' | null;
+ totalUsers: number | null;
+ freeTierLimit: number;
+}
+
+const defaultState: LicenseAlertState = {
+ active: false,
+ audience: null,
+ totalUsers: null,
+ freeTierLimit: 5,
+};
+
+export function useLicenseAlert(): LicenseAlertState {
+ const [state, setState] = useState(defaultState);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const handleAlert = (event: Event) => {
+ const detail = (event as CustomEvent).detail;
+ if (detail?.active) {
+ setState({
+ active: true,
+ audience: detail.audience ?? 'user',
+ totalUsers:
+ typeof detail.totalUsers === 'number' ? detail.totalUsers : null,
+ freeTierLimit: detail.freeTierLimit ?? 5,
+ });
+ } else {
+ setState(defaultState);
+ }
+ };
+
+ window.addEventListener(UPGRADE_BANNER_ALERT_EVENT, handleAlert as EventListener);
+
+ return () => {
+ window.removeEventListener(UPGRADE_BANNER_ALERT_EVENT, handleAlert as EventListener);
+ };
+ }, []);
+
+ return state;
+}
+
diff --git a/frontend/src/core/hooks/useOs.ts b/frontend/src/core/hooks/useOs.ts
new file mode 100644
index 000000000..03bdf68bf
--- /dev/null
+++ b/frontend/src/core/hooks/useOs.ts
@@ -0,0 +1,89 @@
+import { useEffect, useState } from 'react';
+
+export type OS =
+ | 'windows'
+ | 'mac-intel'
+ | 'mac-apple'
+ | 'linux-x64'
+ | 'linux-arm64'
+ | 'ios'
+ | 'android'
+ | 'unknown';
+
+function parseUA(ua: string): OS {
+ const uaLower = ua.toLowerCase();
+
+ // iOS (includes iPadOS masquerading as Mac in some cases)
+ const isIOS = /iphone|ipad|ipod/.test(uaLower) || (ua.includes('Macintosh') && typeof window !== 'undefined' && 'ontouchstart' in window);
+ if (isIOS) return 'ios';
+
+ if (/android/.test(uaLower)) return 'android';
+ if (/windows nt/.test(uaLower)) return 'windows';
+ if (/mac os x/.test(uaLower)) {
+ // Default to Intel; refine via hints below
+ let detected: OS = 'mac-intel';
+ // Safari on Apple Silicon sometimes exposes both tokens
+ if (ua.includes('Apple') && ua.includes('ARM')) {
+ detected = 'mac-apple';
+ }
+ return detected; // will be further refined via Client Hints if available
+ }
+ if (/linux|x11/.test(uaLower)) return 'linux-x64';
+
+ return 'unknown';
+}
+
+export function useOs(): OS {
+ const [os, setOs] = useState('unknown');
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function detect() {
+ // Start with UA fallback
+ let detected: OS = parseUA(navigator.userAgent);
+
+ // Try Client Hints for better platform + architecture
+ const uaData = (navigator as any).userAgentData;
+ if (uaData?.getHighEntropyValues) {
+ try {
+ const { platform, architecture, bitness } = await uaData.getHighEntropyValues([
+ 'platform',
+ 'architecture',
+ 'bitness',
+ 'platformVersion',
+ ]);
+
+ const plat = (platform || '').toLowerCase();
+ if (plat.includes('windows')) detected = 'windows';
+ else if (plat.includes('ios')) detected = 'ios';
+ else if (plat.includes('android')) detected = 'android';
+ else if (plat.includes('mac')) {
+ // CH “architecture” is often "arm" on Apple Silicon
+ detected = architecture?.toLowerCase().includes('arm') ? 'mac-apple' : 'mac-intel';
+ } else if (plat.includes('linux') || plat.includes('chrome os')) {
+ const archLower = (architecture || '').toLowerCase();
+ const isArm = archLower.includes('arm') || (bitness === '32' && /aarch|arm/.test(architecture || ''));
+ detected = isArm ? 'linux-arm64' : 'linux-x64';
+ }
+ } catch {
+ // ignore
+ }
+ } else {
+ // Heuristic Apple Silicon from UA when no Client Hints (Safari): uncertain, prefer not to guess
+ // Keep detected as-is (often 'mac-intel').
+ }
+
+ if (!cancelled) setOs(detected);
+ }
+
+ detect();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return os;
+}
+
+
diff --git a/frontend/src/core/hooks/useServerExperience.ts b/frontend/src/core/hooks/useServerExperience.ts
new file mode 100644
index 000000000..8dd60069a
--- /dev/null
+++ b/frontend/src/core/hooks/useServerExperience.ts
@@ -0,0 +1,157 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppConfig } from '@app/contexts/AppConfigContext';
+
+const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
+const FREE_TIER_LIMIT = 5;
+
+type UserCountSource = 'admin' | 'estimate' | 'unknown';
+
+export type ServerScenarioKey =
+ | 'unknown'
+ | 'licensed'
+ | 'no-login-user-under-limit-no-license'
+ | 'no-login-admin-under-limit-no-license'
+ | 'no-login-user-over-limit-no-license'
+ | 'no-login-admin-over-limit-no-license'
+ | 'login-user-under-limit-no-license'
+ | 'login-admin-under-limit-no-license'
+ | 'login-user-over-limit-no-license'
+ | 'login-admin-over-limit-no-license';
+
+export interface ServerExperienceValue {
+ loginEnabled: boolean;
+ configIsAdmin: boolean;
+ effectiveIsAdmin: boolean;
+ selfReportedAdmin: boolean;
+ isAuthenticated: boolean;
+ isNewServer: boolean | null;
+ isNewUser: boolean | null;
+ premiumEnabled: boolean | null;
+ license: string | undefined;
+ runningProOrHigher: boolean | undefined;
+ runningEE: boolean | undefined;
+ hasPaidLicense: boolean;
+ licenseKeyValid: boolean | null;
+ licenseLoading: boolean;
+ licenseInfoAvailable: boolean;
+ totalUsers: number | null;
+ weeklyActiveUsers: number | null;
+ userCountLoading: boolean;
+ userCountError: string | null;
+ userCountSource: UserCountSource;
+ userCountResolved: boolean;
+ overFreeTierLimit: boolean | null;
+ freeTierLimit: number;
+ refreshUserCounts: () => Promise;
+ setSelfReportedAdmin: (value: boolean) => void;
+ scenarioKey: ServerScenarioKey;
+}
+
+function readSelfReportedAdmin(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ try {
+ return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+export function useServerExperience(): ServerExperienceValue {
+ const { config } = useAppConfig();
+ const [selfReportedAdmin, setSelfReportedAdminState] = useState(readSelfReportedAdmin);
+
+ const loginEnabled = config?.enableLogin !== false;
+ const configIsAdmin = Boolean(config?.isAdmin);
+ const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
+ const hasPaidLicense = config?.license === 'PRO' || config?.license === 'ENTERPRISE';
+
+ const setSelfReportedAdmin = useCallback((value: boolean) => {
+ setSelfReportedAdminState(value);
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ if (value) {
+ window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true');
+ } else {
+ window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
+ }
+ } catch {
+ // ignore storage write failures
+ }
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === SELF_REPORTED_ADMIN_KEY) {
+ setSelfReportedAdminState(event.newValue === 'true');
+ }
+ };
+ window.addEventListener('storage', handleStorage);
+ return () => window.removeEventListener('storage', handleStorage);
+ }, []);
+
+ useEffect(() => {
+ if (config?.isNewServer && !loginEnabled && !selfReportedAdmin) {
+ setSelfReportedAdmin(true);
+ }
+ }, [config?.isNewServer, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
+
+ const scenarioKey: ServerScenarioKey = useMemo(() => {
+ if (hasPaidLicense) {
+ return 'licensed';
+ }
+ return 'unknown';
+ }, [hasPaidLicense]);
+
+ const value = useMemo(() => ({
+ loginEnabled,
+ configIsAdmin,
+ effectiveIsAdmin,
+ selfReportedAdmin,
+ isAuthenticated: false,
+ isNewServer: config?.isNewServer ?? null,
+ isNewUser: config?.isNewUser ?? null,
+ premiumEnabled: config?.premiumEnabled ?? null,
+ license: config?.license,
+ runningProOrHigher: config?.runningProOrHigher,
+ runningEE: config?.runningEE,
+ hasPaidLicense,
+ licenseKeyValid: config?.premiumEnabled ?? null,
+ licenseLoading: false,
+ licenseInfoAvailable: false,
+ totalUsers: null,
+ weeklyActiveUsers: null,
+ userCountLoading: false,
+ userCountError: null,
+ userCountSource: 'unknown',
+ userCountResolved: false,
+ overFreeTierLimit: null,
+ freeTierLimit: FREE_TIER_LIMIT,
+ refreshUserCounts: async () => {},
+ setSelfReportedAdmin,
+ scenarioKey,
+ }), [
+ config?.isNewServer,
+ config?.isNewUser,
+ config?.license,
+ config?.premiumEnabled,
+ config?.runningEE,
+ config?.runningProOrHigher,
+ configIsAdmin,
+ effectiveIsAdmin,
+ hasPaidLicense,
+ loginEnabled,
+ scenarioKey,
+ selfReportedAdmin,
+ setSelfReportedAdmin,
+ ]);
+
+ return value;
+}
+
diff --git a/frontend/src/core/hooks/useShouldShowWelcomeModal.ts b/frontend/src/core/hooks/useShouldShowWelcomeModal.ts
index 953150fb8..44685557f 100644
--- a/frontend/src/core/hooks/useShouldShowWelcomeModal.ts
+++ b/frontend/src/core/hooks/useShouldShowWelcomeModal.ts
@@ -5,7 +5,8 @@ export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
const isMobile = useIsMobile();
- return !preferences.hasCompletedOnboarding
+ return preferences.hasSeenIntroOnboarding
+ && !preferences.hasCompletedOnboarding
&& preferences.toolPanelModePromptSeen
&& !isMobile;
}
diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx
index 0de70011a..cc9a3e783 100644
--- a/frontend/src/core/pages/HomePage.tsx
+++ b/frontend/src/core/pages/HomePage.tsx
@@ -9,6 +9,7 @@ import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useLogoPath } from "@app/hooks/useLogoPath";
+import { useCookieConsentContext } from "@app/contexts/CookieConsentContext";
import { useFileContext } from "@app/contexts/file/fileHooks";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
@@ -22,7 +23,6 @@ import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
-import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
import "@app/pages/HomePage.css";
@@ -49,6 +49,7 @@ export default function HomePage() {
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const { config } = useAppConfig();
+ const { hasResponded: cookieConsentResponded } = useCookieConsentContext();
const isMobile = useIsMobile();
const sliderRef = useRef(null);
const [activeMobileView, setActiveMobileView] = useState("tools");
@@ -63,10 +64,10 @@ export default function HomePage() {
// Show admin analytics choice modal if analytics settings not configured
useEffect(() => {
- if (config && config.enableAnalytics === null) {
+ if (config && config.enableAnalytics === null && cookieConsentResponded) {
setShowAnalyticsModal(true);
}
- }, [config]);
+ }, [config, cookieConsentResponded]);
// Auto-switch to viewer when going from 0 to 1 file
useEffect(() => {
@@ -187,7 +188,6 @@ export default function HomePage() {
opened={showAnalyticsModal}
onClose={() => setShowAnalyticsModal(false)}
/>
-
{isMobile ? (
diff --git a/frontend/src/core/services/preferencesService.ts b/frontend/src/core/services/preferencesService.ts
index bc30f453c..80c35e4b1 100644
--- a/frontend/src/core/services/preferencesService.ts
+++ b/frontend/src/core/services/preferencesService.ts
@@ -7,8 +7,11 @@ export interface UserPreferences {
defaultToolPanelMode: ToolPanelMode;
theme: ThemeMode;
toolPanelModePromptSeen: boolean;
+ hasSelectedToolPanelMode: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
+ hasSeenIntroOnboarding: boolean;
+ hasSeenCookieBanner: boolean;
hideUnavailableTools: boolean;
hideUnavailableConversions: boolean;
}
@@ -19,8 +22,11 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE,
theme: getSystemTheme(),
toolPanelModePromptSeen: false,
+ hasSelectedToolPanelMode: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
+ hasSeenIntroOnboarding: false,
+ hasSeenCookieBanner: false,
hideUnavailableTools: false,
hideUnavailableConversions: false,
};
diff --git a/frontend/src/core/services/updateService.ts b/frontend/src/core/services/updateService.ts
index f134e403e..343898232 100644
--- a/frontend/src/core/services/updateService.ts
+++ b/frontend/src/core/services/updateService.ts
@@ -1,3 +1,5 @@
+import { DOWNLOAD_BASE_URL } from '@app/constants/downloads';
+
export interface UpdateSummary {
latest_version: string | null;
latest_stable_version?: string | null;
@@ -71,11 +73,9 @@ export class UpdateService {
return null;
}
- const baseUrl = 'https://files.stirlingpdf.com/';
-
// Determine file based on machine type and security
if (machineInfo.machineType === 'Server-jar') {
- return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
+ return DOWNLOAD_BASE_URL + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
}
// Client installations
@@ -84,11 +84,11 @@ export class UpdateService {
const type = machineInfo.activeSecurity ? '-server-security' : '-server';
if (os === 'unix') {
- return baseUrl + os + type + '.jar';
+ return DOWNLOAD_BASE_URL + os + type + '.jar';
} else if (os === 'win') {
- return baseUrl + os + '-installer.exe';
+ return DOWNLOAD_BASE_URL + os + '-installer.exe';
} else if (os === 'mac') {
- return baseUrl + os + '-installer.dmg';
+ return DOWNLOAD_BASE_URL + os + '-installer.dmg';
}
}
diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css
index 5e8d4087b..ddf8c22bf 100644
--- a/frontend/src/core/styles/theme.css
+++ b/frontend/src/core/styles/theme.css
@@ -328,6 +328,19 @@
--compare-page-label-fg: var(--text-secondary);
}
+/* Onboarding (light mode) */
+:root {
+ --onboarding-title: #0A0A0A;
+ --onboarding-body: #4A5565;
+ --onboarding-primary-button-bg: #101828;
+ --onboarding-primary-button-text: #FFFFFF;
+ --onboarding-secondary-button-bg: #FFFFFF;
+ --onboarding-secondary-button-text: #6A7282;
+ --onboarding-secondary-button-border: #E5E5E5;
+ --onboarding-step-active: #1E2939;
+ --onboarding-step-inactive: #D1D5DC;
+}
+
[data-mantine-color-scheme="dark"] {
/* Dark theme gray scale (inverted) */
--gray-50: 17 24 39;
@@ -548,6 +561,16 @@
--modal-content-bg: #2A2F36;
--modal-header-border: rgba(255, 255, 255, 0.08);
+ /* Onboarding (dark mode) */
+ --onboarding-title: #F9FAFB;
+ --onboarding-body: #D1D5DB;
+ --onboarding-primary-button-bg: #FFFFFF;
+ --onboarding-primary-button-text: #0B1220;
+ --onboarding-secondary-button-bg: transparent;
+ --onboarding-secondary-button-text: #E5E7EB;
+ --onboarding-secondary-button-border: #3A4047;
+ --onboarding-step-active: #E5E7EB;
+ --onboarding-step-inactive: #4B5563;
/* API Keys section colors (dark mode) */
--api-keys-card-bg: #2A2F36;
--api-keys-card-border: #3A4047;
diff --git a/frontend/src/core/testing/serverExperienceSimulations.ts b/frontend/src/core/testing/serverExperienceSimulations.ts
new file mode 100644
index 000000000..f136ce489
--- /dev/null
+++ b/frontend/src/core/testing/serverExperienceSimulations.ts
@@ -0,0 +1,192 @@
+import type { AppConfig } from '@app/contexts/AppConfigContext';
+
+interface LicenseInfo {
+ licenseType: string;
+ enabled: boolean;
+ maxUsers: number;
+ hasKey: boolean;
+}
+
+interface WauResponse {
+ trackingSince: string;
+ daysOnline: number;
+ totalUniqueBrowsers: number;
+ weeklyActiveUsers: number;
+}
+
+interface AdminUsageResponse {
+ totalUsers?: number;
+}
+
+interface SimulationScenario {
+ label: string;
+ appConfig: AppConfig;
+ wau?: WauResponse;
+ adminUsage?: AdminUsageResponse;
+ licenseInfo: LicenseInfo;
+}
+
+const DEV_TESTING_MODE = false;
+const SIMULATION_INDEX = 0;
+
+const FREE_LICENSE_INFO: LicenseInfo = {
+ licenseType: 'NORMAL',
+ enabled: false,
+ maxUsers: 5,
+ hasKey: false,
+};
+
+const BASE_NO_LOGIN_CONFIG: AppConfig = {
+ enableAnalytics: true,
+ appVersion: '2.0.0',
+ serverCertificateEnabled: false,
+ enableAlphaFunctionality: false,
+ serverPort: 8080,
+ premiumEnabled: false,
+ runningProOrHigher: false,
+ runningEE: false,
+ enableLogin: false,
+ activeSecurity: false,
+ languages: [],
+ contextPath: '/',
+ license: 'NORMAL',
+ baseUrl: 'http://localhost',
+ enableEmailInvites: true,
+};
+
+const BASE_LOGIN_CONFIG: AppConfig = {
+ ...BASE_NO_LOGIN_CONFIG,
+ enableLogin: true,
+ activeSecurity: true,
+};
+
+const SIMULATION_SCENARIOS: SimulationScenario[] = [
+ {
+ label: 'no-login-user-under-limit (no-license)',
+ appConfig: {
+ ...BASE_NO_LOGIN_CONFIG,
+ },
+ wau: {
+ trackingSince: '2025-11-18T23:20:12.520884200Z',
+ daysOnline: 0,
+ totalUniqueBrowsers: 3,
+ weeklyActiveUsers: 3,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'no-login-admin-under-limit (no-license)',
+ appConfig: {
+ ...BASE_NO_LOGIN_CONFIG,
+ },
+ wau: {
+ trackingSince: '2025-10-01T00:00:00Z',
+ daysOnline: 14,
+ totalUniqueBrowsers: 4,
+ weeklyActiveUsers: 4,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'no-login-user-over-limit (no-license)',
+ appConfig: {
+ ...BASE_NO_LOGIN_CONFIG,
+ },
+ wau: {
+ trackingSince: '2025-09-01T00:00:00Z',
+ daysOnline: 30,
+ totalUniqueBrowsers: 12,
+ weeklyActiveUsers: 9,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'no-login-admin-over-limit (no-license)',
+ appConfig: {
+ ...BASE_NO_LOGIN_CONFIG,
+ },
+ wau: {
+ trackingSince: '2025-08-15T00:00:00Z',
+ daysOnline: 45,
+ totalUniqueBrowsers: 18,
+ weeklyActiveUsers: 12,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'login-user-under-limit (no-license)',
+ appConfig: {
+ ...BASE_LOGIN_CONFIG,
+ isAdmin: false,
+ },
+ adminUsage: {
+ totalUsers: 3,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'login-admin-under-limit (no-license)',
+ appConfig: {
+ ...BASE_LOGIN_CONFIG,
+ isAdmin: true,
+ },
+ adminUsage: {
+ totalUsers: 4,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'login-user-over-limit (no-license)',
+ appConfig: {
+ ...BASE_LOGIN_CONFIG,
+ isAdmin: false,
+ },
+ adminUsage: {
+ totalUsers: 12,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'login-admin-over-limit (no-license)',
+ appConfig: {
+ ...BASE_LOGIN_CONFIG,
+ isAdmin: true,
+ },
+ adminUsage: {
+ totalUsers: 57,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+];
+
+function getActiveScenario(): SimulationScenario | null {
+ if (!DEV_TESTING_MODE) {
+ return null;
+ }
+ const scenario = SIMULATION_SCENARIOS[SIMULATION_INDEX];
+ if (!scenario) {
+ console.warn('[Simulation] SIMULATION_INDEX out of range, using live backend.');
+ return null;
+ }
+ console.warn(`[Simulation] Using scenario #${SIMULATION_INDEX} (${scenario.label}).`);
+ return scenario;
+}
+
+export function getSimulatedAppConfig(): AppConfig | null {
+ return getActiveScenario()?.appConfig ?? null;
+}
+
+export function getSimulatedWauResponse(): WauResponse | null {
+ return getActiveScenario()?.wau ?? null;
+}
+
+export function getSimulatedAdminUsage(): AdminUsageResponse | null {
+ return getActiveScenario()?.adminUsage ?? null;
+}
+
+export function getSimulatedLicenseInfo(): LicenseInfo | null {
+ return getActiveScenario()?.licenseInfo ?? null;
+}
+
+export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;
+
diff --git a/frontend/src/core/types/types.ts b/frontend/src/core/types/types.ts
new file mode 100644
index 000000000..2ddd9479a
--- /dev/null
+++ b/frontend/src/core/types/types.ts
@@ -0,0 +1,34 @@
+import { ReactNode } from 'react';
+
+export interface AnimatedCircleConfig {
+ size: number;
+ color: string;
+ opacity?: number;
+ blur?: number;
+ position: 'bottom-left' | 'top-right';
+ amplitude?: number;
+ duration?: number;
+ delay?: number;
+ offsetX?: number;
+ offsetY?: number;
+}
+
+export interface AnimatedSlideBackgroundProps {
+ gradientStops: [string, string];
+ circles: AnimatedCircleConfig[];
+}
+
+export interface SlideConfig {
+ key: string;
+ title: ReactNode;
+ body: ReactNode;
+ background: AnimatedSlideBackgroundProps;
+ downloadUrl?: string;
+}
+
+export interface LicenseNotice {
+ totalUsers: number | null;
+ freeTierLimit: number;
+ isOverLimit: boolean;
+ requiresLicense: boolean;
+}
diff --git a/frontend/src/desktop/components/DesktopBannerInitializer.tsx b/frontend/src/desktop/components/DesktopBannerInitializer.tsx
index 9a74f0511..0a1546dc0 100644
--- a/frontend/src/desktop/components/DesktopBannerInitializer.tsx
+++ b/frontend/src/desktop/components/DesktopBannerInitializer.tsx
@@ -1,12 +1,21 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
+import UpgradeBanner from '@app/components/shared/UpgradeBanner';
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
- setBanner(
);
+ setBanner(
+ <>
+
+
+ >,
+ );
+ return () => {
+ setBanner(null);
+ };
}, [setBanner]);
return null;
diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx
index 00964a866..a0e9a63dc 100644
--- a/frontend/src/proprietary/components/AppProviders.tsx
+++ b/frontend/src/proprietary/components/AppProviders.tsx
@@ -2,8 +2,9 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo
import { AuthProvider } from "@app/auth/UseSession";
import { LicenseProvider } from "@app/contexts/LicenseContext";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
+import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
+import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
-import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
@@ -13,12 +14,14 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
>
-
-
-
- {children}
-
-
+
+
+
+
+ {children}
+
+
+
diff --git a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx
index a52fe39f2..9a13d18a2 100644
--- a/frontend/src/proprietary/components/shared/InviteMembersModal.tsx
+++ b/frontend/src/proprietary/components/shared/InviteMembersModal.tsx
@@ -22,6 +22,7 @@ import { userManagementService } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
+import { useNavigate } from 'react-router-dom';
interface InviteMembersModalProps {
opened: boolean;
@@ -31,6 +32,7 @@ interface InviteMembersModalProps {
export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
+ const navigate = useNavigate();
const [teams, setTeams] = useState
([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
@@ -45,6 +47,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
+ const hasNoSlots = licenseInfo ? licenseInfo.availableSlots <= 0 : false;
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
@@ -242,6 +245,21 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
onClose();
};
+ const handleGoToPlan = () => {
+ handleClose();
+ navigate('/settings/adminPlan');
+ };
+
+ const handlePrimaryAction = () => {
+ if (inviteMode === 'email') {
+ handleEmailInvite();
+ } else if (inviteMode === 'link') {
+ handleGenerateInviteLink();
+ } else {
+ handleInviteUser();
+ }
+ };
+
return (
-
- 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
-
- {licenseInfo.availableSlots > 0
- ? t('workspace.people.license.slotsAvailable', {
- count: licenseInfo.availableSlots,
- defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
- })
- : t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
-
+
+
+ 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
+
+ {licenseInfo.availableSlots > 0
+ ? t('workspace.people.license.slotsAvailable', {
+ count: licenseInfo.availableSlots,
+ defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
+ })
+ : t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
+
+
+ {licenseInfo.availableSlots === 0 && (
+
+ {t('workspace.people.actions.upgrade', 'Upgrade')}
+
+ )}
{t('workspace.people.license.currentUsage', {
@@ -495,8 +520,8 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
{/* Action Button */}
{
const { t } = useTranslation();
- const { user } = useAuth();
+ const navigate = useNavigate();
+ const { hasResponded: cookieChoiceMade } = useCookieConsentContext();
+ const { isOpen: tourOpen } = useOnboarding();
const { openCheckout } = useCheckout();
- const { licenseInfo, loading: licenseLoading } = useLicense();
- const [isVisible, setIsVisible] = useState(false);
+ const {
+ totalUsers,
+ userCountResolved,
+ userCountLoading,
+ effectiveIsAdmin: configIsAdmin,
+ hasPaidLicense,
+ licenseLoading,
+ freeTierLimit,
+ overFreeTierLimit,
+ scenarioKey,
+ } = useServerExperience();
+ const [sessionBlocked, setSessionBlocked] = useState(true);
+ const [friendlyVisible, setFriendlyVisible] = useState(false);
+ const isDev = import.meta.env.DEV;
+ const [testScenario, setTestScenario] = useState(null);
- // Check if user should see the banner
+ // Track onboarding session flag so we don't show banner if onboarding ran this load
useEffect(() => {
- // Don't show if not logged in
- if (!user) {
- setIsVisible(false);
+ if (typeof window === 'undefined') {
return;
}
- // Don't show if Supabase is not configured (no checkout available)
- if (!isSupabaseConfigured) {
- setIsVisible(false);
+ const evaluateBlock = () => {
+ const blocked = window.sessionStorage.getItem(ONBOARDING_SESSION_BLOCK_KEY) === 'true';
+ setSessionBlocked(blocked);
+ };
+
+ evaluateBlock();
+
+ const timer = window.setTimeout(() => {
+ evaluateBlock();
+ }, 1000);
+
+ const handleOnboardingEvent = () => {
+ evaluateBlock();
+ };
+
+ window.addEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
+
+ return () => {
+ clearTimeout(timer);
+ window.removeEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!isDev || typeof window === 'undefined') {
return;
}
- // Don't show while license is loading
- if (licenseLoading) {
+ const handleTestEvent = (event: Event) => {
+ const { detail } = event as CustomEvent;
+ setTestScenario(detail?.scenario ?? null);
+
+ if (detail?.scenario === 'friendly') {
+ setFriendlyVisible(true);
+ } else if (!detail?.scenario) {
+ setFriendlyVisible(false);
+ }
+ };
+
+ window.addEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
+ return () => {
+ window.removeEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
+ };
+ }, [isDev]);
+
+ const isAdmin = configIsAdmin;
+
+ const scenario = isDev ? testScenario : null;
+ const scenarioIsFriendly = scenario === 'friendly';
+ const scenarioIsUrgentUser = scenario === 'urgent-user';
+
+ const userCountKnown = typeof totalUsers === 'number';
+ const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
+ const isOverLimit = userCountKnown ? totalUsers > freeTierLimit : overFreeTierLimit;
+ const baseTotalUsersLoaded = userCountResolved && !userCountLoading;
+
+ const scenarioProvidesInfo =
+ scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
+ const derivedIsAdmin = scenarioProvidesInfo
+ ? scenarioKey!.includes('admin')
+ : isAdmin;
+ const derivedHasPaidLicense =
+ scenarioKey === 'licensed'
+ ? true
+ : scenarioKey === 'unknown'
+ ? hasPaidLicense
+ : false;
+ const derivedIsUnderLimit = scenarioProvidesInfo
+ ? scenarioKey!.includes('under-limit')
+ : isUnderLimit === true;
+ const derivedIsOverLimit = scenarioProvidesInfo
+ ? scenarioKey!.includes('over-limit')
+ : isOverLimit === true;
+
+ const effectiveIsAdmin = scenario
+ ? scenarioIsUrgentUser
+ ? false
+ : true
+ : derivedIsAdmin;
+ const effectiveTotalUsers =
+ scenario != null ? (scenarioIsFriendly ? 3 : 8) : totalUsers;
+ const effectiveTotalUsersLoaded = scenario != null ? true : baseTotalUsersLoaded;
+ const effectiveHasPaidLicense = scenario != null ? false : derivedHasPaidLicense;
+ const effectiveIsUnderLimit =
+ scenario != null ? scenarioIsFriendly : derivedIsUnderLimit;
+ const effectiveIsOverLimit =
+ scenario != null ? !scenarioIsFriendly : derivedIsOverLimit;
+
+ const isDerivedAdmin = scenario
+ ? !scenarioIsUrgentUser
+ : scenarioKey === 'login-user-over-limit-no-license'
+ ? false
+ : effectiveIsAdmin;
+
+ const shouldShowFriendlyBase = Boolean(
+ isDerivedAdmin &&
+ !effectiveHasPaidLicense &&
+ effectiveIsUnderLimit &&
+ effectiveTotalUsersLoaded,
+ );
+ const shouldShowUrgentBase = Boolean(
+ !effectiveHasPaidLicense &&
+ effectiveTotalUsersLoaded &&
+ (effectiveIsOverLimit || scenarioKey === 'login-user-over-limit-no-license'),
+ );
+
+ const shouldEvaluateFriendly = scenario
+ ? scenarioIsFriendly
+ : Boolean(
+ shouldShowFriendlyBase &&
+ !licenseLoading &&
+ effectiveTotalUsersLoaded &&
+ cookieChoiceMade &&
+ !tourOpen &&
+ !sessionBlocked,
+ );
+ const shouldEvaluateUrgent = scenario
+ ? Boolean(scenario && !scenarioIsFriendly)
+ : Boolean(
+ shouldShowUrgentBase &&
+ !licenseLoading &&
+ cookieChoiceMade &&
+ !tourOpen &&
+ !sessionBlocked,
+ );
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
return;
}
- // Check if banner was dismissed
- const dismissed = localStorage.getItem('upgradeBannerDismissed');
- if (dismissed === 'true') {
- setIsVisible(false);
+ if (!shouldShowFriendlyBase && effectiveTotalUsersLoaded) {
+ window.localStorage.removeItem(FRIENDLY_LAST_SEEN_KEY);
+ }
+ }, [shouldShowFriendlyBase, effectiveTotalUsersLoaded]);
+
+ useEffect(() => {
+ if (scenario === 'friendly') {
return;
}
- // Check license status from global context
- const tier = mapLicenseToTier(licenseInfo);
-
- // Show banner only for free tier users
- if (tier === 'free' || tier === null) {
- setIsVisible(true);
- } else {
- // Auto-hide banner if user upgrades
- setIsVisible(false);
+ if (!shouldEvaluateFriendly) {
+ setFriendlyVisible(false);
+ return;
}
- }, [user, licenseInfo, licenseLoading]);
- // Handle dismiss
- const handleDismiss = () => {
- localStorage.setItem('upgradeBannerDismissed', 'true');
- setIsVisible(false);
- };
+ if (friendlyVisible || typeof window === 'undefined' || userCountLoading) {
+ return;
+ }
- // Handle upgrade button click
- const handleUpgrade = () => {
- openCheckout('server', {
- // Currency auto-detected from locale in CheckoutContext
- minimumSeats: 1,
- onSuccess: () => {
- // Banner will auto-hide on next render when license is detected
- setIsVisible(false);
- },
+ const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
+ const lastShown = lastShownRaw ? parseInt(lastShownRaw, 10) : 0;
+ const now = Date.now();
+ const due = !Number.isFinite(lastShown) || now - lastShown >= WEEK_IN_MS;
+ setFriendlyVisible(due);
+ }, [scenario, shouldEvaluateFriendly, friendlyVisible, userCountLoading]);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const detail = shouldEvaluateUrgent
+ ? {
+ active: true,
+ audience: effectiveIsAdmin ? 'admin' : 'user',
+ totalUsers: effectiveTotalUsers ?? null,
+ freeTierLimit,
+ }
+ : { active: false };
+
+ console.debug('[UpgradeBanner] Dispatching alert event', {
+ shouldEvaluateUrgent,
+ detail,
+ totalUsers: effectiveTotalUsers,
+ freeTierLimit,
+ effectiveIsAdmin,
+ effectiveHasPaidLicense,
+ userCountLoaded: effectiveTotalUsersLoaded,
});
+
+ window.dispatchEvent(
+ new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
+ );
+ }, [shouldEvaluateUrgent, effectiveIsAdmin, effectiveTotalUsers, scenario, freeTierLimit]);
+
+ useEffect(() => {
+ return () => {
+ if (typeof window !== 'undefined') {
+ window.dispatchEvent(
+ new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail: { active: false } }),
+ );
+ }
+ };
+ }, []);
+
+ const recordFriendlyLastShown = useCallback(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
+ }, []);
+
+ useEffect(() => {
+ if (friendlyVisible) {
+ recordFriendlyLastShown();
+ }
+ }, [friendlyVisible, recordFriendlyLastShown]);
+
+ const handleUpgrade = () => {
+ recordFriendlyLastShown();
+
+ const hideBanner = () => setFriendlyVisible(false);
+ const navigateFallback = () => {
+ navigate('/settings/adminPlan');
+ hideBanner();
+ };
+
+ try {
+ openCheckout('server', {
+ minimumSeats: 1,
+ onSuccess: () => {
+ hideBanner();
+ },
+ onError: () => {
+ navigateFallback();
+ },
+ });
+ } catch (error) {
+ console.error('[UpgradeBanner] Failed to open checkout, redirecting instead', error);
+ navigateFallback();
+ return;
+ }
+
+ // Keep legacy behavior so banner disappears once the user initiates checkout
+ hideBanner();
};
- // Don't render anything if loading or not visible
- if (licenseLoading || !isVisible) {
+ const handleFriendlyDismiss = () => {
+ recordFriendlyLastShown();
+ setFriendlyVisible(false);
+ };
+
+ const handleSeeInfo = () => {
+ if (typeof window === 'undefined' || !effectiveIsAdmin) {
+ return;
+ }
+
+ const detail: ServerLicenseRequestPayload = {
+ licenseNotice: {
+ totalUsers: effectiveTotalUsers ?? null,
+ freeTierLimit,
+ isOverLimit: effectiveIsOverLimit ?? false,
+ },
+ selfReportedAdmin: true,
+ deferUntilTourComplete: false,
+ };
+
+ window.dispatchEvent(
+ new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }),
+ );
+ };
+
+ const renderUrgentBanner = () => {
+ if (!shouldEvaluateUrgent) {
+ console.debug('[UpgradeBanner] renderUrgentBanner → hidden (shouldEvaluateUrgent=false)');
+ return null;
+ }
+ console.debug('[UpgradeBanner] renderUrgentBanner → visible', {
+ totalUsers: effectiveTotalUsers,
+ freeTierLimit,
+ effectiveIsAdmin,
+ effectiveHasPaidLicense,
+ });
+
+ const buttonText = effectiveIsAdmin ? t('upgradeBanner.seeInfo', 'See info') : undefined;
+
+ const attentionMessage = effectiveIsAdmin
+ ? t(
+ 'upgradeBanner.attentionBodyAdmin',
+ 'Review the license requirements to keep this server compliant.',
+ )
+ : t(
+ 'upgradeBanner.attentionBody',
+ 'Your admin needs to sign in to see more info. Please contact them immediately.',
+ );
+
+ return (
+
+ );
+ };
+
+ if (!friendlyVisible && !shouldEvaluateUrgent) {
return null;
}
return (
-
-
-
-
-
-
- {t('upgradeBanner.title', 'Upgrade to Server Plan')}
-
-
- {t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')}
-
-
-
-
-
- }
- >
- {t('upgradeBanner.upgradeButton', 'Upgrade Now')}
-
-
-
-
-
-
-
+ <>
+ {friendlyVisible && (
+
+ )}
+ {renderUrgentBanner()}
+ >
);
};
diff --git a/frontend/src/proprietary/components/shared/UpgradeBannerInitializer.tsx b/frontend/src/proprietary/components/shared/UpgradeBannerInitializer.tsx
new file mode 100644
index 000000000..72e57060b
--- /dev/null
+++ b/frontend/src/proprietary/components/shared/UpgradeBannerInitializer.tsx
@@ -0,0 +1,17 @@
+import { useEffect } from 'react';
+import { useBanner } from '@app/contexts/BannerContext';
+import UpgradeBanner from '@app/components/shared/UpgradeBanner';
+
+export function UpgradeBannerInitializer() {
+ const { setBanner } = useBanner();
+
+ useEffect(() => {
+ setBanner( );
+ return () => {
+ setBanner(null);
+ };
+ }, [setBanner]);
+
+ return null;
+}
+
diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx
index 9be46d1d8..e03db434f 100644
--- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx
+++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
@@ -9,6 +9,9 @@ import AvailablePlansSection from '@app/components/shared/config/configSections/
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
+import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
+import { InfoBanner } from '@app/components/shared/InfoBanner';
+import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
@@ -25,10 +28,11 @@ const AdminPlanSection: React.FC = () => {
const [licenseKeyInput, setLicenseKeyInput] = useState('');
const [savingLicense, setSavingLicense] = useState(false);
const { plans, loading, error, refetch } = usePlans(currency);
+ const licenseAlert = useLicenseAlert();
// Check if we should use static version
useEffect(() => {
- // Check if Stripe and Supabase are configured
+ // Check if Stripe is configured
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!stripeKey || !isSupabaseConfigured || error) {
setUseStaticVersion(true);
@@ -146,6 +150,23 @@ const AdminPlanSection: React.FC = () => {
[openCheckout, currency, refetch, licenseInfo, t]
);
+ const shouldShowLicenseWarning = licenseAlert.active && licenseAlert.audience === 'admin';
+ const formattedUserCount = useMemo(() => {
+ if (licenseAlert.totalUsers == null) {
+ return t('plan.licenseWarning.overLimit', 'more than {{limit}}', {
+ limit: licenseAlert.freeTierLimit,
+ });
+ }
+ return licenseAlert.totalUsers.toLocaleString();
+ }, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
+
+ const scrollToPlans = useCallback(() => {
+ const el = document.getElementById('available-plans-section');
+ if (el) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }, []);
+
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return ;
@@ -175,6 +196,40 @@ const AdminPlanSection: React.FC = () => {
return (
+ {shouldShowLicenseWarning && (
+
+ )}
+ {/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
+ {licenseInfo?.licenseKey && isSupabaseConfigured && (
+
+
+
+ {t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
+
+
+
+
+ )}
+
{
);
}
- const chartData = data?.endpoints?.map((e) => ({ label: e.endpoint, value: e.visits })) || [];
+ const endpoints = data?.endpoints ?? [];
+ const chartData = endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
- const displayedVisits = data?.endpoints?.reduce((sum, e) => sum + e.visits, 0) || 0;
+ const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
+ const totalVisits = data?.totalVisits ?? displayedVisits ?? 0;
+ const totalEndpoints = data?.totalEndpoints ?? endpoints.length ?? 0;
- const displayedPercentage = (data?.totalVisits || 0) > 0
- ? ((displayedVisits / (data?.totalVisits || 1)) * 100).toFixed(1)
+ const displayedPercentage = totalVisits > 0
+ ? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
: '0';
return (
@@ -220,7 +223,7 @@ const AdminUsageSection: React.FC = () => {
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
- {data.totalEndpoints}
+ {totalEndpoints}
@@ -228,7 +231,7 @@ const AdminUsageSection: React.FC = () => {
{t('usage.stats.totalVisits', 'Total Visits')}
- {data.totalVisits.toLocaleString()}
+ {totalVisits.toLocaleString()}
@@ -253,7 +256,7 @@ const AdminUsageSection: React.FC = () => {
{/* Chart and Table */}
-
+
);
};
diff --git a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx
index bc11025cd..611912dbc 100644
--- a/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx
+++ b/frontend/src/proprietary/components/shared/config/configSections/PeopleSection.tsx
@@ -27,6 +27,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
+import { useNavigate } from 'react-router-dom';
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
import { useLicense } from '@app/contexts/LicenseContext';
@@ -34,6 +35,7 @@ export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
+ const navigate = useNavigate();
const { licenseInfo: globalLicenseInfo } = useLicense();
const [users, setUsers] = useState([]);
const [teams, setTeams] = useState([]);
@@ -53,6 +55,24 @@ export default function PeopleSection() {
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
+ const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false;
+ const handleAddMembersClick = () => {
+ if (!loginEnabled) {
+ return;
+ }
+ if (hasNoSlots) {
+ navigate('/settings/adminPlan');
+ return;
+ }
+ setInviteModalOpened(true);
+ };
+
+ const addMemberTooltip = !loginEnabled
+ ? t('workspace.people.loginRequired', 'Enable login mode first')
+ : hasNoSlots
+ ? t('workspace.people.license.noSlotsAvailable', 'No user slots available')
+ : null;
+
// Form state for edit user modal
const [editForm, setEditForm] = useState({
@@ -326,9 +346,18 @@ export default function PeopleSection() {
{licenseInfo.availableSlots === 0 && (
-
- {t('workspace.people.license.noSlotsAvailable', 'No slots available')}
-
+
+
+ {t('workspace.people.license.noSlotsAvailable', 'No slots available')}
+
+ navigate('/settings/adminPlan')}
+ >
+ {t('workspace.people.actions.upgrade', 'Upgrade')}
+
+
)}
{licenseInfo.grandfatheredUserCount > 0 && (
@@ -369,14 +398,14 @@ export default function PeopleSection() {
style={{ maxWidth: 300 }}
/>
0)}
position="bottom"
withArrow
>
}
- onClick={() => setInviteModalOpened(true)}
+ onClick={handleAddMembersClick}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.people.addMembers')}
diff --git a/frontend/src/proprietary/contexts/LicenseContext.tsx b/frontend/src/proprietary/contexts/LicenseContext.tsx
index a76596c2e..0f92e3b68 100644
--- a/frontend/src/proprietary/contexts/LicenseContext.tsx
+++ b/frontend/src/proprietary/contexts/LicenseContext.tsx
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
+import { getSimulatedLicenseInfo } from '@app/testing/serverExperienceSimulations';
interface LicenseContextValue {
licenseInfo: LicenseInfo | null;
@@ -57,6 +58,14 @@ export const LicenseProvider: React.FC = ({ children }) =>
console.log('[LicenseContext] Fetching license info');
try {
+ const testInfo = getSimulatedLicenseInfo();
+ if (testInfo) {
+ setLicenseInfo(testInfo);
+ setLoading(false);
+ setError(null);
+ return;
+ }
+
setLoading(true);
setError(null);
const info = await licenseService.getLicenseInfo();
diff --git a/frontend/src/proprietary/contexts/ServerExperienceContext.tsx b/frontend/src/proprietary/contexts/ServerExperienceContext.tsx
new file mode 100644
index 000000000..338dc159a
--- /dev/null
+++ b/frontend/src/proprietary/contexts/ServerExperienceContext.tsx
@@ -0,0 +1,367 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+import apiClient from '@app/services/apiClient';
+import { useAppConfig } from '@app/contexts/AppConfigContext';
+import { useAuth } from '@app/auth/UseSession';
+import { useLicense } from '@app/contexts/LicenseContext';
+import {
+ getSimulatedAdminUsage,
+ getSimulatedWauResponse,
+} from '@app/testing/serverExperienceSimulations';
+
+const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
+const FREE_TIER_LIMIT = 5;
+
+type UserCountSource = 'admin' | 'estimate' | 'unknown';
+
+interface WeeklyActiveUsersResponse {
+ trackingSince: string;
+ daysOnline: number;
+ totalUniqueBrowsers: number;
+ weeklyActiveUsers: number;
+}
+
+interface UserCountState {
+ totalUsers: number | null;
+ weeklyActiveUsers: number | null;
+ loading: boolean;
+ source: UserCountSource;
+ lastUpdated: number | null;
+ error: string | null;
+}
+
+export type ServerScenarioKey =
+ | 'unknown'
+ | 'licensed'
+ | 'no-login-user-under-limit-no-license'
+ | 'no-login-admin-under-limit-no-license'
+ | 'no-login-user-over-limit-no-license'
+ | 'no-login-admin-over-limit-no-license'
+ | 'login-user-under-limit-no-license'
+ | 'login-admin-under-limit-no-license'
+ | 'login-user-over-limit-no-license'
+ | 'login-admin-over-limit-no-license';
+
+export interface ServerExperienceValue {
+ loginEnabled: boolean;
+ configIsAdmin: boolean;
+ effectiveIsAdmin: boolean;
+ selfReportedAdmin: boolean;
+ isAuthenticated: boolean;
+ isNewServer: boolean | null;
+ isNewUser: boolean | null;
+ premiumEnabled: boolean | null;
+ license: string | undefined;
+ runningProOrHigher: boolean | undefined;
+ runningEE: boolean | undefined;
+ hasPaidLicense: boolean;
+ licenseKeyValid: boolean | null;
+ licenseLoading: boolean;
+ licenseInfoAvailable: boolean;
+ totalUsers: number | null;
+ weeklyActiveUsers: number | null;
+ userCountLoading: boolean;
+ userCountError: string | null;
+ userCountSource: UserCountSource;
+ userCountResolved: boolean;
+ overFreeTierLimit: boolean | null;
+ freeTierLimit: number;
+ refreshUserCounts: () => Promise;
+ setSelfReportedAdmin: (value: boolean) => void;
+ scenarioKey: ServerScenarioKey;
+}
+
+const ServerExperienceContext = createContext(undefined);
+
+function getStoredSelfReportedAdmin(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ try {
+ return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+function getErrorMessage(error: unknown): string {
+ if (typeof error === 'string') {
+ return error;
+ }
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'response' in error &&
+ typeof (error as any).response?.data?.message === 'string'
+ ) {
+ return (error as any).response.data.message;
+ }
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return 'Unable to load server usage';
+}
+
+export function ServerExperienceProvider({ children }: { children: ReactNode }) {
+ const { config } = useAppConfig();
+ const { user } = useAuth();
+ const { licenseInfo, loading: licenseLoading } = useLicense();
+
+ const [selfReportedAdmin, setSelfReportedAdminState] = useState(getStoredSelfReportedAdmin);
+ const [userCountState, setUserCountState] = useState({
+ totalUsers: null,
+ weeklyActiveUsers: null,
+ loading: false,
+ source: 'unknown',
+ lastUpdated: null,
+ error: null,
+ });
+
+ const loginEnabled = config?.enableLogin !== false;
+ const configIsAdmin = Boolean(config?.isAdmin);
+ const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
+ const isAuthenticated = Boolean(user);
+
+ const setSelfReportedAdmin = useCallback((value: boolean) => {
+ setSelfReportedAdminState(value);
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ if (value) {
+ window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true');
+ } else {
+ window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
+ }
+ } catch {
+ // ignore storage failures
+ }
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === SELF_REPORTED_ADMIN_KEY) {
+ setSelfReportedAdminState(event.newValue === 'true');
+ }
+ };
+ window.addEventListener('storage', handleStorage);
+ return () => window.removeEventListener('storage', handleStorage);
+ }, []);
+
+ useEffect(() => {
+ if (!config) {
+ return;
+ }
+ if (config.isNewServer && !loginEnabled && !selfReportedAdmin) {
+ setSelfReportedAdmin(true);
+ }
+ }, [config, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
+
+ const fetchUserCounts = useCallback(async () => {
+ if (!config) {
+ return;
+ }
+
+ const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
+ const shouldUseEstimate = config.enableLogin === false;
+
+ if (!shouldUseAdminData && !shouldUseEstimate) {
+ setUserCountState((prev) => ({
+ ...prev,
+ totalUsers: null,
+ weeklyActiveUsers: null,
+ loading: false,
+ source: 'unknown',
+ error: null,
+ }));
+ return;
+ }
+
+ setUserCountState((prev) => ({
+ ...prev,
+ loading: true,
+ error: null,
+ }));
+
+ try {
+ if (shouldUseAdminData) {
+ const testResponse = getSimulatedAdminUsage();
+ const responseData =
+ testResponse ??
+ (
+ await apiClient.get<{ totalUsers?: number }>(
+ '/api/v1/proprietary/ui-data/admin-settings',
+ { suppressErrorToast: true } as any,
+ )
+ ).data;
+ const totalUsers =
+ typeof responseData?.totalUsers === 'number' ? responseData.totalUsers : null;
+ setUserCountState({
+ totalUsers,
+ weeklyActiveUsers: null,
+ loading: false,
+ source: 'admin',
+ lastUpdated: Date.now(),
+ error: null,
+ });
+ return;
+ }
+
+ if (shouldUseEstimate) {
+ const testResponse = getSimulatedWauResponse();
+ const responseData =
+ testResponse ??
+ (
+ await apiClient.get('/api/v1/info/wau', {
+ suppressErrorToast: true,
+ } as any)
+ ).data;
+ const weeklyActiveUsers =
+ typeof responseData?.weeklyActiveUsers === 'number'
+ ? responseData.weeklyActiveUsers
+ : null;
+ setUserCountState({
+ totalUsers: weeklyActiveUsers,
+ weeklyActiveUsers,
+ loading: false,
+ source: 'estimate',
+ lastUpdated: Date.now(),
+ error: null,
+ });
+ }
+ } catch (error) {
+ setUserCountState({
+ totalUsers: null,
+ weeklyActiveUsers: null,
+ loading: false,
+ source: 'unknown',
+ lastUpdated: null,
+ error: getErrorMessage(error),
+ });
+ }
+ }, [config]);
+
+ useEffect(() => {
+ void fetchUserCounts();
+ }, [fetchUserCounts]);
+
+ const refreshUserCounts = useCallback(async () => {
+ await fetchUserCounts();
+ }, [fetchUserCounts]);
+
+ const hasPaidLicense = useMemo(() => {
+ return config?.license === 'PRO' || config?.license === 'ENTERPRISE';
+ }, [config?.license]);
+
+ const licenseKeyValid = useMemo(() => {
+ if (licenseInfo) {
+ return licenseInfo.hasKey && licenseInfo.enabled;
+ }
+ if (config?.premiumEnabled) {
+ return true;
+ }
+ return null;
+ }, [config?.premiumEnabled, licenseInfo]);
+
+ const overFreeTierLimit = useMemo(() => {
+ if (typeof userCountState.totalUsers !== 'number') {
+ return null;
+ }
+ return userCountState.totalUsers > FREE_TIER_LIMIT;
+ }, [userCountState.totalUsers]);
+
+ const userCountResolved =
+ !userCountState.loading && userCountState.source !== 'unknown' && userCountState.totalUsers !== null;
+
+ const scenarioKey = useMemo(() => {
+ if (hasPaidLicense) {
+ return 'licensed';
+ }
+ if (!userCountResolved || typeof userCountState.totalUsers !== 'number') {
+ return 'unknown';
+ }
+ const overLimit = userCountState.totalUsers > FREE_TIER_LIMIT;
+
+ if (!loginEnabled) {
+ if (selfReportedAdmin) {
+ return overLimit
+ ? 'no-login-admin-over-limit-no-license'
+ : 'no-login-admin-under-limit-no-license';
+ }
+ return overLimit
+ ? 'no-login-user-over-limit-no-license'
+ : 'no-login-user-under-limit-no-license';
+ }
+
+ if (configIsAdmin) {
+ return overLimit
+ ? 'login-admin-over-limit-no-license'
+ : 'login-admin-under-limit-no-license';
+ }
+
+ return overLimit
+ ? 'login-user-over-limit-no-license'
+ : 'login-user-under-limit-no-license';
+ }, [
+ hasPaidLicense,
+ userCountResolved,
+ userCountState.totalUsers,
+ loginEnabled,
+ selfReportedAdmin,
+ configIsAdmin,
+ ]);
+
+ const value: ServerExperienceValue = {
+ loginEnabled,
+ configIsAdmin,
+ effectiveIsAdmin,
+ selfReportedAdmin,
+ isAuthenticated,
+ isNewServer: config?.isNewServer ?? null,
+ isNewUser: config?.isNewUser ?? null,
+ premiumEnabled: config?.premiumEnabled ?? null,
+ license: config?.license,
+ runningProOrHigher: config?.runningProOrHigher,
+ runningEE: config?.runningEE,
+ hasPaidLicense,
+ licenseKeyValid,
+ licenseLoading,
+ licenseInfoAvailable: Boolean(licenseInfo),
+ totalUsers: userCountState.totalUsers,
+ weeklyActiveUsers: userCountState.weeklyActiveUsers,
+ userCountLoading: userCountState.loading,
+ userCountError: userCountState.error,
+ userCountSource: userCountState.source,
+ userCountResolved,
+ overFreeTierLimit,
+ freeTierLimit: FREE_TIER_LIMIT,
+ refreshUserCounts,
+ setSelfReportedAdmin,
+ scenarioKey,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useServerExperienceContext() {
+ const context = useContext(ServerExperienceContext);
+ if (!context) {
+ throw new Error('useServerExperience must be used within ServerExperienceProvider');
+ }
+ return context;
+}
+
diff --git a/frontend/src/proprietary/hooks/useServerExperience.ts b/frontend/src/proprietary/hooks/useServerExperience.ts
new file mode 100644
index 000000000..6a9862275
--- /dev/null
+++ b/frontend/src/proprietary/hooks/useServerExperience.ts
@@ -0,0 +1,6 @@
+import { useServerExperienceContext } from '@app/contexts/ServerExperienceContext';
+
+export function useServerExperience() {
+ return useServerExperienceContext();
+}
+
diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts
new file mode 100644
index 000000000..41aec7120
--- /dev/null
+++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts
@@ -0,0 +1,202 @@
+import type { AppConfig } from '@app/contexts/AppConfigContext';
+import type { LicenseInfo } from '@app/services/licenseService';
+
+interface WauResponse {
+ trackingSince: string;
+ daysOnline: number;
+ totalUniqueBrowsers: number;
+ weeklyActiveUsers: number;
+}
+
+interface AdminUsageResponse {
+ totalUsers?: number;
+}
+
+interface SimulationScenario {
+ /**
+ * Human-friendly label describing the scenario.
+ * Keep in sync with the comment map below.
+ */
+ label: string;
+ appConfig: AppConfig;
+ wau?: WauResponse;
+ adminUsage?: AdminUsageResponse;
+ licenseInfo: LicenseInfo;
+}
+
+const DEV_TESTING_MODE = false;
+
+/**
+ * Scenario index cheat sheet:
+ * 0 → no-login-user-under-limit (no license)
+ * 1 → no-login-admin-under-limit (no license)
+ * 2 → no-login-user-over-limit (no license)
+ * 3 → no-login-admin-over-limit (no license)
+ * 4 → login-user-under-limit (no license)
+ * 5 → login-admin-under-limit (no license)
+ * 6 → login-user-over-limit (no license)
+ * 7 → login-admin-over-limit (no license)
+ */
+const SIMULATION_INDEX = 0;
+
+const FREE_LICENSE_INFO: LicenseInfo = {
+ licenseType: 'NORMAL',
+ enabled: false,
+ maxUsers: 5,
+ hasKey: false,
+};
+
+const BASE_NO_LOGIN_CONFIG: AppConfig = {
+ enableAnalytics: true,
+ appVersion: '2.0.0',
+ serverCertificateEnabled: false,
+ enableAlphaFunctionality: false,
+ serverPort: 8080,
+ premiumEnabled: false,
+ runningProOrHigher: false,
+ runningEE: false,
+ enableLogin: false,
+ activeSecurity: false,
+ languages: [],
+ contextPath: '/',
+ license: 'NORMAL',
+ baseUrl: 'http://localhost',
+ enableEmailInvites: true,
+};
+
+const BASE_LOGIN_CONFIG: AppConfig = {
+ ...BASE_NO_LOGIN_CONFIG,
+ enableLogin: true,
+ activeSecurity: true,
+};
+
+const SIMULATION_SCENARIOS: SimulationScenario[] = [
+ {
+ label: 'no-login-user-under-limit (no-license)',
+ appConfig: {
+ ...BASE_NO_LOGIN_CONFIG,
+ },
+ wau: {
+ trackingSince: '2025-11-18T23:20:12.520884200Z',
+ daysOnline: 0,
+ totalUniqueBrowsers: 3,
+ weeklyActiveUsers: 3,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'no-login-admin-under-limit (no-license)',
+ appConfig: {
+ ...BASE_NO_LOGIN_CONFIG,
+ },
+ wau: {
+ trackingSince: '2025-10-01T00:00:00Z',
+ daysOnline: 14,
+ totalUniqueBrowsers: 4,
+ weeklyActiveUsers: 4,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'no-login-user-over-limit (no-license)',
+ appConfig: {
+ ...BASE_NO_LOGIN_CONFIG,
+ },
+ wau: {
+ trackingSince: '2025-09-01T00:00:00Z',
+ daysOnline: 30,
+ totalUniqueBrowsers: 12,
+ weeklyActiveUsers: 9,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'no-login-admin-over-limit (no-license)',
+ appConfig: {
+ ...BASE_NO_LOGIN_CONFIG,
+ },
+ wau: {
+ trackingSince: '2025-08-15T00:00:00Z',
+ daysOnline: 45,
+ totalUniqueBrowsers: 18,
+ weeklyActiveUsers: 12,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'login-user-under-limit (no-license)',
+ appConfig: {
+ ...BASE_LOGIN_CONFIG,
+ isAdmin: false,
+ },
+ adminUsage: {
+ totalUsers: 3,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'login-admin-under-limit (no-license)',
+ appConfig: {
+ ...BASE_LOGIN_CONFIG,
+ isAdmin: true,
+ },
+ adminUsage: {
+ totalUsers: 4,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'login-user-over-limit (no-license)',
+ appConfig: {
+ ...BASE_LOGIN_CONFIG,
+ isAdmin: false,
+ },
+ adminUsage: {
+ totalUsers: 12,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+ {
+ label: 'login-admin-over-limit (no-license)',
+ appConfig: {
+ ...BASE_LOGIN_CONFIG,
+ isAdmin: true,
+ },
+ adminUsage: {
+ totalUsers: 57,
+ },
+ licenseInfo: { ...FREE_LICENSE_INFO },
+ },
+];
+
+function getActiveScenario(): SimulationScenario | null {
+ if (!DEV_TESTING_MODE) {
+ return null;
+ }
+ const scenario = SIMULATION_SCENARIOS[SIMULATION_INDEX];
+ if (!scenario) {
+ console.warn('[Simulation] SIMULATION_INDEX out of range, using live backend.');
+ return null;
+ }
+ console.warn(`[Simulation] Using scenario #${SIMULATION_INDEX} (${scenario.label}).`);
+ return scenario;
+}
+
+export function getSimulatedAppConfig(): AppConfig | null {
+ return getActiveScenario()?.appConfig ?? null;
+}
+
+export function getSimulatedWauResponse(): WauResponse | null {
+ return getActiveScenario()?.wau ?? null;
+}
+
+export function getSimulatedAdminUsage(): AdminUsageResponse | null {
+ return getActiveScenario()?.adminUsage ?? null;
+}
+
+export function getSimulatedLicenseInfo(): LicenseInfo | null {
+ return getActiveScenario()?.licenseInfo ?? null;
+}
+
+export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;
+