diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml
index 2404918b8..d48567e68 100644
--- a/.github/ISSUE_TEMPLATE/1.bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml
@@ -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'
diff --git a/.gitignore b/.gitignore
index 753389d1a..8b543f6b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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*
diff --git a/next.config.ts b/next.config.ts
index 99dcca0d8..1a4e2e0ec 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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,
diff --git a/package.json b/package.json
index a48e8c8eb..8f0a57822 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3391f4fb4..851abba35 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}
diff --git a/prisma/migrations/15_add_share/migration.sql b/prisma/migrations/15_add_share/migration.sql
new file mode 100644
index 000000000..89aece1ee
--- /dev/null
+++ b/prisma/migrations/15_add_share/migration.sql
@@ -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";
diff --git a/prisma/migrations/16_boards/migration.sql b/prisma/migrations/16_boards/migration.sql
new file mode 100644
index 000000000..ad8ee1729
--- /dev/null
+++ b/prisma/migrations/16_boards/migration.sql
@@ -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");
\ No newline at end of file
diff --git a/prisma/migrations/17_remove_duplicate_key/migration.sql b/prisma/migrations/17_remove_duplicate_key/migration.sql
new file mode 100644
index 000000000..75f7191ea
--- /dev/null
+++ b/prisma/migrations/17_remove_duplicate_key/migration.sql
@@ -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";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index aeb11648d..e58ebd0b0 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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")
+}
diff --git a/public/images/country/t1.png b/public/images/country/t1.png
new file mode 100644
index 000000000..c45ed0fe9
Binary files /dev/null and b/public/images/country/t1.png differ
diff --git a/public/intl/messages/zh-CN.json b/public/intl/messages/zh-CN.json
index b3d2f3c00..a4ad51fa5 100644
--- a/public/intl/messages/zh-CN.json
+++ b/public/intl/messages/zh-CN.json
@@ -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": [
diff --git a/public/iso-3166-2.json b/public/iso-3166-2.json
index 347313d72..2b3b5a802 100644
--- a/public/iso-3166-2.json
+++ b/public/iso-3166-2.json
@@ -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",
diff --git a/scripts/build-geo.js b/scripts/build-geo.js
index a83caa6c0..e36b097c2 100644
--- a/scripts/build-geo.js
+++ b/scripts/build-geo.js
@@ -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()));
});
});
diff --git a/src/app/(main)/admin/teams/AdminTeamsPage.tsx b/src/app/(main)/admin/teams/AdminTeamsPage.tsx
index 41e6f4afb..7905f7f63 100644
--- a/src/app/(main)/admin/teams/AdminTeamsPage.tsx
+++ b/src/app/(main)/admin/teams/AdminTeamsPage.tsx
@@ -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 (
-
+
+
+
diff --git a/src/app/(main)/admin/users/UserAddForm.tsx b/src/app/(main)/admin/users/UserAddForm.tsx
index 6c365510c..84b8399cd 100644
--- a/src/app/(main)/admin/users/UserAddForm.tsx
+++ b/src/app/(main)/admin/users/UserAddForm.tsx
@@ -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 }) {
diff --git a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx
index 28bf030f5..68aa7f6e1 100644
--- a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx
+++ b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx
@@ -30,7 +30,11 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
};
return (
-
+
+
+
+
);
}
diff --git a/src/app/(main)/settings/preferences/VersionSetting.tsx b/src/app/(main)/settings/preferences/VersionSetting.tsx
new file mode 100644
index 000000000..afca1de6b
--- /dev/null
+++ b/src/app/(main)/settings/preferences/VersionSetting.tsx
@@ -0,0 +1,8 @@
+'use client';
+
+import { Text } from '@umami/react-zen';
+import { CURRENT_VERSION } from '@/lib/constants';
+
+export function VersionSetting() {
+ return {CURRENT_VERSION};
+}
diff --git a/src/app/(main)/teams/TeamAddForm.tsx b/src/app/(main)/teams/TeamAddForm.tsx
index c95259f47..3b827776b 100644
--- a/src/app/(main)/teams/TeamAddForm.tsx
+++ b/src/app/(main)/teams/TeamAddForm.tsx
@@ -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:
+ {isAdmin && (
+
+
+
+ )}
diff --git a/src/app/(main)/teams/TeamsMemberAddButton.tsx b/src/app/(main)/teams/TeamsMemberAddButton.tsx
new file mode 100644
index 000000000..f1bbf2586
--- /dev/null
+++ b/src/app/(main)/teams/TeamsMemberAddButton.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx
index 3ddbe000b..4bbb8905a 100644
--- a/src/app/(main)/teams/[teamId]/TeamSettings.tsx
+++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx
@@ -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 }) {
+
+ {formatMessage(labels.members)}
+ {isAdmin && }
+
{isTeamOwner && (
diff --git a/src/app/(main)/websites/WebsitesPage.tsx b/src/app/(main)/websites/WebsitesPage.tsx
index 31de7047c..6f3548a97 100644
--- a/src/app/(main)/websites/WebsitesPage.tsx
+++ b/src/app/(main)/websites/WebsitesPage.tsx
@@ -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 (
-
+ {showActions && }
-
+
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
index e336a3db6..d81519d76 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
@@ -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 }) {
-
-
- {({ close }) => {
- return (
-
- );
- }}
-
-
+ {!isSharePage && (
+
+
+ {({ close }) => {
+ return (
+
+ );
+ }}
+
+
+ )}
{data?.map(
(
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
index 57bce52f8..a56917b76 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
@@ -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 (
-
-
-
+ {!isSharePage && (
+
+
+
+ )}
{data && (
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
index b6c4a11d6..1d0b96e50 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
@@ -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(type, {
websiteId,
startDate,
@@ -45,21 +47,23 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
-
-
- {({ close }) => {
- return (
-
- );
- }}
-
-
+ {!isSharePage && (
+
+
+ {({ close }) => {
+ return (
+
+ );
+ }}
+
+
+ )}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
index ff7b49fbd..fe4550d6e 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
@@ -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 (
-
-
-
+ {!isSharePage && (
+
+
+
+ )}
{data && (
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
index 3327a425a..1b893d27e 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
@@ -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));
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
index 14b8341df..f1a8976f6 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
@@ -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 (
@@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
/>
+
+
+
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
index 0e782a164..faee8b9a4 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
@@ -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 (
-
+
{data && (
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx
index b2ea2a83e..896c733a8 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx
@@ -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 (
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx
index 29c3954f2..4bac4ff6f 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx
@@ -169,6 +169,12 @@ export function WebsiteExpandedMenu({
path: updateParams({ view: 'hostname' }),
icon: ,
},
+ {
+ id: 'distinctId',
+ label: formatMessage(labels.distinctId),
+ path: updateParams({ view: 'distinctId' }),
+ icon: ,
+ },
{
id: 'tag',
label: formatMessage(labels.tag),
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
index 7dd1d771c..1dee80220 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
@@ -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 }) {
}
- titleHref={renderUrl(`/websites/${website.id}`, false)}
+ titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined}
>
{showActions && (
-
-
-
-
-
-
- {formatMessage(labels.edit)}
-
-
+
+
+
+
+ {formatMessage(labels.edit)}
+
)}
);
}
-
-const ShareButton = ({ websiteId, shareId }) => {
- const { formatMessage, labels } = useMessages();
-
- return (
- } label={formatMessage(labels.share)} width="800px">
- {({ close }) => {
- return ;
- }}
-
- );
-};
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx
index 301895341..132d3b14e 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx
@@ -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 }) {
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
index 6c91ba6dd..605ee3855 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
@@ -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 || {};
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx
index ad05b7065..9f72c303d 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx
@@ -29,6 +29,7 @@ export function WebsiteNav({
event: undefined,
compare: undefined,
view: undefined,
+ unit: undefined,
});
const items = [
diff --git a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx
index f587e1121..5acc9e682 100644
--- a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx
@@ -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 }) {
+
+
+
diff --git a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
index bca8d2447..32d641b06 100644
--- a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
+++ b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
@@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
return (
-
+
diff --git a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
index 13c05160a..4daf17fc6 100644
--- a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
+++ b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
@@ -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),
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
index 55ec04037..f62d8a4c8 100644
--- a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
@@ -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 (
+
+
+ {metrics?.map(({ label, value, formatValue }) => {
+ return ;
+ })}
+
+
handleSelect(key)}>
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
index 7fb2eb41f..41c2b1e8b 100644
--- a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
+++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
@@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
const { updateParams } = useNavigation();
const { formatValue } = useFormat();
+ const renderLink = (label: string, hostname: string) => {
+ return (
+
+ {label}
+
+ );
+ };
+
return (
@@ -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)}
{row.hasData > 0 && }
diff --git a/src/app/(main)/websites/[websiteId]/layout.tsx b/src/app/(main)/websites/[websiteId]/layout.tsx
index 67595e9d1..b12ff9505 100644
--- a/src/app/(main)/websites/[websiteId]/layout.tsx
+++ b/src/app/(main)/websites/[websiteId]/layout.tsx
@@ -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 {children};
}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
index 10763618b..9cbbd371b 100644
--- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
@@ -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: (
@@ -100,7 +102,12 @@ export function RealtimeLog({ data }: { data: any }) {
if (__type === TYPE_PAGEVIEW) {
return (
-
+
{urlPath}
);
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
index cbb281083..df0ef834a 100644
--- a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
@@ -39,10 +39,23 @@ export function SessionActivity({
const { isMobile } = useMobile();
let lastDay = null;
+ const renderLink = (label: string, hostname: string) => {
+ return (
+
+ {label}
+
+ );
+ };
+
return (
- {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)}
- {eventName || urlPath}
+ {eventName || renderLink(urlPath, hostname)}
{hasData > 0 && }
diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx
new file mode 100644
index 000000000..35e96df3f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx
@@ -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 (
+ }
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ {slug},
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx
new file mode 100644
index 000000000..df1c2e648
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx
@@ -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 (
+ } title={formatMessage(labels.share)} variant="quiet" width="600px">
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
new file mode 100644
index 000000000..4b86247ac
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
@@ -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(null);
+ const [isLoading, setIsLoading] = useState(!!shareId);
+ const [isPending, setIsPending] = useState(false);
+ const [error, setError] = useState(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 = {};
+ 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 ;
+ }
+
+ const url = isEditing ? getUrl(share?.slug || '') : null;
+
+ // Build default values from share parameters
+ const defaultValues: Record = {
+ 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 (
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx b/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
new file mode 100644
index 000000000..57701ac67
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
@@ -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 (
+
+
+ {({ name }: any) => name}
+
+
+ {({ slug }: any) => {
+ const url = getUrl(slug);
+ return (
+
+ {url}
+
+ );
+ }}
+
+
+ {(row: any) => }
+
+
+ {({ id, slug }: any) => {
+ return (
+
+
+
+
+ );
+ }}
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
index 3970cdbdb..d39c45315 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
@@ -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 (
@@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
-
+
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
index 56c6f436f..8472ca977 100644
--- a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
@@ -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 (
-
+
+
+ {formatMessage(labels.share)}
+ }
+ label={formatMessage(labels.add)}
+ title={formatMessage(labels.share)}
+ variant="primary"
+ width="600px"
+ >
+ {({ close }) => }
+
+
+ {hasShares ? (
+ <>
+ {formatMessage(messages.shareUrl)}
+
+ >
+ ) : (
+ {formatMessage(messages.noDataAvailable)}
+ )}
+
);
}
diff --git a/src/app/(main)/websites/[websiteId]/settings/constants.ts b/src/app/(main)/websites/[websiteId]/settings/constants.ts
new file mode 100644
index 000000000..f4a3df80f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/constants.ts
@@ -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' },
+ ],
+ },
+];
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
index 7bf0a8137..153f1f523 100644
--- a/src/app/api/auth/logout/route.ts
+++ b/src/app/api/auth/logout/route.ts
@@ -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];
diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts
index bba3dde30..f82228695 100644
--- a/src/app/api/auth/sso/route.ts
+++ b/src/app/api/auth/sso/route.ts
@@ -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 });
}
diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts
index 4e40caa4b..101a12243 100644
--- a/src/app/api/config/route.ts
+++ b/src/app/api/config/route.ts
@@ -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,
});
}
diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts
index 29e853196..b53d225d4 100644
--- a/src/app/api/reports/journey/route.ts
+++ b/src/app/api/reports/journey/route.ts
@@ -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);
diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts
deleted file mode 100644
index bef87c4fb..000000000
--- a/src/app/api/share/[shareId]/route.ts
+++ /dev/null
@@ -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 });
-}
diff --git a/src/app/api/share/[slug]/route.ts b/src/app/api/share/[slug]/route.ts
new file mode 100644
index 000000000..e7d5372fe
--- /dev/null
+++ b/src/app/api/share/[slug]/route.ts
@@ -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 {
+ 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 {
+ 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 = {
+ 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);
+}
diff --git a/src/app/api/share/id/[shareId]/route.ts b/src/app/api/share/id/[shareId]/route.ts
new file mode 100644
index 000000000..80da17b80
--- /dev/null
+++ b/src/app/api/share/id/[shareId]/route.ts
@@ -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();
+}
diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts
new file mode 100644
index 000000000..a772b4ab4
--- /dev/null
+++ b/src/app/api/share/route.ts
@@ -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);
+}
diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts
index 53ef59239..c571f4055 100644
--- a/src/app/api/teams/route.ts
+++ b/src/app/api/teams/route.ts
@@ -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);
diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts
index aade8aa8d..e642fe3c1 100644
--- a/src/app/api/users/[userId]/route.ts
+++ b/src/app/api/users/[userId]/route.ts
@@ -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) {
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
index dbb114cf7..4335c33fb 100644
--- a/src/app/api/users/route.ts
+++ b/src/app/api/users/route.ts
@@ -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);
diff --git a/src/app/api/websites/[websiteId]/events/stats/route.ts b/src/app/api/websites/[websiteId]/events/stats/route.ts
new file mode 100644
index 000000000..61e151d4a
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/events/stats/route.ts
@@ -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 });
+}
diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts
index b4c0e7e88..59f314d38 100644
--- a/src/app/api/websites/[websiteId]/route.ts
+++ b/src/app/api/websites/[websiteId]/route.ts
@@ -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(
diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts
index 45927656c..db34193ed 100644
--- a/src/app/api/websites/[websiteId]/segments/route.ts
+++ b/src/app/api/websites/[websiteId]/segments/route.ts
@@ -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);
diff --git a/src/app/api/websites/[websiteId]/shares/route.ts b/src/app/api/websites/[websiteId]/shares/route.ts
new file mode 100644
index 000000000..65d53771d
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/shares/route.ts
@@ -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);
+}
diff --git a/src/app/api/websites/[websiteId]/stats/route.ts b/src/app/api/websites/[websiteId]/stats/route.ts
index 07c8b9699..9d21f4f55 100644
--- a/src/app/api/websites/[websiteId]/stats/route.ts
+++ b/src/app/api/websites/[websiteId]/stats/route.ts
@@ -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,
diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts
index e2b26c108..dd8e0ffdd 100644
--- a/src/app/api/websites/route.ts
+++ b/src/app/api/websites/route.ts
@@ -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);
diff --git a/src/app/share/[...shareId]/Footer.tsx b/src/app/share/[...shareId]/Footer.tsx
deleted file mode 100644
index f29486286..000000000
--- a/src/app/share/[...shareId]/Footer.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Row, Text } from '@umami/react-zen';
-import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
-
-export function Footer() {
- return (
-
-
- umami {`v${CURRENT_VERSION}`}
-
-
- );
-}
diff --git a/src/app/share/[...shareId]/Header.tsx b/src/app/share/[...shareId]/Header.tsx
deleted file mode 100644
index d7b7dcb42..000000000
--- a/src/app/share/[...shareId]/Header.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
- umami
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/share/[...shareId]/ShareFooter.tsx b/src/app/share/[...shareId]/ShareFooter.tsx
new file mode 100644
index 000000000..5348ac63c
--- /dev/null
+++ b/src/app/share/[...shareId]/ShareFooter.tsx
@@ -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 (
+
+
+ {whiteLabel.name}
+
+
+ );
+ }
+
+ return (
+
+
+ umami {`v${CURRENT_VERSION}`}
+
+
+ );
+}
diff --git a/src/app/share/[...shareId]/ShareHeader.tsx b/src/app/share/[...shareId]/ShareHeader.tsx
new file mode 100644
index 000000000..abd8511df
--- /dev/null
+++ b/src/app/share/[...shareId]/ShareHeader.tsx
@@ -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 (
+
+
+
+ {logoImage ? (
+
+ ) : (
+
+
+
+ )}
+ {logoName}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/share/[...shareId]/ShareNav.tsx b/src/app/share/[...shareId]/ShareNav.tsx
new file mode 100644
index 000000000..b494046d4
--- /dev/null
+++ b/src/app/share/[...shareId]/ShareNav.tsx
@@ -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;
+ 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: ,
+ path: renderPath(''),
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ icon: ,
+ path: renderPath('/events'),
+ },
+ {
+ id: 'sessions',
+ label: formatMessage(labels.sessions),
+ icon: ,
+ path: renderPath('/sessions'),
+ },
+ {
+ id: 'realtime',
+ label: formatMessage(labels.realtime),
+ icon: ,
+ path: renderPath('/realtime'),
+ },
+ {
+ id: 'compare',
+ label: formatMessage(labels.compare),
+ icon: ,
+ path: renderPath('/compare'),
+ },
+ {
+ id: 'breakdown',
+ label: formatMessage(labels.breakdown),
+ icon: ,
+ path: renderPath('/breakdown'),
+ },
+ ],
+ },
+ {
+ section: 'behavior',
+ label: formatMessage(labels.behavior),
+ items: [
+ {
+ id: 'goals',
+ label: formatMessage(labels.goals),
+ icon: ,
+ path: renderPath('/goals'),
+ },
+ {
+ id: 'funnels',
+ label: formatMessage(labels.funnels),
+ icon: ,
+ path: renderPath('/funnels'),
+ },
+ {
+ id: 'journeys',
+ label: formatMessage(labels.journeys),
+ icon: ,
+ path: renderPath('/journeys'),
+ },
+ {
+ id: 'retention',
+ label: formatMessage(labels.retention),
+ icon: ,
+ path: renderPath('/retention'),
+ },
+ ],
+ },
+ {
+ section: 'growth',
+ label: formatMessage(labels.growth),
+ items: [
+ {
+ id: 'utm',
+ label: formatMessage(labels.utm),
+ icon: ,
+ path: renderPath('/utm'),
+ },
+ {
+ id: 'revenue',
+ label: formatMessage(labels.revenue),
+ icon: ,
+ path: renderPath('/revenue'),
+ },
+ {
+ id: 'attribution',
+ label: formatMessage(labels.attribution),
+ icon: ,
+ 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 (
+
+
+
+ );
+}
diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx
index 7ed066735..91a8b298c 100644
--- a/src/app/share/[...shareId]/SharePage.tsx
+++ b/src/app/share/[...shareId]/SharePage.tsx
@@ -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> = {
+ '': 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 (
-
-
-
-
-
-
-
-
+
+
+
+
+ {({ close }) => {
+ return ;
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/app/share/[...shareId]/page.tsx b/src/app/share/[...shareId]/page.tsx
index b9900eb7c..3a21f8365 100644
--- a/src/app/share/[...shareId]/page.tsx
+++ b/src/app/share/[...shareId]/page.tsx
@@ -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 ;
+ return ;
}
diff --git a/src/components/common/PageBody.tsx b/src/components/common/PageBody.tsx
index f07e589ba..c70b9dfec 100644
--- a/src/components/common/PageBody.tsx
+++ b/src/components/common/PageBody.tsx
@@ -31,6 +31,7 @@ export function PageBody({
({
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`, {
diff --git a/src/components/hooks/queries/useEventStatsQuery.ts b/src/components/hooks/queries/useEventStatsQuery.ts
new file mode 100644
index 000000000..44316ca5f
--- /dev/null
+++ b/src/components/hooks/queries/useEventStatsQuery.ts
@@ -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,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['websites:events:stats', { websiteId, startAt, endAt, ...filters }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/events/stats`, {
+ startAt,
+ endAt,
+ ...filters,
+ }),
+ select: response => response.data,
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useResultQuery.ts b/src/components/hooks/queries/useResultQuery.ts
index c6fce1289..1828e594a 100644
--- a/src/components/hooks/queries/useResultQuery.ts
+++ b/src/components/hooks/queries/useResultQuery.ts
@@ -10,7 +10,7 @@ export function useResultQuery(
) {
const { websiteId, ...parameters } = params;
const { post, useQuery } = useApi();
- const { startDate, endDate, timezone } = useDateParameters();
+ const { startDate, endDate, timezone, unit } = useDateParameters();
const filters = useFilterParameters();
return useQuery({
@@ -22,6 +22,7 @@ export function useResultQuery(
startDate,
endDate,
timezone,
+ unit,
...params,
...filters,
},
@@ -35,6 +36,7 @@ export function useResultQuery(
startDate,
endDate,
timezone,
+ unit,
...parameters,
},
}),
diff --git a/src/components/hooks/queries/useShareTokenQuery.ts b/src/components/hooks/queries/useShareTokenQuery.ts
index dbad3dcd7..446e33dab 100644
--- a/src/components/hooks/queries/useShareTokenQuery.ts
+++ b/src/components/hooks/queries/useShareTokenQuery.ts
@@ -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);
diff --git a/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
index b2e90199f..1611c7f8a 100644
--- a/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
+++ b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
@@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
options?: ReactQueryOptions,
) {
const { get, useQuery } = useApi();
- const { startAt, endAt, unit, timezone } = useDateParameters();
+ const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery({
@@ -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,
}),
diff --git a/src/components/hooks/queries/useWebsiteMetricsQuery.ts b/src/components/hooks/queries/useWebsiteMetricsQuery.ts
index 67c5e4d47..cd064af68 100644
--- a/src/components/hooks/queries/useWebsiteMetricsQuery.ts
+++ b/src/components/hooks/queries/useWebsiteMetricsQuery.ts
@@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
options?: ReactQueryOptions,
) {
const { get, useQuery } = useApi();
- const { startAt, endAt, unit, timezone } = useDateParameters();
+ const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery({
@@ -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,
}),
diff --git a/src/components/hooks/queries/useWebsiteSharesQuery.ts b/src/components/hooks/queries/useWebsiteSharesQuery.ts
new file mode 100644
index 000000000..298e4d264
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSharesQuery.ts
@@ -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,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteStatsQuery.ts b/src/components/hooks/queries/useWebsiteStatsQuery.ts
index e9a0c48ca..48484a07e 100644
--- a/src/components/hooks/queries/useWebsiteStatsQuery.ts
+++ b/src/components/hooks/queries/useWebsiteStatsQuery.ts
@@ -19,17 +19,16 @@ export interface WebsiteStatsData {
}
export function useWebsiteStatsQuery(
- websiteId: string,
+ { websiteId, compare }: { websiteId: string; compare?: string },
options?: UseQueryOptions,
) {
const { get, useQuery } = useApi();
- const { startAt, endAt, unit, timezone } = useDateParameters();
+ const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery({
- 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,
});
diff --git a/src/components/hooks/queries/useWeeklyTrafficQuery.ts b/src/components/hooks/queries/useWeeklyTrafficQuery.ts
index a76ebb3db..df729ffd9 100644
--- a/src/components/hooks/queries/useWeeklyTrafficQuery.ts
+++ b/src/components/hooks/queries/useWeeklyTrafficQuery.ts
@@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record {
return get(`/websites/${websiteId}/sessions/weekly`, {
startAt,
endAt,
- unit,
timezone,
...params,
...filters,
diff --git a/src/components/hooks/useDateRange.ts b/src/components/hooks/useDateRange.ts
index 755f36eeb..5090bd3d8 100644
--- a/src/components/hooks/useDateRange.ts
+++ b/src/components/hooks/useDateRange.ts
@@ -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`),
diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts
index 22a1dcf30..039b71573 100644
--- a/src/components/hooks/useFields.ts
+++ b/src/components/hooks/useFields.ts
@@ -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) },
];
diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts
index 540321203..c141a3be8 100644
--- a/src/components/hooks/useFilterParameters.ts
+++ b/src/components/hooks/useFilterParameters.ts
@@ -18,6 +18,7 @@ export function useFilterParameters() {
event,
tag,
hostname,
+ distinctId,
page,
pageSize,
search,
@@ -42,6 +43,7 @@ export function useFilterParameters() {
event,
tag,
hostname,
+ distinctId,
search,
segment,
cohort,
@@ -61,6 +63,7 @@ export function useFilterParameters() {
event,
tag,
hostname,
+ distinctId,
page,
pageSize,
search,
diff --git a/src/components/input/FilterEditForm.tsx b/src/components/input/FilterEditForm.tsx
index 44f43844a..87acc515f 100644
--- a/src/components/input/FilterEditForm.tsx
+++ b/src/components/input/FilterEditForm.tsx
@@ -22,6 +22,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
const [currentCohort, setCurrentCohort] = useState(cohort);
const { isMobile } = useMobile();
const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
+ const excludeEvent = !pathname.endsWith('/events');
const handleReset = () => {
setCurrentFilters([]);
@@ -61,7 +62,13 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
websiteId={websiteId}
value={currentFilters}
onChange={setCurrentFilters}
- exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
+ exclude={
+ excludeFilters
+ ? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event']
+ : excludeEvent
+ ? ['event']
+ : []
+ }
/>
diff --git a/src/components/input/UnitFilter.tsx b/src/components/input/UnitFilter.tsx
new file mode 100644
index 000000000..84a15f350
--- /dev/null
+++ b/src/components/input/UnitFilter.tsx
@@ -0,0 +1,71 @@
+import { ListItem, Row, Select } from '@umami/react-zen';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
+import { getItem } from '@/lib/storage';
+
+export function UnitFilter() {
+ const { formatMessage, labels } = useMessages();
+ const { router, query, updateParams } = useNavigation();
+
+ const DATE_RANGE_UNIT_CONFIG = {
+ '0week': {
+ defaultUnit: 'day',
+ availableUnits: ['day', 'hour'],
+ },
+ '7day': {
+ defaultUnit: 'day',
+ availableUnits: ['day', 'hour'],
+ },
+ '0month': {
+ defaultUnit: 'day',
+ availableUnits: ['day', 'hour'],
+ },
+ '30day': {
+ defaultUnit: 'day',
+ availableUnits: ['day', 'hour'],
+ },
+ '90day': {
+ defaultUnit: 'day',
+ availableUnits: ['day', 'month'],
+ },
+ '6month': {
+ defaultUnit: 'month',
+ availableUnits: ['month', 'day'],
+ },
+ };
+
+ const unitConfig =
+ DATE_RANGE_UNIT_CONFIG[query.date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE];
+
+ if (!unitConfig) {
+ return null;
+ }
+
+ const handleChange = (value: string) => {
+ router.push(updateParams({ unit: value }));
+ };
+
+ const options = unitConfig.availableUnits.map(unit => ({
+ id: unit,
+ label: formatMessage(labels[unit]),
+ }));
+
+ const selectedUnit = query.unit ?? unitConfig.defaultUnit;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/input/UserSelect.tsx b/src/components/input/UserSelect.tsx
new file mode 100644
index 000000000..ccb3d4325
--- /dev/null
+++ b/src/components/input/UserSelect.tsx
@@ -0,0 +1,71 @@
+import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
+import { useMemo, useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { useMessages, useTeamMembersQuery, useUsersQuery } from '@/components/hooks';
+
+export function UserSelect({
+ teamId,
+ onChange,
+ ...props
+}: {
+ teamId?: string;
+} & SelectProps) {
+ const { formatMessage, messages } = useMessages();
+ const { data: users, isLoading: usersLoading } = useUsersQuery();
+ const { data: teamMembers, isLoading: teamMembersLoading } = useTeamMembersQuery(teamId);
+ const [username, setUsername] = useState();
+ const [search, setSearch] = useState('');
+
+ const listItems = useMemo(() => {
+ if (!users) {
+ return [];
+ }
+ if (!teamId || !teamMembers) {
+ return users.data;
+ }
+ const teamMemberIds = teamMembers.data.map(({ userId }) => userId);
+ return users.data.filter(({ id }) => !teamMemberIds.includes(id));
+ }, [users, teamMembers, teamId]);
+
+ const handleSearch = (value: string) => {
+ setSearch(value);
+ };
+
+ const handleOpenChange = () => {
+ setSearch('');
+ };
+
+ const handleChange = (id: string) => {
+ setUsername(listItems.find(item => item.id === id)?.username);
+ onChange(id);
+ };
+
+ const renderValue = () => {
+ return (
+
+ {username}
+
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx
index 18b4f13be..a76058eca 100644
--- a/src/components/input/WebsiteDateFilter.tsx
+++ b/src/components/input/WebsiteDateFilter.tsx
@@ -41,7 +41,7 @@ export function WebsiteDateFilter({
}),
);
} else {
- router.push(updateParams({ date, offset: undefined }));
+ router.push(updateParams({ date, offset: undefined, unit: undefined }));
}
};
diff --git a/src/components/input/WebsiteSelect.tsx b/src/components/input/WebsiteSelect.tsx
index 8d81eb9a0..330f826aa 100644
--- a/src/components/input/WebsiteSelect.tsx
+++ b/src/components/input/WebsiteSelect.tsx
@@ -26,7 +26,7 @@ export function WebsiteSelect({
const { user } = useLoginQuery();
const { data, isLoading } = useUserWebsitesQuery(
{ userId: user?.id, teamId },
- { search, pageSize: 10, includeTeams },
+ { search, pageSize: 20, includeTeams },
);
const listItems: { id: string; name: string }[] = data?.data || [];
@@ -65,7 +65,7 @@ export function WebsiteSelect({
renderValue={renderValue}
listProps={{
renderEmptyState: () => ,
- style: { maxHeight: '400px' },
+ style: { maxHeight: 'calc(42vh - 65px)' },
}}
>
{({ id, name }: any) => {name}}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 0438c06e6..de29c3064 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -146,6 +146,7 @@ export const labels = defineMessages({
poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' },
pageViews: { id: 'label.page-views', defaultMessage: 'Page views' },
uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' },
+ uniqueEvents: { id: 'label.unique-events', defaultMessage: 'Unique Events' },
bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' },
viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' },
visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' },
@@ -245,7 +246,10 @@ export const labels = defineMessages({
tag: { id: 'label.tag', defaultMessage: 'Tag' },
segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
+ minute: { id: 'label.minute', defaultMessage: 'Minute' },
+ hour: { id: 'label.hour', defaultMessage: 'Hour' },
day: { id: 'label.day', defaultMessage: 'Day' },
+ month: { id: 'label.month', defaultMessage: 'Month' },
date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
create: { id: 'label.create', defaultMessage: 'Create' },
@@ -351,6 +355,7 @@ export const labels = defineMessages({
growth: { id: 'label.growth', defaultMessage: 'Growth' },
account: { id: 'label.account', defaultMessage: 'Account' },
application: { id: 'label.application', defaultMessage: 'Application' },
+ version: { id: 'label.version', defaultMessage: 'Version' },
saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' },
saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' },
analysis: { id: 'label.analysis', defaultMessage: 'Analysis' },
diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx
index d15bcf135..590fd5ac4 100644
--- a/src/components/metrics/MetricCard.tsx
+++ b/src/components/metrics/MetricCard.tsx
@@ -25,7 +25,7 @@ export const MetricCard = ({
showChange = false,
}: MetricCardProps) => {
const diff = value - change;
- const pct = ((value - diff) / diff) * 100;
+ const pct = diff !== 0 ? ((value - diff) / diff) * 100 : value !== 0 ? 100 : 0;
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });
diff --git a/src/index.ts b/src/index.ts
index 907c5623b..df164b9d0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -19,11 +19,13 @@ export * from '@/app/(main)/teams/TeamAddForm';
export * from '@/app/(main)/teams/TeamJoinForm';
export * from '@/app/(main)/teams/TeamLeaveButton';
export * from '@/app/(main)/teams/TeamLeaveForm';
+export * from '@/app/(main)/teams/TeamMemberAddForm';
export * from '@/app/(main)/teams/TeamProvider';
export * from '@/app/(main)/teams/TeamsAddButton';
export * from '@/app/(main)/teams/TeamsDataTable';
export * from '@/app/(main)/teams/TeamsHeader';
export * from '@/app/(main)/teams/TeamsJoinButton';
+export * from '@/app/(main)/teams/TeamsMemberAddButton';
export * from '@/app/(main)/teams/TeamsTable';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
diff --git a/src/lang/bg-BG.json b/src/lang/bg-BG.json
index 4b0effc89..50099032f 100644
--- a/src/lang/bg-BG.json
+++ b/src/lang/bg-BG.json
@@ -93,7 +93,7 @@
"label.event-name": "Име на събитие",
"label.events": "Събития",
"label.exists": "Съществува",
- "label.exit": "Exit URL",
+ "label.exit": "URL за изход",
"label.false": "Грешно",
"label.field": "Поле",
"label.fields": "Полета",
@@ -135,7 +135,7 @@
"label.last-days": "Последните {x} дни",
"label.last-hours": "Последните {x} часа",
"label.last-months": "Последните {x} месеца",
- "label.last-seen": "Last seen",
+ "label.last-seen": "Последно видяно",
"label.leave": "Напусни",
"label.leave-team": "Напусни екип",
"label.less-than": "По-малко от",
@@ -161,7 +161,7 @@
"label.none": "Няма",
"label.number-of-records": "{x} {x, plural, one {един} other {други}}",
"label.ok": "Добре",
- "label.online": "Online",
+ "label.online": "Онлайн",
"label.organic-search": "Органично търсене",
"label.organic-shopping": "Органично пазаруване",
"label.organic-social": "Органични социални мрежи",
@@ -185,9 +185,9 @@
"label.paths": "Пътища",
"label.pixels": "Пиксели",
"label.powered-by": "Поддържано от {name}",
- "label.previous": "Previous",
- "label.previous-period": "Previous period",
- "label.previous-year": "Previous year",
+ "label.previous": "Предишен",
+ "label.previous-period": "Предишен период",
+ "label.previous-year": "Предишна година",
"label.profile": "Профил",
"label.properties": "Свойства",
"label.property": "Свойство",
@@ -211,8 +211,8 @@
"label.reset-website": "Нулирай уебсайт",
"label.retention": "Привързване",
"label.retention-description": "Измерете привързаността към вашия уебсайт, като проследявате колко често потребителите се връщат.",
- "label.revenue": "Revenue",
- "label.revenue-description": "Look into your revenue across time.",
+ "label.revenue": "Приходи",
+ "label.revenue-description": "Прегледайте приходите си във времето.",
"label.role": "Роля",
"label.run-query": "Изпълни запитване",
"label.save": "Запази",
@@ -260,14 +260,14 @@
"label.total": "Общо",
"label.total-records": "Общо записи",
"label.tracking-code": "Код за проследяване",
- "label.transactions": "Transactions",
+ "label.transactions": "Транзакции",
"label.transfer": "Прехвърли",
"label.transfer-website": "Прехвърляне на уебсайт",
"label.true": "Вярно",
"label.type": "Вид",
"label.unique": "Уникален",
"label.unique-visitors": "Уникални посетители",
- "label.uniqueCustomers": "Unique Customers",
+ "label.uniqueCustomers": "Уникални клиенти",
"label.unknown": "Неизвестен",
"label.untitled": "Без заглавие",
"label.update": "Актуализирай",
@@ -282,7 +282,7 @@
"label.view-only": "Само за преглед",
"label.views": "Прегледи",
"label.views-per-visit": "Прегледи на посещение",
- "label.visit-duration": "Visit duration",
+ "label.visit-duration": "Продължителност на посещение",
"label.visitors": "Посетители",
"label.visits": "Посещения",
"label.website": "Уебсайт",
@@ -292,8 +292,8 @@
"label.yesterday": "Вчера",
"message.action-confirmation": "Въведете {confirmation} в полето по-долу, за да потвърдите.",
"message.active-users": "{x} {x, plural, one {активен един} other {активни други}}",
- "message.bad-request": "Bad request",
- "message.collected-data": "Collected data",
+ "message.bad-request": "Невалидна заявка",
+ "message.collected-data": "Събрани данни",
"message.confirm-delete": "Сигурни ли сте, че искате да изтриете {target}?",
"message.confirm-leave": "Сигурни ли сте, че искате да напуснете {target}?",
"message.confirm-remove": "Сигурни ли сте, че искате да премахнете {target}?",
@@ -302,7 +302,7 @@
"message.delete-website-warning": "Всички данни за уебсайта ще бъдат изтрити.",
"message.error": "Възникна грешка.",
"message.event-log": "{event} на {url}",
- "message.forbidden": "Forbidden",
+ "message.forbidden": "Забранено",
"message.go-to-settings": "Отидете в настройките",
"message.incorrect-username-password": "Неправилно потребителско име и/или парола.",
"message.invalid-domain": "Невалиден домейн. Не включвайте http/https.",
@@ -316,13 +316,13 @@
"message.no-teams": "Няма създадени екипи.",
"message.no-users": "Няма потребители.",
"message.no-websites-configured": "Нямате конфигурирани уебсайтове.",
- "message.not-found": "Not found",
- "message.nothing-selected": "Nothing selected.",
+ "message.not-found": "Не е намерено",
+ "message.nothing-selected": "Няма избрано.",
"message.page-not-found": "Страницата не е намерена",
"message.reset-website": "За да нулирате този уебсайт, въведете {confirmation} в полето по-долу, за да потвърдите.",
"message.reset-website-warning": "Всички статистически данни за този уебсайт ще бъдат изтрити, но вашите настройки ще останат непроменени.",
"message.saved": "Запазено.",
- "message.sever-error": "Server error",
+ "message.sever-error": "Сървърна грешка",
"message.share-url": "Статистиката за вашия уебсайт е публично достъпна на следния URL адрес:",
"message.team-already-member": "Вече сте член на екипа.",
"message.team-not-found": "Екипът не е намерен.",
@@ -332,7 +332,7 @@
"message.transfer-user-website-to-team": "Изберете екипът на който да бъде прехвърлен уебсайта.",
"message.transfer-website": "Прехвърли собствеността на уебсайта към вашия акаунт или към друг екип.",
"message.triggered-event": "Активирано събитие",
- "message.unauthorized": "Unauthorized",
+ "message.unauthorized": "Неоторизиран достъп",
"message.user-deleted": "Потребителят е изтрит.",
"message.viewed-page": "Страницата е видяна",
"message.visitor-log": "Посетител от {country}, използващ {browser} на {os} {device}"
diff --git a/src/lang/fi-FI.json b/src/lang/fi-FI.json
index daaa62f0d..686294701 100644
--- a/src/lang/fi-FI.json
+++ b/src/lang/fi-FI.json
@@ -37,7 +37,7 @@
"label.compare-dates": "Vertaa päivämääriä",
"label.confirm": "Vahvista",
"label.confirm-password": "Vahvista salasana",
- "label.contains": "Contains",
+ "label.contains": "Sisältää",
"label.content": "Sisältö",
"label.continue": "Jatka",
"label.conversion": "Konversio",
@@ -96,7 +96,7 @@
"label.false": "Epätosi",
"label.field": "Kenttä",
"label.fields": "Kentät",
- "label.filter": "Filter",
+ "label.filter": "Suodatin",
"label.filter-combined": "Yhdistetty",
"label.filter-raw": "Käsittelemätön",
"label.filters": "Suodattimet",
@@ -151,7 +151,7 @@
"label.members": "Jäsenet",
"label.min": "Minimi",
"label.mobile": "Puhelin",
- "label.model": "Model",
+ "label.model": "Malli",
"label.more": "Lisää",
"label.my-account": "Oma tili",
"label.my-websites": "Omat verkkosivut",
@@ -184,9 +184,9 @@
"label.paths": "Polut",
"label.pixels": "Pikselit",
"label.powered-by": "Voimanlähteenä {name}",
- "label.previous": "Previous",
- "label.previous-period": "Previous period",
- "label.previous-year": "Previous year",
+ "label.previous": "Edellinen",
+ "label.previous-period": "Edellinen ajanjakso",
+ "label.previous-year": "Edellinen vuosi",
"label.profile": "Profiili",
"label.properties": "Ominaisuudet",
"label.property": "Ominaisuus",
@@ -195,16 +195,16 @@
"label.query-parameters": "Kyselyn parametrit",
"label.realtime": "Juuri nyt",
"label.referral": "Viittaus",
- "label.referrer": "Referrer",
+ "label.referrer": "Viittaaja",
"label.referrers": "Viittaajat",
"label.refresh": "Päivitä",
- "label.regenerate": "Regenerate",
- "label.region": "Region",
- "label.regions": "Regions",
+ "label.regenerate": "Luo uudelleen",
+ "label.region": "Alue",
+ "label.regions": "Alueet",
"label.remaining": "Jäljellä",
- "label.remove": "Remove",
- "label.remove-member": "Remove member",
- "label.reports": "Reports",
+ "label.remove": "Poista",
+ "label.remove-member": "Poista jäsen",
+ "label.reports": "Raportit",
"label.required": "Vaaditaan",
"label.reset": "Nollaa",
"label.reset-website": "Nollaa tilastot",
@@ -212,19 +212,19 @@
"label.retention-description": "Measure your website stickiness by tracking how often users return.",
"label.revenue": "Tulot",
"label.revenue-description": "Katso tulosi ajan mittaan.",
- "label.role": "Role",
- "label.run-query": "Run query",
+ "label.role": "Rooli",
+ "label.run-query": "Suorita kysely",
"label.save": "Tallenna",
"label.screens": "Näytöt",
- "label.search": "Search",
- "label.select": "Select",
- "label.select-date": "Select date",
+ "label.search": "Haku",
+ "label.select": "Valitse",
+ "label.select-date": "Valitse päivämäärä",
"label.select-filter": "Valitse suodatin",
- "label.select-role": "Select role",
- "label.select-website": "Select website",
+ "label.select-role": "Valitse rooli",
+ "label.select-website": "Valitse verkkosivu",
"label.session": "Istunto",
"label.session-data": "Istuntotiedot",
- "label.sessions": "Sessions",
+ "label.sessions": "Istunnot",
"label.settings": "Asetukset",
"label.share": "Jaa",
"label.share-url": "Jaa URL",
@@ -233,107 +233,107 @@
"label.sources": "Lähteet",
"label.start-step": "Aloitusvaihe",
"label.steps": "Vaiheet",
- "label.sum": "Sum",
+ "label.sum": "Summa",
"label.tablet": "Tabletti",
"label.tag": "Tunniste",
"label.tags": "Tunnisteet",
- "label.team": "Team",
- "label.team-id": "Team ID",
- "label.team-manager": "Team manager",
- "label.team-member": "Team member",
- "label.team-name": "Team name",
- "label.team-owner": "Team owner",
+ "label.team": "Tiimi",
+ "label.team-id": "Tiimin ID",
+ "label.team-manager": "Tiimin johtaja",
+ "label.team-member": "Tiimin jäsen",
+ "label.team-name": "Tiimin nimi",
+ "label.team-owner": "Tiimin omistaja",
"label.team-settings": "Tiimin asetukset",
"label.team-view-only": "Team view only",
- "label.team-websites": "Team websites",
- "label.teams": "Teams",
+ "label.team-websites": "Tiimin verkkosivut",
+ "label.teams": "Tiimit",
"label.terms": "Ehdot",
"label.theme": "Teema",
"label.this-month": "Tämä kuukausi",
"label.this-week": "Tämä viikko",
"label.this-year": "Tämä vuosi",
"label.timezone": "Aikavyöhyke",
- "label.title": "Title",
+ "label.title": "Otsikko",
"label.today": "Tänään",
"label.toggle-charts": "Kytke kaaviot päälle/pois",
- "label.total": "Total",
- "label.total-records": "Total records",
+ "label.total": "Yhteensä",
+ "label.total-records": "Tietueita yhteensä",
"label.tracking-code": "Seurantakoodi",
- "label.transactions": "Transactions",
- "label.transfer": "Transfer",
- "label.transfer-website": "Transfer website",
- "label.true": "True",
- "label.type": "Type",
- "label.unique": "Unique",
+ "label.transactions": "Transaktiot",
+ "label.transfer": "Siirrä",
+ "label.transfer-website": "Siirrä verkkosivu",
+ "label.true": "Tosi",
+ "label.type": "Tyyppi",
+ "label.unique": "Uniikki",
"label.unique-visitors": "Yksittäiset kävijät",
- "label.uniqueCustomers": "Unique Customers",
+ "label.uniqueCustomers": "Uniikit asiakkaat",
"label.unknown": "Tuntematon",
- "label.untitled": "Untitled",
- "label.update": "Update",
- "label.user": "User",
+ "label.untitled": "Nimetön",
+ "label.update": "Päivitä",
+ "label.user": "Käyttäjä",
"label.username": "Käyttäjänimi",
- "label.users": "Users",
+ "label.users": "Käyttäjät",
"label.utm": "UTM",
- "label.utm-description": "Track your campaigns through UTM parameters.",
- "label.value": "Value",
- "label.view": "View",
+ "label.utm-description": "Seuraa kampanjoitasi UTM-parametrien avulla.",
+ "label.value": "Arvo",
+ "label.view": "Näytä",
"label.view-details": "Katso tiedot",
- "label.view-only": "View only",
+ "label.view-only": "Vain katselu",
"label.views": "Näyttökerrat",
- "label.views-per-visit": "Views per visit",
+ "label.views-per-visit": "Katselukerrat vierailua kohti",
"label.visit-duration": "Keskimääräinen vierailuaika",
"label.visitors": "Vierailijat",
- "label.visits": "Visits",
- "label.website": "Website",
- "label.website-id": "Website ID",
+ "label.visits": "Vierailut",
+ "label.website": "Verkkosivu",
+ "label.website-id": "Verkkosivun ID",
"label.websites": "Verkkosivut",
- "label.window": "Window",
- "label.yesterday": "Yesterday",
- "label.behavior": "Behavior",
- "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "label.window": "Ikkuna",
+ "label.yesterday": "Eilen",
+ "label.behavior": "Käyttäytyminen",
+ "message.action-confirmation": "Kirjoita {confirmation} alla olevaan kenttään vahvistaaksesi.",
"message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
- "message.bad-request": "Bad request",
- "message.collected-data": "Collected data",
+ "message.bad-request": "Virheellinen pyyntö",
+ "message.collected-data": "Kerätty data",
"message.confirm-delete": "Haluatko varmasti poistaa sivuston {target}?",
- "message.confirm-leave": "Are you sure you want to leave {target}?",
- "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-leave": "Haluatko varmasti poistua {target}?",
+ "message.confirm-remove": "Haluatko varmasti poistaa {target}?",
"message.confirm-reset": "Haluatko varmasti poistaa sivuston {target} tilastot?",
- "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-team-warning": "Tiimin poistaminen poistaa myös kaikki tiimin sivustot.",
"message.delete-website-warning": "Kaikki siihen liittyvät tiedot poistetaan.",
"message.error": "Jotain meni pieleen.",
"message.event-log": "{event} on {url}",
- "message.forbidden": "Forbidden",
+ "message.forbidden": "Kielletty",
"message.go-to-settings": "Mene asetuksiin",
"message.incorrect-username-password": "Väärä käyttäjänimi/salasana.",
"message.invalid-domain": "Virheellinen verkkotunnus",
- "message.min-password-length": "Minimum length of {n} characters",
- "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.min-password-length": "Vähimmäispituus {n} merkkiä",
+ "message.new-version-available": "Umamista on saatavilla uusi versio {version}!",
"message.no-data-available": "Tietoja ei ole käytettävissä.",
- "message.no-event-data": "No event data is available.",
+ "message.no-event-data": "Tapahtumatietoja ei ole saatavilla.",
"message.no-match-password": "Salasanat eivät täsmää",
- "message.no-results-found": "No results were found.",
- "message.no-team-websites": "This team does not have any websites.",
- "message.no-teams": "You have not created any teams.",
- "message.no-users": "There are no users.",
+ "message.no-results-found": "Tuloksia ei löytynyt.",
+ "message.no-team-websites": "Tällä tiimillä ei ole verkkosivustoja.",
+ "message.no-teams": "Et ole luonut yhtään tiimiä.",
+ "message.no-users": "Käyttäjiä ei ole.",
"message.no-websites-configured": "Sinulla ei ole määritettyjä verkkosivustoja.",
- "message.not-found": "Not found",
- "message.nothing-selected": "Nothing selected.",
+ "message.not-found": "Ei löytynyt",
+ "message.nothing-selected": "Mitään ei ole valittu.",
"message.page-not-found": "Sivua ei löydetty.",
- "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website": "Nollaa verkkosivusto kirjoittamalla {confirmation} alla olevaan kenttään vahvistaaksesi.",
"message.reset-website-warning": "Kaikki sivuston tilastot poistetaan, mutta seurantakoodi pysyy muuttumattomana.",
"message.saved": "Tallennettu onnistuneesti.",
- "message.sever-error": "Server error",
+ "message.sever-error": "Palvelinvirhe",
"message.share-url": "Tämä on julkisesti jaettu URL sivustolle {target}.",
- "message.team-already-member": "You are already a member of the team.",
- "message.team-not-found": "Team not found.",
- "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.team-already-member": "Olet jo tiimissä.",
+ "message.team-not-found": "Tiimiä ei löydetty.",
+ "message.team-websites-info": "Verkkosivuja voi tarkastella kuka tahansa tiimin jäsen.",
"message.tracking-code": "Seurantakoodi",
- "message.transfer-team-website-to-user": "Transfer this website to your account?",
- "message.transfer-user-website-to-team": "Select the team to transfer this website to.",
- "message.transfer-website": "Transfer website ownership to your account or another team.",
- "message.triggered-event": "Triggered event",
- "message.unauthorized": "Unauthorized",
- "message.user-deleted": "User deleted.",
- "message.viewed-page": "Viewed page",
+ "message.transfer-team-website-to-user": "Siirretäänkö tämä verkkosivu tilillesi?",
+ "message.transfer-user-website-to-team": "Valitse tiimi, johon verkkosivusto siirretään.",
+ "message.transfer-website": "Siirrä verkkosivusto tiliisi tai toiselle tiimille.",
+ "message.triggered-event": "Laukaistu tapahtuma",
+ "message.unauthorized": "Ei oikeuksia",
+ "message.user-deleted": "Käyttäjä poistettu.",
+ "message.viewed-page": "Katsottu sivu",
"message.visitor-log": "Vierailija maasta {country} selaimella {browser} laitteella {os} {device}"
-}
+}
\ No newline at end of file
diff --git a/src/lang/ja-JP.json b/src/lang/ja-JP.json
index 7d2bf4030..20b48f40f 100644
--- a/src/lang/ja-JP.json
+++ b/src/lang/ja-JP.json
@@ -23,7 +23,7 @@
"label.behavior": "行動",
"label.boards": "ボード",
"label.bounce-rate": "直帰率",
- "label.breakdown": "故障",
+ "label.breakdown": "内訳",
"label.browser": "ブラウザ",
"label.browsers": "ブラウザ",
"label.campaigns": "キャンペーン",
diff --git a/src/lang/zh-CN.json b/src/lang/zh-CN.json
index c6f01dd5c..5490df422 100644
--- a/src/lang/zh-CN.json
+++ b/src/lang/zh-CN.json
@@ -1,11 +1,15 @@
{
"label.access-code": "访问代码",
+ "label.account": "账户",
+ "label.action": "行为",
"label.actions": "用户行为",
"label.activity": "活动日志",
"label.add": "添加",
"label.add-board": "添加看板",
"label.add-description": "添加描述",
+ "label.add-link": "添加链接",
"label.add-member": "添加成员",
+ "label.add-pixel": "添加像素",
"label.add-step": "添加步骤",
"label.add-website": "添加网站",
"label.admin": "管理员",
@@ -13,10 +17,13 @@
"label.after": "之后",
"label.all": "所有",
"label.all-time": "所有时间段",
+ "label.analysis": "分析",
"label.analytics": "分析",
+ "label.application": "应用",
"label.apply": "应用",
"label.attribution": "归因",
"label.attribution-description": "查看用户如何与您的营销互动,以及是什么促成了转化。",
+ "label.audience": "受众",
"label.average": "平均",
"label.back": "返回",
"label.before": "之前",
@@ -29,11 +36,14 @@
"label.campaigns": "活动",
"label.cancel": "取消",
"label.change-password": "修改密码",
+ "label.channel": "渠道",
"label.channels": "渠道",
+ "label.chart": "图表",
"label.cities": "市/县",
"label.city": "市/县",
"label.clear-all": "清除全部",
"label.cohort": "队列",
+ "label.cohorts": "队列",
"label.compare": "比较",
"label.compare-dates": "比较日期",
"label.confirm": "确认",
@@ -53,6 +63,7 @@
"label.create-user": "创建用户",
"label.created": "已创建",
"label.created-by": "创建者",
+ "label.criteria": "条件",
"label.currency": "货币",
"label.current": "当前",
"label.current-password": "当前密码",
@@ -70,24 +81,28 @@
"label.delete-website": "删除网站",
"label.description": "描述",
"label.desktop": "台式机",
+ "label.destination-url": "目标URL",
"label.details": "详细信息",
"label.device": "设备",
"label.devices": "设备",
"label.direct": "直接",
"label.dismiss": "关闭",
"label.distinct-id": "唯一ID",
+ "label.documentation": "文档",
"label.does-not-contain": "不包含",
"label.does-not-include": "不包括",
"label.doest-not-exist": "不存在",
"label.domain": "域名",
+ "label.download": "下载",
"label.dropoff": "丢弃",
"label.edit": "编辑",
"label.edit-dashboard": "编辑仪表盘",
"label.edit-member": "编辑成员",
- "label.email": "Email",
+ "label.email": "邮箱",
"label.enable-share-url": "启用共享链接",
"label.end-step": "结束步骤",
"label.entry": "入口 URL",
+ "label.environment": "环境",
"label.event": "事件",
"label.event-data": "事件数据",
"label.event-name": "事件名称",
@@ -112,11 +127,13 @@
"label.greater-than": "大于",
"label.greater-than-equals": "大于或等于",
"label.grouped": "分组",
+ "label.growth": "增长",
"label.hostname": "主机名",
"label.includes": "包括",
"label.insight": "洞察",
"label.insights": "见解",
"label.insights-description": "通过使用筛选器和划分时间段来更深入地研究数据。",
+ "label.invalid-url": "无效URL",
"label.is": "等于",
"label.is-false": "否",
"label.is-not": "不等于",
@@ -140,7 +157,9 @@
"label.leave-team": "离开团队",
"label.less-than": "少于",
"label.less-than-equals": "少于等于",
+ "label.link": "链接",
"label.links": "链接",
+ "label.location": "位置",
"label.login": "登录",
"label.logout": "退出",
"label.manage": "管理",
@@ -161,7 +180,7 @@
"label.none": "无",
"label.number-of-records": "{x} {x, plural, one {record} other {records}}",
"label.ok": "好的",
- "label.online": "Online",
+ "label.online": "在线",
"label.organic-search": "自然搜索",
"label.organic-shopping": "自然购物",
"label.organic-social": "自然社交",
@@ -183,19 +202,22 @@
"label.password": "密码",
"label.path": "路径",
"label.paths": "路径",
+ "label.pixel": "像素",
"label.pixels": "像素",
"label.powered-by": "由 {name} 提供支持",
+ "label.preferences": "偏好",
"label.previous": "先前",
"label.previous-period": "上一时期",
"label.previous-year": "上一年",
"label.profile": "个人资料",
+ "label.profiles": "个人资料",
"label.properties": "属性",
"label.property": "属性",
"label.queries": "查询",
"label.query": "查询",
"label.query-parameters": "查询参数",
"label.realtime": "实时",
- "label.referral": "Referral",
+ "label.referral": "来源",
"label.referrer": "来源",
"label.referrers": "来源域名",
"label.refresh": "刷新",
@@ -216,8 +238,13 @@
"label.role": "角色",
"label.run-query": "查询",
"label.save": "保存",
+ "label.save-cohort": "保存为群组",
+ "label.save-segment": "保存为细分",
+ "label.screen": "屏幕",
"label.screens": "屏幕尺寸",
"label.search": "搜索",
+ "label.segment": "细分",
+ "label.segments": "细分",
"label.select": "选择",
"label.select-date": "选择日期",
"label.select-filter": "选择筛选器",
@@ -235,6 +262,9 @@
"label.start-step": "开始步骤",
"label.steps": "步骤",
"label.sum": "总和",
+ "label.support": "支持",
+ "label.switch-account": "切换账户",
+ "label.table": "表格",
"label.tablet": "平板",
"label.tag": "标签",
"label.tags": "标签",
@@ -260,6 +290,7 @@
"label.total": "总数",
"label.total-records": "总记录数",
"label.tracking-code": "跟踪代码",
+ "label.traffic": "流量",
"label.transactions": "交易",
"label.transfer": "转移",
"label.transfer-website": "转移网站",
@@ -292,7 +323,7 @@
"label.yesterday": "昨天",
"message.action-confirmation": "请在下方输入框中输入 {confirmation} 以确认操作。",
"message.active-users": "当前在线 {x} 位访客",
- "message.bad-request": "Bad request",
+ "message.bad-request": "请求错误",
"message.collected-data": "已收集的数据",
"message.confirm-delete": "你确定要删除 {target} 吗?",
"message.confirm-leave": "你确定要离开 {target} 吗?",
@@ -302,7 +333,7 @@
"message.delete-website-warning": "所有相关数据将会被删除。",
"message.error": "发生错误。",
"message.event-log": "{url} 上的 {event}",
- "message.forbidden": "Forbidden",
+ "message.forbidden": "禁止访问",
"message.go-to-settings": "去设置",
"message.incorrect-username-password": "用户名或密码不正确。",
"message.invalid-domain": "无效域名",
@@ -316,13 +347,13 @@
"message.no-teams": "您尚未创建任何团队。",
"message.no-users": "暂无用户。",
"message.no-websites-configured": "你还没有设置任何网站。",
- "message.not-found": "Not found",
- "message.nothing-selected": "Nothing selected.",
+ "message.not-found": "未找到",
+ "message.nothing-selected": "未选择",
"message.page-not-found": "页面未找到。",
"message.reset-website": "如确定要重置该网站,请在下面输入 {confirmation} 以确认。",
"message.reset-website-warning": "此网站的所有统计数据将被删除,但您的跟踪代码将保持不变。",
"message.saved": "保存成功。",
- "message.sever-error": "Server error",
+ "message.sever-error": "服务器错误",
"message.share-url": "这是 {target} 的共享链接。",
"message.team-already-member": "你已是该团队的成员。",
"message.team-not-found": "未找到团队。",
@@ -332,7 +363,7 @@
"message.transfer-user-website-to-team": "选择要转移此网站的团队。",
"message.transfer-website": "将网站所有权转移到您的账户或其他团队。",
"message.triggered-event": "触发事件",
- "message.unauthorized": "Unauthorized",
+ "message.unauthorized": "未授权",
"message.user-deleted": "用户已删除。",
"message.viewed-page": "已浏览页面",
"message.visitor-log": "来自 {country} 的访客在搭载 {os} 的 {device} 上使用 {browser} 浏览器进行访问。"
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index ba6d8b09f..832dfb600 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,7 +1,6 @@
import debug from 'debug';
import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
-import { secret } from '@/lib/crypto';
-import { getRandomChars } from '@/lib/generate';
+import { createAuthKey, secret } from '@/lib/crypto';
import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
import redis from '@/lib/redis';
import { ensureArray } from '@/lib/utils';
@@ -53,7 +52,7 @@ export async function checkAuth(request: Request) {
}
export async function saveAuth(data: any, expire = 0) {
- const authKey = `auth:${getRandomChars(32)}`;
+ const authKey = `auth:${createAuthKey()}`;
if (redis.enabled) {
await redis.client.set(authKey, data);
diff --git a/src/lib/charts.ts b/src/lib/charts.ts
index 7d4208e2a..1458cff84 100644
--- a/src/lib/charts.ts
+++ b/src/lib/charts.ts
@@ -11,7 +11,6 @@ export function renderDateLabels(unit: string, locale: string) {
switch (unit) {
case 'minute':
- return formatDate(d, 'h:mm', locale);
case 'hour':
return formatDate(d, 'p', locale);
case 'day':
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
index f2ebbb722..0a336f804 100644
--- a/src/lib/clickhouse.ts
+++ b/src/lib/clickhouse.ts
@@ -61,7 +61,7 @@ function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
function getDateSQL(field: string, unit: string, timezone?: string) {
if (timezone) {
- return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`;
+ return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'), '${timezone}')`;
}
return `toDateTime(date_trunc('${unit}', ${field}))`;
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index e5090c3c0..3da177c00 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -4,6 +4,7 @@ export const LOCALE_CONFIG = 'umami.locale';
export const TIMEZONE_CONFIG = 'umami.timezone';
export const DATE_RANGE_CONFIG = 'umami.date-range';
export const THEME_CONFIG = 'umami.theme';
+export const CURRENCY_CONFIG = 'umami.currency';
export const DASHBOARD_CONFIG = 'umami.dashboard';
export const LAST_TEAM_CONFIG = 'umami.last-team';
export const VERSION_CHECK = 'umami.version-check';
@@ -25,6 +26,7 @@ export const DEFAULT_WEBSITE_LIMIT = 10;
export const DEFAULT_RESET_DATE = '2000-01-01';
export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_DATE_COMPARE = 'prev';
+export const DEFAULT_CURRENCY = 'USD';
export const REALTIME_RANGE = 30;
export const REALTIME_INTERVAL = 10000;
@@ -53,6 +55,7 @@ export const SESSION_COLUMNS = [
'country',
'city',
'region',
+ 'distinctId',
];
export const SEGMENT_TYPES = {
@@ -67,6 +70,7 @@ export const FILTER_COLUMNS = {
referrer: 'referrer_domain',
domain: 'referrer_domain',
hostname: 'hostname',
+ distinctId: 'distinct_id',
title: 'page_title',
query: 'url_query',
os: 'os',
@@ -93,6 +97,13 @@ export const EVENT_TYPE = {
pixelEvent: 4,
} as const;
+export const ENTITY_TYPE = {
+ website: 1,
+ link: 2,
+ pixel: 3,
+ board: 4,
+} as const;
+
export const DATA_TYPE = {
string: 1,
number: 2,
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
index a6d912b82..ee4c977fe 100644
--- a/src/lib/crypto.ts
+++ b/src/lib/crypto.ts
@@ -63,3 +63,7 @@ export function uuid(...args: any) {
return process.env.USE_UUIDV7 ? v7() : v4();
}
+
+export function createAuthKey() {
+ return crypto.randomBytes(16).toString('hex');
+}
diff --git a/src/lib/date.ts b/src/lib/date.ts
index 3c1fd1b7d..91af88f67 100644
--- a/src/lib/date.ts
+++ b/src/lib/date.ts
@@ -9,6 +9,7 @@ import {
differenceInCalendarMonths,
differenceInCalendarWeeks,
differenceInCalendarYears,
+ differenceInDays,
differenceInHours,
differenceInMinutes,
endOfDay,
@@ -136,7 +137,12 @@ export function parseDateValue(value: string) {
return { num: +num, unit };
}
-export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange {
+export function parseDateRange(
+ value: string,
+ unitValue?: string,
+ locale = 'en-US',
+ timezone?: string,
+): DateRange {
if (typeof value !== 'string') {
return null;
}
@@ -146,7 +152,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
const startDate = new Date(+startTime);
const endDate = new Date(+endTime);
- const unit = getMinimumUnit(startDate, endDate);
+ const unit = getMinimumUnit(startDate, endDate, true);
return {
startDate,
@@ -169,14 +175,14 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
endDate: endOfHour(now),
offset: 0,
num: num || 1,
- unit,
+ unit: unitValue || unit,
value,
};
case 'day':
return {
startDate: num ? subDays(startOfDay(now), num) : startOfDay(now),
endDate: endOfDay(now),
- unit: num ? 'day' : 'hour',
+ unit: unitValue ? unitValue : num ? 'day' : 'hour',
offset: 0,
num: num || 1,
value,
@@ -187,7 +193,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
? subWeeks(startOfWeek(now, { locale: dateLocale }), num)
: startOfWeek(now, { locale: dateLocale }),
endDate: endOfWeek(now, { locale: dateLocale }),
- unit: 'day',
+ unit: unitValue || 'day',
offset: 0,
num: num || 1,
value,
@@ -196,7 +202,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
return {
startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now),
endDate: endOfMonth(now),
- unit: num ? 'month' : 'day',
+ unit: unitValue ? unitValue : num ? 'month' : 'day',
offset: 0,
num: num || 1,
value,
@@ -205,7 +211,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
return {
startDate: num ? subYears(startOfYear(now), num) : startOfYear(now),
endDate: endOfYear(now),
- unit: 'month',
+ unit: unitValue || 'month',
offset: 0,
num: num || 1,
value,
@@ -273,12 +279,20 @@ export function getAllowedUnits(startDate: Date, endDate: Date) {
return index >= 0 ? units.splice(index) : [];
}
-export function getMinimumUnit(startDate: number | Date, endDate: number | Date) {
+export function getMinimumUnit(
+ startDate: number | Date,
+ endDate: number | Date,
+ isDateRange: boolean = false,
+) {
if (differenceInMinutes(endDate, startDate) <= 60) {
return 'minute';
- } else if (differenceInHours(endDate, startDate) <= 48) {
+ } else if (
+ isDateRange
+ ? differenceInHours(endDate, startDate) <= 48
+ : differenceInDays(endDate, startDate) <= 30
+ ) {
return 'hour';
- } else if (differenceInCalendarMonths(endDate, startDate) <= 6) {
+ } else if (differenceInCalendarMonths(endDate, startDate) <= 7) {
return 'day';
} else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
return 'month';
diff --git a/src/lib/detect.ts b/src/lib/detect.ts
index 68cb66726..910d122be 100644
--- a/src/lib/detect.ts
+++ b/src/lib/detect.ts
@@ -28,6 +28,12 @@ const PROVIDER_HEADERS = [
regionHeader: 'cloudfront-viewer-country-region',
cityHeader: 'cloudfront-viewer-city',
},
+ // EdgeOne headers (requires custom request headers in Rule Priorities, see: https://edgeone.ai/document/46151)
+ {
+ countryHeader: 'eo-ipcountry',
+ regionHeader: 'eo-region-code',
+ cityHeader: 'eo-ipcity',
+ },
];
export function getDevice(userAgent: string, screen: string = '') {
diff --git a/src/lib/entity.ts b/src/lib/entity.ts
new file mode 100644
index 000000000..fd26252dd
--- /dev/null
+++ b/src/lib/entity.ts
@@ -0,0 +1,12 @@
+import type { Link, Pixel, Website } from '@/generated/prisma/client';
+import { getLink, getPixel, getWebsite } from '@/queries/prisma';
+
+export async function getEntity(entityId: string): Promise {
+ const website = await getWebsite(entityId);
+ const link = await getLink(entityId);
+ const pixel = await getPixel(entityId);
+
+ const entity = website || link || pixel;
+
+ return entity;
+}
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 52fd3048d..035a18111 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -1,3 +1,5 @@
+import { DEFAULT_CURRENCY } from './constants';
+
export function parseTime(val: number) {
const days = ~~(val / 86400);
const hours = ~~(val / 3600) - days * 24;
@@ -94,7 +96,7 @@ export function formatCurrency(value: number, currency: string, locale = 'en-US'
// Fallback to default currency format if an error occurs
formattedValue = new Intl.NumberFormat(locale, {
style: 'currency',
- currency: 'USD',
+ currency: DEFAULT_CURRENCY,
});
}
diff --git a/src/lib/ip.ts b/src/lib/ip.ts
index 5cd775746..a0e3a8252 100644
--- a/src/lib/ip.ts
+++ b/src/lib/ip.ts
@@ -1,3 +1,5 @@
+import ipaddr from 'ipaddr.js';
+
export const IP_ADDRESS_HEADERS = [
'true-client-ip', // CDN
'cf-connecting-ip', // Cloudflare
@@ -13,35 +15,87 @@ export const IP_ADDRESS_HEADERS = [
'x-forwarded',
];
+/**
+ * Normalize IP strings to a canonical form:
+ * - strips IPv4-mapped IPv6 (e.g. ::ffff:192.0.2.1 -> 192.0.2.1)
+ * - keeps valid IPv4/IPv6 as-is (canonically formatted by ipaddr.js)
+ */
+function normalizeIp(ip?: string | null) {
+ if (!ip) return ip;
+
+ try {
+ const parsed = ipaddr.parse(ip);
+
+ if (parsed.kind() === 'ipv6' && (parsed as ipaddr.IPv6).isIPv4MappedAddress()) {
+ return (parsed as ipaddr.IPv6).toIPv4Address().toString();
+ }
+
+ return parsed.toString();
+ } catch {
+ // Fallback: return original if parsing fails
+ return ip;
+ }
+}
+
+function resolveIp(ip?: string | null) {
+ if (!ip) return ip;
+
+ // First, try as-is
+ const normalized = normalizeIp(ip);
+ try {
+ ipaddr.parse(normalized);
+ return normalized;
+ } catch {
+ // try stripping port (handles IPv4:port; leaves IPv6 intact)
+ const stripped = stripPort(ip);
+ if (stripped !== ip) {
+ const normalizedStripped = normalizeIp(stripped);
+ try {
+ ipaddr.parse(normalizedStripped);
+ return normalizedStripped;
+ } catch {
+ return normalizedStripped;
+ }
+ }
+
+ return normalized;
+ }
+}
+
export function getIpAddress(headers: Headers) {
const customHeader = process.env.CLIENT_IP_HEADER;
if (customHeader && headers.get(customHeader)) {
- return headers.get(customHeader);
+ return resolveIp(headers.get(customHeader));
}
- const header = IP_ADDRESS_HEADERS.find(name => {
- return headers.get(name);
- });
+ const header = IP_ADDRESS_HEADERS.find(name => headers.get(name));
+ if (!header) {
+ return undefined;
+ }
const ip = headers.get(header);
if (header === 'x-forwarded-for') {
- return ip?.split(',')?.[0]?.trim();
+ return resolveIp(ip?.split(',')?.[0]?.trim());
}
if (header === 'forwarded') {
const match = ip.match(/for=(\[?[0-9a-fA-F:.]+\]?)/);
if (match) {
- return match[1];
+ return resolveIp(match[1]);
}
}
- return ip;
+ return resolveIp(ip);
}
-export function stripPort(ip: string) {
+export function stripPort(ip?: string | null) {
+ if (!ip) {
+ return ip;
+ }
+
if (ip.startsWith('[')) {
const endBracket = ip.indexOf(']');
if (endBracket !== -1) {
diff --git a/src/lib/load.ts b/src/lib/load.ts
index d4d6c3c75..bf527975f 100644
--- a/src/lib/load.ts
+++ b/src/lib/load.ts
@@ -38,3 +38,9 @@ export async function fetchSession(websiteId: string, sessionId: string): Promis
return session;
}
+
+export async function fetchAccount(userId: string) {
+ const account = await redis.client.get(`account:${userId}`);
+
+ return account;
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
index 64cb870fd..bfd007d1b 100644
--- a/src/lib/prisma.ts
+++ b/src/lib/prisma.ts
@@ -74,15 +74,21 @@ function getSearchSQL(column: string, param: string = 'search'): string {
function mapFilter(column: string, operator: string, name: string, type: string = '') {
const value = `{{${name}${type ? `::${type}` : ''}}}`;
+ if (name.startsWith('cohort_')) {
+ name = name.slice('cohort_'.length);
+ }
+
+ const table = SESSION_COLUMNS.includes(name) ? 'session' : 'website_event';
+
switch (operator) {
case OPERATORS.equals:
- return `${column} = ${value}`;
+ return `${table}.${column} = ${value}`;
case OPERATORS.notEquals:
- return `${column} != ${value}`;
+ return `${table}.${column} != ${value}`;
case OPERATORS.contains:
- return `${column} ilike ${value}`;
+ return `${table}.${column} ilike ${value}`;
case OPERATORS.doesNotContain:
- return `${column} not ilike ${value}`;
+ return `${table}.${column} not ilike ${value}`;
default:
return '';
}
diff --git a/src/lib/request.ts b/src/lib/request.ts
index 42c449048..7f9163cca 100644
--- a/src/lib/request.ts
+++ b/src/lib/request.ts
@@ -1,8 +1,9 @@
+import { startOfMonth, subMonths } from 'date-fns';
import { z } from 'zod';
import { checkAuth } from '@/lib/auth';
import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS } from '@/lib/constants';
import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date';
-import { fetchWebsite } from '@/lib/load';
+import { fetchAccount, fetchWebsite } from '@/lib/load';
import { filtersArrayToObject } from '@/lib/params';
import { badRequest, unauthorized } from '@/lib/response';
import type { QueryFilters } from '@/lib/types';
@@ -16,7 +17,7 @@ export async function parseRequest(
const url = new URL(request.url);
let query = Object.fromEntries(url.searchParams);
let body = await getJsonBody(request);
- let error: () => undefined | undefined;
+ let error: () => undefined | undefined | Response;
let auth = null;
if (schema) {
@@ -82,6 +83,15 @@ export function getRequestFilters(query: Record) {
export async function setWebsiteDate(websiteId: string, data: Record) {
const website = await fetchWebsite(websiteId);
+ const cloudMode = !!process.env.CLOUD_MODE;
+
+ if (cloudMode && website && !website.teamId) {
+ const account = await fetchAccount(website.userId);
+
+ if (!account?.hasSubscription) {
+ data.startDate = maxDate(data.startDate, startOfMonth(subMonths(new Date(), 6)));
+ }
+ }
if (website?.resetAt) {
data.startDate = maxDate(data.startDate, new Date(website?.resetAt));
diff --git a/src/lib/schema.ts b/src/lib/schema.ts
index 38f7339a1..a3c56a0f1 100644
--- a/src/lib/schema.ts
+++ b/src/lib/schema.ts
@@ -20,7 +20,7 @@ export const dateRangeParams = {
endDate: z.coerce.date().optional(),
timezone: timezoneParam.optional(),
unit: unitParam.optional(),
- compare: z.string().optional(),
+ compare: z.enum(['prev', 'yoy']).optional(),
};
export const filterParams = {
@@ -36,6 +36,7 @@ export const filterParams = {
city: z.string().optional(),
tag: z.string().optional(),
hostname: z.string().optional(),
+ distinctId: z.string().optional(),
language: z.string().optional(),
event: z.string().optional(),
segment: z.uuid().optional(),
@@ -89,6 +90,7 @@ export const fieldsParam = z.enum([
'city',
'tag',
'hostname',
+ 'distinctId',
'language',
'event',
]);
@@ -104,6 +106,23 @@ export const reportTypeParam = z.enum([
'utm',
]);
+export const operatorParam = z.enum([
+ 'eq',
+ 'neq',
+ 's',
+ 'ns',
+ 'c',
+ 'dnc',
+ 't',
+ 'f',
+ 'gt',
+ 'lt',
+ 'gte',
+ 'lte',
+ 'bf',
+ 'af',
+]);
+
export const goalReportSchema = z.object({
type: z.literal('goal'),
parameters: z
@@ -149,6 +168,7 @@ export const journeyReportSchema = z.object({
steps: z.coerce.number().min(2).max(7),
startStep: z.string().optional(),
endStep: z.string().optional(),
+ eventType: z.coerce.number().int().positive().optional(),
}),
});
@@ -157,7 +177,7 @@ export const retentionReportSchema = z.object({
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
- timezone: z.string().optional(),
+ timezone: timezoneParam.optional(),
}),
});
@@ -174,7 +194,8 @@ export const revenueReportSchema = z.object({
parameters: z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
- timezone: z.string().optional(),
+ unit: unitParam.optional(),
+ timezone: timezoneParam.optional(),
currency: z.string(),
}),
});
@@ -230,3 +251,22 @@ export const reportResultSchema = z.intersection(
);
export const segmentTypeParam = z.enum(['segment', 'cohort']);
+
+export const segmentParamSchema = z.object({
+ filters: z
+ .array(
+ z.object({
+ name: z.string(),
+ operator: operatorParam,
+ value: z.string(),
+ }),
+ )
+ .optional(),
+ dateRange: z.string().optional(),
+ action: z
+ .object({
+ type: z.string(),
+ value: z.string(),
+ })
+ .optional(),
+});
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 9c0619794..f3466734d 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -141,3 +141,9 @@ export interface ApiError extends Error {
code?: string;
message: string;
}
+
+export interface WhiteLabel {
+ name: string;
+ url: string;
+ image: string;
+}
diff --git a/src/permissions/entity.ts b/src/permissions/entity.ts
new file mode 100644
index 000000000..bab804dd7
--- /dev/null
+++ b/src/permissions/entity.ts
@@ -0,0 +1,77 @@
+import { hasPermission } from '@/lib/auth';
+import { PERMISSIONS } from '@/lib/constants';
+import { getEntity } from '@/lib/entity';
+import type { Auth } from '@/lib/types';
+import { getTeamUser } from '@/queries/prisma';
+
+export async function canViewEntity({ user }: Auth, entityId: string) {
+ if (!user) {
+ return false;
+ }
+
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const entity = await getEntity(entityId);
+
+ if (entity.userId) {
+ return user.id === entity.userId;
+ }
+
+ if (entity.teamId) {
+ const teamUser = await getTeamUser(entity.teamId, user.id);
+
+ return !!teamUser;
+ }
+
+ return false;
+}
+
+export async function canUpdateEntity({ user }: Auth, entityId: string) {
+ if (!user) {
+ return false;
+ }
+
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const entity = await getEntity(entityId);
+
+ if (entity.userId) {
+ return user.id === entity.userId;
+ }
+
+ if (entity.teamId) {
+ const teamUser = await getTeamUser(entity.teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate);
+ }
+
+ return false;
+}
+
+export async function canDeleteEntity({ user }: Auth, entityId: string) {
+ if (!user) {
+ return false;
+ }
+
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const entity = await getEntity(entityId);
+
+ if (entity.userId) {
+ return user.id === entity.userId;
+ }
+
+ if (entity.teamId) {
+ const teamUser = await getTeamUser(entity.teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete);
+ }
+
+ return false;
+}
diff --git a/src/permissions/index.ts b/src/permissions/index.ts
index a70808e69..475cdaa48 100644
--- a/src/permissions/index.ts
+++ b/src/permissions/index.ts
@@ -1,3 +1,4 @@
+export * from './entity';
export * from './link';
export * from './pixel';
export * from './report';
diff --git a/src/permissions/link.ts b/src/permissions/link.ts
index c027a0b6c..8dd1d7c6b 100644
--- a/src/permissions/link.ts
+++ b/src/permissions/link.ts
@@ -4,7 +4,11 @@ import type { Auth } from '@/lib/types';
import { getLink, getTeamUser } from '@/queries/prisma';
export async function canViewLink({ user }: Auth, linkId: string) {
- if (user?.isAdmin) {
+ if (!user) {
+ return false;
+ }
+
+ if (user.isAdmin) {
return true;
}
@@ -24,6 +28,10 @@ export async function canViewLink({ user }: Auth, linkId: string) {
}
export async function canUpdateLink({ user }: Auth, linkId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -44,6 +52,10 @@ export async function canUpdateLink({ user }: Auth, linkId: string) {
}
export async function canDeleteLink({ user }: Auth, linkId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
diff --git a/src/permissions/pixel.ts b/src/permissions/pixel.ts
index 2131874f1..14b69acef 100644
--- a/src/permissions/pixel.ts
+++ b/src/permissions/pixel.ts
@@ -4,7 +4,11 @@ import type { Auth } from '@/lib/types';
import { getPixel, getTeamUser } from '@/queries/prisma';
export async function canViewPixel({ user }: Auth, pixelId: string) {
- if (user?.isAdmin) {
+ if (!user) {
+ return false;
+ }
+
+ if (user.isAdmin) {
return true;
}
@@ -24,6 +28,10 @@ export async function canViewPixel({ user }: Auth, pixelId: string) {
}
export async function canUpdatePixel({ user }: Auth, pixelId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -44,6 +52,10 @@ export async function canUpdatePixel({ user }: Auth, pixelId: string) {
}
export async function canDeletePixel({ user }: Auth, pixelId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
diff --git a/src/permissions/report.ts b/src/permissions/report.ts
index 01b547694..5f9da019f 100644
--- a/src/permissions/report.ts
+++ b/src/permissions/report.ts
@@ -3,11 +3,11 @@ import type { Auth } from '@/lib/types';
import { canViewWebsite } from './website';
export async function canViewReport(auth: Auth, report: Report) {
- if (auth.user.isAdmin) {
+ if (auth.user?.isAdmin) {
return true;
}
- if (auth.user.id === report.userId) {
+ if (auth.user?.id === report.userId) {
return true;
}
@@ -15,6 +15,10 @@ export async function canViewReport(auth: Auth, report: Report) {
}
export async function canUpdateReport({ user }: Auth, report: Report) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
diff --git a/src/permissions/team.ts b/src/permissions/team.ts
index 0f07c1a4f..130290af2 100644
--- a/src/permissions/team.ts
+++ b/src/permissions/team.ts
@@ -4,6 +4,10 @@ import type { Auth } from '@/lib/types';
import { getTeamUser } from '@/queries/prisma';
export async function canViewTeam({ user }: Auth, teamId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -12,14 +16,22 @@ export async function canViewTeam({ user }: Auth, teamId: string) {
}
export async function canCreateTeam({ user }: Auth) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
- return !!user;
+ return hasPermission(user.role, PERMISSIONS.teamCreate);
}
export async function canUpdateTeam({ user }: Auth, teamId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -30,6 +42,10 @@ export async function canUpdateTeam({ user }: Auth, teamId: string) {
}
export async function canDeleteTeam({ user }: Auth, teamId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -40,6 +56,10 @@ export async function canDeleteTeam({ user }: Auth, teamId: string) {
}
export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUserId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -54,6 +74,10 @@ export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUs
}
export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -64,5 +88,5 @@ export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
}
export async function canViewAllTeams({ user }: Auth) {
- return user.isAdmin;
+ return user?.isAdmin ?? false;
}
diff --git a/src/permissions/user.ts b/src/permissions/user.ts
index 2ed8f276e..8aa453aec 100644
--- a/src/permissions/user.ts
+++ b/src/permissions/user.ts
@@ -1,10 +1,14 @@
import type { Auth } from '@/lib/types';
export async function canCreateUser({ user }: Auth) {
- return user.isAdmin;
+ return user?.isAdmin ?? false;
}
export async function canViewUser({ user }: Auth, viewedUserId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -13,10 +17,14 @@ export async function canViewUser({ user }: Auth, viewedUserId: string) {
}
export async function canViewUsers({ user }: Auth) {
- return user.isAdmin;
+ return user?.isAdmin ?? false;
}
export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -25,5 +33,5 @@ export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
}
export async function canDeleteUser({ user }: Auth) {
- return user.isAdmin;
+ return user?.isAdmin ?? false;
}
diff --git a/src/permissions/website.ts b/src/permissions/website.ts
index 97952eed9..9ad25ae59 100644
--- a/src/permissions/website.ts
+++ b/src/permissions/website.ts
@@ -1,7 +1,8 @@
import { hasPermission } from '@/lib/auth';
import { PERMISSIONS } from '@/lib/constants';
+import { getEntity } from '@/lib/entity';
import type { Auth } from '@/lib/types';
-import { getLink, getPixel, getTeamUser, getWebsite } from '@/queries/prisma';
+import { getTeamUser, getWebsite } from '@/queries/prisma';
export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) {
if (user?.isAdmin) {
@@ -12,13 +13,9 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
return true;
}
- const website = await getWebsite(websiteId);
- const link = await getLink(websiteId);
- const pixel = await getPixel(websiteId);
+ const entity = await getEntity(websiteId);
- const entity = website || link || pixel;
-
- if (!entity) {
+ if (!entity || !user) {
return false;
}
@@ -36,10 +33,14 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
}
export async function canViewAllWebsites({ user }: Auth) {
- return user.isAdmin;
+ return user?.isAdmin ?? false;
}
export async function canCreateWebsite({ user }: Auth) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -48,6 +49,10 @@ export async function canCreateWebsite({ user }: Auth) {
}
export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -72,6 +77,10 @@ export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
}
export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
+ if (!user) {
+ return false;
+ }
+
if (user.isAdmin) {
return true;
}
@@ -96,6 +105,10 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
}
export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string, userId: string) {
+ if (!user) {
+ return false;
+ }
+
const website = await getWebsite(websiteId);
if (!website) {
@@ -112,6 +125,10 @@ export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string
}
export async function canTransferWebsiteToTeam({ user }: Auth, websiteId: string, teamId: string) {
+ if (!user) {
+ return false;
+ }
+
const website = await getWebsite(websiteId);
if (!website) {
diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts
index b9730f511..4dedb2b5c 100644
--- a/src/queries/prisma/index.ts
+++ b/src/queries/prisma/index.ts
@@ -2,6 +2,7 @@ export * from './link';
export * from './pixel';
export * from './report';
export * from './segment';
+export * from './share';
export * from './team';
export * from './teamUser';
export * from './user';
diff --git a/src/queries/prisma/share.ts b/src/queries/prisma/share.ts
new file mode 100644
index 000000000..53246ffbe
--- /dev/null
+++ b/src/queries/prisma/share.ts
@@ -0,0 +1,64 @@
+import type { Prisma } from '@/generated/prisma/client';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export async function findShare(criteria: Prisma.ShareFindUniqueArgs) {
+ return prisma.client.share.findUnique(criteria);
+}
+
+export async function getShare(shareId: string) {
+ return findShare({
+ where: {
+ id: shareId,
+ },
+ });
+}
+
+export async function getShareByCode(slug: string) {
+ return findShare({
+ where: {
+ slug,
+ },
+ });
+}
+
+export async function getSharesByEntityId(entityId: string, filters?: QueryFilters) {
+ const { pagedQuery } = prisma;
+
+ return pagedQuery(
+ 'share',
+ {
+ where: {
+ entityId,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ filters,
+ );
+}
+
+export async function createShare(
+ data: Prisma.ShareCreateInput | Prisma.ShareUncheckedCreateInput,
+) {
+ return prisma.client.share.create({
+ data,
+ });
+}
+
+export async function updateShare(
+ shareId: string,
+ data: Prisma.ShareUpdateInput | Prisma.ShareUncheckedUpdateInput,
+) {
+ return prisma.client.share.update({
+ where: {
+ id: shareId,
+ },
+ data,
+ });
+}
+
+export async function deleteShare(shareId: string) {
+ return prisma.client.share.delete({ where: { id: shareId } });
+}
diff --git a/src/queries/prisma/user.ts b/src/queries/prisma/user.ts
index 14376fc26..467ea1e02 100644
--- a/src/queries/prisma/user.ts
+++ b/src/queries/prisma/user.ts
@@ -18,7 +18,7 @@ async function findUser(criteria: Prisma.UserFindUniqueArgs, options: GetUserOpt
...criteria,
where: {
...criteria.where,
- ...(showDeleted && { deletedAt: null }),
+ ...(showDeleted ? {} : { deletedAt: null }),
},
select: {
id: true,
diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts
index 79cb72473..fe57589c5 100644
--- a/src/queries/prisma/website.ts
+++ b/src/queries/prisma/website.ts
@@ -16,15 +16,6 @@ export async function getWebsite(websiteId: string) {
});
}
-export async function getSharedWebsite(shareId: string) {
- return findWebsite({
- where: {
- shareId,
- deletedAt: null,
- },
- });
-}
-
export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
const { search } = filters;
const { getSearchParameters, pagedQuery } = prisma;
@@ -132,42 +123,46 @@ export async function updateWebsite(
}
export async function resetWebsite(websiteId: string) {
- const { client, transaction } = prisma;
+ const { transaction } = prisma;
const cloudMode = !!process.env.CLOUD_MODE;
return transaction(
- [
- client.revenue.deleteMany({
+ async tx => {
+ await tx.revenue.deleteMany({
where: { websiteId },
- }),
- client.eventData.deleteMany({
+ });
+
+ await tx.eventData.deleteMany({
where: { websiteId },
- }),
- client.sessionData.deleteMany({
+ });
+
+ await tx.sessionData.deleteMany({
where: { websiteId },
- }),
- client.websiteEvent.deleteMany({
+ });
+
+ await tx.websiteEvent.deleteMany({
where: { websiteId },
- }),
- client.session.deleteMany({
+ });
+
+ await tx.session.deleteMany({
where: { websiteId },
- }),
- client.website.update({
+ });
+
+ const website = await tx.website.update({
where: { id: websiteId },
data: {
resetAt: new Date(),
},
- }),
- ],
+ });
+
+ return website;
+ },
{
timeout: 30000,
},
).then(async data => {
if (cloudMode) {
- await redis.client.set(
- `website:${websiteId}`,
- data.find(website => website.id),
- );
+ await redis.client.set(`website:${websiteId}`, data);
}
return data;
@@ -175,43 +170,52 @@ export async function resetWebsite(websiteId: string) {
}
export async function deleteWebsite(websiteId: string) {
- const { client, transaction } = prisma;
+ const { transaction } = prisma;
const cloudMode = !!process.env.CLOUD_MODE;
return transaction(
- [
- client.revenue.deleteMany({
+ async tx => {
+ await tx.revenue.deleteMany({
where: { websiteId },
- }),
- client.eventData.deleteMany({
+ });
+
+ await tx.eventData.deleteMany({
where: { websiteId },
- }),
- client.sessionData.deleteMany({
+ });
+
+ await tx.sessionData.deleteMany({
where: { websiteId },
- }),
- client.websiteEvent.deleteMany({
+ });
+
+ await tx.websiteEvent.deleteMany({
where: { websiteId },
- }),
- client.session.deleteMany({
+ });
+
+ await tx.session.deleteMany({
where: { websiteId },
- }),
- client.report.deleteMany({
+ });
+
+ await tx.report.deleteMany({
where: { websiteId },
- }),
- client.segment.deleteMany({
+ });
+
+ await tx.segment.deleteMany({
where: { websiteId },
- }),
- cloudMode
- ? client.website.update({
+ });
+
+ const website = cloudMode
+ ? await tx.website.update({
data: {
deletedAt: new Date(),
},
where: { id: websiteId },
})
- : client.website.delete({
+ : await tx.website.delete({
where: { id: websiteId },
- }),
- ],
+ });
+
+ return website;
+ },
{
timeout: 30000,
},
diff --git a/src/queries/sql/events/getEventDataEvents.ts b/src/queries/sql/events/getEventDataEvents.ts
index 6c8f12c19..8ed6633ea 100644
--- a/src/queries/sql/events/getEventDataEvents.ts
+++ b/src/queries/sql/events/getEventDataEvents.ts
@@ -93,11 +93,15 @@ async function clickhouseQuery(
string_value as propertyValue,
count(*) as total
from event_data
- join website_event
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2) website_event
on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
and website_event.website_id = event_data.website_id
- and website_event.website_id = {websiteId:UUID}
- and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${cohortQuery}
where event_data.website_id = {websiteId:UUID}
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
@@ -120,11 +124,15 @@ async function clickhouseQuery(
data_type as dataType,
count(*) as total
from event_data
- join website_event
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2) website_event
on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
and website_event.website_id = event_data.website_id
- and website_event.website_id = {websiteId:UUID}
- and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${cohortQuery}
where event_data.website_id = {websiteId:UUID}
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
diff --git a/src/queries/sql/events/getEventDataFields.ts b/src/queries/sql/events/getEventDataFields.ts
index 933776909..709454929 100644
--- a/src/queries/sql/events/getEventDataFields.ts
+++ b/src/queries/sql/events/getEventDataFields.ts
@@ -65,11 +65,15 @@ async function clickhouseQuery(
string_value) as "value",
count(*) as "total"
from event_data
- join website_event
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2) website_event
on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
and website_event.website_id = event_data.website_id
- and website_event.website_id = {websiteId:UUID}
- and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${cohortQuery}
where event_data.website_id = {websiteId:UUID}
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
diff --git a/src/queries/sql/events/getEventDataProperties.ts b/src/queries/sql/events/getEventDataProperties.ts
index 82c078f8d..82d0b6b62 100644
--- a/src/queries/sql/events/getEventDataProperties.ts
+++ b/src/queries/sql/events/getEventDataProperties.ts
@@ -69,11 +69,15 @@ async function clickhouseQuery(
data_key as propertyName,
count(*) as total
from event_data
- join website_event
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2) website_event
on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
and website_event.website_id = event_data.website_id
- and website_event.website_id = {websiteId:UUID}
- and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${cohortQuery}
where event_data.website_id = {websiteId:UUID}
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
diff --git a/src/queries/sql/events/getEventDataStats.ts b/src/queries/sql/events/getEventDataStats.ts
index 89e135828..9d178f676 100644
--- a/src/queries/sql/events/getEventDataStats.ts
+++ b/src/queries/sql/events/getEventDataStats.ts
@@ -72,11 +72,15 @@ async function clickhouseQuery(
data_key,
count(*) as "total"
from event_data
- join website_event
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2) website_event
on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
and website_event.website_id = event_data.website_id
- and website_event.website_id = {websiteId:UUID}
- and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${cohortQuery}
where event_data.website_id = {websiteId:UUID}
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
diff --git a/src/queries/sql/events/getEventDataValues.ts b/src/queries/sql/events/getEventDataValues.ts
index 0426e646e..7c1ae282e 100644
--- a/src/queries/sql/events/getEventDataValues.ts
+++ b/src/queries/sql/events/getEventDataValues.ts
@@ -72,11 +72,15 @@ async function clickhouseQuery(
string_value) as "value",
count(*) as "total"
from event_data
- join website_event
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2) website_event
on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
and website_event.website_id = event_data.website_id
- and website_event.website_id = {websiteId:UUID}
- and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${cohortQuery}
where event_data.website_id = {websiteId:UUID}
and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
diff --git a/src/queries/sql/events/getEventExpandedMetrics.ts b/src/queries/sql/events/getEventExpandedMetrics.ts
index f03a347df..86bda8509 100644
--- a/src/queries/sql/events/getEventExpandedMetrics.ts
+++ b/src/queries/sql/events/getEventExpandedMetrics.ts
@@ -58,7 +58,7 @@ async function relationalQuery(
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
from (
select
- ${column} name,
+ ${column} as "name",
website_event.session_id,
website_event.visit_id,
count(*) as "c",
@@ -72,6 +72,7 @@ async function relationalQuery(
${filterQuery}
group by name, website_event.session_id, website_event.visit_id
) as t
+ where name != ''
group by name
order by visitors desc, visits desc
limit ${limit}
diff --git a/src/queries/sql/events/getWebsiteEventStats.ts b/src/queries/sql/events/getWebsiteEventStats.ts
new file mode 100644
index 000000000..27179d10a
--- /dev/null
+++ b/src/queries/sql/events/getWebsiteEventStats.ts
@@ -0,0 +1,97 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getWebsiteEventStats';
+
+export interface WebsiteEventStatsData {
+ events: number;
+ visitors: number;
+ visits: number;
+ uniqueEvents: number;
+}
+
+export async function getWebsiteEventStats(
+ ...args: [websiteId: string, filters: QueryFilters]
+): Promise {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise {
+ const { parseFilters, rawQuery } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ cast(coalesce(sum(t.c), 0) as bigint) as "events",
+ count(distinct t.session_id) as "visitors",
+ count(distinct t.visit_id) as "visits",
+ count(distinct t.event_name) as "uniqueEvents"
+ from (
+ select
+ website_event.session_id,
+ website_event.visit_id,
+ website_event.event_name,
+ count(*) as "c"
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type = 2
+ ${filterQuery}
+ group by 1, 2, 3
+ ) as t
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ ).then(result => result?.[0]);
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ sum(t.c) as "events",
+ uniq(t.session_id) as "visitors",
+ uniq(t.visit_id) as "visits",
+ count(distinct t.event_name) as "uniqueEvents"
+ from (
+ select
+ session_id,
+ visit_id,
+ event_name,
+ count(*) c
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2
+ ${filterQuery}
+ group by session_id, visit_id, event_name
+ ) as t;
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ ).then(result => result?.[0]);
+}
diff --git a/src/queries/sql/getChannelExpandedMetrics.ts b/src/queries/sql/getChannelExpandedMetrics.ts
index 33640d599..f674d182f 100644
--- a/src/queries/sql/getChannelExpandedMetrics.ts
+++ b/src/queries/sql/getChannelExpandedMetrics.ts
@@ -89,7 +89,7 @@ async function relationalQuery(
when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
- else '' end AS name,
+ else '' end as "name",
session_id,
visit_id,
c,
diff --git a/src/queries/sql/getRealtimeActivity.ts b/src/queries/sql/getRealtimeActivity.ts
index 075b65e2e..c847b6f7d 100644
--- a/src/queries/sql/getRealtimeActivity.ts
+++ b/src/queries/sql/getRealtimeActivity.ts
@@ -30,7 +30,8 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
session.device,
session.country,
website_event.url_path as "urlPath",
- website_event.referrer_domain as "referrerDomain"
+ website_event.referrer_domain as "referrerDomain",
+ website_event.hostname
from website_event
${cohortQuery}
inner join session
@@ -65,7 +66,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promis
device,
country,
url_path as urlPath,
- referrer_domain as referrerDomain
+ referrer_domain as referrerDomain,
+ hostname
from website_event
${cohortQuery}
where website_id = {websiteId:UUID}
diff --git a/src/queries/sql/getWeeklyTraffic.ts b/src/queries/sql/getWeeklyTraffic.ts
index 7bbe78a7a..1868b9221 100644
--- a/src/queries/sql/getWeeklyTraffic.ts
+++ b/src/queries/sql/getWeeklyTraffic.ts
@@ -14,7 +14,7 @@ export async function getWeeklyTraffic(...args: [websiteId: string, filters: Que
}
async function relationalQuery(websiteId: string, filters: QueryFilters) {
- const timezone = 'utc';
+ const { timezone = 'utc' } = filters;
const { rawQuery, getDateWeeklySQL, parseFilters } = prisma;
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
...filters,
@@ -33,7 +33,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
and website_event.created_at between {{startDate}} and {{endDate}}
${filterQuery}
group by time
- order by 2
+ order by 1
`,
queryParams,
FUNCTION_NAME,
diff --git a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts
index 986d7d5a5..ccb0be53c 100644
--- a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts
+++ b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts
@@ -86,7 +86,7 @@ async function relationalQuery(
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
from (
select
- ${column} as name,
+ ${column} as "name",
website_event.session_id,
website_event.visit_id,
count(*) as "c",
diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts
index 1d040781d..29068f7de 100644
--- a/src/queries/sql/reports/getAttribution.ts
+++ b/src/queries/sql/reports/getAttribution.ts
@@ -52,8 +52,8 @@ async function relationalQuery(
function getUTMQuery(utmColumn: string) {
return `
select
- coalesce(we.${utmColumn}, '') name,
- ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
+ coalesce(we.${utmColumn}, '') as "name",
+ ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} as "value"
from model m
join website_event we
on we.created_at = m.created_at
@@ -128,7 +128,7 @@ async function relationalQuery(
`
${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)}
- select coalesce(we.referrer_domain, '') name,
+ select coalesce(we.referrer_domain, '') as "name",
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
from model m
join website_event we
@@ -166,8 +166,8 @@ async function relationalQuery(
when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads'
when coalesce(twclid, '') != '' then 'Twitter Ads (X)'
else ''
- end name,
- ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
+ end as "name",
+ ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} as "value"
from model m
join website_event we
on we.created_at = m.created_at
diff --git a/src/queries/sql/reports/getBreakdown.ts b/src/queries/sql/reports/getBreakdown.ts
index 51773d864..c84db7697 100644
--- a/src/queries/sql/reports/getBreakdown.ts
+++ b/src/queries/sql/reports/getBreakdown.ts
@@ -131,5 +131,5 @@ function parseFields(fields: string[]) {
}
function parseFieldsByName(fields: string[]) {
- return `${fields.map(name => name).join(',')}`;
+ return `${fields.map(name => `"${name}"`).join(',')}`;
}
diff --git a/src/queries/sql/reports/getJourney.ts b/src/queries/sql/reports/getJourney.ts
index 283e0fad4..d12d371b6 100644
--- a/src/queries/sql/reports/getJourney.ts
+++ b/src/queries/sql/reports/getJourney.ts
@@ -60,7 +60,7 @@ async function relationalQuery(
endStepQuery: string;
params: Record;
} {
- const params = {};
+ const params: { startStep?: string; endStep?: string } = {};
let sequenceQuery = '';
let startStepQuery = '';
let endStepQuery = '';
@@ -119,7 +119,7 @@ async function relationalQuery(
select distinct
website_event.visit_id,
website_event.referrer_path,
- coalesce(nullIf(website_event.event_name, ''), website_event.url_path) event,
+ coalesce(nullIf(website_event.event_name, ''), website_event.url_path) "event",
row_number() OVER (PARTITION BY visit_id ORDER BY website_event.created_at) AS event_number
from website_event
${cohortQuery}
@@ -172,7 +172,7 @@ async function clickhouseQuery(
endStepQuery: string;
params: Record;
} {
- const params = {};
+ const params: { startStep?: string; endStep?: string } = {};
let sequenceQuery = '';
let startStepQuery = '';
let endStepQuery = '';
diff --git a/src/queries/sql/reports/getRevenue.ts b/src/queries/sql/reports/getRevenue.ts
index fa25078c3..30d7d7f1f 100644
--- a/src/queries/sql/reports/getRevenue.ts
+++ b/src/queries/sql/reports/getRevenue.ts
@@ -41,14 +41,17 @@ async function relationalQuery(
currency,
});
- const joinQuery = filterQuery
- ? `join website_event
- on website_event.website_id = revenue.website_id
- and website_event.session_id = revenue.session_id
- and website_event.event_id = revenue.event_id
- and website_event.website_id = {{websiteId::uuid}}
- and website_event.created_at between {{startDate}} and {{endDate}}`
- : '';
+ const joinQuery =
+ filterQuery || cohortQuery
+ ? `join (select *
+ from website_event
+ where website_id = {{websiteId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}
+ and event_type = 2) website_event
+ on website_event.website_id = revenue.website_id
+ and website_event.session_id = revenue.session_id
+ and website_event.event_id = revenue.event_id`
+ : '';
const chart = await rawQuery(
`
@@ -62,7 +65,7 @@ async function relationalQuery(
${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
- and revenue.currency = upper({{currency}})
+ and upper(revenue.currency) = {{currency}}
${filterQuery}
group by x, t
order by t
@@ -73,8 +76,8 @@ async function relationalQuery(
const country = await rawQuery(
`
select
- session.country as name,
- sum(revenue) value
+ session.country as "name",
+ sum(revenue) as "value"
from revenue
${joinQuery}
join session
@@ -83,7 +86,7 @@ async function relationalQuery(
${cohortQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
- and revenue.currency = upper({{currency}})
+ and upper(revenue.currency) = {{currency}}
${filterQuery}
group by session.country
`,
@@ -102,7 +105,7 @@ async function relationalQuery(
${joinSessionQuery}
where revenue.website_id = {{websiteId::uuid}}
and revenue.created_at between {{startDate}} and {{endDate}}
- and revenue.currency = upper({{currency}})
+ and upper(revenue.currency) = {{currency}}
${filterQuery}
`,
queryParams,
@@ -129,12 +132,15 @@ async function clickhouseQuery(
});
const joinQuery = filterQuery
- ? `join website_event
- on website_event.website_id = website_revenue.website_id
- and website_event.session_id = website_revenue.session_id
- and website_event.event_id = website_revenue.event_id
- and website_event.website_id = {websiteId:UUID}
- and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}`
+ ? `any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2) website_event
+ on website_event.website_id = website_revenue.website_id
+ and website_event.session_id = website_revenue.session_id
+ and website_event.event_id = website_revenue.event_id`
: '';
const chart = await rawQuery<
@@ -154,7 +160,7 @@ async function clickhouseQuery(
${cohortQuery}
where website_revenue.website_id = {websiteId:UUID}
and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
- and website_revenue.currency = upper({currency:String})
+ and upper(website_revenue.currency) = {currency:String}
${filterQuery}
group by x, t
order by t
@@ -170,19 +176,22 @@ async function clickhouseQuery(
>(
`
select
- website_event.country as name,
- sum(website_revenue.revenue) as value
+ website_event.country as "name",
+ sum(website_revenue.revenue) as "value"
from website_revenue
- join website_event
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2) website_event
on website_event.website_id = website_revenue.website_id
and website_event.session_id = website_revenue.session_id
and website_event.event_id = website_revenue.event_id
- and website_event.website_id = {websiteId:UUID}
- and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
${cohortQuery}
where website_revenue.website_id = {websiteId:UUID}
and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
- and website_revenue.currency = upper({currency:String})
+ and upper(website_revenue.currency) = {currency:String}
${filterQuery}
group by website_event.country
order by value desc
@@ -205,7 +214,7 @@ async function clickhouseQuery(
${cohortQuery}
where website_revenue.website_id = {websiteId:UUID}
and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
- and website_revenue.currency = upper({currency:String})
+ and upper(website_revenue.currency) = {currency:String}
${filterQuery}
`,
queryParams,
diff --git a/src/queries/sql/sessions/getSessionActivity.ts b/src/queries/sql/sessions/getSessionActivity.ts
index af31fca6a..1ac7e6ff8 100644
--- a/src/queries/sql/sessions/getSessionActivity.ts
+++ b/src/queries/sql/sessions/getSessionActivity.ts
@@ -29,6 +29,7 @@ async function relationalQuery(websiteId: string, sessionId: string, filters: Qu
event_type as "eventType",
event_name as "eventName",
visit_id as "visitId",
+ hostname,
event_id IN (select website_event_id
from event_data
where website_id = {{websiteId::uuid}}
@@ -60,6 +61,7 @@ async function clickhouseQuery(websiteId: string, sessionId: string, filters: Qu
event_type as eventType,
event_name as eventName,
visit_id as visitId,
+ hostname,
event_id IN (select event_id
from event_data
where website_id = {websiteId:UUID}
diff --git a/src/queries/sql/sessions/getSessionExpandedMetrics.ts b/src/queries/sql/sessions/getSessionExpandedMetrics.ts
index 85c129391..6b85cd452 100644
--- a/src/queries/sql/sessions/getSessionExpandedMetrics.ts
+++ b/src/queries/sql/sessions/getSessionExpandedMetrics.ts
@@ -65,7 +65,7 @@ async function relationalQuery(
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
from (
select
- ${column} name,
+ ${column} as "name",
${includeCountry ? 'country,' : ''}
website_event.session_id,
website_event.visit_id,
@@ -82,6 +82,7 @@ async function relationalQuery(
group by name, website_event.session_id, website_event.visit_id
${includeCountry ? ', country' : ''}
) as t
+ where name != ''
group by name
${includeCountry ? ', country' : ''}
order by visitors desc, visits desc
diff --git a/src/queries/sql/sessions/saveSessionData.ts b/src/queries/sql/sessions/saveSessionData.ts
index 740931777..cce6cd28c 100644
--- a/src/queries/sql/sessions/saveSessionData.ts
+++ b/src/queries/sql/sessions/saveSessionData.ts
@@ -46,31 +46,23 @@ export async function relationalQuery({
createdAt,
}));
- const existing = await client.sessionData.findMany({
- where: {
- sessionId,
- },
- select: {
- id: true,
- sessionId: true,
- dataKey: true,
- },
- });
-
for (const data of flattenedData) {
const { sessionId, dataKey, ...props } = data;
- const record = existing.find(e => e.sessionId === sessionId && e.dataKey === dataKey);
- if (record) {
- await client.sessionData.update({
- where: {
- id: record.id,
- },
- data: {
- ...props,
- },
- });
- } else {
+ // Try to update existing record using compound where clause
+ // This is safer than using id from a previous query due to race conditions
+ const updateResult = await client.sessionData.updateMany({
+ where: {
+ sessionId,
+ dataKey,
+ },
+ data: {
+ ...props,
+ },
+ });
+
+ // If no record was updated, create a new one
+ if (updateResult.count === 0) {
await client.sessionData.create({
data,
});
diff --git a/src/styles/global.css b/src/styles/global.css
index e9fca9fdf..6e767563b 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -41,3 +41,18 @@ a:hover {
border: 4px solid rgba(0, 0, 0, 0);
background-clip: padding-box;
}
+
+/* Fix autofill background color to match dark theme */
+input:-webkit-autofill,
+input:-webkit-autofill:hover,
+input:-webkit-autofill:focus,
+input:-webkit-autofill:active,
+textarea:-webkit-autofill,
+textarea:-webkit-autofill:hover,
+textarea:-webkit-autofill:focus,
+select:-webkit-autofill,
+select:-webkit-autofill:hover,
+select:-webkit-autofill:focus {
+ -webkit-box-shadow: 0 0 0 1000px var(--background-color) inset !important;
+ transition: color 5000s ease-in-out 0s;
+}
diff --git a/src/tracker/index.js b/src/tracker/index.js
index ad3648aca..85d274301 100644
--- a/src/tracker/index.js
+++ b/src/tracker/index.js
@@ -12,7 +12,13 @@
if (!currentScript) return;
const { hostname, href, origin } = location;
- const localStorage = href.startsWith('data:') ? undefined : window.localStorage;
+
+ let localStorage;
+ try {
+ localStorage = href.startsWith('data:') ? undefined : window.localStorage;
+ } catch {
+ /* (DOMException) SecurityError: Access is denied for this document. */
+ }
const _data = 'data-';
const _false = 'false';