Merge branch 'dev' into dependabot/npm_and_yarn/next-15.5.10

This commit is contained in:
Francis Cao 2026-01-28 09:18:46 -08:00 committed by GitHub
commit 9e7285cf2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
160 changed files with 3216 additions and 859 deletions

View file

@ -24,13 +24,13 @@ body:
render: shell render: shell
- type: input - type: input
attributes: attributes:
label: Which Umami version are you using? (if relevant) label: Which Umami version are you using?
description: 'For example: 2.18.0, 2.15.1, 1.39.0, etc' description: 'For example: 2.18.0, 2.15.1, 1.39.0, etc'
- type: input - type: input
attributes: attributes:
label: Which browser are you using? (if relevant) label: How are you deploying your application?
description: 'For example: Chrome, Edge, Firefox, etc' description: 'For example: Vercel, Railway, Docker, etc'
- type: input - type: input
attributes: attributes:
label: How are you deploying your application? (if relevant) label: Which browser are you using?
description: 'For example: Vercel, Railway, Docker, etc' description: 'For example: Chrome, Edge, Firefox, etc'

3
.gitignore vendored
View file

@ -21,6 +21,7 @@ package-lock.json
/dist /dist
/generated /generated
/src/generated /src/generated
pm2.yml
# misc # misc
.DS_Store .DS_Store
@ -30,6 +31,8 @@ package-lock.json
*.log *.log
.vscode .vscode
.tool-versions .tool-versions
.claude
nul
# debug # debug
npm-debug.log* npm-debug.log*

View file

@ -8,6 +8,7 @@ const cloudMode = process.env.CLOUD_MODE || '';
const cloudUrl = process.env.CLOUD_URL || ''; const cloudUrl = process.env.CLOUD_URL || '';
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || ''; const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || '';
const corsMaxAge = process.env.CORS_MAX_AGE || ''; const corsMaxAge = process.env.CORS_MAX_AGE || '';
const defaultCurrency = process.env.DEFAULT_CURRENCY || '';
const defaultLocale = process.env.DEFAULT_LOCALE || ''; const defaultLocale = process.env.DEFAULT_LOCALE || '';
const forceSSL = process.env.FORCE_SSL || ''; const forceSSL = process.env.FORCE_SSL || '';
const frameAncestors = process.env.ALLOWED_FRAME_URLS || ''; const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
@ -170,6 +171,7 @@ export default {
cloudMode, cloudMode,
cloudUrl, cloudUrl,
currentVersion: pkg.version, currentVersion: pkg.version,
defaultCurrency,
defaultLocale, defaultLocale,
}, },
basePath, basePath,

View file

@ -11,7 +11,7 @@
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev -p 3001 --turbo", "dev": "next dev -p 3002 --turbo",
"build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app", "build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app",
"start": "next start", "start": "next start",
"build-docker": "npm-run-all build-db build-tracker build-geo build-app", "build-docker": "npm-run-all build-db build-tracker build-geo build-app",
@ -97,7 +97,7 @@
"is-docker": "^3.0.0", "is-docker": "^3.0.0",
"is-localhost-ip": "^2.0.0", "is-localhost-ip": "^2.0.0",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"kafkajs": "^2.1.0", "kafkajs": "^2.1.0",
"lucide-react": "^0.543.0", "lucide-react": "^0.543.0",
@ -143,8 +143,8 @@
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"babel-plugin-react-compiler": "19.1.0-rc.2", "babel-plugin-react-compiler": "19.1.0-rc.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"cypress": "^13.6.6", "cypress": "^15.8.0",
"extract-react-intl-messages": "^4.1.1", "extract-react-intl-messages": "^5.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"lint-staged": "^16.2.6", "lint-staged": "^16.2.6",
@ -164,7 +164,7 @@
"stylelint-config-css-modules": "^4.5.1", "stylelint-config-css-modules": "^4.5.1",
"stylelint-config-prettier": "^9.0.3", "stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^14.0.0", "stylelint-config-recommended": "^14.0.0",
"tar": "^6.1.2", "tar": "^7.5.4",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsup": "^8.5.0", "tsup": "^8.5.0",

278
pnpm-lock.yaml generated
View file

