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
- type: input
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'
- type: input
attributes:
label: Which browser are you using? (if relevant)
description: 'For example: Chrome, Edge, Firefox, etc'
label: How are you deploying your application?
description: 'For example: Vercel, Railway, Docker, etc'
- type: input
attributes:
label: How are you deploying your application? (if relevant)
description: 'For example: Vercel, Railway, Docker, etc'
label: Which browser are you using?
description: 'For example: Chrome, Edge, Firefox, etc'

3
.gitignore vendored
View file

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

View file

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

View file

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

278
pnpm-lock.yaml generated
View file

@ -117,8 +117,8 @@ importers:
specifier: ^5.1.31
version: 5.1.32
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
specifier: ^9.0.3
version: 9.0.3
jszip:
specifier: ^3.10.1
version: 3.10.1
@ -250,11 +250,11 @@ importers:
specifier: ^10.1.0
version: 10.1.0
cypress:
specifier: ^13.6.6
version: 13.17.0
specifier: ^15.8.0
version: 15.9.0
extract-react-intl-messages:
specifier: ^4.1.1
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))
specifier: ^5.0.0
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:
specifier: ^9.1.7
version: 9.1.7
@ -313,8 +313,8 @@ importers:
specifier: ^14.0.0
version: 14.0.1(stylelint@15.11.0(typescript@5.9.3))
tar:
specifier: ^6.1.2
version: 6.2.1
specifier: ^7.5.4
version: 7.5.4
ts-jest:
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)
@ -566,10 +566,6 @@ packages:
resolution: {integrity: sha512-co2spjR7wZoZ3Ck0H/jv76bpiuO3oJHtOmq9/gxFiod2DcT9NFg01u/hXcG8MJFnEJuMB6e3vGqS6IOnLwHqRw==}
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':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@ -687,8 +683,8 @@ packages:
peerDependencies:
postcss-selector-parser: ^6.0.13
'@cypress/request@3.0.9':
resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==}
'@cypress/request@3.0.10':
resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==}
engines: {node: '>= 6'}
'@cypress/xvfb@1.2.4':
@ -1442,6 +1438,10 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
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':
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
engines: {node: '>=8'}
@ -2811,6 +2811,9 @@ packages:
'@types/stack-utils@2.0.3':
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':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
@ -2972,9 +2975,6 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@ -3183,17 +3183,13 @@ packages:
chart.js: '>=2.8.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:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@2.0.0:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
@ -3224,8 +3220,8 @@ packages:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
cli-table3@0.6.5:
resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==}
cli-table3@0.6.1:
resolution: {integrity: sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==}
engines: {node: 10.* || >= 12.*}
cli-truncate@2.1.0:
@ -3280,6 +3276,10 @@ packages:
colorette@2.0.20:
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:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@ -3478,9 +3478,9 @@ packages:
resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==}
engines: {node: '>=0.10.0'}
cypress@13.17.0:
resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==}
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
cypress@15.9.0:
resolution: {integrity: sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==}
engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0}
hasBin: true
d3-array@2.12.1:
@ -3845,9 +3845,9 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
extract-react-intl-messages@4.1.1:
resolution: {integrity: sha512-dPogci5X7HVtV7VbUxajH/1YgfNRaW2VtEiVidZ/31Tq8314uzOtzVMNo0IrAPD2E+H1wHoPiu/j565TZsyIZg==}
engines: {node: '>=10'}
extract-react-intl-messages@5.0.0:
resolution: {integrity: sha512-7K1aA3WxhhjBXsuZ2buZm5MLuPHjzkbErV2qqhf0m0K9RMqdwe6mYrOAMZ+1z1bfrngwQ2Iv44+RLjILO8qPdA==}
engines: {node: '>=20'}
hasBin: true
extract-zip@2.0.1:
@ -3906,6 +3906,10 @@ packages:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
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:
resolution: {integrity: sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==}
engines: {node: '>=12.0.0'}
@ -3925,6 +3929,10 @@ packages:
fix-dts-default-cjs-exports@1.0.1:
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:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0}
@ -3933,6 +3941,9 @@ packages:
resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==}
hasBin: true
flatted@2.0.2:
resolution: {integrity: sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==}
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
@ -3974,10 +3985,6 @@ packages:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
fs-minipass@2.1.0:
resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
engines: {node: '>= 8'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -4042,9 +4049,6 @@ packages:
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
getos@3.2.1:
resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==}
getpass@0.1.7:
resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==}
@ -4147,6 +4151,10 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasha@5.2.2:
resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==}
engines: {node: '>=8'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@ -4730,8 +4738,8 @@ packages:
jsonify@0.0.1:
resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jsprim@2.0.2:
@ -4741,11 +4749,11 @@ packages:
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
kafkajs@2.2.4:
resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==}
@ -4768,10 +4776,6 @@ packages:
known-css-properties@0.36.0:
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:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@ -4865,10 +4869,6 @@ packages:
lodash.once@4.1.1:
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:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
@ -5054,21 +5054,17 @@ packages:
minimist@1.2.8:
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:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@2.1.2:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
@ -5952,8 +5948,8 @@ packages:
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
qs@6.14.0:
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@ -6177,6 +6173,11 @@ packages:
rfdc@1.4.1:
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:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
@ -6603,14 +6604,19 @@ packages:
engines: {node: '>=14.0.0'}
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:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
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
tar@7.5.4:
resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==}
engines: {node: '>=18'}
terser@5.43.1:
resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==}
@ -6995,6 +7001,10 @@ packages:
resolution: {integrity: sha512-PxiShnxf0IlnQuMYOPPhPkhExoCQuTUNPOa/2JWCYTmBquU9njyyDuwRKN26IZBlp4yn1nt+Agh2HOOBl+55HQ==}
engines: {node: '>=8.3'}
write@1.0.3:
resolution: {integrity: sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==}
engines: {node: '>=4'}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@ -7009,6 +7019,10 @@ packages:
yallist@4.0.0:
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:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
@ -7305,9 +7319,6 @@ snapshots:
dependencies:
'@clickhouse/client-common': 1.14.0
'@colors/colors@1.5.0':
optional: true
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@ -7405,7 +7416,7 @@ snapshots:
dependencies:
postcss-selector-parser: 6.1.2
'@cypress/request@3.0.9':
'@cypress/request@3.0.10':
dependencies:
aws-sign2: 0.7.0
aws4: 1.13.2
@ -7420,7 +7431,7 @@ snapshots:
json-stringify-safe: 5.0.1
mime-types: 2.1.35
performance-now: 2.1.0
qs: 6.14.0
qs: 6.14.1
safe-buffer: 5.2.1
tough-cookie: 5.1.2
tunnel-agent: 0.6.0
@ -8011,6 +8022,10 @@ snapshots:
wrap-ansi: 8.1.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':
dependencies:
camelcase: 5.3.1
@ -9864,6 +9879,8 @@ snapshots:
'@types/stack-utils@2.0.3': {}
'@types/tmp@0.2.6': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/yargs-parser@21.0.3': {}
@ -10070,8 +10087,6 @@ snapshots:
async-function@1.0.0: {}
async@3.2.6: {}
asynckit@0.4.0: {}
at-least-node@1.0.0: {}
@ -10323,13 +10338,11 @@ snapshots:
chart.js: 4.5.1
date-fns: 2.30.0
check-more-types@2.24.0: {}
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
chownr@2.0.0: {}
chownr@3.0.0: {}
ci-info@3.9.0: {}
@ -10353,11 +10366,11 @@ snapshots:
dependencies:
restore-cursor: 5.1.0
cli-table3@0.6.5:
cli-table3@0.6.1:
dependencies:
string-width: 4.2.3
optionalDependencies:
'@colors/colors': 1.5.0
colors: 1.4.0
cli-truncate@2.1.0:
dependencies:
@ -10403,6 +10416,9 @@ snapshots:
colorette@2.0.20: {}
colors@1.4.0:
optional: true
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@ -10618,22 +10634,22 @@ snapshots:
dependencies:
array-find-index: 1.0.2
cypress@13.17.0:
cypress@15.9.0:
dependencies:
'@cypress/request': 3.0.9
'@cypress/request': 3.0.10
'@cypress/xvfb': 1.2.4(supports-color@8.1.1)
'@types/sinonjs__fake-timers': 8.1.1
'@types/sizzle': 2.3.9
'@types/tmp': 0.2.6
arch: 2.2.0
blob-util: 2.0.2
bluebird: 3.7.2
buffer: 5.7.1
cachedir: 2.4.0
chalk: 4.1.2
check-more-types: 2.24.0
ci-info: 4.3.0
cli-cursor: 3.1.0
cli-table3: 0.6.5
cli-table3: 0.6.1
commander: 6.2.1
common-tags: 1.8.2
dayjs: 1.11.13
@ -10645,9 +10661,8 @@ snapshots:
extract-zip: 2.0.1(supports-color@8.1.1)
figures: 3.2.0
fs-extra: 9.1.0
getos: 3.2.1
hasha: 5.2.2
is-installed-globally: 0.4.0
lazy-ass: 1.6.0
listr2: 3.14.0(enquirer@2.4.1)
lodash: 4.17.21
log-symbols: 4.1.0
@ -10657,8 +10672,8 @@ snapshots:
process: 0.11.10
proxy-from-env: 1.0.0
request-progress: 3.0.0
semver: 7.7.3
supports-color: 8.1.1
systeminformation: 5.30.5
tmp: 0.2.5
tree-kill: 1.2.2
untildify: 4.0.0
@ -11131,17 +11146,17 @@ snapshots:
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:
'@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))
file-entry-cache: 5.0.1
flat: 5.0.2
glob: 7.2.3
js-yaml: 3.14.1
load-json-file: 6.2.0
lodash.merge: 4.6.2
lodash.mergewith: 4.6.2
lodash.pick: 4.4.0
meow: 6.1.1
mkdirp: 1.0.4
pify: 5.0.0
@ -11209,6 +11224,10 @@ snapshots:
dependencies:
escape-string-regexp: 1.0.5
file-entry-cache@5.0.1:
dependencies:
flat-cache: 2.0.1
file-entry-cache@7.0.2:
dependencies:
flat-cache: 3.2.0
@ -11233,6 +11252,12 @@ snapshots:
mlly: 1.8.0
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:
dependencies:
flatted: 3.3.3
@ -11241,6 +11266,8 @@ snapshots:
flat@5.0.2: {}
flatted@2.0.2: {}
flatted@3.3.3: {}
for-each@0.3.5:
@ -11293,10 +11320,6 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@ -11363,10 +11386,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
getos@3.2.1:
dependencies:
async: 3.2.6
getpass@0.1.7:
dependencies:
assert-plus: 1.0.0
@ -11501,6 +11520,11 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hasha@5.2.2:
dependencies:
is-stream: 2.0.1
type-fest: 0.8.1
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@ -12236,9 +12260,9 @@ snapshots:
jsonify@0.0.1: {}
jsonwebtoken@9.0.2:
jsonwebtoken@9.0.3:
dependencies:
jws: 3.2.2
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
@ -12263,15 +12287,15 @@ snapshots:
readable-stream: 2.3.8
setimmediate: 1.0.5
jwa@1.4.2:
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
jws@4.0.1:
dependencies:
jwa: 1.4.2
jwa: 2.0.1
safe-buffer: 5.2.1
kafkajs@2.2.4: {}
@ -12289,8 +12313,6 @@ snapshots:
known-css-properties@0.36.0:
optional: true
lazy-ass@1.6.0: {}
leven@3.1.0: {}
lie@3.3.0:
@ -12383,8 +12405,6 @@ snapshots:
lodash.once@4.1.1: {}
lodash.pick@4.4.0: {}
lodash.truncate@4.4.2: {}
lodash.uniq@4.5.0: {}
@ -12575,18 +12595,15 @@ snapshots:
minimist@1.2.8: {}
minipass@3.3.6:
dependencies:
yallist: 4.0.0
minipass@5.0.0: {}
minipass@7.1.2: {}
minizlib@2.1.2:
minizlib@3.1.0:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
minipass: 7.1.2
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
mkdirp@1.0.4: {}
@ -13431,7 +13448,7 @@ snapshots:
pure-rand@7.0.1: {}
qs@6.14.0:
qs@6.14.1:
dependencies:
side-channel: 1.1.0
@ -13769,6 +13786,10 @@ snapshots:
rfdc@1.4.1: {}
rimraf@2.6.3:
dependencies:
glob: 7.2.3
rimraf@3.0.2:
dependencies:
glob: 7.2.3
@ -14336,6 +14357,8 @@ snapshots:
csso: 5.0.5
picocolors: 1.1.1
systeminformation@5.30.5: {}
table@6.9.0:
dependencies:
ajv: 8.17.1
@ -14344,14 +14367,13 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
tar@6.2.1:
tar@7.5.4:
dependencies:
chownr: 2.0.0
fs-minipass: 2.1.0
minipass: 5.0.0
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.2
minizlib: 3.1.0
yallist: 5.0.0
terser@5.43.1:
dependencies:
@ -14758,6 +14780,10 @@ snapshots:
sort-keys: 4.2.0
write-file-atomic: 3.0.3
write@1.0.3:
dependencies:
mkdirp: 0.5.6
xtend@4.0.2: {}
y18n@5.0.8: {}
@ -14766,6 +14792,8 @@ snapshots:
yallist@4.0.0: {}
yallist@5.0.0: {}
yaml@1.10.2: {}
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 {
id String @id @unique @map("user_id") @db.Uuid
id String @id() @map("user_id") @db.Uuid
username String @unique @db.VarChar(255)
password String @db.VarChar(60)
role String @map("role") @db.VarChar(50)
@ -27,12 +27,13 @@ model User {
pixels Pixel[] @relation("user")
teams TeamUser[]
reports Report[]
boards Board[] @relation("user")
@@map("user")
}
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
browser String? @db.VarChar(20)
os String? @db.VarChar(20)
@ -64,10 +65,9 @@ model Session {
}
model Website {
id String @id @unique @map("website_id") @db.Uuid
id String @id() @map("website_id") @db.Uuid
name String @db.VarChar(100)
domain String? @db.VarChar(500)
shareId String? @unique @map("share_id") @db.VarChar(50)
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
@ -88,7 +88,6 @@ model Website {
@@index([userId])
@@index([teamId])
@@index([createdAt])
@@index([shareId])
@@index([createdBy])
@@map("website")
}
@ -187,7 +186,7 @@ model SessionData {
}
model Team {
id String @id() @unique() @map("team_id") @db.Uuid
id String @id() @map("team_id") @db.Uuid
name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183)
@ -199,13 +198,14 @@ model Team {
members TeamUser[]
links Link[]
pixels Pixel[]
boards Board[]
@@index([accessCode])
@@map("team")
}
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
userId String @map("user_id") @db.Uuid
role String @db.VarChar(50)
@ -221,7 +221,7 @@ model TeamUser {
}
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
websiteId String @map("website_id") @db.Uuid
type String @db.VarChar(50)
@ -242,7 +242,7 @@ model Report {
}
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
type String @db.VarChar(50)
name String @db.VarChar(200)
@ -257,7 +257,7 @@ model Segment {
}
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
sessionId String @map("session_id") @db.Uuid
eventId String @map("event_id") @db.Uuid
@ -277,7 +277,7 @@ model Revenue {
}
model Link {
id String @id() @unique() @map("link_id") @db.Uuid
id String @id() @map("link_id") @db.Uuid
name String @db.VarChar(100)
url String @db.VarChar(500)
slug String @unique() @db.VarChar(100)
@ -298,7 +298,7 @@ model Link {
}
model Pixel {
id String @id() @unique() @map("pixel_id") @db.Uuid
id String @id() @map("pixel_id") @db.Uuid
name String @db.VarChar(100)
slug String @unique() @db.VarChar(100)
userId String? @map("user_id") @db.Uuid
@ -316,3 +316,39 @@ model Pixel {
@@index([createdAt])
@@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": "访问代码"
}
],
"label.account": [
{
"type": 0,
"value": "账户"
}
],
"label.action": [
{
"type": 0,
"value": "行为"
}
],
"label.actions": [
{
"type": 0,
@ -35,12 +47,24 @@
"value": "添加描述"
}
],
"label.add-link": [
{
"type": 0,
"value": "添加链接"
}
],
"label.add-member": [
{
"type": 0,
"value": "添加成员"
}
],
"label.add-pixel": [
{
"type": 0,
"value": "添加像素"
}
],
"label.add-step": [
{
"type": 0,
@ -83,12 +107,24 @@
"value": "所有时间段"
}
],
"label.analysis": [
{
"type": 0,
"value": "分析"
}
],
"label.analytics": [
{
"type": 0,
"value": "分析"
}
],
"label.application": [
{
"type": 0,
"value": "应用"
}
],
"label.apply": [
{
"type": 0,
@ -107,6 +143,12 @@
"value": "查看用户如何与您的营销互动,以及是什么促成了转化。"
}
],
"label.audience": [
{
"type": 0,
"value": "受众"
}
],
"label.average": [
{
"type": 0,
@ -125,6 +167,12 @@
"value": "之前"
}
],
"label.behavior": [
{
"type": 0,
"value": "行为"
}
],
"label.boards": [
{
"type": 0,
@ -173,12 +221,24 @@
"value": "修改密码"
}
],
"label.channel": [
{
"type": 0,
"value": "渠道"
}
],
"label.channels": [
{
"type": 0,
"value": "渠道"
}
],
"label.chart": [
{
"type": 0,
"value": "图表"
}
],
"label.cities": [
{
"type": 0,
@ -203,6 +263,12 @@
"value": "队列"
}
],
"label.cohorts": [
{
"type": 0,
"value": "队列"
}
],
"label.compare": [
{
"type": 0,
@ -317,6 +383,12 @@
"value": "创建者"
}
],
"label.criteria": [
{
"type": 0,
"value": "条件"
}
],
"label.currency": [
{
"type": 0,
@ -419,6 +491,12 @@
"value": "台式机"
}
],
"label.destination-url": [
{
"type": 0,
"value": "目标URL"
}
],
"label.details": [
{
"type": 0,
@ -455,6 +533,12 @@
"value": "唯一ID"
}
],
"label.documentation": [
{
"type": 0,
"value": "文档"
}
],
"label.does-not-contain": [
{
"type": 0,
@ -479,6 +563,12 @@
"value": "域名"
}
],
"label.download": [
{
"type": 0,
"value": "下载"
}
],
"label.dropoff": [
{
"type": 0,
@ -506,7 +596,7 @@
"label.email": [
{
"type": 0,
"value": "Email"
"value": "邮箱"
}
],
"label.enable-share-url": [
@ -527,6 +617,12 @@
"value": "入口 URL"
}
],
"label.environment": [
{
"type": 0,
"value": "环境"
}
],
"label.event": [
{
"type": 0,
@ -671,6 +767,12 @@
"value": "分组"
}
],
"label.growth": [
{
"type": 0,
"value": "增长"
}
],
"label.hostname": [
{
"type": 0,
@ -701,6 +803,12 @@
"value": "通过使用筛选器和划分时间段来更深入地研究数据。"
}
],
"label.invalid-url": [
{
"type": 0,
"value": "无效URL"
}
],
"label.is": [
{
"type": 0,
@ -863,12 +971,24 @@
"value": "少于等于"
}
],
"label.link": [
{
"type": 0,
"value": "链接"
}
],
"label.links": [
{
"type": 0,
"value": "链接"
}
],
"label.location": [
{
"type": 0,
"value": "位置"
}
],
"label.login": [
{
"type": 0,
@ -1020,7 +1140,7 @@
"label.online": [
{
"type": 0,
"value": "Online"
"value": "在线"
}
],
"label.organic-search": [
@ -1165,6 +1285,12 @@
"value": "路径"
}
],
"label.pixel": [
{
"type": 0,
"value": "像素"
}
],
"label.pixels": [
{
"type": 0,
@ -1185,6 +1311,12 @@
"value": " 提供支持"
}
],
"label.preferences": [
{
"type": 0,
"value": "偏好"
}
],
"label.previous": [
{
"type": 0,
@ -1209,6 +1341,12 @@
"value": "个人资料"
}
],
"label.profiles": [
{
"type": 0,
"value": "个人资料"
}
],
"label.properties": [
{
"type": 0,
@ -1248,7 +1386,7 @@
"label.referral": [
{
"type": 0,
"value": "Referral"
"value": "来源"
}
],
"label.referrer": [
@ -1371,6 +1509,24 @@
"value": "保存"
}
],
"label.save-cohort": [
{
"type": 0,
"value": "保存为群组"
}
],
"label.save-segment": [
{
"type": 0,
"value": "保存为细分"
}
],
"label.screen": [
{
"type": 0,
"value": "屏幕"
}
],
"label.screens": [
{
"type": 0,
@ -1383,6 +1539,18 @@
"value": "搜索"
}
],
"label.segment": [
{
"type": 0,
"value": "细分"
}
],
"label.segments": [
{
"type": 0,
"value": "细分"
}
],
"label.select": [
{
"type": 0,
@ -1485,6 +1653,24 @@
"value": "总和"
}
],
"label.support": [
{
"type": 0,
"value": "支持"
}
],
"label.switch-account": [
{
"type": 0,
"value": "切换账户"
}
],
"label.table": [
{
"type": 0,
"value": "表格"
}
],
"label.tablet": [
{
"type": 0,
@ -1635,6 +1821,12 @@
"value": "跟踪代码"
}
],
"label.traffic": [
{
"type": 0,
"value": "流量"
}
],
"label.transactions": [
{
"type": 0,
@ -1846,7 +2038,7 @@
"message.bad-request": [
{
"type": 0,
"value": "Bad request"
"value": "请求错误"
}
],
"message.collected-data": [
@ -1946,7 +2138,7 @@
"message.forbidden": [
{
"type": 0,
"value": "Forbidden"
"value": "禁止访问"
}
],
"message.go-to-settings": [
@ -2046,13 +2238,13 @@
"message.not-found": [
{
"type": 0,
"value": "Not found"
"value": "未找到"
}
],
"message.nothing-selected": [
{
"type": 0,
"value": "Nothing selected."
"value": "未选择"
}
],
"message.page-not-found": [
@ -2090,7 +2282,7 @@
"message.sever-error": [
{
"type": 0,
"value": "Server error"
"value": "服务器错误"
}
],
"message.share-url": [
@ -2158,7 +2350,7 @@
"message.unauthorized": [
{
"type": 0,
"value": "Unauthorized"
"value": "未授权"
}
],
"message.user-deleted": [

View file

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

View file

@ -3,7 +3,7 @@ import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import https from 'https';
import tar from 'tar';
import { list } from 'tar';
import zlib from 'zlib';
if (process.env.VERCEL && !process.env.BUILD_GEO) {
@ -40,7 +40,7 @@ const isDirectMmdb = url.endsWith('.mmdb');
const downloadCompressed = url =>
new Promise(resolve => {
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 { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { TeamsAddButton } from '../../teams/TeamsAddButton';
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages();
const handleSave = () => {};
return (
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.teams)} />
<PageHeader title={formatMessage(labels.teams)}>
<TeamsAddButton onSave={handleSave} isAdmin={true} />
</PageHeader>
<Panel>
<AdminTeamsDataTable />
</Panel>

View file

@ -10,6 +10,7 @@ import {
TextField,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { messages } from '@/components/messages';
import { ROLES } from '@/lib/constants';
export function UserAddForm({ onSave, onClose }) {
@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) {
<FormField
label={formatMessage(labels.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" />
</FormField>

View file

@ -30,7 +30,11 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
};
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)}>
<TextField data-test="input-username" />
</FormField>

View file

@ -1,5 +1,5 @@
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 { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
@ -15,7 +15,8 @@ export function LinkDeleteButton({
onSave?: () => void;
}) {
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) => {
await mutateAsync(null, {

View file

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

View file

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

View file

@ -4,21 +4,30 @@ import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
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';
export function LinksPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
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 (
<PageBody>
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.links)}>
<LinkAddButton teamId={teamId} />
{showActions && <LinkAddButton teamId={teamId} />}
</PageHeader>
<Panel>
<LinksDataTable />
<LinksDataTable showActions={showActions} />
</Panel>
</Column>
</PageBody>

View file

@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { LinkDeleteButton } from './LinkDeleteButton';
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 { websiteId, renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('link');
@ -36,16 +40,18 @@ export function LinksTable(props: DataTableProps) {
<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, name }: any) => {
return (
<Row>
<LinkEditButton linkId={id} />
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
</Row>
);
}}
</DataColumn>
{showActions && (
<DataColumn id="action" align="end" width="100px">
{({ id, name }: any) => {
return (
<Row>
<LinkEditButton linkId={id} />
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
</Row>
);
}}
</DataColumn>
)}
</DataTable>
);
}

View file

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

View file

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

View file

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

View file

@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
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 { PixelsDataTable } from './PixelsDataTable';
export function PixelsPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
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 (
<PageBody>
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.pixels)}>
<PixelAddButton teamId={teamId} />
{showActions && <PixelAddButton teamId={teamId} />}
</PageHeader>
<Panel>
<PixelsDataTable />
<PixelsDataTable showActions={showActions} />
</Panel>
</Column>
</PageBody>

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import { DateRangeSetting } from './DateRangeSetting';
import { LanguageSetting } from './LanguageSetting';
import { ThemeSetting } from './ThemeSetting';
import { TimezoneSetting } from './TimezoneSetting';
import { VersionSetting } from './VersionSetting';
export function PreferenceSettings() {
const { user } = useLoginQuery();
@ -31,6 +32,10 @@ export function PreferenceSettings() {
<Label>{formatMessage(labels.theme)}</Label>
<ThemeSetting />
</Column>
<Column>
<Label>{formatMessage(labels.version)}</Label>
<VersionSetting />
</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,
} from '@umami/react-zen';
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 { mutateAsync, error, isPending } = useUpdateQuery('/teams');
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
<FormField name="name" label={formatMessage(labels.name)}>
<TextField autoComplete="off" />
</FormField>
{isAdmin && (
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
</FormField>
)}
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{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 { TeamAddForm } from './TeamAddForm';
export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
export function TeamsAddButton({
onSave,
isAdmin = false,
}: {
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
</Button>
<Modal>
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />}
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
</Dialog>
</Modal>
</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 { PageHeader } from '@/components/common/PageHeader';
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 { labels } from '@/components/messages';
import { ROLES } from '@/lib/constants';
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
import { TeamEditForm } from './TeamEditForm';
import { TeamManage } from './TeamManage';
import { TeamMembersDataTable } from './TeamMembersDataTable';
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
const team: any = useTeam();
const { user } = useLoginQuery();
const { pathname } = useNavigation();
const { formatMessage } = useMessages();
const isAdmin = pathname.includes('/admin');
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
</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} />
</Panel>
{isTeamOwner && (

View file

@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
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 { WebsitesDataTable } from './WebsitesDataTable';
export function WebsitesPage() {
const { user } = useLoginQuery();
const { teamId } = useNavigation();
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 (
<PageBody>
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.websites)}>
<WebsiteAddButton teamId={teamId} />
{showActions && <WebsiteAddButton teamId={teamId} />}
</PageHeader>
<Panel>
<WebsitesDataTable teamId={teamId} />
<WebsitesDataTable teamId={teamId} showActions={showActions} />
</Panel>
</Column>
</PageBody>

View file

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

View file

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

View file

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

View file

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

View file

@ -21,9 +21,15 @@ export interface JourneyProps {
steps: number;
startStep?: 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 [activeNode, setActiveNode] = useState(null);
const { formatMessage, labels } = useMessages();
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
steps,
startStep,
endStep,
view,
eventType: EVENT_TYPES[view],
});
useEscapeKey(() => setSelectedNode(null));

View file

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

View file

@ -12,9 +12,10 @@ import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
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 { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
export interface RevenueProps {
websiteId: string;
@ -24,7 +25,15 @@ export interface 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 { locale, dateLocale } = useLocale();
const { countryNames } = useCountryNames(locale);
@ -107,7 +116,7 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
return (
<Column gap>
<Grid columns="280px" gap>
<CurrencySelect value={currency} onChange={setCurrency} />
<CurrencySelect value={currency} onChange={handleCurrencyChange} />
</Grid>
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (

View file

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

View file

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

View file

@ -1,14 +1,18 @@
import { Icon, Row, Text } from '@umami/react-zen';
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
import { Favicon } from '@/components/common/Favicon';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
import { Edit, Share } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { Edit } from '@/components/icons';
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 { renderUrl, pathname } = useNavigation();
const isSettings = pathname.endsWith('/settings');
@ -23,35 +27,20 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
<PageHeader
title={website.name}
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">
<ActiveUsers websiteId={website.id} />
{showActions && (
<Row alignItems="center" gap>
<ShareButton websiteId={website.id} shareId={website.shareId} />
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</LinkButton>
</Row>
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</LinkButton>
)}
</Row>
</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';
import { Fragment } from 'react';
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 }) {
const { formatMessage, labels } = useMessages();
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
<MenuTrigger>
<Button variant="quiet">
<Icon>
<More />
<MoreHorizontal />
</Icon>
</Button>
<Popover placement="bottom">

View file

@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
export function WebsiteMetricsBar({
websiteId,
compareMode,
}: {
websiteId: string;
showChange?: boolean;
compareMode?: boolean;
}) {
const { isAllTime } = useDateRange();
const { isAllTime, dateCompare } = useDateRange();
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 || {};

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,18 @@
'use client';
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 { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
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 { formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
import { EventProperties } from './EventProperties';
import { EventsDataTable } from './EventsDataTable';
@ -15,16 +21,61 @@ const KEY_NAME = 'umami.events.tab';
export function EventsPage({ websiteId }) {
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) => {
setItem(KEY_NAME, 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 (
<Column gap="3">
<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>
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
<TabList>

View file

@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
const { updateParams } = useNavigation();
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 (
<DataTable {...props}>
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
title={row.eventName || row.urlPath}
truncate
>
{row.eventName || row.urlPath}
{row.eventName || renderLink(row.urlPath, row.hostname)}
</Text>
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
</Row>

View file

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

View file

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

View file

@ -39,10 +39,23 @@ export function SessionActivity({
const { isMobile } = useMobile();
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 (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<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));
lastDay = createdAt;
@ -61,7 +74,7 @@ export function SessionActivity({
: formatMessage(labels.viewedPage)}
</Text>
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
{eventName || urlPath}
{eventName || renderLink(urlPath, hostname)}
</Text>
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
</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 { Panel } from '@/components/common/Panel';
import { useWebsite } from '@/components/hooks';
import { WebsiteData } from './WebsiteData';
import { WebsiteEditForm } from './WebsiteEditForm';
import { WebsiteShareForm } from './WebsiteShareForm';
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
const website = useWebsite();
return (
<Column gap="6">
<Panel>
@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
<WebsiteTrackingCode websiteId={websiteId} />
</Panel>
<Panel>
<WebsiteShareForm websiteId={websiteId} shareId={website.shareId} />
<WebsiteShareForm websiteId={websiteId} />
</Panel>
<Panel>
<WebsiteData websiteId={websiteId} />

View file

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

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

View file

@ -1,7 +1,7 @@
import { saveAuth } from '@/lib/auth';
import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { json, serverError } from '@/lib/response';
export async function POST(request: Request) {
const { auth, error } = await parseRequest(request);
@ -10,9 +10,13 @@ export async function POST(request: Request) {
return error();
}
if (redis.enabled) {
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
if (!redis.enabled) {
return serverError({
message: 'Redis is disabled',
});
}
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,
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
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 { eventType } = parameters;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
if (eventType) {
filters.eventType = eventType;
}
const queryFilters = await getQueryFilters(filters, websiteId);
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) {
const schema = z.object({
name: z.string().max(50),
ownerId: z.uuid().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -40,7 +41,7 @@ export async function POST(request: Request) {
return unauthorized();
}
const { name } = body;
const { name, ownerId } = body;
const team = await createTeam(
{
@ -48,7 +49,7 @@ export async function POST(request: Request) {
name,
accessCode: `team_${getRandomChars(16)}`,
},
auth.user.id,
ownerId ?? auth.user.id,
);
return json(team);

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
import { hashPassword } from '@/lib/password';
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 { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
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 }> }) {
const schema = z.object({
username: z.string().max(255).optional(),
password: z.string().max(255).optional(),
password: z.string().min(8).max(255).optional(),
role: userRoleParam.optional(),
});
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
const user = await getUser(userId);
if (!user) {
return notFound();
}
const data: any = {};
if (password) {

View file

@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema';
import { canCreateUser } from '@/permissions';
import { createUser, getUserByUsername } from '@/queries/prisma';
@ -11,8 +12,8 @@ export async function POST(request: Request) {
const schema = z.object({
id: z.uuid().optional(),
username: z.string().max(255),
password: z.string(),
role: z.string().regex(/admin|user|view-only/i),
password: z.string().min(8).max(255),
role: userRoleParam,
});
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 { SHARE_ID_REGEX } from '@/lib/constants';
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 { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
@ -33,7 +32,6 @@ export async function POST(
const schema = z.object({
name: z.string().optional(),
domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -43,23 +41,15 @@ export async function POST(
}
const { websiteId } = await params;
const { name, domain, shareId } = body;
const { name, domain } = body;
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
try {
const website = await updateWebsite(websiteId, { name, domain, shareId });
const website = await updateWebsite(websiteId, { name, domain });
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);
}
return Response.json(website);
}
export async function DELETE(

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
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 { createSegment, getWebsiteSegments } from '@/queries/prisma';
@ -42,7 +42,7 @@ export async function POST(
const schema = z.object({
type: segmentTypeParam,
name: z.string().max(200),
parameters: anyObjectParam,
parameters: segmentParamSchema,
});
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 { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate);
const { startDate, endDate } = getCompareDate(
filters.compare ?? 'prev',
filters.startDate,
filters.endDate,
);
const comparison = await getWebsiteStats(websiteId, {
...filters,

View file

@ -1,6 +1,6 @@
import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import redis from '@/lib/redis';
import { fetchAccount } from '@/lib/load';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
@ -52,7 +52,7 @@ export async function POST(request: Request) {
const { id, name, domain, shareId, teamId } = body;
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) {
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';
import { Column, useTheme } from '@umami/react-zen';
import { useEffect } from 'react';
import { Column, Grid, Row, useTheme } from '@umami/react-zen';
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 { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { useShareTokenQuery } from '@/components/hooks';
import { Footer } from './Footer';
import { Header } from './Header';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
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 { 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(() => {
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) {
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 (
<Column backgroundColor="2">
<PageBody gap>
<Header />
<WebsiteProvider websiteId={shareToken.websiteId}>
<WebsiteHeader showActions={false} />
<WebsitePage websiteId={shareToken.websiteId} />
</WebsiteProvider>
<Footer />
</PageBody>
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
<Grid columns="auto 1fr" flexGrow={1} backgroundColor="3" borderRadius>
<MobileMenuButton>
{({ close }) => {
return <ShareNav shareId={shareId} parameters={parameters} onItemClick={close} />;
}}
</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>
);
}

View file

@ -2,6 +2,7 @@ import { SharePage } from './SharePage';
export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) {
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
{...props}
width="100%"
minHeight="100vh"
paddingBottom="6"
maxWidth={maxWidth}
paddingX={{ xs: '3', md: '6' }}

View file

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

View file

@ -16,7 +16,7 @@ export function useEventDataValuesQuery(
return useQuery<any>({
queryKey: [
'websites:event-data:values',
{ websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters },
{ websiteId, startAt, endAt, unit, timezone, ...filters, event, propertyName },
],
queryFn: () =>
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 { post, useQuery } = useApi();
const { startDate, endDate, timezone } = useDateParameters();
const { startDate, endDate, timezone, unit } = useDateParameters();
const filters = useFilterParameters();
return useQuery<T>({
@ -22,6 +22,7 @@ export function useResultQuery<T = any>(
startDate,
endDate,
timezone,
unit,
...params,
...filters,
},
@ -35,6 +36,7 @@ export function useResultQuery<T = any>(
startDate,
endDate,
timezone,
unit,
...parameters,
},
}),

View file

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

View file

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

View file

@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
options?: ReactQueryOptions<WebsiteMetricsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<WebsiteMetricsData>({
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
websiteId,
startAt,
endAt,
unit,
timezone,
...filters,
...params,
},
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
get(`/websites/${websiteId}/metrics`, {
startAt,
endAt,
unit,
timezone,
...filters,
...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(
websiteId: string,
{ websiteId, compare }: { websiteId: string; compare?: string },
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<WebsiteStatsData>({
queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
queryFn: () =>
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
enabled: !!websiteId,
...options,
});

View file

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

View file

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

View file

@ -15,6 +15,7 @@ export function useFields() {
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
{ 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: 'event', type: 'string', label: formatMessage(labels.event) },
];

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