diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml
index 2404918b..d48567e6 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 753389d1..8b543f6b 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 99dcca0d..1a4e2e0e 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 a48e8c8e..8f0a5782 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 3391f4fb..851abba3 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 00000000..89aece1e
--- /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 00000000..ad8ee172
--- /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 00000000..75f7191e
--- /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 aeb11648..e58ebd0b 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 00000000..c45ed0fe
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 b3d2f3c0..a4ad51fa 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 347313d7..2b3b5a80 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 a83caa6c..e36b097c 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 41e6f4af..7905f7f6 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 6c365510..84b8399c 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 28bf030f..68aa7f6e 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 00000000..afca1de6
--- /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 c95259f4..3b827776 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 00000000..f1bbf258
--- /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 3ddbe000..4bbb8905 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 31de7047..6f3548a9 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 e336a3db..d81519d7 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 57bce52f..a56917b7 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 b6c4a11d..1d0b96e5 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 ff7b49fb..fe4550d6 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 3327a425..1b893d27 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 14b8341d..f1a8976f 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 0e782a16..faee8b9a 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 b2ea2a83..896c733a 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 29c3954f..4bac4ff6 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 7dd1d771..1dee8022 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 30189534..132d3b14 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 6c91ba6d..605ee385 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 ad05b706..9f72c303 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 f587e112..5acc9e68 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 bca8d244..32d641b0 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 13c05160..4daf17fc 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 55ec0403..f62d8a4c 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 7fb2eb41..41c2b1e8 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 67595e9d..b12ff950 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 10763618..9cbbd371 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 cbb28108..df0ef834 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 00000000..35e96df3
--- /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 00000000..df1c2e64
--- /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 00000000..4b86247a
--- /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 00000000..57701ac6
--- /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 3970cdbd..d39c4531 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 56c6f436..8472ca97 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 00000000..f4a3df80
--- /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 7bf0a813..153f1f52 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 bba3dde3..f8222869 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 4e40caa4..101a1224 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 29e85319..b53d225d 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 bef87c4f..00000000
--- 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 00000000..e7d5372f
--- /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 00000000..80da17b8
--- /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 00000000..a772b4ab
--- /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 53ef5923..c571f405 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 aade8aa8..e642fe3c 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 dbb114cf..4335c33f 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 00000000..61e151d4
--- /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 b4c0e7e8..59f314d3 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 45927656..db34193e 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 00000000..65d53771
--- /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 07c8b969..9d21f4f5 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 e2b26c10..dd8e0ffd 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 f2948628..00000000
--- 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 d7b7dcb4..00000000
--- 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 00000000..5348ac63
--- /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 00000000..abd8511d
--- /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 00000000..b494046d
--- /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 7ed06673..91a8b298 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 b9900eb7..3a21f836 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 f07e589b..c70b9dfe 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 00000000..44316ca5
--- /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 c6fce128..1828e594 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 dbad3dcd..446e33da 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 b2e90199..1611c7f8 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 67c5e4d4..cd064af6 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 00000000..298e4d26
--- /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 e9a0c48c..48484a07 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 a76ebb3d..df729ffd 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 755f36ee..5090bd3d 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 22a1dcf3..039b7157 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 54032120..c141a3be 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 44f43844..87acc515 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 00000000..84a15f35
--- /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 00000000..ccb3d432
--- /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 18b4f13b..a76058ec 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 8d81eb9a..330f826a 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 0438c06e..de29c306 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 d15bcf13..590fd5ac 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 907c5623..df164b9d 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 4b0effc8..50099032 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 daaa62f0..68629470 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 7d2bf403..20b48f40 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 c6f01dd5..5490df42 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 ba6d8b09..832dfb60 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 7d4208e2..1458cff8 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 f2ebbb72..0a336f80 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 e5090c3c..3da177c0 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 a6d912b8..ee4c977f 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 3c1fd1b7..91af88f6 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 68cb6672..910d122b 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 00000000..fd26252d
--- /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 52fd3048..035a1811 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 5cd77574..a0e3a825 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 d4d6c3c7..bf527975 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 64cb870f..bfd007d1 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 42c44904..7f9163cc 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 38f7339a..a3c56a0f 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 9c061979..f3466734 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 00000000..bab804dd
--- /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 a70808e6..475cdaa4 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 c027a0b6..8dd1d7c6 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 2131874f..14b69ace 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 01b54769..5f9da019 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 0f07c1a4..130290af 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 2ed8f276..8aa453ae 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 97952eed..9ad25ae5 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 b9730f51..4dedb2b5 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 00000000..53246ffb
--- /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 14376fc2..467ea1e0 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 79cb7247..fe57589c 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 6c8f12c1..8ed6633e 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 93377690..70945492 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 82c078f8..82d0b6b6 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 89e13582..9d178f67 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 0426e646..7c1ae282 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 f03a347d..86bda850 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 00000000..27179d10
--- /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 33640d59..f674d182 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 075b65e2..c847b6f7 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 7bbe78a7..1868b922 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 986d7d5a..ccb0be53 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 1d040781..29068f7d 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 51773d86..c84db769 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 283e0fad..d12d371b 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 fa25078c..30d7d7f1 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 af31fca6..1ac7e6ff 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 85c12939..6b85cd45 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 74093177..cce6cd28 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 e9fca9fd..6e767563 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 ad3648ac..85d27430 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';