@ -117,8 +117,8 @@ importers:
specifier: ^5.1.31 specifier: ^5.1.31
version: 5.1.32 version: 5.1.32
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.3
version: 9.0.2 version: 9.0.3
jszip: jszip:
specifier: ^3.10.1 specifier: ^3.10.1
version: 3.10.1 version: 3.10.1
@ -250,11 +250,11 @@ importers:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0 version: 10.1.0
cypress: cypress:
specifier: ^13.6.6 specifier: ^15.8.0
version: 13.17.0 version: 15.9.0
extract-react-intl-messages: extract-react-intl-messages:
specifier: ^4.1.1 specifier: ^5.0.0
version: 4.1.1(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)) version: 5.0.0(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3))
husky: husky:
specifier: ^9.1.7 specifier: ^9.1.7
version: 9.1.7 version: 9.1.7
@ -313,8 +313,8 @@ importers:
specifier: ^14.0.0 specifier: ^14.0.0
version: 14.0.1(stylelint@15.11.0(typescript@5.9.3)) version: 14.0.1(stylelint@15.11.0(typescript@5.9.3))
tar: tar:
specifier: ^6.1.2 specifier: ^7.5.4
version: 6.2.1 version: 7.5.4
ts-jest: ts-jest:
specifier: ^29.4.6 specifier: ^29.4.6
version: 29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) version: 29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)
@ -566,10 +566,6 @@ packages:
resolution: {integrity: sha512-co2spjR7wZoZ3Ck0H/jv76bpiuO3oJHtOmq9/gxFiod2DcT9NFg01u/hXcG8MJFnEJuMB6e3vGqS6IOnLwHqRw==} resolution: {integrity: sha512-co2spjR7wZoZ3Ck0H/jv76bpiuO3oJHtOmq9/gxFiod2DcT9NFg01u/hXcG8MJFnEJuMB6e3vGqS6IOnLwHqRw==}
engines: {node: '>=16'} engines: {node: '>=16'}
'@colors/colors@1.5.0':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
'@cspotcode/source-map-support@0.8.1': '@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -687,8 +683,8 @@ packages:
peerDependencies: peerDependencies:
postcss-selector-parser: ^6.0.13 postcss-selector-parser: ^6.0.13
'@cypress/request@3.0.9': '@cypress/request@3.0.10':
resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==} resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
'@cypress/xvfb@1.2.4': '@cypress/xvfb@1.2.4':
@ -1442,6 +1438,10 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@istanbuljs/load-nyc-config@1.1.0': '@istanbuljs/load-nyc-config@1.1.0':
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -2811,6 +2811,9 @@ packages:
'@types/stack-utils@2.0.3': '@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/tmp@0.2.6':
resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==}
'@types/use-sync-external-store@0.0.6': '@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@ -2972,9 +2975,6 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0: asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@ -3183,17 +3183,13 @@ packages:
chart.js: '>=2.8.0' chart.js: '>=2.8.0'
date-fns: '>=2.0.0' date-fns: '>=2.0.0'
check-more-types@2.24.0:
resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==}
engines: {node: '>= 0.8.0'}
chokidar@4.0.3: chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
chownr@2.0.0: chownr@3.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=10'} engines: {node: '>=18'}
ci-info@3.9.0: ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
@ -3224,8 +3220,8 @@ packages:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'} engines: {node: '>=18'}
cli-table3@0.6.5: cli-table3@0.6.1:
resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} resolution: {integrity: sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==}
engines: {node: 10.* || >= 12.*} engines: {node: 10.* || >= 12.*}
cli-truncate@2.1.0: cli-truncate@2.1.0:
@ -3280,6 +3276,10 @@ packages:
colorette@2.0.20: colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
colors@1.4.0:
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
engines: {node: '>=0.1.90'}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -3478,9 +3478,9 @@ packages:
resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==} resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
cypress@13.17.0: cypress@15.9.0:
resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==} resolution: {integrity: sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==}
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0}
hasBin: true hasBin: true
d3-array@2.12.1: d3-array@2.12.1:
@ -3845,9 +3845,9 @@ packages:
extend@3.0.2: extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
extract-react-intl-messages@4.1.1: extract-react-intl-messages@5.0.0:
resolution: {integrity: sha512-dPogci5X7HVtV7VbUxajH/1YgfNRaW2VtEiVidZ/31Tq8314uzOtzVMNo0IrAPD2E+H1wHoPiu/j565TZsyIZg==} resolution: {integrity: sha512-7K1aA3WxhhjBXsuZ2buZm5MLuPHjzkbErV2qqhf0m0K9RMqdwe6mYrOAMZ+1z1bfrngwQ2Iv44+RLjILO8qPdA==}
engines: {node: '>=10'} engines: {node: '>=20'}
hasBin: true hasBin: true
extract-zip@2.0.1: extract-zip@2.0.1:
@ -3906,6 +3906,10 @@ packages:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'} engines: {node: '>=8'}
file-entry-cache@5.0.1:
resolution: {integrity: sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==}
engines: {node: '>=4'}
file-entry-cache@7.0.2: file-entry-cache@7.0.2:
resolution: {integrity: sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==} resolution: {integrity: sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -3925,6 +3929,10 @@ packages:
fix-dts-default-cjs-exports@1.0.1: fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
flat-cache@2.0.1:
resolution: {integrity: sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==}
engines: {node: '>=4'}
flat-cache@3.2.0: flat-cache@3.2.0:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0} engines: {node: ^10.12.0 || >=12.0.0}
@ -3933,6 +3941,9 @@ packages:
resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==}
hasBin: true hasBin: true
flatted@2.0.2:
resolution: {integrity: sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==}
flatted@3.3.3: flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
@ -3974,10 +3985,6 @@ packages:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
fs.realpath@1.0.0: fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -4042,9 +4049,6 @@ packages:
get-tsconfig@4.13.0: get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
getos@3.2.1:
resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==}
getpass@0.1.7: getpass@0.1.7:
resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==}
@ -4147,6 +4151,10 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
hasha@5.2.2:
resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==}
engines: {node: '>=8'}
hasown@2.0.2: hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -4730,8 +4738,8 @@ packages:
jsonify@0.0.1: jsonify@0.0.1:
resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==}
jsonwebtoken@9.0.2: jsonwebtoken@9.0.3:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'} engines: {node: '>=12', npm: '>=6'}
jsprim@2.0.2: jsprim@2.0.2:
@ -4741,11 +4749,11 @@ packages:
jszip@3.10.1: jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
jwa@1.4.2: jwa@2.0.1:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@3.2.2: jws@4.0.1:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
kafkajs@2.2.4: kafkajs@2.2.4:
resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==}
@ -4768,10 +4776,6 @@ packages:
known-css-properties@0.36.0: known-css-properties@0.36.0:
resolution: {integrity: sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==} resolution: {integrity: sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==}
lazy-ass@1.6.0:
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==}
engines: {node: '> 0.8'}
leven@3.1.0: leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -4865,10 +4869,6 @@ packages:
lodash.once@4.1.1: lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.pick@4.4.0:
resolution: {integrity: sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==}
deprecated: This package is deprecated. Use destructuring assignment syntax instead.
lodash.truncate@4.4.2: lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
@ -5054,21 +5054,17 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
minipass@5.0.0:
resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
engines: {node: '>=8'}
minipass@7.1.2: minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
minizlib@2.1.2: minizlib@3.1.0:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 8'} engines: {node: '>= 18'}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
mkdirp@1.0.4: mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
@ -5952,8 +5948,8 @@ packages:
pure-rand@7.0.1: pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
qs@6.14.0: qs@6.14.1:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
queue-microtask@1.2.3: queue-microtask@1.2.3:
@ -6177,6 +6173,11 @@ packages:
rfdc@1.4.1: rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rimraf@2.6.3:
resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rimraf@3.0.2: rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported deprecated: Rimraf versions prior to v4 are no longer supported
@ -6603,14 +6604,19 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
systeminformation@5.30.5:
resolution: {integrity: sha512-DpWmpCckhwR3hG+6udb6/aQB7PpiqVnvSljrjbKxNSvTRsGsg7NVE3/vouoYf96xgwMxXFKcS4Ux+cnkFwYM7A==}
engines: {node: '>=8.0.0'}
os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
hasBin: true
table@6.9.0: table@6.9.0:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
tar@6.2.1: tar@7.5.4:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==}
engines: {node: '>=10'} engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
terser@5.43.1: terser@5.43.1:
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==} resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
@ -6995,6 +7001,10 @@ packages:
resolution: {integrity: sha512-PxiShnxf0IlnQuMYOPPhPkhExoCQuTUNPOa/2JWCYTmBquU9njyyDuwRKN26IZBlp4yn1nt+Agh2HOOBl+55HQ==} resolution: {integrity: sha512-PxiShnxf0IlnQuMYOPPhPkhExoCQuTUNPOa/2JWCYTmBquU9njyyDuwRKN26IZBlp4yn1nt+Agh2HOOBl+55HQ==}
engines: {node: '>=8.3'} engines: {node: '>=8.3'}
write@1.0.3:
resolution: {integrity: sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==}
engines: {node: '>=4'}
xtend@4.0.2: xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@ -7009,6 +7019,10 @@ packages:
yallist@4.0.0: yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yaml@1.10.2: yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -7305,9 +7319,6 @@ snapshots:
dependencies: dependencies:
'@clickhouse/client-common': 1.14.0 '@clickhouse/client-common': 1.14.0
'@colors/colors@1.5.0':
optional: true
'@cspotcode/source-map-support@0.8.1': '@cspotcode/source-map-support@0.8.1':
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.9 '@jridgewell/trace-mapping': 0.3.9
@ -7405,7 +7416,7 @@ snapshots:
dependencies: dependencies:
postcss-selector-parser: 6.1.2 postcss-selector-parser: 6.1.2
'@cypress/request@3.0.9': '@cypress/request@3.0.10':
dependencies: dependencies:
aws-sign2: 0.7.0 aws-sign2: 0.7.0
aws4: 1.13.2 aws4: 1.13.2
@ -7420,7 +7431,7 @@ snapshots:
json-stringify-safe: 5.0.1 json-stringify-safe: 5.0.1
mime-types: 2.1.35 mime-types: 2.1.35
performance-now: 2.1.0 performance-now: 2.1.0
qs: 6.14.0 qs: 6.14.1
safe-buffer: 5.2.1 safe-buffer: 5.2.1
tough-cookie: 5.1.2 tough-cookie: 5.1.2
tunnel-agent: 0.6.0 tunnel-agent: 0.6.0
@ -8011,6 +8022,10 @@ snapshots:
wrap-ansi: 8.1.0 wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0 wrap-ansi-cjs: wrap-ansi@7.0.0
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.2
'@istanbuljs/load-nyc-config@1.1.0': '@istanbuljs/load-nyc-config@1.1.0':
dependencies: dependencies:
camelcase: 5.3.1 camelcase: 5.3.1
@ -9864,6 +9879,8 @@ snapshots:
'@types/stack-utils@2.0.3': {} '@types/stack-utils@2.0.3': {}
'@types/tmp@0.2.6': {}
'@types/use-sync-external-store@0.0.6': {} '@types/use-sync-external-store@0.0.6': {}
'@types/yargs-parser@21.0.3': {} '@types/yargs-parser@21.0.3': {}
@ -10070,8 +10087,6 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
async@3.2.6: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
at-least-node@1.0.0: {} at-least-node@1.0.0: {}
@ -10323,13 +10338,11 @@ snapshots:
chart.js: 4.5.1 chart.js: 4.5.1
date-fns: 2.30.0 date-fns: 2.30.0
check-more-types@2.24.0: {}
chokidar@4.0.3: chokidar@4.0.3:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
chownr@2.0.0: {} chownr@3.0.0: {}
ci-info@3.9.0: {} ci-info@3.9.0: {}
@ -10353,11 +10366,11 @@ snapshots:
dependencies: dependencies:
restore-cursor: 5.1.0 restore-cursor: 5.1.0
cli-table3@0.6.5: cli-table3@0.6.1:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
optionalDependencies: optionalDependencies:
'@colors/colors': 1.5.0 colors: 1.4.0
cli-truncate@2.1.0: cli-truncate@2.1.0:
dependencies: dependencies:
@ -10403,6 +10416,9 @@ snapshots:
colorette@2.0.20: {} colorette@2.0.20: {}
colors@1.4.0:
optional: true
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@ -10618,22 +10634,22 @@ snapshots:
dependencies: dependencies:
array-find-index: 1.0.2 array-find-index: 1.0.2
cypress@13.17.0: cypress@15.9.0:
dependencies: dependencies:
'@cypress/request': 3.0.9 '@cypress/request': 3.0.10
'@cypress/xvfb': 1.2.4(supports-color@8.1.1) '@cypress/xvfb': 1.2.4(supports-color@8.1.1)
'@types/sinonjs__fake-timers': 8.1.1 '@types/sinonjs__fake-timers': 8.1.1
'@types/sizzle': 2.3.9 '@types/sizzle': 2.3.9
'@types/tmp': 0.2.6
arch: 2.2.0 arch: 2.2.0
blob-util: 2.0.2 blob-util: 2.0.2
bluebird: 3.7.2 bluebird: 3.7.2
buffer: 5.7.1 buffer: 5.7.1
cachedir: 2.4.0 cachedir: 2.4.0
chalk: 4.1.2 chalk: 4.1.2
check-more-types: 2.24.0
ci-info: 4.3.0 ci-info: 4.3.0
cli-cursor: 3.1.0 cli-cursor: 3.1.0
cli-table3: 0.6.5 cli-table3: 0.6.1
commander: 6.2.1 commander: 6.2.1
common-tags: 1.8.2 common-tags: 1.8.2
dayjs: 1.11.13 dayjs: 1.11.13
@ -10645,9 +10661,8 @@ snapshots:
extract-zip: 2.0.1(supports-color@8.1.1) extract-zip: 2.0.1(supports-color@8.1.1)
figures: 3.2.0 figures: 3.2.0
fs-extra: 9.1.0 fs-extra: 9.1.0
getos: 3.2.1 hasha: 5.2.2
is-installed-globally: 0.4.0 is-installed-globally: 0.4.0
lazy-ass: 1.6.0
listr2: 3.14.0(enquirer@2.4.1) listr2: 3.14.0(enquirer@2.4.1)
lodash: 4.17.21 lodash: 4.17.21
log-symbols: 4.1.0 log-symbols: 4.1.0
@ -10657,8 +10672,8 @@ snapshots:
process: 0.11.10 process: 0.11.10
proxy-from-env: 1.0.0 proxy-from-env: 1.0.0
request-progress: 3.0.0 request-progress: 3.0.0
semver: 7.7.3
supports-color: 8.1.1 supports-color: 8.1.1
systeminformation: 5.30.5
tmp: 0.2.5 tmp: 0.2.5
tree-kill: 1.2.2 tree-kill: 1.2.2
untildify: 4.0.0 untildify: 4.0.0
@ -11131,17 +11146,17 @@ snapshots:
extend@3.0.2: {} extend@3.0.2: {}
extract-react-intl-messages@4.1.1(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)): extract-react-intl-messages@5.0.0(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)):
dependencies: dependencies:
'@babel/core': 7.28.3 '@babel/core': 7.28.3
babel-plugin-react-intl: 7.9.4(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)) babel-plugin-react-intl: 7.9.4(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3))
file-entry-cache: 5.0.1
flat: 5.0.2 flat: 5.0.2
glob: 7.2.3 glob: 7.2.3
js-yaml: 3.14.1 js-yaml: 3.14.1
load-json-file: 6.2.0 load-json-file: 6.2.0
lodash.merge: 4.6.2 lodash.merge: 4.6.2
lodash.mergewith: 4.6.2 lodash.mergewith: 4.6.2
lodash.pick: 4.4.0
meow: 6.1.1 meow: 6.1.1
mkdirp: 1.0.4 mkdirp: 1.0.4
pify: 5.0.0 pify: 5.0.0
@ -11209,6 +11224,10 @@ snapshots:
dependencies: dependencies:
escape-string-regexp: 1.0.5 escape-string-regexp: 1.0.5
file-entry-cache@5.0.1:
dependencies:
flat-cache: 2.0.1
file-entry-cache@7.0.2: file-entry-cache@7.0.2:
dependencies: dependencies:
flat-cache: 3.2.0 flat-cache: 3.2.0
@ -11233,6 +11252,12 @@ snapshots:
mlly: 1.8.0 mlly: 1.8.0
rollup: 4.53.3 rollup: 4.53.3
flat-cache@2.0.1:
dependencies:
flatted: 2.0.2
rimraf: 2.6.3
write: 1.0.3
flat-cache@3.2.0: flat-cache@3.2.0:
dependencies: dependencies:
flatted: 3.3.3 flatted: 3.3.3
@ -11241,6 +11266,8 @@ snapshots:
flat@5.0.2: {} flat@5.0.2: {}
flatted@2.0.2: {}
flatted@3.3.3: {} flatted@3.3.3: {}
for-each@0.3.5: for-each@0.3.5:
@ -11293,10 +11320,6 @@ snapshots:
jsonfile: 6.2.0 jsonfile: 6.2.0
universalify: 2.0.1 universalify: 2.0.1
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
fs.realpath@1.0.0: {} fs.realpath@1.0.0: {}
fsevents@2.3.3: fsevents@2.3.3:
@ -11363,10 +11386,6 @@ snapshots:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 resolve-pkg-maps: 1.0.0
getos@3.2.1:
dependencies:
async: 3.2.6
getpass@0.1.7: getpass@0.1.7:
dependencies: dependencies:
assert-plus: 1.0.0 assert-plus: 1.0.0
@ -11501,6 +11520,11 @@ snapshots:
dependencies: dependencies:
has-symbols: 1.1.0 has-symbols: 1.1.0
hasha@5.2.2:
dependencies:
is-stream: 2.0.1
type-fest: 0.8.1
hasown@2.0.2: hasown@2.0.2:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@ -12236,9 +12260,9 @@ snapshots:
jsonify@0.0.1: {} jsonify@0.0.1: {}
jsonwebtoken@9.0.2: jsonwebtoken@9.0.3:
dependencies: dependencies:
jws: 3.2.2 jws: 4.0.1
lodash.includes: 4.3.0 lodash.includes: 4.3.0
lodash.isboolean: 3.0.3 lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4 lodash.isinteger: 4.0.4
@ -12263,15 +12287,15 @@ snapshots:
readable-stream: 2.3.8 readable-stream: 2.3.8
setimmediate: 1.0.5 setimmediate: 1.0.5
jwa@1.4.2: jwa@2.0.1:
dependencies: dependencies:
buffer-equal-constant-time: 1.0.1 buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11 ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1 safe-buffer: 5.2.1
jws@3.2.2: jws@4.0.1:
dependencies: dependencies:
jwa: 1.4.2 jwa: 2.0.1
safe-buffer: 5.2.1 safe-buffer: 5.2.1
kafkajs@2.2.4: {} kafkajs@2.2.4: {}
@ -12289,8 +12313,6 @@ snapshots:
known-css-properties@0.36.0: known-css-properties@0.36.0:
optional: true optional: true
lazy-ass@1.6.0: {}
leven@3.1.0: {} leven@3.1.0: {}
lie@3.3.0: lie@3.3.0:
@ -12383,8 +12405,6 @@ snapshots:
lodash.once@4.1.1: {} lodash.once@4.1.1: {}
lodash.pick@4.4.0: {}
lodash.truncate@4.4.2: {} lodash.truncate@4.4.2: {}
lodash.uniq@4.5.0: {} lodash.uniq@4.5.0: {}
@ -12575,18 +12595,15 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
minipass@3.3.6:
dependencies:
yallist: 4.0.0
minipass@5.0.0: {}
minipass@7.1.2: {} minipass@7.1.2: {}
minizlib@2.1.2: minizlib@3.1.0:
dependencies: dependencies:
minipass: 3.3.6 minipass: 7.1.2
yallist: 4.0.0
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
mkdirp@1.0.4: {} mkdirp@1.0.4: {}
@ -13431,7 +13448,7 @@ snapshots:
pure-rand@7.0.1: {} pure-rand@7.0.1: {}
qs@6.14.0: qs@6.14.1:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
@ -13769,6 +13786,10 @@ snapshots:
rfdc@1.4.1: {} rfdc@1.4.1: {}
rimraf@2.6.3:
dependencies:
glob: 7.2.3
rimraf@3.0.2: rimraf@3.0.2:
dependencies: dependencies:
glob: 7.2.3 glob: 7.2.3
@ -14336,6 +14357,8 @@ snapshots:
csso: 5.0.5 csso: 5.0.5
picocolors: 1.1.1 picocolors: 1.1.1
systeminformation@5.30.5: {}
table@6.9.0: table@6.9.0:
dependencies: dependencies:
ajv: 8.17.1 ajv: 8.17.1
@ -14344,14 +14367,13 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
tar@6.2.1: tar@7.5.4:
dependencies: dependencies:
chownr: 2.0.0 '@isaacs/fs-minipass': 4.0.1
fs-minipass: 2.1.0 chownr: 3.0.0
minipass: 5.0.0 minipass: 7.1.2
minizlib: 2.1.2 minizlib: 3.1.0
mkdirp: 1.0.4 yallist: 5.0.0
yallist: 4.0.0
terser@5.43.1: terser@5.43.1:
dependencies: dependencies:
@ -14758,6 +14780,10 @@ snapshots:
sort-keys: 4.2.0 sort-keys: 4.2.0
write-file-atomic: 3.0.3 write-file-atomic: 3.0.3
write@1.0.3:
dependencies:
mkdirp: 0.5.6
xtend@4.0.2: {} xtend@4.0.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}
@ -14766,6 +14792,8 @@ snapshots:
yallist@4.0.0: {} yallist@4.0.0: {}
yallist@5.0.0: {}
yaml@1.10.2: {} yaml@1.10.2: {}
yaml@2.8.1: {} yaml@2.8.1: {}

View file

