diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7b38ccbec..1784dc9be 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,7 +72,7 @@ jobs:
CI: true
- name: 📊 Upload coverage report
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
if: always()
with:
name: coverage-report
@@ -108,7 +108,7 @@ jobs:
VITE_EXTERNAL_SUPABASE_ANON_KEY: ${{ secrets.VITE_EXTERNAL_SUPABASE_ANON_KEY }}
- name: 📦 Upload build artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
diff --git a/bun.lock b/bun.lock
index dcfea8562..9f971f93f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -37,6 +37,7 @@
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
+ "@radix-ui/react-visually-hidden": "^1.2.4",
"@supabase/supabase-js": "^2.87.1",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-virtual": "^3.13.13",
@@ -563,7 +564,7 @@
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
- "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+ "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.4.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
@@ -1849,8 +1850,6 @@
"@apideck/better-ajv-errors/ajv": ["ajv@8.18.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
- "@asamuzakjp/css-color/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
-
"@babel/core/semver": ["semver@6.3.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/semver/-/semver-6.3.1.tgz", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -1877,12 +1876,22 @@
"@radix-ui/react-menu/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
+ "@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
"@radix-ui/react-popover/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
"@radix-ui/react-select/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
+ "@radix-ui/react-select/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
+ "@radix-ui/react-toast/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
"@radix-ui/react-tooltip/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
+ "@radix-ui/react-tooltip/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
+
+ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
+
"@rollup/plugin-babel/rollup": ["rollup@2.80.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/rollup/-/rollup-2.80.0.tgz", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="],
"@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" } }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
@@ -1915,8 +1924,6 @@
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
- "cssstyle/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
-
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"filelist/minimatch": ["minimatch@5.1.9", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/minimatch/-/minimatch-5.1.9.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
@@ -1949,8 +1956,6 @@
"strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
- "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"terser/commander": ["commander@2.20.3", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
@@ -1975,6 +1980,8 @@
"@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
+
"@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
@@ -1987,8 +1994,6 @@
"source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/webidl-conversions/-/webidl-conversions-4.0.2.tgz", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
- "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
@@ -2047,8 +2052,6 @@
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
- "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
"vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
diff --git a/index.html b/index.html
index aa81e81f0..833153a1f 100644
--- a/index.html
+++ b/index.html
@@ -4,10 +4,8 @@
-
+
-
-
@@ -22,7 +20,7 @@
-
+
@@ -31,20 +29,20 @@
-
-
-
+
+
+
-
-
-
+
+
+
-
+
@@ -73,49 +71,43 @@
}
-
-
+
+
+
+
+
diff --git a/package.json b/package.json
index e97c5e465..e4b7938d8 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
+ "test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
@@ -43,6 +44,7 @@
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
+ "@radix-ui/react-visually-hidden": "^1.2.4",
"@supabase/supabase-js": "^2.87.1",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-virtual": "^3.13.13",
diff --git a/public/sw.js b/public/sw.js
deleted file mode 100644
index d3f16326e..000000000
--- a/public/sw.js
+++ /dev/null
@@ -1,191 +0,0 @@
-// Push-only legacy service worker shim.
-// Important: this file must never cache the app shell, otherwise old UI bundles can reappear.
-const LEGACY_CACHE_PREFIX = 'whatsapp-crm-v';
-
-self.addEventListener('install', () => {
- console.log('[ServiceWorker] Install');
- self.skipWaiting();
-});
-
-self.addEventListener('activate', (event) => {
- console.log('[ServiceWorker] Activate');
- event.waitUntil(
- caches.keys().then((cacheKeys) => {
- return Promise.all(
- cacheKeys.map((key) => {
- if (key.startsWith(LEGACY_CACHE_PREFIX)) {
- console.log('[ServiceWorker] Removing old cache', key);
- return caches.delete(key);
- }
-
- return Promise.resolve(false);
- })
- );
- })
- );
- self.clients.claim();
-});
-
-// Push event - handle incoming push notifications
-self.addEventListener('push', (event) => {
- console.log('[ServiceWorker] Push received');
-
- let data = {
- title: 'Nova mensagem',
- body: 'Você recebeu uma nova mensagem',
- icon: '/favicon.ico',
- badge: '/favicon.ico',
- tag: 'default',
- data: {},
- category: 'general',
- };
-
- if (event.data) {
- try {
- const payload = event.data.json();
- data = { ...data, ...payload };
- } catch (e) {
- data.body = event.data.text();
- }
- }
-
- // Define actions based on notification category
- let actions = [
- { action: 'view', title: 'Ver' },
- { action: 'dismiss', title: 'Dispensar' },
- ];
-
- // Security-specific actions
- if (data.category === 'security') {
- actions = [
- { action: 'view', title: 'Ver Detalhes' },
- { action: 'secure', title: 'Proteger Conta' },
- ];
- }
-
- // Determine icon based on category
- let icon = data.icon;
- if (data.category === 'security') {
- icon = '/favicon.ico'; // Could use a security-specific icon
- }
-
- const options = {
- body: data.body,
- icon: icon,
- badge: data.badge,
- tag: data.tag || data.category + '-' + Date.now(),
- data: { ...data.data, category: data.category },
- vibrate: data.category === 'security' ? [300, 100, 300, 100, 300] : [200, 100, 200],
- requireInteraction: data.category === 'security' || data.requireInteraction || false,
- actions: actions,
- silent: data.silent || false,
- };
-
- event.waitUntil(
- self.registration.showNotification(data.title, options)
- );
-});
-
-// Notification click event
-self.addEventListener('notificationclick', (event) => {
- console.log('[ServiceWorker] Notification click received', event.action);
-
- event.notification.close();
-
- const notificationData = event.notification.data || {};
- let targetUrl = '/';
-
- // Determine target URL based on notification data and category
- if (notificationData.category === 'security') {
- targetUrl = '/?view=security';
- } else if (notificationData.conversationId) {
- targetUrl = `/?conversation=${notificationData.conversationId}`;
- } else if (notificationData.url) {
- targetUrl = notificationData.url;
- }
-
- // Handle different actions
- if (event.action === 'view' || !event.action) {
- event.waitUntil(
- clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
- // Check if there's already a window open
- for (const client of clientList) {
- if (client.url.includes(self.registration.scope) && 'focus' in client) {
- client.postMessage({
- type: 'NOTIFICATION_CLICK',
- data: notificationData,
- });
- return client.focus();
- }
- }
- // Open a new window if none exists
- if (clients.openWindow) {
- return clients.openWindow(targetUrl);
- }
- })
- );
- } else if (event.action === 'secure') {
- // Handle security action - go directly to security settings
- event.waitUntil(
- clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
- for (const client of clientList) {
- if (client.url.includes(self.registration.scope) && 'focus' in client) {
- client.postMessage({
- type: 'SECURITY_ACTION',
- data: notificationData,
- });
- return client.focus();
- }
- }
- if (clients.openWindow) {
- return clients.openWindow('/?view=security');
- }
- })
- );
- } else if (event.action === 'reply') {
- // Handle quick reply action
- event.waitUntil(
- clients.matchAll({ type: 'window' }).then((clientList) => {
- for (const client of clientList) {
- client.postMessage({
- type: 'QUICK_REPLY',
- data: notificationData,
- });
- }
- })
- );
- }
-});
-
-// Notification close event
-self.addEventListener('notificationclose', (event) => {
- console.log('[ServiceWorker] Notification closed', event.notification.tag);
-});
-
-// Message event - handle messages from the main thread
-self.addEventListener('message', (event) => {
- console.log('[ServiceWorker] Message received', event.data);
-
- if (event.data.type === 'SKIP_WAITING') {
- self.skipWaiting();
- }
-
- if (event.data.type === 'SHOW_NOTIFICATION') {
- const { title, options } = event.data;
- self.registration.showNotification(title, options);
- }
-});
-
-// Background sync for offline message queue
-self.addEventListener('sync', (event) => {
- console.log('[ServiceWorker] Sync event', event.tag);
-
- if (event.tag === 'send-messages') {
- event.waitUntil(sendQueuedMessages());
- }
-});
-
-async function sendQueuedMessages() {
- // Implementation for sending queued messages when back online
- console.log('[ServiceWorker] Processing queued messages');
-}
diff --git a/src/App.tsx b/src/App.tsx
index 5312070be..7d73d400c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -37,10 +37,13 @@ function DeferredProviders({ children }: { children?: React.ReactNode }) {
}
// Retry wrapper for lazy imports to handle transient network failures
-function lazyWithRetry(factory: () => Promise, retries = 3): React.LazyExoticComponent {
+function lazyWithRetry>(
+ factory: () => Promise<{ default: T }>,
+ retries = 3
+): React.LazyExoticComponent {
return lazy(() => {
let attempt = 0;
- const load = (): Promise =>
+ const load = (): Promise<{ default: T }> =>
factory().catch((err: unknown) => {
attempt++;
if (attempt < retries) {
@@ -75,13 +78,13 @@ const ChatPopup = lazyWithRetry(() => import("./pages/ChatPopup"));
function RouteLoadingFallback() {
return (
-
-
-
+
+
+
-
-
-
+
Carregando página...
@@ -110,12 +113,14 @@ const log = getLogger('App');
function AppContent() {
const [deferredReady, setDeferredReady] = useState(false);
- // Defer non-critical features to after first paint
useEffect(() => {
- const id = requestAnimationFrame(() => {
- setTimeout(() => setDeferredReady(true), 800);
- });
- return () => cancelAnimationFrame(id);
+ console.log('[BOOT] AppContent mounted');
+ if (window.performance && window.performance.mark) {
+ performance.mark('app-content-mounted');
+ const measure = performance.measure('total-load', undefined, 'app-content-mounted');
+ console.log(`[METRIC] Total Load Time: ${measure.duration.toFixed(2)}ms`);
+ }
+ setDeferredReady(true);
}, []);
// Global unhandled rejection handler
@@ -151,29 +156,93 @@ function AppContent() {
{deferredReady && }
- {deferredReady && }
+ {deferredReady && (
+
+
+
+ )}
- }>
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+ Erro ao carregar roteamento}
+ >
+
}>
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+
+
+
);
diff --git a/src/components/ThemeInitializer.tsx b/src/components/ThemeInitializer.tsx
index 66f4bf04d..50b4dd929 100644
--- a/src/components/ThemeInitializer.tsx
+++ b/src/components/ThemeInitializer.tsx
@@ -28,7 +28,7 @@ type StoredThemeConfig = {
* flash-prevention script in index.html.
*/
export function ThemeInitializer() {
- const { resolvedTheme } = useTheme();
+ const { resolvedTheme, theme } = useTheme();
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
@@ -72,7 +72,7 @@ export function ThemeInitializer() {
}
document.documentElement.style.setProperty('--radius', `${radius / 16}rem`);
- }, [resolvedTheme]);
+ }, [resolvedTheme, theme]);
return null;
}
diff --git a/src/components/admin/PublicApiDashboard.tsx b/src/components/admin/PublicApiDashboard.tsx
index 4bc04e915..97fe49af0 100644
--- a/src/components/admin/PublicApiDashboard.tsx
+++ b/src/components/admin/PublicApiDashboard.tsx
@@ -15,7 +15,7 @@ interface ApiLog {
id: string;
action: string;
created_at: string;
- details: Record
| null;
+ details: Record | null;
entity_type: string | null;
}
diff --git a/src/components/admin/TrainingMode.tsx b/src/components/admin/TrainingMode.tsx
index a84b0bf1c..30f9720fc 100644
--- a/src/components/admin/TrainingMode.tsx
+++ b/src/components/admin/TrainingMode.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -15,6 +15,13 @@ interface SimMessage {
content: string;
}
+interface TrainingSession {
+ id: string;
+ scenario_name: string;
+ status: string;
+ score: number | null;
+}
+
const SCENARIOS = [
{ name: 'Reclamação sobre entrega', type: 'support', customerScript: [
'Boa tarde, meu pedido não chegou e já passaram 5 dias.',
@@ -46,25 +53,17 @@ export function TrainingMode() {
const [customerStep, setCustomerStep] = useState(0);
const [score, setScore] = useState(null);
const [feedback, setFeedback] = useState('');
- const [sessions, setSessions] = useState([]);
+ const [sessions, setSessions] = useState([]);
const [profileId, setProfileId] = useState(null);
- useEffect(() => {
- loadProfile();
- }, []);
-
- useEffect(() => {
- if (profileId) loadSessions();
- }, [profileId]);
-
- const loadProfile = async () => {
+ const loadProfile = useCallback(async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase.from('profiles').select('id').eq('user_id', user.id).single();
if (data) setProfileId(data.id);
- };
+ }, []);
- const loadSessions = async () => {
+ const loadSessions = useCallback(async () => {
if (!profileId) return;
const { data } = await supabase
.from('training_sessions')
@@ -72,8 +71,18 @@ export function TrainingMode() {
.eq('profile_id', profileId)
.order('created_at', { ascending: false })
.limit(10);
- if (data) setSessions(data);
- };
+ if (data) setSessions(data as TrainingSession[]);
+ }, [profileId]);
+
+ useEffect(() => {
+ loadProfile();
+ }, [loadProfile]);
+
+ useEffect(() => {
+ if (profileId) {
+ loadSessions();
+ }
+ }, [profileId, loadSessions]);
const startScenario = async (s: typeof SCENARIOS[0]) => {
if (!profileId) return;
diff --git a/src/components/auth/PasswordStrengthMeter.tsx b/src/components/auth/PasswordStrengthMeter.tsx
index bee4560e0..11f188c42 100644
--- a/src/components/auth/PasswordStrengthMeter.tsx
+++ b/src/components/auth/PasswordStrengthMeter.tsx
@@ -19,7 +19,7 @@ const requirements: PasswordRequirement[] = [
{ label: 'Letra maiúscula (A-Z)', test: (p) => /[A-Z]/.test(p), weight: 1 },
{ label: 'Letra minúscula (a-z)', test: (p) => /[a-z]/.test(p), weight: 1 },
{ label: 'Número (0-9)', test: (p) => /\d/.test(p), weight: 1 },
- { label: 'Caractere especial (!@#$%)', test: (p) => /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(p), weight: 1 },
+ { label: 'Caractere especial (!@#$%)', test: (p) => /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(p), weight: 1 },
];
// Simple hash function for k-anonymity check
diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx
index cece76537..bff3d4412 100644
--- a/src/components/auth/ProtectedRoute.tsx
+++ b/src/components/auth/ProtectedRoute.tsx
@@ -51,7 +51,7 @@ export function ProtectedRoute({
-
Verificando acesso...
+
Verificando acesso...
);
diff --git a/src/components/automations/AutomationCard.tsx b/src/components/automations/AutomationCard.tsx
index 34439b7ba..90b83e03d 100644
--- a/src/components/automations/AutomationCard.tsx
+++ b/src/components/automations/AutomationCard.tsx
@@ -8,6 +8,11 @@ import { cn } from '@/lib/utils';
import { TRIGGER_TYPES, ACTION_TYPES } from './automationConstants';
import type { AutomationRow } from './useAutomations';
+interface AutomationAction {
+ type: string;
+ [key: string]: unknown;
+}
+
interface AutomationCardProps {
automation: AutomationRow;
onToggle: () => void;
@@ -19,7 +24,7 @@ interface AutomationCardProps {
export function AutomationCard({ automation, onToggle, onEdit, onDelete, onDuplicate }: AutomationCardProps) {
const triggerInfo = TRIGGER_TYPES.find(t => t.type === automation.trigger_type);
const TriggerIcon = triggerInfo?.icon || Zap;
- const actions = Array.isArray(automation.actions) ? automation.actions : [];
+ const actions = (Array.isArray(automation.actions) ? automation.actions : []) as unknown as AutomationAction[];
return (
{triggerInfo?.label}
- {actions.map((action: Record, i: number) => {
+ {actions.map((action: AutomationAction, i: number) => {
const actionInfo = ACTION_TYPES.find(a => a.type === action.type);
return {actionInfo?.label};
})}
diff --git a/src/components/automations/useAutomations.ts b/src/components/automations/useAutomations.ts
index 8d844ffc5..595819241 100644
--- a/src/components/automations/useAutomations.ts
+++ b/src/components/automations/useAutomations.ts
@@ -1,4 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Json } from '@/integrations/supabase/types';
import { supabase } from '@/integrations/supabase/client';
import type { Database } from '@/integrations/supabase/types';
import { toast } from 'sonner';
@@ -9,8 +10,8 @@ export interface AutomationRow {
description: string | null;
is_active: boolean;
trigger_type: string;
- trigger_config: Record;
- actions: Record[];
+ trigger_config: Json;
+ actions: Json[];
created_by: string | null;
last_triggered_at: string | null;
trigger_count: number;
diff --git a/src/components/contacts/ContactActivityTimeline.tsx b/src/components/contacts/ContactActivityTimeline.tsx
index 317d3a5ae..b687efd25 100644
--- a/src/components/contacts/ContactActivityTimeline.tsx
+++ b/src/components/contacts/ContactActivityTimeline.tsx
@@ -18,7 +18,7 @@ interface TimelineEvent {
title: string;
description?: string;
timestamp: string;
- metadata?: Record;
+ metadata?: Record;
}
interface ContactActivityTimelineProps {
diff --git a/src/components/contacts/ContactBulkTagDialog.tsx b/src/components/contacts/ContactBulkTagDialog.tsx
index e14ed9bc4..740b23872 100644
--- a/src/components/contacts/ContactBulkTagDialog.tsx
+++ b/src/components/contacts/ContactBulkTagDialog.tsx
@@ -37,11 +37,13 @@ export function ContactBulkTagDialog({
}, [allTags, newTag, search]);
const toggleTag = (tag: string) => {
- setSelectedTags(prev => {
- const next = new Set(prev);
- next.has(tag) ? next.delete(tag) : next.add(tag);
- return next;
- });
+ const next = new Set(selectedTags);
+ if (next.has(tag)) {
+ next.delete(tag);
+ } else {
+ next.add(tag);
+ }
+ setSelectedTags(next);
};
const addNewTag = () => {
diff --git a/src/components/contacts/ContactGroupedList.tsx b/src/components/contacts/ContactGroupedList.tsx
index 39d4a044f..7a1644371 100644
--- a/src/components/contacts/ContactGroupedList.tsx
+++ b/src/components/contacts/ContactGroupedList.tsx
@@ -38,11 +38,13 @@ export function ContactGroupedList({
const [collapsed, setCollapsed] = useState>(new Set());
const toggleGroup = (key: string) => {
- setCollapsed(prev => {
- const next = new Set(prev);
- next.has(key) ? next.delete(key) : next.add(key);
- return next;
- });
+ const next = new Set(collapsed);
+ if (next.has(key)) {
+ next.delete(key);
+ } else {
+ next.add(key);
+ }
+ setCollapsed(next);
};
return (
diff --git a/src/components/inbox/AdvancedMessageMenu.tsx b/src/components/inbox/AdvancedMessageMenu.tsx
index daf5a7a2e..ee29d94a7 100644
--- a/src/components/inbox/AdvancedMessageMenu.tsx
+++ b/src/components/inbox/AdvancedMessageMenu.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Popover,
@@ -68,7 +68,7 @@ export function AdvancedMessageMenu({ instanceName, recipientNumber, onPollSent,
const { sendStickerMessage, sendPollMessage, sendContactMessage, sendStatusMessage, isLoading } = useEvolutionApi();
- const handleSendSticker = async () => {
+ const handleSendSticker = useCallback(async () => {
if (!stickerUrl.trim()) return;
try {
await sendStickerMessage(instanceName, recipientNumber, stickerUrl);
@@ -78,9 +78,9 @@ export function AdvancedMessageMenu({ instanceName, recipientNumber, onPollSent,
} catch {
toast.error('Erro ao enviar figurinha');
}
- };
+ }, [stickerUrl, instanceName, recipientNumber, sendStickerMessage]);
- const handleSendPoll = async () => {
+ const handleSendPoll = useCallback(async () => {
const validOptions = pollOptions.filter(o => o.trim());
if (!pollName.trim() || validOptions.length < 2) {
toast.error('Preencha o título e pelo menos 2 opções');
@@ -102,9 +102,9 @@ export function AdvancedMessageMenu({ instanceName, recipientNumber, onPollSent,
} catch {
toast.error('Erro ao enviar enquete');
}
- };
+ }, [pollName, pollOptions, pollSelectableCount, instanceName, recipientNumber, sendPollMessage, onPollSent]);
- const handleSendContact = async () => {
+ const handleSendContact = useCallback(async () => {
if (!contactCard.fullName.trim() || !contactCard.phoneNumber.trim()) {
toast.error('Nome e telefone são obrigatórios');
return;
@@ -124,9 +124,9 @@ export function AdvancedMessageMenu({ instanceName, recipientNumber, onPollSent,
} catch {
toast.error('Erro ao enviar contato');
}
- };
+ }, [contactCard, instanceName, recipientNumber, sendContactMessage, onContactSent]);
- const handleSendStatus = async () => {
+ const handleSendStatus = useCallback(async () => {
if (!statusText.trim()) return;
try {
await sendStatusMessage(instanceName, { type: 'text', content: statusText });
@@ -136,14 +136,14 @@ export function AdvancedMessageMenu({ instanceName, recipientNumber, onPollSent,
} catch {
toast.error('Erro ao publicar status');
}
- };
+ }, [statusText, instanceName, sendStatusMessage]);
- const menuItems = [
+ const menuItems = useCallback(() => [
{ icon: Sticker, label: 'Figurinha', onClick: () => { setPopoverOpen(false); setStickerDialog(true); } },
{ icon: BarChart3, label: 'Enquete', onClick: () => { setPopoverOpen(false); setPollDialog(true); } },
{ icon: Contact2, label: 'Cartão de Contato', onClick: () => { setPopoverOpen(false); setContactDialog(true); } },
{ icon: Radio, label: 'Status/Story', onClick: () => { setPopoverOpen(false); setStatusDialog(true); } },
- ];
+ ], []);
return (
<>
@@ -154,8 +154,8 @@ export function AdvancedMessageMenu({ instanceName, recipientNumber, onPollSent,
-
- {menuItems.map(({ icon: Icon, label, onClick }) => (
+
+ {menuItems().map(({ icon: Icon, label, onClick }) => (