@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "share" (
"share_id" UUID NOT NULL,
"entity_id" UUID NOT NULL,
"name" VARCHAR(200) NOT NULL,
"share_type" INTEGER NOT NULL,
"slug" VARCHAR(100) NOT NULL,
"parameters" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "share_pkey" PRIMARY KEY ("share_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
-- CreateIndex
CREATE INDEX "share_entity_id_idx" ON "share"("entity_id");
-- MigrateData
INSERT INTO "share" (share_id, entity_id, name, share_type, slug, parameters, created_at)
SELECT gen_random_uuid(),
website_id,
name,
1,
share_id,
'{"overview":true}'::jsonb,
now()
FROM "website"
WHERE share_id IS NOT NULL;
-- DropIndex
DROP INDEX "website_share_id_idx";
-- DropIndex
DROP INDEX "website_share_id_key";
-- AlterTable
ALTER TABLE "website" DROP COLUMN "share_id";

View file

@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "board" (
"board_id" UUID NOT NULL,
"type" VARCHAR(50) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"description" VARCHAR(500) NOT NULL,
"parameters" JSONB NOT NULL,
"slug" VARCHAR(100) NOT NULL,
"user_id" UUID,
"team_id" UUID,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "board_pkey" PRIMARY KEY ("board_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "board_slug_key" ON "board"("slug");
-- CreateIndex
CREATE INDEX "board_slug_idx" ON "board"("slug");
-- CreateIndex
CREATE INDEX "board_user_id_idx" ON "board"("user_id");
-- CreateIndex
CREATE INDEX "board_team_id_idx" ON "board"("team_id");
-- CreateIndex
CREATE INDEX "board_created_at_idx" ON "board"("created_at");

View file

@ -0,0 +1,29 @@
-- DropIndex
DROP INDEX "link_link_id_key";
-- DropIndex
DROP INDEX "pixel_pixel_id_key";
-- DropIndex
DROP INDEX "report_report_id_key";
-- DropIndex
DROP INDEX "revenue_revenue_id_key";
-- DropIndex
DROP INDEX "segment_segment_id_key";
-- DropIndex
DROP INDEX "session_session_id_key";
-- DropIndex
DROP INDEX "team_team_id_key";
-- DropIndex
DROP INDEX "team_user_team_user_id_key";
-- DropIndex
DROP INDEX "user_user_id_key";
-- DropIndex
DROP INDEX "website_website_id_key";

View file

@ -11,7 +11,7 @@ datasource db {
} }
model User { model User {
id String @id @unique @map("user_id") @db.Uuid id String @id() @map("user_id") @db.Uuid
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
password String @db.VarChar(60) password String @db.VarChar(60)
role String @map("role") @db.VarChar(50) role String @map("role") @db.VarChar(50)
@ -27,12 +27,13 @@ model User {
pixels Pixel[] @relation("user") pixels Pixel[] @relation("user")
teams TeamUser[] teams TeamUser[]
reports Report[] reports Report[]
boards Board[] @relation("user")
@@map("user") @@map("user")
} }
model Session { model Session {
id String @id @unique @map("session_id") @db.Uuid id String @id() @map("session_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
browser String? @db.VarChar(20) browser String? @db.VarChar(20)
os String? @db.VarChar(20) os String? @db.VarChar(20)
@ -64,10 +65,9 @@ model Session {
} }
model Website { model Website {
id String @id @unique @map("website_id") @db.Uuid id String @id() @map("website_id") @db.Uuid
name String @db.VarChar(100) name String @db.VarChar(100)
domain String? @db.VarChar(500) domain String? @db.VarChar(500)
shareId String? @unique @map("share_id") @db.VarChar(50)
resetAt DateTime? @map("reset_at") @db.Timestamptz(6) resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
userId String? @map("user_id") @db.Uuid userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid teamId String? @map("team_id") @db.Uuid
@ -88,7 +88,6 @@ model Website {
@@index([userId]) @@index([userId])
@@index([teamId]) @@index([teamId])
@@index([createdAt]) @@index([createdAt])
@@index([shareId])
@@index([createdBy]) @@index([createdBy])
@@map("website") @@map("website")
} }
@ -187,7 +186,7 @@ model SessionData {
} }
model Team { model Team {
id String @id() @unique() @map("team_id") @db.Uuid id String @id() @map("team_id") @db.Uuid
name String @db.VarChar(50) name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50) accessCode String? @unique @map("access_code") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183) logoUrl String? @map("logo_url") @db.VarChar(2183)
@ -199,13 +198,14 @@ model Team {
members TeamUser[] members TeamUser[]
links Link[] links Link[]
pixels Pixel[] pixels Pixel[]
boards Board[]
@@index([accessCode]) @@index([accessCode])
@@map("team") @@map("team")
} }
model TeamUser { model TeamUser {
id String @id() @unique() @map("team_user_id") @db.Uuid id String @id() @map("team_user_id") @db.Uuid
teamId String @map("team_id") @db.Uuid teamId String @map("team_id") @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
role String @db.VarChar(50) role String @db.VarChar(50)
@ -221,7 +221,7 @@ model TeamUser {
} }
model Report { model Report {
id String @id() @unique() @map("report_id") @db.Uuid id String @id() @map("report_id") @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
type String @db.VarChar(50) type String @db.VarChar(50)
@ -242,7 +242,7 @@ model Report {
} }
model Segment { model Segment {
id String @id() @unique() @map("segment_id") @db.Uuid id String @id() @map("segment_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
type String @db.VarChar(50) type String @db.VarChar(50)
name String @db.VarChar(200) name String @db.VarChar(200)
@ -257,7 +257,7 @@ model Segment {
} }
model Revenue { model Revenue {
id String @id() @unique() @map("revenue_id") @db.Uuid id String @id() @map("revenue_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid sessionId String @map("session_id") @db.Uuid
eventId String @map("event_id") @db.Uuid eventId String @map("event_id") @db.Uuid
@ -277,7 +277,7 @@ model Revenue {
} }
model Link { model Link {
id String @id() @unique() @map("link_id") @db.Uuid id String @id() @map("link_id") @db.Uuid
name String @db.VarChar(100) name String @db.VarChar(100)
url String @db.VarChar(500) url String @db.VarChar(500)
slug String @unique() @db.VarChar(100) slug String @unique() @db.VarChar(100)
@ -298,7 +298,7 @@ model Link {
} }
model Pixel { model Pixel {
id String @id() @unique() @map("pixel_id") @db.Uuid id String @id() @map("pixel_id") @db.Uuid
name String @db.VarChar(100) name String @db.VarChar(100)
slug String @unique() @db.VarChar(100) slug String @unique() @db.VarChar(100)
userId String? @map("user_id") @db.Uuid userId String? @map("user_id") @db.Uuid
@ -316,3 +316,39 @@ model Pixel {
@@index([createdAt]) @@index([createdAt])
@@map("pixel") @@map("pixel")
} }
model Board {
id String @id() @map("board_id") @db.Uuid
type String @db.VarChar(50)
name String @db.VarChar(200)
description String @db.VarChar(500)
parameters Json
slug String @unique() @db.VarChar(100)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
@@index([slug])
@@index([userId])
@@index([teamId])
@@index([createdAt])
@@map("board")
}
model Share {
id String @id() @map("share_id") @db.Uuid
entityId String @map("entity_id") @db.Uuid
name String @db.VarChar(200)
shareType Int @map("share_type") @db.Integer
slug String @unique() @db.VarChar(100)
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
@@index([entityId])
@@map("share")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -5,6 +5,18 @@
"value": "访问代码" "value": "访问代码"
} }
], ],
"label.account": [
{
"type": 0,
"value": "账户"
}
],
"label.action": [
{
"type": 0,
"value": "行为"
}
],
"label.actions": [ "label.actions": [
{ {
"type": 0, "type": 0,
@ -35,12 +47,24 @@
"value": "添加描述" "value": "添加描述"
} }
], ],
"label.add-link": [
{
"type": 0,
"value": "添加链接"
}
],
"label.add-member": [ "label.add-member": [
{ {
"type": 0, "type": 0,
"value": "添加成员" "value": "添加成员"
} }
], ],
"label.add-pixel": [
{
"type": 0,
"value": "添加像素"
}
],
"label.add-step": [ "label.add-step": [
{ {
"type": 0, "type": 0,
@ -83,12 +107,24 @@
"value": "所有时间段" "value": "所有时间段"
} }
], ],
"label.analysis": [
{
"type": 0,
"value": "分析"
}
],
"label.analytics": [ "label.analytics": [
{ {
"type": 0, "type": 0,
"value": "分析" "value": "分析"
} }
], ],
"label.application": [
{
"type": 0,
"value": "应用"
}
],
"label.apply": [ "label.apply": [
{ {
"type": 0, "type": 0,
@ -107,6 +143,12 @@
"value": "查看用户如何与您的营销互动,以及是什么促成了转化。" "value": "查看用户如何与您的营销互动,以及是什么促成了转化。"
} }
], ],
"label.audience": [
{
"type": 0,
"value": "受众"
}
],
"label.average": [ "label.average": [
{ {
"type": 0, "type": 0,
@ -125,6 +167,12 @@
"value": "之前" "value": "之前"
} }
], ],
"label.behavior": [
{
"type": 0,
"value": "行为"
}
],
"label.boards": [ "label.boards": [
{ {
"type": 0, "type": 0,
@ -173,12 +221,24 @@
"value": "修改密码" "value": "修改密码"
} }
], ],
"label.channel": [
{
"type": 0,
"value": "渠道"
}
],
"label.channels": [ "label.channels": [
{ {
"type": 0, "type": 0,
"value": "渠道" "value": "渠道"
} }
], ],
"label.chart": [
{
"type": 0,
"value": "图表"
}
],
"label.cities": [ "label.cities": [
{ {
"type": 0, "type": 0,
@ -203,6 +263,12 @@
"value": "队列" "value": "队列"
} }
], ],
"label.cohorts": [
{
"type": 0,
"value": "队列"
}
],
"label.compare": [ "label.compare": [
{ {
"type": 0, "type": 0,
@ -317,6 +383,12 @@
"value": "创建者" "value": "创建者"
} }
], ],
"label.criteria": [
{
"type": 0,
"value": "条件"
}
],
"label.currency": [ "label.currency": [
{ {
"type": 0, "type": 0,
@ -419,6 +491,12 @@
"value": "台式机" "value": "台式机"
} }
], ],
"label.destination-url": [
{
"type": 0,
"value": "目标URL"
}
],
"label.details": [ "label.details": [
{ {
"type": 0, "type": 0,
@ -455,6 +533,12 @@
"value": "唯一ID" "value": "唯一ID"
} }
], ],
"label.documentation": [
{
"type": 0,
"value": "文档"
}
],
"label.does-not-contain": [ "label.does-not-contain": [
{ {
"type": 0, "type": 0,
@ -479,6 +563,12 @@
"value": "域名" "value": "域名"
} }
], ],
"label.download": [
{
"type": 0,
"value": "下载"
}
],
"label.dropoff": [ "label.dropoff": [
{ {
"type": 0, "type": 0,
@ -506,7 +596,7 @@
"label.email": [ "label.email": [
{ {
"type": 0, "type": 0,
"value": "Email" "value": "邮箱"
} }
], ],
"label.enable-share-url": [ "label.enable-share-url": [
@ -527,6 +617,12 @@
"value": "入口 URL" "value": "入口 URL"
} }
], ],
"label.environment": [
{
"type": 0,
"value": "环境"
}
],
"label.event": [ "label.event": [
{ {
"type": 0, "type": 0,
@ -671,6 +767,12 @@
"value": "分组" "value": "分组"
} }
], ],
"label.growth": [
{
"type": 0,
"value": "增长"
}
],
"label.hostname": [ "label.hostname": [
{ {
"type": 0, "type": 0,
@ -701,6 +803,12 @@
"value": "通过使用筛选器和划分时间段来更深入地研究数据。" "value": "通过使用筛选器和划分时间段来更深入地研究数据。"
} }
], ],
"label.invalid-url": [
{
"type": 0,
"value": "无效URL"
}
],
"label.is": [ "label.is": [
{ {
"type": 0, "type": 0,
@ -863,12 +971,24 @@
"value": "少于等于" "value": "少于等于"
} }
], ],
"label.link": [
{
"type": 0,
"value": "链接"
}
],
"label.links": [ "label.links": [
{ {
"type": 0, "type": 0,
"value": "链接" "value": "链接"
} }
], ],
"label.location": [
{
"type": 0,
"value": "位置"
}
],
"label.login": [ "label.login": [
{ {
"type": 0, "type": 0,
@ -1020,7 +1140,7 @@
"label.online": [ "label.online": [
{ {
"type": 0, "type": 0,
"value": "Online" "value": "在线"
} }
], ],
"label.organic-search": [ "label.organic-search": [
@ -1165,6 +1285,12 @@
"value": "路径" "value": "路径"
} }
], ],
"label.pixel": [
{
"type": 0,
"value": "像素"
}
],
"label.pixels": [ "label.pixels": [
{ {
"type": 0, "type": 0,
@ -1185,6 +1311,12 @@
"value": " 提供支持" "value": " 提供支持"
} }
], ],
"label.preferences": [
{
"type": 0,
"value": "偏好"
}
],
"label.previous": [ "label.previous": [
{ {
"type": 0, "type": 0,
@ -1209,6 +1341,12 @@
"value": "个人资料" "value": "个人资料"
} }
], ],
"label.profiles": [
{
"type": 0,
"value": "个人资料"
}
],
"label.properties": [ "label.properties": [
{ {
"type": 0, "type": 0,
@ -1248,7 +1386,7 @@
"label.referral": [ "label.referral": [
{ {
"type": 0, "type": 0,
"value": "Referral" "value": "来源"
} }
], ],
"label.referrer": [ "label.referrer": [
@ -1371,6 +1509,24 @@
"value": "保存" "value": "保存"
} }
], ],
"label.save-cohort": [
{
"type": 0,
"value": "保存为群组"
}
],
"label.save-segment": [
{
"type": 0,
"value": "保存为细分"
}
],
"label.screen": [
{
"type": 0,
"value": "屏幕"
}
],
"label.screens": [ "label.screens": [
{ {
"type": 0, "type": 0,
@ -1383,6 +1539,18 @@
"value": "搜索" "value": "搜索"
} }
], ],
"label.segment": [
{
"type": 0,
"value": "细分"
}
],
"label.segments": [
{
"type": 0,
"value": "细分"
}
],
"label.select": [ "label.select": [
{ {
"type": 0, "type": 0,
@ -1485,6 +1653,24 @@
"value": "总和" "value": "总和"
} }
], ],
"label.support": [
{
"type": 0,
"value": "支持"
}
],
"label.switch-account": [
{
"type": 0,
"value": "切换账户"
}
],
"label.table": [
{
"type": 0,
"value": "表格"
}
],
"label.tablet": [ "label.tablet": [
{ {
"type": 0, "type": 0,
@ -1635,6 +1821,12 @@
"value": "跟踪代码" "value": "跟踪代码"
} }
], ],
"label.traffic": [
{
"type": 0,
"value": "流量"
}
],
"label.transactions": [ "label.transactions": [
{ {
"type": 0, "type": 0,
@ -1846,7 +2038,7 @@
"message.bad-request": [ "message.bad-request": [
{ {
"type": 0, "type": 0,
"value": "Bad request" "value": "请求错误"
} }
], ],
"message.collected-data": [ "message.collected-data": [
@ -1946,7 +2138,7 @@
"message.forbidden": [ "message.forbidden": [
{ {
"type": 0, "type": 0,
"value": "Forbidden" "value": "禁止访问"
} }
], ],
"message.go-to-settings": [ "message.go-to-settings": [
@ -2046,13 +2238,13 @@
"message.not-found": [ "message.not-found": [
{ {
"type": 0, "type": 0,
"value": "Not found" "value": "未找到"
} }
], ],
"message.nothing-selected": [ "message.nothing-selected": [
{ {
"type": 0, "type": 0,
"value": "Nothing selected." "value": "未选择"
} }
], ],
"message.page-not-found": [ "message.page-not-found": [
@ -2090,7 +2282,7 @@
"message.sever-error": [ "message.sever-error": [
{ {
"type": 0, "type": 0,
"value": "Server error" "value": "服务器错误"
} }
], ],
"message.share-url": [ "message.share-url": [
@ -2158,7 +2350,7 @@
"message.unauthorized": [ "message.unauthorized": [
{ {
"type": 0, "type": 0,
"value": "Unauthorized" "value": "未授权"
} }
], ],
"message.user-deleted": [ "message.user-deleted": [

View file

@ -6,13 +6,13 @@
"AD-06": "Sant Julia de Loria", "AD-06": "Sant Julia de Loria",
"AD-07": "Andorra la Vella", "AD-07": "Andorra la Vella",
"AD-08": "Escaldes-Engordany", "AD-08": "Escaldes-Engordany",
"AE-AJ": "'Ajman", "AE-AJ": "Ajman",
"AE-AZ": "Abu Zaby", "AE-AZ": "Abu Dhabi",
"AE-DU": "Dubayy", "AE-DU": "Dubai",
"AE-FU": "Al Fujayrah", "AE-FU": "Al Fujairah",
"AE-RK": "Ra's al Khaymah", "AE-RK": "Ras al Khaimah",
"AE-SH": "Ash Shariqah", "AE-SH": "Sharjah",
"AE-UQ": "Umm al Qaywayn", "AE-UQ": "Umm al Quwain",
"AF-BAL": "Balkh", "AF-BAL": "Balkh",
"AF-BAM": "Bamyan", "AF-BAM": "Bamyan",
"AF-BDG": "Badghis", "AF-BDG": "Badghis",

View file

@ -3,7 +3,7 @@ import 'dotenv/config';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import https from 'https'; import https from 'https';
import tar from 'tar'; import { list } from 'tar';
import zlib from 'zlib'; import zlib from 'zlib';
if (process.env.VERCEL && !process.env.BUILD_GEO) { if (process.env.VERCEL && !process.env.BUILD_GEO) {
@ -40,7 +40,7 @@ const isDirectMmdb = url.endsWith('.mmdb');
const downloadCompressed = url => const downloadCompressed = url =>
new Promise(resolve => { new Promise(resolve => {
https.get(url, res => { https.get(url, res => {
resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t())); resolve(res.pipe(zlib.createGunzip({})).pipe(list()));
}); });
}); });

View file

@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { TeamsAddButton } from '../../teams/TeamsAddButton';
import { AdminTeamsDataTable } from './AdminTeamsDataTable'; import { AdminTeamsDataTable } from './AdminTeamsDataTable';
export function AdminTeamsPage() { export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const handleSave = () => {};
return ( return (
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.teams)} /> <PageHeader title={formatMessage(labels.teams)}>
<TeamsAddButton onSave={handleSave} isAdmin={true} />
</PageHeader>
<Panel> <Panel>
<AdminTeamsDataTable /> <AdminTeamsDataTable />
</Panel> </Panel>

View file

@ -10,6 +10,7 @@ import {
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery } from '@/components/hooks';
import { messages } from '@/components/messages';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
export function UserAddForm({ onSave, onClose }) { export function UserAddForm({ onSave, onClose }) {
@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) {
<FormField <FormField
label={formatMessage(labels.password)} label={formatMessage(labels.password)}
name="password" name="password"
rules={{ required: formatMessage(labels.required) }} rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
}}
> >
<PasswordField autoComplete="new-password" data-test="input-password" /> <PasswordField autoComplete="new-password" data-test="input-password" />
</FormField> </FormField>

View file

@ -30,7 +30,11 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
}; };
return ( return (
<Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}> <Form
onSubmit={handleSubmit}
error={getMessage(error?.code)}
values={{ ...user, password: '' }}
>
<FormField name="username" label={formatMessage(labels.username)}> <FormField name="username" label={formatMessage(labels.username)}>
<TextField data-test="input-username" /> <TextField data-test="input-username" />
</FormField> </FormField>

View file

@ -1,5 +1,5 @@
import { ConfirmationForm } from '@/components/common/ConfirmationForm'; import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages } from '@/components/hooks'; import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons'; import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton'; import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages'; import { messages } from '@/components/messages';
@ -15,7 +15,8 @@ export function LinkDeleteButton({
onSave?: () => void; onSave?: () => void;
}) { }) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages(); const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`); const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
const { touch } = useModified();
const handleConfirm = async (close: () => void) => { const handleConfirm = async (close: () => void) => {
await mutateAsync(null, { await mutateAsync(null, {

View file

@ -4,13 +4,14 @@ import {
Form, Form,
FormField, FormField,
FormSubmitButton, FormSubmitButton,
Grid,
Icon, Icon,
Label, Label,
Loading, Loading,
Row, Row,
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useConfig, useLinkQuery, useMessages } from '@/components/hooks'; import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery'; import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { RefreshCw } from '@/components/icons'; import { RefreshCw } from '@/components/icons';
@ -42,27 +43,20 @@ export function LinkEditForm({
const { linksUrl } = useConfig(); const { linksUrl } = useConfig();
const hostUrl = linksUrl || LINKS_URL; const hostUrl = linksUrl || LINKS_URL;
const { data, isLoading } = useLinkQuery(linkId); const { data, isLoading } = useLinkQuery(linkId);
const [slug, setSlug] = useState(generateId()); const [defaultSlug] = useState(generateId());
const handleSubmit = async (data: any) => { const handleSubmit = async (data: any) => {
await mutateAsync(data, { await mutateAsync(data, {
onSuccess: async () => { onSuccess: async () => {
toast(formatMessage(messages.saved)); toast(formatMessage(messages.saved));
touch('links'); touch('links');
touch(`link:${linkId}`);
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, },
}); });
}; };
const handleSlug = () => {
const slug = generateId();
setSlug(slug);
return slug;
};
const checkUrl = (url: string) => { const checkUrl = (url: string) => {
if (!isValidUrl(url)) { if (!isValidUrl(url)) {
return formatMessage(labels.invalidUrl); return formatMessage(labels.invalidUrl);
@ -70,19 +64,19 @@ export function LinkEditForm({
return true; return true;
}; };
useEffect(() => {
if (data) {
setSlug(data.slug);
}
}, [data]);
if (linkId && isLoading) { if (linkId && isLoading) {
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
return ( return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}> <Form
{({ setValue }) => { onSubmit={handleSubmit}
error={getErrorMessage(error)}
defaultValues={{ slug: defaultSlug, ...data }}
>
{({ setValue, watch }) => {
const slug = watch('slug');
return ( return (
<> <>
<FormField <FormField
@ -101,15 +95,25 @@ export function LinkEditForm({
<TextField placeholder="https://example.com" autoComplete="off" /> <TextField placeholder="https://example.com" autoComplete="off" />
</FormField> </FormField>
<FormField <Grid columns="1fr auto" alignItems="end" gap>
name="slug" <FormField
rules={{ name="slug"
required: formatMessage(labels.required), label={formatMessage({ id: 'label.slug', defaultMessage: 'Slug' })}
}} rules={{
style={{ display: 'none' }} required: formatMessage(labels.required),
> }}
<input type="hidden" /> >
</FormField> <TextField autoComplete="off" />
</FormField>
<Button
variant="quiet"
onPress={() => setValue('slug', generateId(), { shouldDirty: true })}
>
<Icon>
<RefreshCw />
</Icon>
</Button>
</Grid>
<Column> <Column>
<Label>{formatMessage(labels.link)}</Label> <Label>{formatMessage(labels.link)}</Label>
@ -121,14 +125,6 @@ export function LinkEditForm({
allowCopy allowCopy
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
<Button
variant="quiet"
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
>
<Icon>
<RefreshCw />
</Icon>
</Button>
</Row> </Row>
</Column> </Column>

View file

@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
import { useLinksQuery, useNavigation } from '@/components/hooks'; import { useLinksQuery, useNavigation } from '@/components/hooks';
import { LinksTable } from './LinksTable'; import { LinksTable } from './LinksTable';
export function LinksDataTable() { export function LinksDataTable({ showActions = false }: { showActions?: boolean }) {
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const query = useLinksQuery({ teamId }); const query = useLinksQuery({ teamId });
return ( return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}> <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <LinksTable data={data} />} {({ data }) => <LinksTable data={data} showActions={showActions} />}
</DataGrid> </DataGrid>
); );
} }

View file

@ -4,21 +4,30 @@ import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { LinkAddButton } from './LinkAddButton'; import { LinkAddButton } from './LinkAddButton';
export function LinksPage() { export function LinksPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.links)}> <PageHeader title={formatMessage(labels.links)}>
<LinkAddButton teamId={teamId} /> {showActions && <LinkAddButton teamId={teamId} />}
</PageHeader> </PageHeader>
<Panel> <Panel>
<LinksDataTable /> <LinksDataTable showActions={showActions} />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { LinkDeleteButton } from './LinkDeleteButton'; import { LinkDeleteButton } from './LinkDeleteButton';
import { LinkEditButton } from './LinkEditButton'; import { LinkEditButton } from './LinkEditButton';
export function LinksTable(props: DataTableProps) { export interface LinksTableProps extends DataTableProps {
showActions?: boolean;
}
export function LinksTable({ showActions, ...props }: LinksTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { websiteId, renderUrl } = useNavigation(); const { websiteId, renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('link'); const { getSlugUrl } = useSlug('link');
@ -36,16 +40,18 @@ export function LinksTable(props: DataTableProps) {
<DataColumn id="created" label={formatMessage(labels.created)} width="200px"> <DataColumn id="created" label={formatMessage(labels.created)} width="200px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />} {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn> </DataColumn>
<DataColumn id="action" align="end" width="100px"> {showActions && (
{({ id, name }: any) => { <DataColumn id="action" align="end" width="100px">
return ( {({ id, name }: any) => {
<Row> return (
<LinkEditButton linkId={id} /> <Row>
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} /> <LinkEditButton linkId={id} />
</Row> <LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
); </Row>
}} );
</DataColumn> }}
</DataColumn>
)}
</DataTable> </DataTable>
); );
} }

View file

@ -1,8 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getLink } from '@/queries/prisma';
import { LinkPage } from './LinkPage'; import { LinkPage } from './LinkPage';
export default async function ({ params }: { params: Promise<{ linkId: string }> }) { export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
const { linkId } = await params; const { linkId } = await params;
const link = await getLink(linkId);
if (!link || link?.deletedAt) {
return null;
}
return <LinkPage linkId={linkId} />; return <LinkPage linkId={linkId} />;
} }

View file

@ -48,6 +48,7 @@ export function PixelEditForm({
onSuccess: async () => { onSuccess: async () => {
toast(formatMessage(messages.saved)); toast(formatMessage(messages.saved));
touch('pixels'); touch('pixels');
touch(`pixel:${pixelId}`);
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, },

View file

@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
import { useNavigation, usePixelsQuery } from '@/components/hooks'; import { useNavigation, usePixelsQuery } from '@/components/hooks';
import { PixelsTable } from './PixelsTable'; import { PixelsTable } from './PixelsTable';
export function PixelsDataTable() { export function PixelsDataTable({ showActions = false }: { showActions?: boolean }) {
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const query = usePixelsQuery({ teamId }); const query = usePixelsQuery({ teamId });
return ( return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}> <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <PixelsTable data={data} />} {({ data }) => <PixelsTable data={data} showActions={showActions} />}
</DataGrid> </DataGrid>
); );
} }

View file

@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { PixelAddButton } from './PixelAddButton'; import { PixelAddButton } from './PixelAddButton';
import { PixelsDataTable } from './PixelsDataTable'; import { PixelsDataTable } from './PixelsDataTable';
export function PixelsPage() { export function PixelsPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.pixels)}> <PageHeader title={formatMessage(labels.pixels)}>
<PixelAddButton teamId={teamId} /> {showActions && <PixelAddButton teamId={teamId} />}
</PageHeader> </PageHeader>
<Panel> <Panel>
<PixelsDataTable /> <PixelsDataTable showActions={showActions} />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { PixelDeleteButton } from './PixelDeleteButton'; import { PixelDeleteButton } from './PixelDeleteButton';
import { PixelEditButton } from './PixelEditButton'; import { PixelEditButton } from './PixelEditButton';
export function PixelsTable(props: DataTableProps) { export interface PixelsTableProps extends DataTableProps {
showActions?: boolean;
}
export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation(); const { renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('pixel'); const { getSlugUrl } = useSlug('pixel');
@ -31,18 +35,20 @@ export function PixelsTable(props: DataTableProps) {
<DataColumn id="created" label={formatMessage(labels.created)}> <DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />} {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn> </DataColumn>
<DataColumn id="action" align="end" width="100px"> {showActions && (
{(row: any) => { <DataColumn id="action" align="end" width="100px">
const { id, name } = row; {(row: any) => {
const { id, name } = row;
return ( return (
<Row> <Row>
<PixelEditButton pixelId={id} /> <PixelEditButton pixelId={id} />
<PixelDeleteButton pixelId={id} name={name} /> <PixelDeleteButton pixelId={id} name={name} />
</Row> </Row>
); );
}} }}
</DataColumn> </DataColumn>
)}
</DataTable> </DataTable>
); );
} }

View file

@ -1,8 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getPixel } from '@/queries/prisma';
import { PixelPage } from './PixelPage'; import { PixelPage } from './PixelPage';
export default async function ({ params }: { params: { pixelId: string } }) { export default async function ({ params }: { params: { pixelId: string } }) {
const { pixelId } = await params; const { pixelId } = await params;
const pixel = await getPixel(pixelId);
if (!pixel || pixel?.deletedAt) {
return null;
}
return <PixelPage pixelId={pixelId} />; return <PixelPage pixelId={pixelId} />;
} }

View file

@ -4,6 +4,7 @@ import { DateRangeSetting } from './DateRangeSetting';
import { LanguageSetting } from './LanguageSetting'; import { LanguageSetting } from './LanguageSetting';
import { ThemeSetting } from './ThemeSetting'; import { ThemeSetting } from './ThemeSetting';
import { TimezoneSetting } from './TimezoneSetting'; import { TimezoneSetting } from './TimezoneSetting';
import { VersionSetting } from './VersionSetting';
export function PreferenceSettings() { export function PreferenceSettings() {
const { user } = useLoginQuery(); const { user } = useLoginQuery();
@ -31,6 +32,10 @@ export function PreferenceSettings() {
<Label>{formatMessage(labels.theme)}</Label> <Label>{formatMessage(labels.theme)}</Label>
<ThemeSetting /> <ThemeSetting />
</Column> </Column>
<Column>
<Label>{formatMessage(labels.version)}</Label>
<VersionSetting />
</Column>
</Column> </Column>
); );
} }

View file

@ -0,0 +1,8 @@
'use client';
import { Text } from '@umami/react-zen';
import { CURRENT_VERSION } from '@/lib/constants';
export function VersionSetting() {
return <Text>{CURRENT_VERSION}</Text>;
}

View file

@ -7,8 +7,17 @@ import {
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery } from '@/components/hooks';
import { UserSelect } from '@/components/input/UserSelect';
export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { export function TeamAddForm({
onSave,
onClose,
isAdmin,
}: {
onSave: () => void;
onClose: () => void;
isAdmin: boolean;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/teams'); const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
<FormField name="name" label={formatMessage(labels.name)}> <FormField name="name" label={formatMessage(labels.name)}>
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormField> </FormField>
{isAdmin && (
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
</FormField>
)}
<FormButtons> <FormButtons>
<Button isDisabled={isPending} onPress={onClose}> <Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}

View file

@ -0,0 +1,76 @@
import {
Button,
Form,
FormButtons,
FormField,
FormSubmitButton,
ListItem,
Select,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { UserSelect } from '@/components/input/UserSelect';
import { ROLES } from '@/lib/constants';
const roles = [ROLES.teamManager, ROLES.teamMember, ROLES.teamViewOnly];
export function TeamMemberAddForm({
teamId,
onSave,
onClose,
}: {
teamId: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users`);
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
const renderRole = role => {
switch (role) {
case ROLES.teamManager:
return formatMessage(labels.manager);
case ROLES.teamMember:
return formatMessage(labels.member);
case ROLES.teamViewOnly:
return formatMessage(labels.viewOnly);
}
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
name="userId"
label={formatMessage(labels.username)}
rules={{ required: 'Required' }}
>
<UserSelect teamId={teamId} />
</FormField>
<FormField name="role" label={formatMessage(labels.role)} rules={{ required: 'Required' }}>
<Select items={roles} renderValue={value => renderRole(value as any)}>
{roles.map(value => (
<ListItem key={value} id={value}>
{renderRole(value)}
</ListItem>
))}
</Select>
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -4,7 +4,13 @@ import { Plus } from '@/components/icons';
import { messages } from '@/components/messages'; import { messages } from '@/components/messages';
import { TeamAddForm } from './TeamAddForm'; import { TeamAddForm } from './TeamAddForm';
export function TeamsAddButton({ onSave }: { onSave?: () => void }) { export function TeamsAddButton({
onSave,
isAdmin = false,
}: {
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { toast } = useToast(); const { toast } = useToast();
const { touch } = useModified(); const { touch } = useModified();
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}> <Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />} {({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
</Dialog> </Dialog>
</Modal> </Modal>
</DialogTrigger> </DialogTrigger>

View file

@ -0,0 +1,40 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { messages } from '@/components/messages';
import { TeamMemberAddForm } from './TeamMemberAddForm';
export function TeamsMemberAddButton({
teamId,
onSave,
}: {
teamId: string;
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('teams:members');
onSave?.();
};
return (
<DialogTrigger>
<Button>
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addMember)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addMember)} style={{ width: 400 }}>
{({ close }) => <TeamMemberAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,10 +1,12 @@
import { Column } from '@umami/react-zen'; import { Column, Heading, Row } from '@umami/react-zen';
import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks';
import { Users } from '@/components/icons'; import { Users } from '@/components/icons';
import { labels } from '@/components/messages';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
import { TeamEditForm } from './TeamEditForm'; import { TeamEditForm } from './TeamEditForm';
import { TeamManage } from './TeamManage'; import { TeamManage } from './TeamManage';
import { TeamMembersDataTable } from './TeamMembersDataTable'; import { TeamMembersDataTable } from './TeamMembersDataTable';
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
const team: any = useTeam(); const team: any = useTeam();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { pathname } = useNavigation(); const { pathname } = useNavigation();
const { formatMessage } = useMessages();
const isAdmin = pathname.includes('/admin'); const isAdmin = pathname.includes('/admin');
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} /> <TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
</Panel> </Panel>
<Panel> <Panel>
<Row alignItems="center" justifyContent="space-between">
<Heading size="2">{formatMessage(labels.members)}</Heading>
{isAdmin && <TeamsMemberAddButton teamId={teamId} />}
</Row>
<TeamMembersDataTable teamId={teamId} allowEdit={canEdit} /> <TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
</Panel> </Panel>
{isTeamOwner && ( {isTeamOwner && (

View file

@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { WebsiteAddButton } from './WebsiteAddButton'; import { WebsiteAddButton } from './WebsiteAddButton';
import { WebsitesDataTable } from './WebsitesDataTable'; import { WebsitesDataTable } from './WebsitesDataTable';
export function WebsitesPage() { export function WebsitesPage() {
const { user } = useLoginQuery();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.websites)}> <PageHeader title={formatMessage(labels.websites)}>
<WebsiteAddButton teamId={teamId} /> {showActions && <WebsiteAddButton teamId={teamId} />}
</PageHeader> </PageHeader>
<Panel> <Panel>
<WebsitesDataTable teamId={teamId} /> <WebsitesDataTable teamId={teamId} showActions={showActions} />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -1,6 +1,6 @@
import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useResultQuery } from '@/components/hooks'; import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons'; import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ReportEditButton } from '@/components/input/ReportEditButton';
import { ChangeLabel } from '@/components/metrics/ChangeLabel'; import { ChangeLabel } from '@/components/metrics/ChangeLabel';
@ -20,6 +20,8 @@ type FunnelResult = {
export function Funnel({ id, name, type, parameters, websiteId }) { export function Funnel({ id, name, type, parameters, websiteId }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading } = useResultQuery(type, { const { data, error, isLoading } = useResultQuery(type, {
websiteId, websiteId,
...parameters, ...parameters,
@ -36,21 +38,22 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
</Text> </Text>
</Row> </Row>
</Column> </Column>
<Column> {!isSharePage && (
<ReportEditButton id={id} name={name} type={type}> <Column>
{({ close }) => { <ReportEditButton id={id} name={name} type={type}>
return ( {({ close }) => {
<Dialog return (
title={formatMessage(labels.funnel)} <Dialog
variant="modal" title={formatMessage(labels.funnel)}
style={{ minHeight: 300, minWidth: 400 }} style={{ minHeight: 300, minWidth: 400 }}
> >
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} /> <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog> </Dialog>
); );
}} }}
</ReportEditButton> </ReportEditButton>
</Column> </Column>
)}
</Grid> </Grid>
{data?.map( {data?.map(
( (

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useReportsQuery } from '@/components/hooks'; import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { Funnel } from './Funnel'; import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton'; import { FunnelAddButton } from './FunnelAddButton';
@ -13,13 +13,17 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<SectionHeader> {!isSharePage && (
<FunnelAddButton websiteId={websiteId} /> <SectionHeader>
</SectionHeader> <FunnelAddButton websiteId={websiteId} />
</SectionHeader>
)}
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (
<Grid gap> <Grid gap>

View file

@ -1,6 +1,6 @@
import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useResultQuery } from '@/components/hooks'; import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons'; import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ReportEditButton } from '@/components/input/ReportEditButton';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
@ -25,6 +25,8 @@ export type GoalData = { num: number; total: number };
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) { export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, { const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
websiteId, websiteId,
startDate, startDate,
@ -45,21 +47,23 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
</Text> </Text>
</Row> </Row>
</Column> </Column>
<Column> {!isSharePage && (
<ReportEditButton id={id} name={name} type={type}> <Column>
{({ close }) => { <ReportEditButton id={id} name={name} type={type}>
return ( {({ close }) => {
<Dialog return (
title={formatMessage(labels.goal)} <Dialog
variant="modal" title={formatMessage(labels.goal)}
style={{ minHeight: 300, minWidth: 400 }} variant="modal"
> style={{ minHeight: 300, minWidth: 400 }}
<GoalEditForm id={id} websiteId={websiteId} onClose={close} /> >
</Dialog> <GoalEditForm id={id} websiteId={websiteId} onClose={close} />
); </Dialog>
}} );
</ReportEditButton> }}
</Column> </ReportEditButton>
</Column>
)}
</Grid> </Grid>
<Row alignItems="center" justifyContent="space-between" gap> <Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted"> <Text color="muted">

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useReportsQuery } from '@/components/hooks'; import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { Goal } from './Goal'; import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton'; import { GoalAddButton } from './GoalAddButton';
@ -13,13 +13,17 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<SectionHeader> {!isSharePage && (
<GoalAddButton websiteId={websiteId} /> <SectionHeader>
</SectionHeader> <GoalAddButton websiteId={websiteId} />
</SectionHeader>
)}
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (
<Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>

View file

@ -21,9 +21,15 @@ export interface JourneyProps {
steps: number; steps: number;
startStep?: string; startStep?: string;
endStep?: string; endStep?: string;
view: string;
} }
export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) { const EVENT_TYPES = {
views: 1,
events: 2,
};
export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) {
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [activeNode, setActiveNode] = useState(null); const [activeNode, setActiveNode] = useState(null);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
steps, steps,
startStep, startStep,
endStep, endStep,
view,
eventType: EVENT_TYPES[view],
}); });
useEscapeKey(() => setSelectedNode(null)); useEscapeKey(() => setSelectedNode(null));

View file

@ -1,9 +1,10 @@
'use client'; 'use client';
import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen';
import { useState } from 'react'; import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages } from '@/components/hooks'; import { useDateRange, useMessages } from '@/components/hooks';
import { FilterButtons } from '@/components/input/FilterButtons';
import { Journey } from './Journey'; import { Journey } from './Journey';
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7]; const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const [view, setView] = useState('all');
const [steps, setSteps] = useState(DEFAULT_STEP); const [steps, setSteps] = useState(DEFAULT_STEP);
const [startStep, setStartStep] = useState(''); const [startStep, setStartStep] = useState('');
const [endStep, setEndStep] = useState(''); const [endStep, setEndStep] = useState('');
const buttons = [
{
id: 'all',
label: formatMessage(labels.all),
},
{
id: 'views',
label: formatMessage(labels.views),
},
{
id: 'events',
label: formatMessage(labels.events),
},
];
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
/> />
</Column> </Column>
</Grid> </Grid>
<Row justifyContent="flex-end">
<FilterButtons items={buttons} value={view} onChange={setView} />
</Row>
<Panel height="900px" allowFullscreen> <Panel height="900px" allowFullscreen>
<Journey <Journey
websiteId={websiteId} websiteId={websiteId}
@ -60,6 +80,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
steps={steps} steps={steps}
startStep={startStep} startStep={startStep}
endStep={endStep} endStep={endStep}
view={view}
/> />
</Panel> </Panel>
</Column> </Column>

View file

@ -12,9 +12,10 @@ import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar'; import { MetricsBar } from '@/components/metrics/MetricsBar';
import { renderDateLabels } from '@/lib/charts'; import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants'; import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date'; import { generateTimeSeries } from '@/lib/date';
import { formatLongCurrency, formatLongNumber } from '@/lib/format'; import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
export interface RevenueProps { export interface RevenueProps {
websiteId: string; websiteId: string;
@ -24,7 +25,15 @@ export interface RevenueProps {
} }
export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
const [currency, setCurrency] = useState('USD'); const [currency, setCurrency] = useState(
getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY,
);
const handleCurrencyChange = (value: string) => {
setCurrency(value);
setItem(CURRENCY_CONFIG, value);
};
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { locale, dateLocale } = useLocale(); const { locale, dateLocale } = useLocale();
const { countryNames } = useCountryNames(locale); const { countryNames } = useCountryNames(locale);
@ -107,7 +116,7 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
return ( return (
<Column gap> <Column gap>
<Grid columns="280px" gap> <Grid columns="280px" gap>
<CurrencySelect value={currency} onChange={setCurrency} /> <CurrencySelect value={currency} onChange={handleCurrencyChange} />
</Grid> </Grid>
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (

View file

@ -21,30 +21,28 @@ export function WebsiteChart({
const { pageviews, sessions, compare } = (data || {}) as any; const { pageviews, sessions, compare } = (data || {}) as any;
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (data) { if (!data) {
const result = { return { pageviews: [], sessions: [] };
pageviews, }
sessions,
};
if (compare) { return {
result.compare = { pageviews,
pageviews: result.pageviews.map(({ x }, i) => ({ sessions,
...(compare && {
compare: {
pageviews: pageviews.map(({ x }, i) => ({
x, x,
y: compare.pageviews[i]?.y, y: compare.pageviews[i]?.y,
d: compare.pageviews[i]?.x, d: compare.pageviews[i]?.x,
})), })),
sessions: result.sessions.map(({ x }, i) => ({ sessions: sessions.map(({ x }, i) => ({
x, x,
y: compare.sessions[i]?.y, y: compare.sessions[i]?.y,
d: compare.sessions[i]?.x, d: compare.sessions[i]?.x,
})), })),
}; },
} }),
};
return result;
}
return { pageviews: [], sessions: [] };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit]);
return ( return (

View file

@ -169,6 +169,12 @@ export function WebsiteExpandedMenu({
path: updateParams({ view: 'hostname' }), path: updateParams({ view: 'hostname' }),
icon: <Network />, icon: <Network />,
}, },
{
id: 'distinctId',
label: formatMessage(labels.distinctId),
path: updateParams({ view: 'distinctId' }),
icon: <Tag />,
},
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tag), label: formatMessage(labels.tag),

View file

@ -1,14 +1,18 @@
import { Icon, Row, Text } from '@umami/react-zen'; import { Icon, Row, Text } from '@umami/react-zen';
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
import { Favicon } from '@/components/common/Favicon'; import { Favicon } from '@/components/common/Favicon';
import { LinkButton } from '@/components/common/LinkButton'; import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, useNavigation, useWebsite } from '@/components/hooks'; import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
import { Edit, Share } from '@/components/icons'; import { Edit } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { ActiveUsers } from '@/components/metrics/ActiveUsers'; import { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader({ showActions }: { showActions?: boolean }) { export function WebsiteHeader({
showActions,
allowLink = true,
}: {
showActions?: boolean;
allowLink?: boolean;
}) {
const website = useWebsite(); const website = useWebsite();
const { renderUrl, pathname } = useNavigation(); const { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings'); const isSettings = pathname.endsWith('/settings');
@ -23,35 +27,20 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
<PageHeader <PageHeader
title={website.name} title={website.name}
icon={<Favicon domain={website.domain} />} icon={<Favicon domain={website.domain} />}
titleHref={renderUrl(`/websites/${website.id}`, false)} titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined}
> >
<Row alignItems="center" gap="6" wrap="wrap"> <Row alignItems="center" gap="6" wrap="wrap">
<ActiveUsers websiteId={website.id} /> <ActiveUsers websiteId={website.id} />
{showActions && ( {showActions && (
<Row alignItems="center" gap> <LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
<ShareButton websiteId={website.id} shareId={website.shareId} /> <Icon>
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}> <Edit />
<Icon> </Icon>
<Edit /> <Text>{formatMessage(labels.edit)}</Text>
</Icon> </LinkButton>
<Text>{formatMessage(labels.edit)}</Text>
</LinkButton>
</Row>
)} )}
</Row> </Row>
</PageHeader> </PageHeader>
); );
} }
const ShareButton = ({ websiteId, shareId }) => {
const { formatMessage, labels } = useMessages();
return (
<DialogButton icon={<Share />} label={formatMessage(labels.share)} width="800px">
{({ close }) => {
return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />;
}}
</DialogButton>
);
};

View file

@ -10,7 +10,7 @@ import {
} from '@umami/react-zen'; } from '@umami/react-zen';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { Edit, More, Share } from '@/components/icons'; import { Edit, MoreHorizontal, Share } from '@/components/icons';
export function WebsiteMenu({ websiteId }: { websiteId: string }) { export function WebsiteMenu({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
<MenuTrigger> <MenuTrigger>
<Button variant="quiet"> <Button variant="quiet">
<Icon> <Icon>
<More /> <MoreHorizontal />
</Icon> </Icon>
</Button> </Button>
<Popover placement="bottom"> <Popover placement="bottom">

View file

@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
export function WebsiteMetricsBar({ export function WebsiteMetricsBar({
websiteId, websiteId,
compareMode,
}: { }: {
websiteId: string; websiteId: string;
showChange?: boolean; showChange?: boolean;
compareMode?: boolean; compareMode?: boolean;
}) { }) {
const { isAllTime } = useDateRange(); const { isAllTime, dateCompare } = useDateRange();
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId); const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({
websiteId,
compare: compareMode ? dateCompare?.compare : undefined,
});
const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {}; const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};

View file

@ -29,6 +29,7 @@ export function WebsiteNav({
event: undefined, event: undefined,
compare: undefined, compare: undefined,
view: undefined, view: undefined,
unit: undefined,
}); });
const items = [ const items = [

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { Column } from '@umami/react-zen'; import { Column, Row } from '@umami/react-zen';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { UnitFilter } from '@/components/input/UnitFilter';
import { WebsiteChart } from './WebsiteChart'; import { WebsiteChart } from './WebsiteChart';
import { WebsiteControls } from './WebsiteControls'; import { WebsiteControls } from './WebsiteControls';
import { WebsiteMetricsBar } from './WebsiteMetricsBar'; import { WebsiteMetricsBar } from './WebsiteMetricsBar';
@ -13,6 +14,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<Row justifyContent="end">
<UnitFilter />
</Row>
<WebsiteChart websiteId={websiteId} /> <WebsiteChart websiteId={websiteId} />
</Panel> </Panel>
<WebsitePanels websiteId={websiteId} /> <WebsitePanels websiteId={websiteId} />

View file

@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} allowCompare={true} /> <WebsiteControls websiteId={websiteId} allowCompare={true} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} compareMode={true} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<WebsiteChart websiteId={websiteId} compareMode={true} /> <WebsiteChart websiteId={websiteId} compareMode={true} />
</Panel> </Panel>

View file

@ -93,6 +93,11 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
label: formatMessage(labels.hostname), label: formatMessage(labels.hostname),
path: renderPath('hostname'), path: renderPath('hostname'),
}, },
{
id: 'distinctId',
label: formatMessage(labels.distinctId),
path: renderPath('distinctId'),
},
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tags), label: formatMessage(labels.tags),

View file

@ -1,12 +1,18 @@
'use client'; 'use client';
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { type Key, useState } from 'react'; import locale from 'date-fns/locale/af';
import { type Key, useMemo, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
import { EventsChart } from '@/components/metrics/EventsChart'; import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { MetricsTable } from '@/components/metrics/MetricsTable'; import { MetricsTable } from '@/components/metrics/MetricsTable';
import { formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage'; import { getItem, setItem } from '@/lib/storage';
import { EventProperties } from './EventProperties'; import { EventProperties } from './EventProperties';
import { EventsDataTable } from './EventsDataTable'; import { EventsDataTable } from './EventsDataTable';
@ -15,16 +21,61 @@ const KEY_NAME = 'umami.events.tab';
export function EventsPage({ websiteId }) { export function EventsPage({ websiteId }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useEventStatsQuery({
websiteId,
});
const handleSelect = (value: Key) => { const handleSelect = (value: Key) => {
setItem(KEY_NAME, value); setItem(KEY_NAME, value);
setTab(value); setTab(value);
}; };
const metrics = useMemo(() => {
if (!data) return [];
const { events, visitors, visits, uniqueEvents } = data || {};
return [
{
value: visitors,
label: formatMessage(labels.visitors),
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
formatValue: formatLongNumber,
},
{
value: events,
label: formatMessage(labels.events),
formatValue: formatLongNumber,
},
{
value: uniqueEvents,
label: formatMessage(labels.uniqueEvents),
formatValue: formatLongNumber,
},
] as any;
}, [data, locale]);
return ( return (
<Column gap="3"> <Column gap="3">
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<LoadingPanel
data={metrics}
isLoading={isLoading}
isFetching={isFetching}
error={getErrorMessage(error)}
minHeight="136px"
>
<MetricsBar>
{metrics?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
</LoadingPanel>
<Panel> <Panel>
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}> <Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
<TabList> <TabList>

View file

@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
const { updateParams } = useNavigation(); const { updateParams } = useNavigation();
const { formatValue } = useFormat(); const { formatValue } = useFormat();
const renderLink = (label: string, hostname: string) => {
return (
<a
href={`//${hostname}${label}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
);
};
return ( return (
<DataTable {...props}> <DataTable {...props}>
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr"> <DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
title={row.eventName || row.urlPath} title={row.eventName || row.urlPath}
truncate truncate
> >
{row.eventName || row.urlPath} {row.eventName || renderLink(row.urlPath, row.hostname)}
</Text> </Text>
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />} {row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
</Row> </Row>

View file

@ -1,5 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout'; import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout';
import { getWebsite } from '@/queries/prisma';
export default async function ({ export default async function ({
children, children,
@ -9,6 +10,11 @@ export default async function ({
params: Promise<{ websiteId: string }>; params: Promise<{ websiteId: string }>;
}) { }) {
const { websiteId } = await params; const { websiteId } = await params;
const website = await getWebsite(websiteId);
if (!website || website?.deletedAt) {
return null;
}
return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>; return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>;
} }

View file

@ -74,8 +74,9 @@ export function RealtimeLog({ data }: { data: any }) {
os: string; os: string;
country: string; country: string;
device: string; device: string;
hostname: string;
}) => { }) => {
const { __type, eventName, urlPath, browser, os, country, device } = log; const { __type, eventName, urlPath, browser, os, country, device, hostname } = log;
if (__type === TYPE_EVENT) { if (__type === TYPE_EVENT) {
return ( return (
@ -86,7 +87,8 @@ export function RealtimeLog({ data }: { data: any }) {
url: ( url: (
<a <a
key="a" key="a"
href={`//${website?.domain}${urlPath}`} href={`//${hostname}${urlPath}`}
style={{ fontWeight: 'bold' }}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -100,7 +102,12 @@ export function RealtimeLog({ data }: { data: any }) {
if (__type === TYPE_PAGEVIEW) { if (__type === TYPE_PAGEVIEW) {
return ( return (
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener"> <a
href={`//${hostname}${urlPath}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{urlPath} {urlPath}
</a> </a>
); );

View file

@ -39,10 +39,23 @@ export function SessionActivity({
const { isMobile } = useMobile(); const { isMobile } = useMobile();
let lastDay = null; let lastDay = null;
const renderLink = (label: string, hostname: string) => {
return (
<a
href={`//${hostname}${label}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
);
};
return ( return (
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
<Column gap> <Column gap>
{data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hostname, hasData }) => {
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
lastDay = createdAt; lastDay = createdAt;
@ -61,7 +74,7 @@ export function SessionActivity({
: formatMessage(labels.viewedPage)} : formatMessage(labels.viewedPage)}
</Text> </Text>
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate> <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
{eventName || urlPath} {eventName || renderLink(urlPath, hostname)}
</Text> </Text>
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />} {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
</Row> </Row>

View file

@ -0,0 +1,57 @@
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
export function ShareDeleteButton({
shareId,
slug,
onSave,
}: {
shareId: string;
slug: string;
onSave?: () => void;
}) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/share/id/${shareId}`);
const { touch } = useModified();
const handleConfirm = async (close: () => void) => {
await mutateAsync(null, {
onSuccess: () => {
touch('shares');
onSave?.();
close();
},
});
};
return (
<DialogButton
icon={<Trash />}
title={formatMessage(labels.confirm)}
variant="quiet"
width="400px"
>
{({ close }) => (
<ConfirmationForm
message={
<FormattedMessage
{...messages.confirmRemove}
values={{
target: <b>{slug}</b>,
}}
/>
}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
buttonVariant="danger"
/>
)}
</DialogButton>
);
}

View file

@ -0,0 +1,16 @@
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { ShareEditForm } from './ShareEditForm';
export function ShareEditButton({ shareId }: { shareId: string }) {
const { formatMessage, labels } = useMessages();
return (
<DialogButton icon={<Edit />} title={formatMessage(labels.share)} variant="quiet" width="600px">
{({ close }) => {
return <ShareEditForm shareId={shareId} onClose={close} />;
}}
</DialogButton>
);
}

View file

@ -0,0 +1,164 @@
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Grid,
Label,
Loading,
Row,
Text,
TextField,
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
import { SHARE_NAV_ITEMS } from './constants';
export function ShareEditForm({
shareId,
websiteId,
onSave,
onClose,
}: {
shareId?: string;
websiteId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { cloudMode } = useConfig();
const { get, post } = useApi();
const { touch } = useModified();
const { modified } = useModified('shares');
const [share, setShare] = useState<any>(null);
const [isLoading, setIsLoading] = useState(!!shareId);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<any>(null);
const isEditing = !!shareId;
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
useEffect(() => {
if (!shareId) return;
const loadShare = async () => {
setIsLoading(true);
try {
const data = await get(`/share/id/${shareId}`);
setShare(data);
} finally {
setIsLoading(false);
}
};
loadShare();
}, [shareId, modified]);
const handleSubmit = async (data: any) => {
const parameters: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
setIsPending(true);
setError(null);
try {
if (isEditing) {
await post(`/share/id/${shareId}`, { name: data.name, slug: share.slug, parameters });
} else {
await post(`/websites/${websiteId}/shares`, { name: data.name, parameters });
}
touch('shares');
onSave?.();
onClose?.();
} catch (e) {
setError(e);
} finally {
setIsPending(false);
}
};
if (isLoading) {
return <Loading placement="absolute" />;
}
const url = isEditing ? getUrl(share?.slug || '') : null;
// Build default values from share parameters
const defaultValues: Record<string, any> = {
name: share?.name || '',
};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
const defaultSelected = item.id === 'overview' || item.id === 'events';
defaultValues[item.id] = share?.parameters?.[item.id] ?? defaultSelected;
});
});
// Get all item ids for validation
const allItemIds = SHARE_NAV_ITEMS.flatMap(section => section.items.map(item => item.id));
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={defaultValues}>
{({ watch }) => {
const values = watch();
const hasSelection = allItemIds.some(id => values[id]);
return (
<Column gap="6">
{url && (
<Column>
<Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy />
</Column>
)}
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" autoFocus={!isEditing} />
</FormField>
<Grid columns="repeat(auto-fit, minmax(150px, 1fr))" gap="3">
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="3">
<Text weight="bold">{formatMessage((labels as any)[section.section])}</Text>
<Column gap="1">
{section.items.map(item => (
<FormField key={item.id} name={item.id}>
<Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
</FormField>
))}
</Column>
</Column>
))}
</Grid>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton
variant="primary"
isDisabled={isPending || !hasSelection || !values.name}
>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Column>
);
}}
</Form>
);
}

View file

@ -0,0 +1,46 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { useConfig, useMessages } from '@/components/hooks';
import { ShareDeleteButton } from './ShareDeleteButton';
import { ShareEditButton } from './ShareEditButton';
export function SharesTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages();
const { cloudMode } = useConfig();
const getUrl = (slug: string) => {
return `${cloudMode ? process.env.cloudUrl : window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={formatMessage(labels.shareUrl)}>
{({ slug }: any) => {
const url = getUrl(slug);
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => {
return (
<Row>
<ShareEditButton shareId={id} />
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
);
}}
</DataColumn>
</DataTable>
);
}

View file

@ -1,14 +1,11 @@
import { Column } from '@umami/react-zen'; import { Column } from '@umami/react-zen';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useWebsite } from '@/components/hooks';
import { WebsiteData } from './WebsiteData'; import { WebsiteData } from './WebsiteData';
import { WebsiteEditForm } from './WebsiteEditForm'; import { WebsiteEditForm } from './WebsiteEditForm';
import { WebsiteShareForm } from './WebsiteShareForm'; import { WebsiteShareForm } from './WebsiteShareForm';
import { WebsiteTrackingCode } from './WebsiteTrackingCode'; import { WebsiteTrackingCode } from './WebsiteTrackingCode';
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) { export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
const website = useWebsite();
return ( return (
<Column gap="6"> <Column gap="6">
<Panel> <Panel>
@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
<WebsiteTrackingCode websiteId={websiteId} /> <WebsiteTrackingCode websiteId={websiteId} />
</Panel> </Panel>
<Panel> <Panel>
<WebsiteShareForm websiteId={websiteId} shareId={website.shareId} /> <WebsiteShareForm websiteId={websiteId} />
</Panel> </Panel>
<Panel> <Panel>
<WebsiteData websiteId={websiteId} /> <WebsiteData websiteId={websiteId} />

View file

@ -1,93 +1,43 @@
import { import { Column, Heading, Row, Text } from '@umami/react-zen';
Button, import { Plus } from 'lucide-react';
Column, import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
Form, import { DialogButton } from '@/components/input/DialogButton';
FormButtons, import { ShareEditForm } from './ShareEditForm';
FormSubmitButton, import { SharesTable } from './SharesTable';
IconLabel,
Label,
Row,
Switch,
TextField,
} from '@umami/react-zen';
import { RefreshCcw } from 'lucide-react';
import { useState } from 'react';
import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
import { getRandomChars } from '@/lib/generate';
const generateId = () => getRandomChars(16);
export interface WebsiteShareFormProps { export interface WebsiteShareFormProps {
websiteId: string; websiteId: string;
shareId?: string;
onSave?: () => void;
onClose?: () => void;
} }
export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) { export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const [currentId, setCurrentId] = useState(shareId); const { data, isLoading } = useWebsiteSharesQuery({ websiteId });
const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
const { cloudMode } = useConfig();
const getUrl = (shareId: string) => { const shares = data?.data || [];
if (cloudMode) { const hasShares = shares.length > 0;
return `${process.env.cloudUrl}/share/${shareId}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
};
const url = getUrl(currentId);
const handleGenerate = () => {
setCurrentId(generateId());
};
const handleSwitch = () => {
setCurrentId(currentId ? null : generateId());
};
const handleSave = async () => {
const data = {
shareId: currentId,
};
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch(`website:${websiteId}`);
onSave?.();
onClose?.();
},
});
};
return ( return (
<Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}> <Column gap="4">
<Column gap> <Row justifyContent="space-between" alignItems="center">
<Switch isSelected={!!currentId} onChange={handleSwitch}> <Heading>{formatMessage(labels.share)}</Heading>
{formatMessage(labels.enableShareUrl)} <DialogButton
</Switch> icon={<Plus size={16} />}
{currentId && ( label={formatMessage(labels.add)}
<Row alignItems="flex-end" gap> title={formatMessage(labels.share)}
<Column flexGrow={1}> variant="primary"
<Label>{formatMessage(labels.shareUrl)}</Label> width="600px"
<TextField value={url} isReadOnly allowCopy /> >
</Column> {({ close }) => <ShareEditForm websiteId={websiteId} onClose={close} />}
<Column> </DialogButton>
<Button onPress={handleGenerate}> </Row>
<IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} /> {hasShares ? (
</Button> <>
</Column> <Text>{formatMessage(messages.shareUrl)}</Text>
</Row> <SharesTable data={shares} />
)} </>
<FormButtons justifyContent="flex-end"> ) : (
<Row alignItems="center" gap> <Text color="muted">{formatMessage(messages.noDataAvailable)}</Text>
{onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>} )}
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton> </Column>
</Row>
</FormButtons>
</Column>
</Form>
); );
} }

View file

@ -0,0 +1,30 @@
export const SHARE_NAV_ITEMS = [
{
section: 'traffic',
items: [
{ id: 'overview', label: 'overview' },
{ id: 'events', label: 'events' },
{ id: 'sessions', label: 'sessions' },
{ id: 'realtime', label: 'realtime' },
{ id: 'compare', label: 'compare' },
{ id: 'breakdown', label: 'breakdown' },
],
},
{
section: 'behavior',
items: [
{ id: 'goals', label: 'goals' },
{ id: 'funnels', label: 'funnels' },
{ id: 'journeys', label: 'journeys' },
{ id: 'retention', label: 'retention' },
],
},
{
section: 'growth',
items: [
{ id: 'utm', label: 'utm' },
{ id: 'revenue', label: 'revenue' },
{ id: 'attribution', label: 'attribution' },
],
},
];

View file

@ -1,7 +1,14 @@
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
import { ok } from '@/lib/response'; import { ok } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
const { error } = await parseRequest(request);
if (error) {
return error();
}
if (redis.enabled) { if (redis.enabled) {
const token = request.headers.get('authorization')?.split(' ')?.[1]; const token = request.headers.get('authorization')?.split(' ')?.[1];

View file

@ -1,7 +1,7 @@
import { saveAuth } from '@/lib/auth'; import { saveAuth } from '@/lib/auth';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response'; import { json, serverError } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
const { auth, error } = await parseRequest(request); const { auth, error } = await parseRequest(request);
@ -10,9 +10,13 @@ export async function POST(request: Request) {
return error(); return error();
} }
if (redis.enabled) { if (!redis.enabled) {
const token = await saveAuth({ userId: auth.user.id }, 86400); return serverError({
message: 'Redis is disabled',
return json({ user: auth.user, token }); });
} }
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
} }

View file

@ -17,5 +17,6 @@ export async function GET(request: Request) {
telemetryDisabled: !!process.env.DISABLE_TELEMETRY, telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
trackerScriptName: process.env.TRACKER_SCRIPT_NAME, trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
updatesDisabled: !!process.env.DISABLE_UPDATES, updatesDisabled: !!process.env.DISABLE_UPDATES,
currentVersion: !!process.env.currentVersion,
}); });
} }

View file

@ -12,11 +12,16 @@ export async function POST(request: Request) {
} }
const { websiteId, parameters, filters } = body; const { websiteId, parameters, filters } = body;
const { eventType } = parameters;
if (!(await canViewWebsite(auth, websiteId))) { if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
} }
if (eventType) {
filters.eventType = eventType;
}
const queryFilters = await getQueryFilters(filters, websiteId); const queryFilters = await getQueryFilters(filters, websiteId);
const data = await getJourney(websiteId, parameters, queryFilters); const data = await getJourney(websiteId, parameters, queryFilters);

View file

@ -1,19 +0,0 @@
import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt';
import { json, notFound } from '@/lib/response';
import { getSharedWebsite } from '@/queries/prisma';
export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const { shareId } = await params;
const website = await getSharedWebsite(shareId);
if (!website) {
return notFound();
}
const data = { websiteId: website.id };
const token = createToken(data, secret());
return json({ ...data, token });
}

View file

@ -0,0 +1,76 @@
import { ROLES } from '@/lib/constants';
import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt';
import prisma from '@/lib/prisma';
import redis from '@/lib/redis';
import { json, notFound } from '@/lib/response';
import type { WhiteLabel } from '@/lib/types';
import { getShareByCode, getWebsite } from '@/queries/prisma';
async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> {
if (website.userId) {
return website.userId;
}
if (website.teamId) {
const teamOwner = await prisma.client.teamUser.findFirst({
where: {
teamId: website.teamId,
role: ROLES.teamOwner,
},
select: {
userId: true,
},
});
return teamOwner?.userId || null;
}
return null;
}
async function getWhiteLabel(accountId: string): Promise<WhiteLabel | null> {
if (!redis.enabled) {
return null;
}
const data = await redis.client.get(`white-label:${accountId}`);
if (data) {
return data as WhiteLabel;
}
return null;
}
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const share = await getShareByCode(slug);
if (!share) {
return notFound();
}
const website = await getWebsite(share.entityId);
const data: Record<string, any> = {
shareId: share.id,
websiteId: share.entityId,
parameters: share.parameters,
};
data.token = createToken(data, secret());
const accountId = await getAccountId(website);
if (accountId) {
const whiteLabel = await getWhiteLabel(accountId);
if (whiteLabel) {
data.whiteLabel = whiteLabel;
}
}
return json(data);
}

View file

@ -0,0 +1,82 @@
import z from 'zod';
import { parseRequest } from '@/lib/request';
import { json, notFound, ok, unauthorized } from '@/lib/response';
import { anyObjectParam } from '@/lib/schema';
import { canDeleteEntity, canUpdateEntity, canViewEntity } from '@/permissions';
import { deleteShare, getShare, updateShare } from '@/queries/prisma';
export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { shareId } = await params;
const share = await getShare(shareId);
if (!(await canViewEntity(auth, share.entityId))) {
return unauthorized();
}
return json(share);
}
export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const schema = z.object({
name: z.string().max(200),
slug: z.string().max(100),
parameters: anyObjectParam,
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { shareId } = await params;
const { name, slug, parameters } = body;
const share = await getShare(shareId);
if (!share) {
return notFound();
}
if (!(await canUpdateEntity(auth, share.entityId))) {
return unauthorized();
}
const result = await updateShare(shareId, {
name,
slug,
parameters,
} as any);
return json(result);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ shareId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { shareId } = await params;
const share = await getShare(shareId);
if (!(await canDeleteEntity(auth, share.entityId))) {
return unauthorized();
}
await deleteShare(shareId);
return ok();
}

View file

@ -0,0 +1,39 @@
import z from 'zod';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { anyObjectParam } from '@/lib/schema';
import { canUpdateEntity } from '@/permissions';
import { createShare } from '@/queries/prisma';
export async function POST(request: Request) {
const schema = z.object({
entityId: z.uuid(),
shareType: z.coerce.number().int(),
slug: z.string().max(100).optional(),
parameters: anyObjectParam,
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { entityId, shareType, slug, parameters } = body;
if (!(await canUpdateEntity(auth, entityId))) {
return unauthorized();
}
const share = await createShare({
id: uuid(),
entityId,
shareType,
slug: slug || getRandomChars(16),
parameters,
});
return json(share);
}

View file

@ -28,6 +28,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
name: z.string().max(50), name: z.string().max(50),
ownerId: z.uuid().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -40,7 +41,7 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const { name } = body; const { name, ownerId } = body;
const team = await createTeam( const team = await createTeam(
{ {
@ -48,7 +49,7 @@ export async function POST(request: Request) {
name, name,
accessCode: `team_${getRandomChars(16)}`, accessCode: `team_${getRandomChars(16)}`,
}, },
auth.user.id, ownerId ?? auth.user.id,
); );
return json(team); return json(team);

View file

@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { hashPassword } from '@/lib/password'; import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, unauthorized } from '@/lib/response'; import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema'; import { userRoleParam } from '@/lib/schema';
import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions'; import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma'; import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({ const schema = z.object({
username: z.string().max(255).optional(), username: z.string().max(255).optional(),
password: z.string().max(255).optional(), password: z.string().min(8).max(255).optional(),
role: userRoleParam.optional(), role: userRoleParam.optional(),
}); });
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
const user = await getUser(userId); const user = await getUser(userId);
if (!user) {
return notFound();
}
const data: any = {}; const data: any = {};
if (password) { if (password) {

View file

@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
import { hashPassword } from '@/lib/password'; import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response'; import { badRequest, json, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema';
import { canCreateUser } from '@/permissions'; import { canCreateUser } from '@/permissions';
import { createUser, getUserByUsername } from '@/queries/prisma'; import { createUser, getUserByUsername } from '@/queries/prisma';
@ -11,8 +12,8 @@ export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
id: z.uuid().optional(), id: z.uuid().optional(),
username: z.string().max(255), username: z.string().max(255),
password: z.string(), password: z.string().min(8).max(255),
role: z.string().regex(/admin|user|view-only/i), role: userRoleParam,
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { dateRangeParams, filterParams } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getWebsiteEventStats } from '@/queries/sql/events/getWebsiteEventStats';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...dateRangeParams,
...filterParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const data = await getWebsiteEventStats(websiteId, filters);
return json({ data });
}

View file

@ -1,7 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { SHARE_ID_REGEX } from '@/lib/constants';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; import { json, ok, unauthorized } from '@/lib/response';
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma'; import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
@ -33,7 +32,6 @@ export async function POST(
const schema = z.object({ const schema = z.object({
name: z.string().optional(), name: z.string().optional(),
domain: z.string().optional(), domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -43,23 +41,15 @@ export async function POST(
} }
const { websiteId } = await params; const { websiteId } = await params;
const { name, domain, shareId } = body; const { name, domain } = body;
if (!(await canUpdateWebsite(auth, websiteId))) { if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
} }
try { const website = await updateWebsite(websiteId, { name, domain });
const website = await updateWebsite(websiteId, { name, domain, shareId });
return Response.json(website); return Response.json(website);
} catch (e: any) {
if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
return badRequest({ message: 'That share ID is already taken.' });
}
return serverError(e);
}
} }
export async function DELETE( export async function DELETE(

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema'; import { searchParams, segmentParamSchema, segmentTypeParam } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions'; import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createSegment, getWebsiteSegments } from '@/queries/prisma'; import { createSegment, getWebsiteSegments } from '@/queries/prisma';
@ -42,7 +42,7 @@ export async function POST(
const schema = z.object({ const schema = z.object({
type: segmentTypeParam, type: segmentTypeParam,
name: z.string().max(200), name: z.string().max(200),
parameters: anyObjectParam, parameters: segmentParamSchema,
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -0,0 +1,77 @@
import { z } from 'zod';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createShare, getSharesByEntityId } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...filterParams,
...pagingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { page, pageSize, search } = query;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getSharesByEntityId(websiteId, {
page,
pageSize,
search,
});
return json(data);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
name: z.string().max(200),
parameters: anyObjectParam.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { name, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
const slug = getRandomChars(16);
const share = await createShare({
id: uuid(),
entityId: websiteId,
shareType: ENTITY_TYPE.website,
name,
slug,
parameters: shareParameters,
});
return json(share);
}

View file

@ -31,7 +31,11 @@ export async function GET(
const data = await getWebsiteStats(websiteId, filters); const data = await getWebsiteStats(websiteId, filters);
const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate); const { startDate, endDate } = getCompareDate(
filters.compare ?? 'prev',
filters.startDate,
filters.endDate,
);
const comparison = await getWebsiteStats(websiteId, { const comparison = await getWebsiteStats(websiteId, {
...filters, ...filters,

View file

@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import redis from '@/lib/redis'; import { fetchAccount } from '@/lib/load';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema'; import { pagingParams, searchParams } from '@/lib/schema';
@ -52,7 +52,7 @@ export async function POST(request: Request) {
const { id, name, domain, shareId, teamId } = body; const { id, name, domain, shareId, teamId } = body;
if (process.env.CLOUD_MODE && !teamId) { if (process.env.CLOUD_MODE && !teamId) {
const account = await redis.client.get(`account:${auth.user.id}`); const account = await fetchAccount(auth.user.id);
if (!account?.hasSubscription) { if (!account?.hasSubscription) {
const count = await getWebsiteCount(auth.user.id); const count = await getWebsiteCount(auth.user.id);

View file

@ -1,12 +0,0 @@
import { Row, Text } from '@umami/react-zen';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
export function Footer() {
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={HOMEPAGE_URL} target="_blank">
<Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`}
</a>
</Row>
);
}

View file

@ -1,24 +0,0 @@
import { Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Logo } from '@/components/svg';
export function Header() {
return (
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href="https://umami.is" target="_blank" rel="noopener">
<Row alignItems="center" gap>
<Icon>
<Logo />
</Icon>
<Text weight="bold">umami</Text>
</Row>
</a>
<Row alignItems="center" gap>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
</Row>
);
}

View file

@ -0,0 +1,23 @@
import { Row, Text } from '@umami/react-zen';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
import type { WhiteLabel } from '@/lib/types';
export function ShareFooter({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
if (whiteLabel) {
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={whiteLabel.url} target="_blank">
<Text weight="bold">{whiteLabel.name}</Text>
</a>
</Row>
);
}
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={HOMEPAGE_URL} target="_blank">
<Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`}
</a>
</Row>
);
}

View file

@ -0,0 +1,33 @@
import { Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Logo } from '@/components/svg';
import type { WhiteLabel } from '@/lib/types';
export function ShareHeader({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
return (
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href={logoUrl} target="_blank" rel="noopener">
<Row alignItems="center" gap>
{logoImage ? (
<img src={logoImage} alt={logoName} style={{ height: 24 }} />
) : (
<Icon>
<Logo />
</Icon>
)}
<Text weight="bold">{logoName}</Text>
</Row>
</a>
<Row alignItems="center" gap>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
</Row>
);
}

View file

@ -0,0 +1,143 @@
'use client';
import { Column } from '@umami/react-zen';
import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation } from '@/components/hooks';
import { AlignEndHorizontal, Clock, Eye, Sheet, Tag, User } from '@/components/icons';
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
export function ShareNav({
shareId,
parameters,
onItemClick,
}: {
shareId: string;
parameters: Record<string, boolean>;
onItemClick?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const renderPath = (path: string) => `/share/${shareId}${path}`;
const allItems = [
{
section: 'traffic',
label: formatMessage(labels.traffic),
items: [
{
id: 'overview',
label: formatMessage(labels.overview),
icon: <Eye />,
path: renderPath(''),
},
{
id: 'events',
label: formatMessage(labels.events),
icon: <Lightning />,
path: renderPath('/events'),
},
{
id: 'sessions',
label: formatMessage(labels.sessions),
icon: <User />,
path: renderPath('/sessions'),
},
{
id: 'realtime',
label: formatMessage(labels.realtime),
icon: <Clock />,
path: renderPath('/realtime'),
},
{
id: 'compare',
label: formatMessage(labels.compare),
icon: <AlignEndHorizontal />,
path: renderPath('/compare'),
},
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
path: renderPath('/breakdown'),
},
],
},
{
section: 'behavior',
label: formatMessage(labels.behavior),
items: [
{
id: 'goals',
label: formatMessage(labels.goals),
icon: <Target />,
path: renderPath('/goals'),
},
{
id: 'funnels',
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: renderPath('/funnels'),
},
{
id: 'journeys',
label: formatMessage(labels.journeys),
icon: <Path />,
path: renderPath('/journeys'),
},
{
id: 'retention',
label: formatMessage(labels.retention),
icon: <Magnet />,
path: renderPath('/retention'),
},
],
},
{
section: 'growth',
label: formatMessage(labels.growth),
items: [
{
id: 'utm',
label: formatMessage(labels.utm),
icon: <Tag />,
path: renderPath('/utm'),
},
{
id: 'revenue',
label: formatMessage(labels.revenue),
icon: <Money />,
path: renderPath('/revenue'),
},
{
id: 'attribution',
label: formatMessage(labels.attribution),
icon: <Network />,
path: renderPath('/attribution'),
},
],
},
];
// Filter items based on parameters
const items = allItems
.map(section => ({
label: section.label,
items: section.items.filter(item => parameters[item.id] !== false),
}))
.filter(section => section.items.length > 0);
const selectedKey = items
.flatMap(e => e.items)
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
return (
<Column padding="3" position="sticky" top="0" gap>
<SideMenu
items={items}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
</Column>
);
}

View file

@ -1,17 +1,74 @@
'use client'; 'use client';
import { Column, useTheme } from '@umami/react-zen'; import { Column, Grid, Row, useTheme } from '@umami/react-zen';
import { useEffect } from 'react'; import { useRouter } from 'next/navigation';
import { useEffect, useMemo } from 'react';
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage';
import { GoalsPage } from '@/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage';
import { JourneysPage } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage';
import { RetentionPage } from '@/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage';
import { RevenuePage } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage';
import { UTMPage } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage';
import { ComparePage } from '@/app/(main)/websites/[websiteId]/compare/ComparePage';
import { EventsPage } from '@/app/(main)/websites/[websiteId]/events/EventsPage';
import { RealtimePage } from '@/app/(main)/websites/[websiteId]/realtime/RealtimePage';
import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/SessionsPage';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader'; import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage'; import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { useShareTokenQuery } from '@/components/hooks'; import { useShareTokenQuery } from '@/components/hooks';
import { Footer } from './Footer'; import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { Header } from './Header'; import { ShareFooter } from './ShareFooter';
import { ShareHeader } from './ShareHeader';
import { ShareNav } from './ShareNav';
export function SharePage({ shareId }) { const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
'': WebsitePage,
overview: WebsitePage,
events: EventsPage,
sessions: SessionsPage,
realtime: RealtimePage,
compare: ComparePage,
breakdown: BreakdownPage,
goals: GoalsPage,
funnels: FunnelsPage,
journeys: JourneysPage,
retention: RetentionPage,
utm: UTMPage,
revenue: RevenuePage,
attribution: AttributionPage,
};
// All section IDs that can be enabled/disabled via parameters
const ALL_SECTION_IDS = [
'overview',
'events',
'sessions',
'realtime',
'compare',
'breakdown',
'goals',
'funnels',
'journeys',
'retention',
'utm',
'revenue',
'attribution',
];
export function SharePage({ shareId, path = '' }: { shareId: string; path?: string }) {
const { shareToken, isLoading } = useShareTokenQuery(shareId); const { shareToken, isLoading } = useShareTokenQuery(shareId);
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const router = useRouter();
// Calculate allowed sections
const allowedSections = useMemo(() => {
if (!shareToken?.parameters) return [];
const params = shareToken.parameters;
return ALL_SECTION_IDS.filter(id => params[id] !== false);
}, [shareToken?.parameters]);
useEffect(() => { useEffect(() => {
const url = new URL(window?.location?.href); const url = new URL(window?.location?.href);
@ -22,20 +79,77 @@ export function SharePage({ shareId }) {
} }
}, []); }, []);
// Redirect to the only allowed section if there's just one and we're on the base path
useEffect(() => {
if (
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === '' || path === 'overview')
) {
router.replace(`/share/${shareId}/${allowedSections[0]}`);
}
}, [allowedSections, shareId, path, router]);
if (isLoading || !shareToken) { if (isLoading || !shareToken) {
return null; return null;
} }
const { websiteId, parameters = {}, whiteLabel } = shareToken;
// Redirect to only allowed section - return null while redirecting
if (
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === '' || path === 'overview')
) {
return null;
}
// Check if the requested path is allowed
const pageKey = path || '';
const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false;
if (!isAllowed) {
return null;
}
const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage;
return ( return (
<Column backgroundColor="2"> <Column backgroundColor="2">
<PageBody gap> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Header /> <Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
<WebsiteProvider websiteId={shareToken.websiteId}> <Grid columns="auto 1fr" flexGrow={1} backgroundColor="3" borderRadius>
<WebsiteHeader showActions={false} /> <MobileMenuButton>
<WebsitePage websiteId={shareToken.websiteId} /> {({ close }) => {
</WebsiteProvider> return <ShareNav shareId={shareId} parameters={parameters} onItemClick={close} />;
<Footer /> }}
</PageBody> </MobileMenuButton>
</Grid>
</Row>
<Column
display={{ xs: 'none', lg: 'flex' }}
width="240px"
height="100%"
border="right"
backgroundColor
marginRight="2"
>
<Column display={{ xs: 'none', lg: 'flex' }}>
<ShareNav shareId={shareId} parameters={parameters} />
</Column>
</Column>
<PageBody gap>
<WebsiteProvider websiteId={websiteId}>
<ShareHeader whiteLabel={whiteLabel} />
<Column>
<WebsiteHeader showActions={false} />
<PageComponent websiteId={websiteId} />
</Column>
<ShareFooter whiteLabel={whiteLabel} />
</WebsiteProvider>
</PageBody>
</Grid>
</Column> </Column>
); );
} }

View file

@ -2,6 +2,7 @@ import { SharePage } from './SharePage';
export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) { export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) {
const { shareId } = await params; const { shareId } = await params;
const [slug, ...path] = shareId;
return <SharePage shareId={shareId[0]} />; return <SharePage shareId={slug} path={path.join('/')} />;
} }

View file

@ -31,6 +31,7 @@ export function PageBody({
<Column <Column
{...props} {...props}
width="100%" width="100%"
minHeight="100vh"
paddingBottom="6" paddingBottom="6"
maxWidth={maxWidth} maxWidth={maxWidth}
paddingX={{ xs: '3', md: '6' }} paddingX={{ xs: '3', md: '6' }}

View file

@ -51,6 +51,7 @@ export * from './queries/useWebsiteSegmentsQuery';
export * from './queries/useWebsiteSessionQuery'; export * from './queries/useWebsiteSessionQuery';
export * from './queries/useWebsiteSessionStatsQuery'; export * from './queries/useWebsiteSessionStatsQuery';
export * from './queries/useWebsiteSessionsQuery'; export * from './queries/useWebsiteSessionsQuery';
export * from './queries/useWebsiteSharesQuery';
export * from './queries/useWebsiteStatsQuery'; export * from './queries/useWebsiteStatsQuery';
export * from './queries/useWebsitesQuery'; export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteValuesQuery'; export * from './queries/useWebsiteValuesQuery';

View file

@ -16,7 +16,7 @@ export function useEventDataValuesQuery(
return useQuery<any>({ return useQuery<any>({
queryKey: [ queryKey: [
'websites:event-data:values', 'websites:event-data:values',
{ websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters }, { websiteId, startAt, endAt, unit, timezone, ...filters, event, propertyName },
], ],
queryFn: () => queryFn: () =>
get(`/websites/${websiteId}/event-data/values`, { get(`/websites/${websiteId}/event-data/values`, {

View file

@ -0,0 +1,37 @@
import type { UseQueryOptions } from '@tanstack/react-query';
import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters';
export interface EventStatsData {
events: number;
visitors: number;
visits: number;
uniqueEvents: number;
}
type EventStatsApiResponse = {
data: EventStatsData;
};
export function useEventStatsQuery(
{ websiteId }: { websiteId: string },
options?: UseQueryOptions<EventStatsApiResponse, Error, EventStatsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<EventStatsApiResponse, Error, EventStatsData>({
queryKey: ['websites:events:stats', { websiteId, startAt, endAt, ...filters }],
queryFn: () =>
get(`/websites/${websiteId}/events/stats`, {
startAt,
endAt,
...filters,
}),
select: response => response.data,
enabled: !!websiteId,
...options,
});
}

View file

@ -10,7 +10,7 @@ export function useResultQuery<T = any>(
) { ) {
const { websiteId, ...parameters } = params; const { websiteId, ...parameters } = params;
const { post, useQuery } = useApi(); const { post, useQuery } = useApi();
const { startDate, endDate, timezone } = useDateParameters(); const { startDate, endDate, timezone, unit } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<T>({ return useQuery<T>({
@ -22,6 +22,7 @@ export function useResultQuery<T = any>(
startDate, startDate,
endDate, endDate,
timezone, timezone,
unit,
...params, ...params,
...filters, ...filters,
}, },
@ -35,6 +36,7 @@ export function useResultQuery<T = any>(
startDate, startDate,
endDate, endDate,
timezone, timezone,
unit,
...parameters, ...parameters,
}, },
}), }),

View file

@ -3,7 +3,7 @@ import { useApi } from '../useApi';
const selector = (state: { shareToken: string }) => state.shareToken; const selector = (state: { shareToken: string }) => state.shareToken;
export function useShareTokenQuery(shareId: string): { export function useShareTokenQuery(slug: string): {
shareToken: any; shareToken: any;
isLoading?: boolean; isLoading?: boolean;
error?: Error; error?: Error;
@ -11,9 +11,9 @@ export function useShareTokenQuery(shareId: string): {
const shareToken = useApp(selector); const shareToken = useApp(selector);
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { isLoading, error } = useQuery({ const { isLoading, error } = useQuery({
queryKey: ['share', shareId], queryKey: ['share', slug],
queryFn: async () => { queryFn: async () => {
const data = await get(`/share/${shareId}`); const data = await get(`/share/${slug}`);
setShareToken(data); setShareToken(data);

View file

@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
options?: ReactQueryOptions<WebsiteExpandedMetricsData>, options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteExpandedMetricsData>({ return useQuery<WebsiteExpandedMetricsData>({
@ -29,8 +29,6 @@ export function useWebsiteExpandedMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -39,8 +37,6 @@ export function useWebsiteExpandedMetricsQuery(
get(`/websites/${websiteId}/metrics/expanded`, { get(`/websites/${websiteId}/metrics/expanded`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
options?: ReactQueryOptions<WebsiteMetricsData>, options?: ReactQueryOptions<WebsiteMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteMetricsData>({ return useQuery<WebsiteMetricsData>({
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
get(`/websites/${websiteId}/metrics`, { get(`/websites/${websiteId}/metrics`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -0,0 +1,20 @@
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useWebsiteSharesQuery(
{ websiteId }: { websiteId: string },
options?: ReactQueryOptions,
) {
const { modified } = useModified('shares');
const { get } = useApi();
return usePagedQuery({
queryKey: ['websiteShares', { websiteId, modified }],
queryFn: pageParams => {
return get(`/websites/${websiteId}/shares`, pageParams);
},
...options,
});
}

View file

@ -19,17 +19,16 @@ export interface WebsiteStatsData {
} }
export function useWebsiteStatsQuery( export function useWebsiteStatsQuery(
websiteId: string, { websiteId, compare }: { websiteId: string; compare?: string },
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>, options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteStatsData>({ return useQuery<WebsiteStatsData>({
queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }], queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
queryFn: () => queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
enabled: !!websiteId, enabled: !!websiteId,
...options, ...options,
}); });

View file

@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string,
return useQuery({ return useQuery({
queryKey: [ queryKey: [
'sessions', 'sessions',
{ websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, { websiteId, modified, startAt, endAt, timezone, ...params, ...filters },
], ],
queryFn: () => { queryFn: () => {
return get(`/websites/${websiteId}/sessions/weekly`, { return get(`/websites/${websiteId}/sessions/weekly`, {
startAt, startAt,
endAt, endAt,
unit,
timezone, timezone,
...params, ...params,
...filters, ...filters,

View file

@ -7,13 +7,13 @@ import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) { export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const { const {
query: { date = '', offset = 0, compare = 'prev' }, query: { date = '', unit = '', offset = 0, compare = 'prev' },
} = useNavigation(); } = useNavigation();
const { locale } = useLocale(); const { locale } = useLocale();
const dateRange = useMemo(() => { const dateRange = useMemo(() => {
const dateRangeObject = parseDateRange( const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
unit,
locale, locale,
options.timezone, options.timezone,
); );
@ -21,12 +21,13 @@ export function useDateRange(options: { ignoreOffset?: boolean; timezone?: strin
return !options.ignoreOffset && offset return !options.ignoreOffset && offset
? getOffsetDateRange(dateRangeObject, +offset) ? getOffsetDateRange(dateRangeObject, +offset)
: dateRangeObject; : dateRangeObject;
}, [date, offset, options]); }, [date, unit, offset, options]);
const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
return { return {
date, date,
unit,
offset, offset,
compare, compare,
isAllTime: date.endsWith(`:all`), isAllTime: date.endsWith(`:all`),

View file

@ -15,6 +15,7 @@ export function useFields() {
{ name: 'region', type: 'string', label: formatMessage(labels.region) }, { name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) }, { name: 'city', type: 'string', label: formatMessage(labels.city) },
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) }, { name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
{ name: 'distinctId', type: 'string', label: formatMessage(labels.distinctId) },
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) }, { name: 'tag', type: 'string', label: formatMessage(labels.tag) },
{ name: 'event', type: 'string', label: formatMessage(labels.event) }, { name: 'event', type: 'string', label: formatMessage(labels.event) },
]; ];

Some files were not shown because too many files have changed in this diff Show more