mirror of
https://github.com/umami-software/umami.git
synced 2026-02-12 16:45:35 +01:00
Merge branch 'dev' of https://github.com/umami-software/umami into dev
This commit is contained in:
commit
6c832bd0db
113 changed files with 1671 additions and 1335 deletions
|
|
@ -42,7 +42,8 @@
|
||||||
"@typescript-eslint/no-var-requires": "off",
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
"@typescript-eslint/no-empty-interface": "off",
|
"@typescript-eslint/no-empty-interface": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }],
|
"@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }],
|
||||||
"@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }]
|
"@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }],
|
||||||
|
"@typescript-eslint/triple-slash-reference": "off"
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"React": "writable"
|
"React": "writable"
|
||||||
|
|
|
||||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
"@react-spring/web": "^9.7.3",
|
"@react-spring/web": "^9.7.3",
|
||||||
"@svgr/cli": "^8.1.0",
|
"@svgr/cli": "^8.1.0",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@umami/react-zen": "^0.163.0",
|
"@umami/react-zen": "^0.164.0",
|
||||||
"@umami/redis-client": "^0.27.0",
|
"@umami/redis-client": "^0.27.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
|
|
@ -112,7 +112,7 @@
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"maxmind": "^4.3.28",
|
"maxmind": "^4.3.28",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"next": "15.4.7",
|
"next": "15.5.0",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
|
|
@ -132,7 +132,7 @@
|
||||||
"thenby": "^1.3.4",
|
"thenby": "^1.3.4",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.6"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formatjs/cli": "^4.2.29",
|
"@formatjs/cli": "^4.2.29",
|
||||||
|
|
|
||||||
115
pnpm-lock.yaml
generated
115
pnpm-lock.yaml
generated
|
|
@ -45,8 +45,8 @@ importers:
|
||||||
specifier: ^5.83.0
|
specifier: ^5.83.0
|
||||||
version: 5.85.3(react@19.1.1)
|
version: 5.85.3(react@19.1.1)
|
||||||
'@umami/react-zen':
|
'@umami/react-zen':
|
||||||
specifier: ^0.163.0
|
specifier: ^0.164.0
|
||||||
version: 0.163.0(@babel/core@7.28.3)(@types/react@19.1.10)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.1))
|
version: 0.164.0(@babel/core@7.28.3)(@types/react@19.1.10)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.1))
|
||||||
'@umami/redis-client':
|
'@umami/redis-client':
|
||||||
specifier: ^0.27.0
|
specifier: ^0.27.0
|
||||||
version: 0.27.0
|
version: 0.27.0
|
||||||
|
|
@ -135,8 +135,8 @@ importers:
|
||||||
specifier: ^2.3.0
|
specifier: ^2.3.0
|
||||||
version: 2.3.0
|
version: 2.3.0
|
||||||
next:
|
next:
|
||||||
specifier: 15.4.7
|
specifier: 15.5.0
|
||||||
version: 15.4.7(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
version: 15.5.0(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||||
node-fetch:
|
node-fetch:
|
||||||
specifier: ^3.2.8
|
specifier: ^3.2.8
|
||||||
version: 3.3.2
|
version: 3.3.2
|
||||||
|
|
@ -195,8 +195,8 @@ importers:
|
||||||
specifier: ^3.25.76
|
specifier: ^3.25.76
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.6
|
specifier: ^5.0.8
|
||||||
version: 5.0.7(@types/react@19.1.10)(immer@9.0.21)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))
|
version: 5.0.8(@types/react@19.1.10)(immer@9.0.21)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@formatjs/cli':
|
'@formatjs/cli':
|
||||||
specifier: ^4.2.29
|
specifier: ^4.2.29
|
||||||
|
|
@ -1382,56 +1382,56 @@ packages:
|
||||||
resolution: {integrity: sha512-SXQY/nCiSOSAZWNls/DQxrICldUR7PHSMUw2J2/ZejH1dk12Vwd3+SzSihHrRW9PNcErZkC2g3seM7bWZlvBRg==}
|
resolution: {integrity: sha512-SXQY/nCiSOSAZWNls/DQxrICldUR7PHSMUw2J2/ZejH1dk12Vwd3+SzSihHrRW9PNcErZkC2g3seM7bWZlvBRg==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
'@next/env@15.4.7':
|
'@next/env@15.5.0':
|
||||||
resolution: {integrity: sha512-PrBIpO8oljZGTOe9HH0miix1w5MUiGJ/q83Jge03mHEE0E3pyqzAy2+l5G6aJDbXoobmxPJTVhbCuwlLtjSHwg==}
|
resolution: {integrity: sha512-sDaprBAfzCQiOgo2pO+LhnV0Wt2wBgartjrr+dpcTORYVnnXD0gwhHhiiyIih9hQbq+JnbqH4odgcFWhqCGidw==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@14.2.31':
|
'@next/eslint-plugin-next@14.2.31':
|
||||||
resolution: {integrity: sha512-ouaB+l8Cr/uzGxoGHUvd01OnfFTM8qM81Crw1AG0xoWDRN0DKLXyTWVe0FdAOHVBpGuXB87aufdRmrwzZDArIw==}
|
resolution: {integrity: sha512-ouaB+l8Cr/uzGxoGHUvd01OnfFTM8qM81Crw1AG0xoWDRN0DKLXyTWVe0FdAOHVBpGuXB87aufdRmrwzZDArIw==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@15.4.7':
|
'@next/swc-darwin-arm64@15.5.0':
|
||||||
resolution: {integrity: sha512-2Dkb+VUTp9kHHkSqtws4fDl2Oxms29HcZBwFIda1X7Ztudzy7M6XF9HDS2dq85TmdN47VpuhjE+i6wgnIboVzQ==}
|
resolution: {integrity: sha512-v7Jj9iqC6enxIRBIScD/o0lH7QKvSxq2LM8UTyqJi+S2w2QzhMYjven4vgu/RzgsdtdbpkyCxBTzHl/gN5rTRg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@15.4.7':
|
'@next/swc-darwin-x64@15.5.0':
|
||||||
resolution: {integrity: sha512-qaMnEozKdWezlmh1OGDVFueFv2z9lWTcLvt7e39QA3YOvZHNpN2rLs/IQLwZaUiw2jSvxW07LxMCWtOqsWFNQg==}
|
resolution: {integrity: sha512-s2Nk6ec+pmYmAb/utawuURy7uvyYKDk+TRE5aqLRsdnj3AhwC9IKUBmhfnLmY/+P+DnwqpeXEFIKe9tlG0p6CA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@15.4.7':
|
'@next/swc-linux-arm64-gnu@15.5.0':
|
||||||
resolution: {integrity: sha512-ny7lODPE7a15Qms8LZiN9wjNWIeI+iAZOFDOnv2pcHStncUr7cr9lD5XF81mdhrBXLUP9yT9RzlmSWKIazWoDw==}
|
resolution: {integrity: sha512-mGlPJMZReU4yP5fSHjOxiTYvZmwPSWn/eF/dcg21pwfmiUCKS1amFvf1F1RkLHPIMPfocxLViNWFvkvDB14Isg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@15.4.7':
|
'@next/swc-linux-arm64-musl@15.5.0':
|
||||||
resolution: {integrity: sha512-4SaCjlFR/2hGJqZLLWycccy1t+wBrE/vyJWnYaZJhUVHccpGLG5q0C+Xkw4iRzUIkE+/dr90MJRUym3s1+vO8A==}
|
resolution: {integrity: sha512-biWqIOE17OW/6S34t1X8K/3vb1+svp5ji5QQT/IKR+VfM3B7GvlCwmz5XtlEan2ukOUf9tj2vJJBffaGH4fGRw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@15.4.7':
|
'@next/swc-linux-x64-gnu@15.5.0':
|
||||||
resolution: {integrity: sha512-2uNXjxvONyRidg00VwvlTYDwC9EgCGNzPAPYbttIATZRxmOZ3hllk/YYESzHZb65eyZfBR5g9xgCZjRAl9YYGg==}
|
resolution: {integrity: sha512-zPisT+obYypM/l6EZ0yRkK3LEuoZqHaSoYKj+5jiD9ESHwdr6QhnabnNxYkdy34uCigNlWIaCbjFmQ8FY5AlxA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@15.4.7':
|
'@next/swc-linux-x64-musl@15.5.0':
|
||||||
resolution: {integrity: sha512-ceNbPjsFgLscYNGKSu4I6LYaadq2B8tcK116nVuInpHHdAWLWSwVK6CHNvCi0wVS9+TTArIFKJGsEyVD1H+4Kg==}
|
resolution: {integrity: sha512-+t3+7GoU9IYmk+N+FHKBNFdahaReoAktdOpXHFIPOU1ixxtdge26NgQEEkJkCw2dHT9UwwK5zw4mAsURw4E8jA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@15.4.7':
|
'@next/swc-win32-arm64-msvc@15.5.0':
|
||||||
resolution: {integrity: sha512-pZyxmY1iHlZJ04LUL7Css8bNvsYAMYOY9JRwFA3HZgpaNKsJSowD09Vg2R9734GxAcLJc2KDQHSCR91uD6/AAw==}
|
resolution: {integrity: sha512-d8MrXKh0A+c9DLiy1BUFwtg3Hu90Lucj3k6iKTUdPOv42Ve2UiIG8HYi3UAb8kFVluXxEfdpCoPPCSODk5fDcw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@15.4.7':
|
'@next/swc-win32-x64-msvc@15.5.0':
|
||||||
resolution: {integrity: sha512-HjuwPJ7BeRzgl3KrjKqD2iDng0eQIpIReyhpF5r4yeAHFwWRuAhfW92rWv/r3qeQHEwHsLRzFDvMqRjyM5DI6A==}
|
resolution: {integrity: sha512-Fe1tGHxOWEyQjmygWkkXSwhFcTJuimrNu52JEuwItrKJVV4iRjbWp9I7zZjwqtiNnQmxoEvoisn8wueFLrNpvQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
@ -2549,8 +2549,8 @@ packages:
|
||||||
resolution: {integrity: sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==}
|
resolution: {integrity: sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@umami/react-zen@0.163.0':
|
'@umami/react-zen@0.164.0':
|
||||||
resolution: {integrity: sha512-H+Z7sADljnBdzRQdOUIHXKphiPkzHKTLTNtBf/VbylzXg5A61e+OYoDG37eOkR+JFU9+KmJnF+zOiXyA33LW0A==}
|
resolution: {integrity: sha512-z27uy0W3ZL0MH2cdVuu0c4guInHJQC2rYcAXxwxOAdEMtkzWym9ODfK3v5ihqS6oct+6er/bS1yVJ8gNnRvXDw==}
|
||||||
|
|
||||||
'@umami/redis-client@0.27.0':
|
'@umami/redis-client@0.27.0':
|
||||||
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
|
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
|
||||||
|
|
@ -3005,6 +3005,9 @@ packages:
|
||||||
caniuse-lite@1.0.30001735:
|
caniuse-lite@1.0.30001735:
|
||||||
resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==}
|
resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==}
|
||||||
|
|
||||||
|
caniuse-lite@1.0.30001736:
|
||||||
|
resolution: {integrity: sha512-ImpN5gLEY8gWeqfLUyEF4b7mYWcYoR2Si1VhnrbM4JizRFmfGaAQ12PhNykq6nvI4XvKLrsp8Xde74D5phJOSw==}
|
||||||
|
|
||||||
caseless@0.12.0:
|
caseless@0.12.0:
|
||||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||||
|
|
||||||
|
|
@ -5177,8 +5180,8 @@ packages:
|
||||||
neo-async@2.6.2:
|
neo-async@2.6.2:
|
||||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||||
|
|
||||||
next@15.4.7:
|
next@15.5.0:
|
||||||
resolution: {integrity: sha512-OcqRugwF7n7mC8OSYjvsZhhG1AYSvulor1EIUsIkbbEbf1qoE5EbH36Swj8WhF4cHqmDgkiam3z1c1W0J1Wifg==}
|
resolution: {integrity: sha512-N1lp9Hatw3a9XLt0307lGB4uTKsXDhyOKQo7uYMzX4i0nF/c27grcGXkLdb7VcT8QPYLBa8ouIyEoUQJ2OyeNQ==}
|
||||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -7088,8 +7091,8 @@ packages:
|
||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
zustand@5.0.7:
|
zustand@5.0.8:
|
||||||
resolution: {integrity: sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==}
|
resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': '>=18.0.0'
|
'@types/react': '>=18.0.0'
|
||||||
|
|
@ -8178,34 +8181,34 @@ snapshots:
|
||||||
|
|
||||||
'@netlify/plugin-nextjs@5.12.0': {}
|
'@netlify/plugin-nextjs@5.12.0': {}
|
||||||
|
|
||||||
'@next/env@15.4.7': {}
|
'@next/env@15.5.0': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@14.2.31':
|
'@next/eslint-plugin-next@14.2.31':
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 10.3.10
|
glob: 10.3.10
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@15.4.7':
|
'@next/swc-darwin-arm64@15.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@15.4.7':
|
'@next/swc-darwin-x64@15.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@15.4.7':
|
'@next/swc-linux-arm64-gnu@15.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@15.4.7':
|
'@next/swc-linux-arm64-musl@15.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@15.4.7':
|
'@next/swc-linux-x64-gnu@15.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@15.4.7':
|
'@next/swc-linux-x64-musl@15.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@15.4.7':
|
'@next/swc-win32-arm64-msvc@15.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@15.4.7':
|
'@next/swc-win32-x64-msvc@15.5.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
|
@ -9846,7 +9849,7 @@ snapshots:
|
||||||
'@typescript-eslint/types': 8.39.1
|
'@typescript-eslint/types': 8.39.1
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 4.2.1
|
||||||
|
|
||||||
'@umami/react-zen@0.163.0(@babel/core@7.28.3)(@types/react@19.1.10)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.1))':
|
'@umami/react-zen@0.164.0(@babel/core@7.28.3)(@types/react@19.1.10)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fontsource/jetbrains-mono': 5.2.6
|
'@fontsource/jetbrains-mono': 5.2.6
|
||||||
'@internationalized/date': 3.8.2
|
'@internationalized/date': 3.8.2
|
||||||
|
|
@ -9856,14 +9859,14 @@ snapshots:
|
||||||
glob: 10.4.5
|
glob: 10.4.5
|
||||||
highlight.js: 11.11.1
|
highlight.js: 11.11.1
|
||||||
lucide-react: 0.511.0(react@19.1.1)
|
lucide-react: 0.511.0(react@19.1.1)
|
||||||
next: 15.4.7(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
next: 15.5.0(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||||
react: 19.1.1
|
react: 19.1.1
|
||||||
react-aria-components: 1.9.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
react-aria-components: 1.9.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||||
react-dom: 19.1.1(react@19.1.1)
|
react-dom: 19.1.1(react@19.1.1)
|
||||||
react-hook-form: 7.62.0(react@19.1.1)
|
react-hook-form: 7.62.0(react@19.1.1)
|
||||||
react-icons: 5.5.0(react@19.1.1)
|
react-icons: 5.5.0(react@19.1.1)
|
||||||
thenby: 1.3.4
|
thenby: 1.3.4
|
||||||
zustand: 5.0.7(@types/react@19.1.10)(immer@9.0.21)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))
|
zustand: 5.0.8(@types/react@19.1.10)(immer@9.0.21)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- '@opentelemetry/api'
|
- '@opentelemetry/api'
|
||||||
|
|
@ -10374,12 +10377,14 @@ snapshots:
|
||||||
caniuse-api@3.0.0:
|
caniuse-api@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.25.2
|
browserslist: 4.25.2
|
||||||
caniuse-lite: 1.0.30001735
|
caniuse-lite: 1.0.30001736
|
||||||
lodash.memoize: 4.1.2
|
lodash.memoize: 4.1.2
|
||||||
lodash.uniq: 4.5.0
|
lodash.uniq: 4.5.0
|
||||||
|
|
||||||
caniuse-lite@1.0.30001735: {}
|
caniuse-lite@1.0.30001735: {}
|
||||||
|
|
||||||
|
caniuse-lite@1.0.30001736: {}
|
||||||
|
|
||||||
caseless@0.12.0: {}
|
caseless@0.12.0: {}
|
||||||
|
|
||||||
chalk@2.4.2:
|
chalk@2.4.2:
|
||||||
|
|
@ -12959,24 +12964,24 @@ snapshots:
|
||||||
|
|
||||||
neo-async@2.6.2: {}
|
neo-async@2.6.2: {}
|
||||||
|
|
||||||
next@15.4.7(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
|
next@15.5.0(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 15.4.7
|
'@next/env': 15.5.0
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
caniuse-lite: 1.0.30001735
|
caniuse-lite: 1.0.30001736
|
||||||
postcss: 8.4.31
|
postcss: 8.4.31
|
||||||
react: 19.1.1
|
react: 19.1.1
|
||||||
react-dom: 19.1.1(react@19.1.1)
|
react-dom: 19.1.1(react@19.1.1)
|
||||||
styled-jsx: 5.1.6(@babel/core@7.28.3)(react@19.1.1)
|
styled-jsx: 5.1.6(@babel/core@7.28.3)(react@19.1.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 15.4.7
|
'@next/swc-darwin-arm64': 15.5.0
|
||||||
'@next/swc-darwin-x64': 15.4.7
|
'@next/swc-darwin-x64': 15.5.0
|
||||||
'@next/swc-linux-arm64-gnu': 15.4.7
|
'@next/swc-linux-arm64-gnu': 15.5.0
|
||||||
'@next/swc-linux-arm64-musl': 15.4.7
|
'@next/swc-linux-arm64-musl': 15.5.0
|
||||||
'@next/swc-linux-x64-gnu': 15.4.7
|
'@next/swc-linux-x64-gnu': 15.5.0
|
||||||
'@next/swc-linux-x64-musl': 15.4.7
|
'@next/swc-linux-x64-musl': 15.5.0
|
||||||
'@next/swc-win32-arm64-msvc': 15.4.7
|
'@next/swc-win32-arm64-msvc': 15.5.0
|
||||||
'@next/swc-win32-x64-msvc': 15.4.7
|
'@next/swc-win32-x64-msvc': 15.5.0
|
||||||
babel-plugin-react-compiler: 19.1.0-rc.2
|
babel-plugin-react-compiler: 19.1.0-rc.2
|
||||||
sharp: 0.34.3
|
sharp: 0.34.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
@ -15124,7 +15129,7 @@ snapshots:
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zustand@5.0.7(@types/react@19.1.10)(immer@9.0.21)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)):
|
zustand@5.0.8(@types/react@19.1.10)(immer@9.0.21)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.1.10
|
'@types/react': 19.1.10
|
||||||
immer: 9.0.21
|
immer: 9.0.21
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Logo,
|
Logo,
|
||||||
Grid2X2,
|
Pixel,
|
||||||
Settings,
|
Settings,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
|
|
@ -21,6 +21,7 @@ import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
|
||||||
import { TeamsButton } from '@/components/input/TeamsButton';
|
import { TeamsButton } from '@/components/input/TeamsButton';
|
||||||
import { PanelButton } from '@/components/input/PanelButton';
|
import { PanelButton } from '@/components/input/PanelButton';
|
||||||
import { ProfileButton } from '@/components/input/ProfileButton';
|
import { ProfileButton } from '@/components/input/ProfileButton';
|
||||||
|
import { LanguageButton } from '@/components/input/LanguageButton';
|
||||||
|
|
||||||
export function SideNav(props: SidebarProps) {
|
export function SideNav(props: SidebarProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
@ -52,7 +53,7 @@ export function SideNav(props: SidebarProps) {
|
||||||
id: 'pixels',
|
id: 'pixels',
|
||||||
label: formatMessage(labels.pixels),
|
label: formatMessage(labels.pixels),
|
||||||
path: '/pixels',
|
path: '/pixels',
|
||||||
icon: <Grid2X2 />,
|
icon: <Pixel />,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -97,6 +98,7 @@ export function SideNav(props: SidebarProps) {
|
||||||
<ProfileButton />
|
<ProfileButton />
|
||||||
{!isCollapsed && !hasNav && (
|
{!isCollapsed && !hasNav && (
|
||||||
<Row>
|
<Row>
|
||||||
|
<LanguageButton />
|
||||||
<ThemeButton />
|
<ThemeButton />
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
|
import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
|
||||||
import { TeamProvider } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
import { TeamProvider } from '@/app/(main)/teams/TeamProvider';
|
||||||
|
|
||||||
export function AdminTeamPage({ teamId }: { teamId: string }) {
|
export function AdminTeamPage({ teamId }: { teamId: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,13 @@ import {
|
||||||
PasswordField,
|
PasswordField,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useApi, useLoginQuery, useMessages, useModified } from '@/components/hooks';
|
import { useApi, useLoginQuery, useMessages, useModified, useUser } from '@/components/hooks';
|
||||||
import { ROLES } from '@/lib/constants';
|
import { ROLES } from '@/lib/constants';
|
||||||
import { useContext } from 'react';
|
|
||||||
import { UserContext } from './UserProvider';
|
|
||||||
|
|
||||||
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
|
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
|
||||||
const { formatMessage, labels, messages, getMessage } = useMessages();
|
const { formatMessage, labels, messages, getMessage } = useMessages();
|
||||||
const { post, useMutation } = useApi();
|
const { post, useMutation } = useApi();
|
||||||
const user = useContext(UserContext);
|
const user = useUser();
|
||||||
const { user: login } = useLoginQuery();
|
const { user: login } = useLoginQuery();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { touch } = useModified();
|
const { touch } = useModified();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useContext } from 'react';
|
|
||||||
import { User } from '@/components/icons';
|
import { User } from '@/components/icons';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider';
|
import { useUser } from '@/components/hooks';
|
||||||
|
|
||||||
export function UserHeader() {
|
export function UserHeader() {
|
||||||
const user = useContext(UserContext);
|
const user = useUser();
|
||||||
|
|
||||||
return <PageHeader title={user?.username} icon={<User />} />;
|
return <PageHeader title={user?.username} icon={<User />} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
|
import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
|
||||||
import { WebsiteProvider } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
||||||
export function AdminWebsitePage({ websiteId }: { websiteId: string }) {
|
export function AdminWebsitePage({ websiteId }: { websiteId: string }) {
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ export function LinkEditForm({
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const { mutate, error, isPending, touch } = useUpdateQuery(
|
const { mutate, error, isPending, touch, toast } = useUpdateQuery(
|
||||||
linkId ? `/links/${linkId}` : '/links',
|
linkId ? `/links/${linkId}` : '/links',
|
||||||
{
|
{
|
||||||
id: linkId,
|
id: linkId,
|
||||||
|
|
@ -48,6 +48,7 @@ export function LinkEditForm({
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
mutate(data, {
|
mutate(data, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
toast(formatMessage(messages.saved));
|
||||||
touch('links');
|
touch('links');
|
||||||
onSave?.();
|
onSave?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
|
|
|
||||||
20
src/app/(main)/links/LinkProvider.tsx
Normal file
20
src/app/(main)/links/LinkProvider.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
'use client';
|
||||||
|
import { createContext, ReactNode } from 'react';
|
||||||
|
import { useLinkQuery } from '@/components/hooks';
|
||||||
|
import { Loading } from '@umami/react-zen';
|
||||||
|
|
||||||
|
export const LinkContext = createContext(null);
|
||||||
|
|
||||||
|
export function LinkProvider({ linkId, children }: { linkId?: string; children: ReactNode }) {
|
||||||
|
const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
|
||||||
|
|
||||||
|
if (isFetching && isLoading) {
|
||||||
|
return <Loading position="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LinkContext.Provider value={link}>{children}</LinkContext.Provider>;
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
|
import Link from 'next/link';
|
||||||
import { DataTable, DataColumn, Row } from '@umami/react-zen';
|
import { DataTable, DataColumn, Row } from '@umami/react-zen';
|
||||||
import { useConfig, useMessages, useNavigation } from '@/components/hooks';
|
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
import { DateDistance } from '@/components/common/DateDistance';
|
import { DateDistance } from '@/components/common/DateDistance';
|
||||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||||
import { LinkEditButton } from './LinkEditButton';
|
import { LinkEditButton } from './LinkEditButton';
|
||||||
import { LinkDeleteButton } from './LinkDeleteButton';
|
import { LinkDeleteButton } from './LinkDeleteButton';
|
||||||
import { LINKS_URL } from '@/lib/constants';
|
|
||||||
|
|
||||||
export function LinksTable({ data = [] }) {
|
export function LinksTable({ data = [] }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { websiteId } = useNavigation();
|
const { websiteId, renderUrl } = useNavigation();
|
||||||
const { linksUrl } = useConfig();
|
const { getSlugUrl } = useSlug('link');
|
||||||
const hostUrl = linksUrl || LINKS_URL;
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return <Empty />;
|
return <Empty />;
|
||||||
|
|
@ -19,10 +18,14 @@ export function LinksTable({ data = [] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable data={data}>
|
<DataTable data={data}>
|
||||||
<DataColumn id="name" label={formatMessage(labels.name)} />
|
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||||
|
{({ id, name }: any) => {
|
||||||
|
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
|
||||||
|
}}
|
||||||
|
</DataColumn>
|
||||||
<DataColumn id="slug" label={formatMessage(labels.link)}>
|
<DataColumn id="slug" label={formatMessage(labels.link)}>
|
||||||
{({ slug }: any) => {
|
{({ slug }: any) => {
|
||||||
const url = `${hostUrl}/${slug}`;
|
const url = getSlugUrl(slug);
|
||||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
|
|
|
||||||
32
src/app/(main)/links/[linkId]/LinkControls.tsx
Normal file
32
src/app/(main)/links/[linkId]/LinkControls.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Column, Row } from '@umami/react-zen';
|
||||||
|
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
|
||||||
|
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||||
|
import { FilterBar } from '@/components/input/FilterBar';
|
||||||
|
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
||||||
|
import { ExportButton } from '@/components/input/ExportButton';
|
||||||
|
|
||||||
|
export function LinkControls({
|
||||||
|
linkId: websiteId,
|
||||||
|
allowFilter = true,
|
||||||
|
allowDateFilter = true,
|
||||||
|
allowMonthFilter,
|
||||||
|
allowDownload = false,
|
||||||
|
}: {
|
||||||
|
linkId: string;
|
||||||
|
allowFilter?: boolean;
|
||||||
|
allowDateFilter?: boolean;
|
||||||
|
allowMonthFilter?: boolean;
|
||||||
|
allowDownload?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Column gap>
|
||||||
|
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||||
|
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||||
|
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
|
||||||
|
{allowDownload && <ExportButton websiteId={websiteId} />}
|
||||||
|
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
||||||
|
</Row>
|
||||||
|
{allowFilter && <FilterBar websiteId={websiteId} />}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/(main)/links/[linkId]/LinkHeader.tsx
Normal file
22
src/app/(main)/links/[linkId]/LinkHeader.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { useLink, useMessages, useSlug } from '@/components/hooks';
|
||||||
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
|
import { Icon, Text } from '@umami/react-zen';
|
||||||
|
import { ExternalLink, Link } from '@/components/icons';
|
||||||
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
|
|
||||||
|
export function LinkHeader() {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { getSlugUrl } = useSlug('link');
|
||||||
|
const link = useLink();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageHeader title={link.name} description={link.url} icon={<Link />}>
|
||||||
|
<LinkButton href={getSlugUrl(link.slug)} target="_blank">
|
||||||
|
<Icon>
|
||||||
|
<ExternalLink />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.view)}</Text>
|
||||||
|
</LinkButton>
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
Normal file
71
src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useDateRange, useMessages } from '@/components/hooks';
|
||||||
|
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||||
|
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||||
|
import { formatLongNumber } from '@/lib/format';
|
||||||
|
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
|
||||||
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
|
||||||
|
export function LinkMetricsBar({
|
||||||
|
linkId,
|
||||||
|
}: {
|
||||||
|
linkId: string;
|
||||||
|
showChange?: boolean;
|
||||||
|
compareMode?: boolean;
|
||||||
|
}) {
|
||||||
|
const { dateRange } = useDateRange(linkId);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(linkId);
|
||||||
|
const isAllTime = dateRange.value === 'all';
|
||||||
|
|
||||||
|
const { pageviews, visitors, visits, comparison } = data || {};
|
||||||
|
|
||||||
|
const metrics = data
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: visitors,
|
||||||
|
label: formatMessage(labels.visitors),
|
||||||
|
change: visitors - comparison.visitors,
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: visits,
|
||||||
|
label: formatMessage(labels.visits),
|
||||||
|
change: visits - comparison.visits,
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: pageviews,
|
||||||
|
label: formatMessage(labels.views),
|
||||||
|
change: pageviews - comparison.pageviews,
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingPanel
|
||||||
|
data={metrics}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isFetching={isFetching}
|
||||||
|
error={error}
|
||||||
|
minHeight="136px"
|
||||||
|
>
|
||||||
|
<MetricsBar>
|
||||||
|
{metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
|
||||||
|
return (
|
||||||
|
<MetricCard
|
||||||
|
key={label}
|
||||||
|
value={value}
|
||||||
|
previousValue={prev}
|
||||||
|
label={label}
|
||||||
|
change={change}
|
||||||
|
formatValue={formatValue}
|
||||||
|
reverseColors={reverseColors}
|
||||||
|
showChange={!isAllTime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MetricsBar>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/(main)/links/[linkId]/LinkPage.tsx
Normal file
25
src/app/(main)/links/[linkId]/LinkPage.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
'use client';
|
||||||
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
|
import { LinkProvider } from '@/app/(main)/links/LinkProvider';
|
||||||
|
import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||||
|
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
|
||||||
|
import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
|
||||||
|
import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
|
||||||
|
|
||||||
|
export function LinkPage({ linkId }: { linkId: string }) {
|
||||||
|
return (
|
||||||
|
<LinkProvider linkId={linkId}>
|
||||||
|
<PageBody gap>
|
||||||
|
<LinkHeader />
|
||||||
|
<LinkControls linkId={linkId} />
|
||||||
|
<LinkMetricsBar linkId={linkId} showChange={true} />
|
||||||
|
<Panel>
|
||||||
|
<WebsiteChart websiteId={linkId} />
|
||||||
|
</Panel>
|
||||||
|
<LinkPanels linkId={linkId} />
|
||||||
|
</PageBody>
|
||||||
|
</LinkProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/app/(main)/links/[linkId]/LinkPanels.tsx
Normal file
83
src/app/(main)/links/[linkId]/LinkPanels.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
|
||||||
|
import { GridRow } from '@/components/common/GridRow';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||||
|
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function LinkPanels({ linkId }: { linkId: string }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const tableProps = {
|
||||||
|
websiteId: linkId,
|
||||||
|
limit: 10,
|
||||||
|
allowDownload: false,
|
||||||
|
showMore: true,
|
||||||
|
metric: formatMessage(labels.visitors),
|
||||||
|
};
|
||||||
|
const rowProps = { minHeight: 570 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid gap="3">
|
||||||
|
<GridRow layout="two" {...rowProps}>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.sources)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
|
||||||
|
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="referrer">
|
||||||
|
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="channel">
|
||||||
|
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.environment)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
|
||||||
|
<Tab id="os">{formatMessage(labels.os)}</Tab>
|
||||||
|
<Tab id="device">{formatMessage(labels.devices)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="browser">
|
||||||
|
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="os">
|
||||||
|
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="device">
|
||||||
|
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
<GridRow layout="two" {...rowProps}>
|
||||||
|
<Panel noPadding>
|
||||||
|
<WorldMap websiteId={linkId} />
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.location)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="country">{formatMessage(labels.countries)}</Tab>
|
||||||
|
<Tab id="region">{formatMessage(labels.regions)}</Tab>
|
||||||
|
<Tab id="city">{formatMessage(labels.cities)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="country">
|
||||||
|
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="region">
|
||||||
|
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="city">
|
||||||
|
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/(main)/links/[linkId]/page.tsx
Normal file
12
src/app/(main)/links/[linkId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { LinkPage } from './LinkPage';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
|
||||||
|
const { linkId } = await params;
|
||||||
|
|
||||||
|
return <LinkPage linkId={linkId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Link',
|
||||||
|
};
|
||||||
|
|
@ -31,8 +31,8 @@ export function PixelEditForm({
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const { mutate, error, isPending, touch } = useUpdateQuery(
|
const { mutate, error, isPending, touch, toast } = useUpdateQuery(
|
||||||
pixelId ? `/pixels/${pixelId}` : '/pixels',
|
pixelId ? `/pixels/${pixelId}` : '/pixels',
|
||||||
{
|
{
|
||||||
id: pixelId,
|
id: pixelId,
|
||||||
|
|
@ -47,6 +47,7 @@ export function PixelEditForm({
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
mutate(data, {
|
mutate(data, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
toast(formatMessage(messages.saved));
|
||||||
touch('pixels');
|
touch('pixels');
|
||||||
onSave?.();
|
onSave?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
|
|
|
||||||
20
src/app/(main)/pixels/PixelProvider.tsx
Normal file
20
src/app/(main)/pixels/PixelProvider.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
'use client';
|
||||||
|
import { createContext, ReactNode } from 'react';
|
||||||
|
import { usePixelQuery } from '@/components/hooks';
|
||||||
|
import { Loading } from '@umami/react-zen';
|
||||||
|
|
||||||
|
export const PixelContext = createContext(null);
|
||||||
|
|
||||||
|
export function PixelProvider({ pixelId, children }: { pixelId?: string; children: ReactNode }) {
|
||||||
|
const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId);
|
||||||
|
|
||||||
|
if (isFetching && isLoading) {
|
||||||
|
return <Loading position="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pixel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PixelContext.Provider value={pixel}>{children}</PixelContext.Provider>;
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
|
import Link from 'next/link';
|
||||||
import { DataTable, DataColumn, Row } from '@umami/react-zen';
|
import { DataTable, DataColumn, Row } from '@umami/react-zen';
|
||||||
import { useConfig, useMessages } from '@/components/hooks';
|
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
import { DateDistance } from '@/components/common/DateDistance';
|
import { DateDistance } from '@/components/common/DateDistance';
|
||||||
import { PixelEditButton } from './PixelEditButton';
|
import { PixelEditButton } from './PixelEditButton';
|
||||||
import { PixelDeleteButton } from './PixelDeleteButton';
|
import { PixelDeleteButton } from './PixelDeleteButton';
|
||||||
import { PIXELS_URL } from '@/lib/constants';
|
|
||||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||||
|
|
||||||
export function PixelsTable({ data = [] }) {
|
export function PixelsTable({ data = [] }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { pixelsUrl } = useConfig();
|
const { renderUrl } = useNavigation();
|
||||||
const hostUrl = pixelsUrl || PIXELS_URL;
|
const { getSlugUrl } = useSlug('pixel');
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return <Empty />;
|
return <Empty />;
|
||||||
|
|
@ -18,10 +18,14 @@ export function PixelsTable({ data = [] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable data={data}>
|
<DataTable data={data}>
|
||||||
<DataColumn id="name" label={formatMessage(labels.name)} />
|
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||||
|
{({ id, name }: any) => {
|
||||||
|
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
|
||||||
|
}}
|
||||||
|
</DataColumn>
|
||||||
<DataColumn id="url" label="URL">
|
<DataColumn id="url" label="URL">
|
||||||
{({ slug }: any) => {
|
{({ slug }: any) => {
|
||||||
const url = `${hostUrl}/${slug}`;
|
const url = getSlugUrl(slug);
|
||||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
|
|
|
||||||
32
src/app/(main)/pixels/[pixelId]/PixelControls.tsx
Normal file
32
src/app/(main)/pixels/[pixelId]/PixelControls.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Column, Row } from '@umami/react-zen';
|
||||||
|
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
|
||||||
|
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||||
|
import { FilterBar } from '@/components/input/FilterBar';
|
||||||
|
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
||||||
|
import { ExportButton } from '@/components/input/ExportButton';
|
||||||
|
|
||||||
|
export function PixelControls({
|
||||||
|
pixelId: websiteId,
|
||||||
|
allowFilter = true,
|
||||||
|
allowDateFilter = true,
|
||||||
|
allowMonthFilter,
|
||||||
|
allowDownload = false,
|
||||||
|
}: {
|
||||||
|
pixelId: string;
|
||||||
|
allowFilter?: boolean;
|
||||||
|
allowDateFilter?: boolean;
|
||||||
|
allowMonthFilter?: boolean;
|
||||||
|
allowDownload?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Column gap>
|
||||||
|
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||||
|
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||||
|
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
|
||||||
|
{allowDownload && <ExportButton websiteId={websiteId} />}
|
||||||
|
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
||||||
|
</Row>
|
||||||
|
{allowFilter && <FilterBar websiteId={websiteId} />}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
Normal file
22
src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { usePixel, useMessages, useSlug } from '@/components/hooks';
|
||||||
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
|
import { Icon, Text } from '@umami/react-zen';
|
||||||
|
import { ExternalLink, Pixel } from '@/components/icons';
|
||||||
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
|
|
||||||
|
export function PixelHeader() {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { getSlugUrl } = useSlug('pixel');
|
||||||
|
const pixel = usePixel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageHeader title={pixel.name} description={pixel.url} icon={<Pixel />}>
|
||||||
|
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank">
|
||||||
|
<Icon>
|
||||||
|
<ExternalLink />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.view)}</Text>
|
||||||
|
</LinkButton>
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
Normal file
71
src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useDateRange, useMessages } from '@/components/hooks';
|
||||||
|
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||||
|
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||||
|
import { formatLongNumber } from '@/lib/format';
|
||||||
|
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
|
||||||
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
|
||||||
|
export function PixelMetricsBar({
|
||||||
|
pixelId,
|
||||||
|
}: {
|
||||||
|
pixelId: string;
|
||||||
|
showChange?: boolean;
|
||||||
|
compareMode?: boolean;
|
||||||
|
}) {
|
||||||
|
const { dateRange } = useDateRange(pixelId);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId);
|
||||||
|
const isAllTime = dateRange.value === 'all';
|
||||||
|
|
||||||
|
const { pageviews, visitors, visits, comparison } = data || {};
|
||||||
|
|
||||||
|
const metrics = data
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: visitors,
|
||||||
|
label: formatMessage(labels.visitors),
|
||||||
|
change: visitors - comparison.visitors,
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: visits,
|
||||||
|
label: formatMessage(labels.visits),
|
||||||
|
change: visits - comparison.visits,
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: pageviews,
|
||||||
|
label: formatMessage(labels.views),
|
||||||
|
change: pageviews - comparison.pageviews,
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingPanel
|
||||||
|
data={metrics}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isFetching={isFetching}
|
||||||
|
error={error}
|
||||||
|
minHeight="136px"
|
||||||
|
>
|
||||||
|
<MetricsBar>
|
||||||
|
{metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
|
||||||
|
return (
|
||||||
|
<MetricCard
|
||||||
|
key={label}
|
||||||
|
value={value}
|
||||||
|
previousValue={prev}
|
||||||
|
label={label}
|
||||||
|
change={change}
|
||||||
|
formatValue={formatValue}
|
||||||
|
reverseColors={reverseColors}
|
||||||
|
showChange={!isAllTime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MetricsBar>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/(main)/pixels/[pixelId]/PixelPage.tsx
Normal file
25
src/app/(main)/pixels/[pixelId]/PixelPage.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
'use client';
|
||||||
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
|
import { PixelProvider } from '@/app/(main)/pixels/PixelProvider';
|
||||||
|
import { PixelHeader } from '@/app/(main)/pixels/[pixelId]/PixelHeader';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||||
|
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
|
||||||
|
import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls';
|
||||||
|
import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels';
|
||||||
|
|
||||||
|
export function PixelPage({ pixelId }: { pixelId: string }) {
|
||||||
|
return (
|
||||||
|
<PixelProvider pixelId={pixelId}>
|
||||||
|
<PageBody gap>
|
||||||
|
<PixelHeader />
|
||||||
|
<PixelControls pixelId={pixelId} />
|
||||||
|
<PixelMetricsBar pixelId={pixelId} showChange={true} />
|
||||||
|
<Panel>
|
||||||
|
<WebsiteChart websiteId={pixelId} />
|
||||||
|
</Panel>
|
||||||
|
<PixelPanels pixelId={pixelId} />
|
||||||
|
</PageBody>
|
||||||
|
</PixelProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
Normal file
83
src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
|
||||||
|
import { GridRow } from '@/components/common/GridRow';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||||
|
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function PixelPanels({ pixelId }: { pixelId: string }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const tableProps = {
|
||||||
|
websiteId: pixelId,
|
||||||
|
limit: 10,
|
||||||
|
allowDownload: false,
|
||||||
|
showMore: true,
|
||||||
|
metric: formatMessage(labels.visitors),
|
||||||
|
};
|
||||||
|
const rowProps = { minHeight: 570 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid gap="3">
|
||||||
|
<GridRow layout="two" {...rowProps}>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.sources)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
|
||||||
|
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="referrer">
|
||||||
|
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="channel">
|
||||||
|
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.environment)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
|
||||||
|
<Tab id="os">{formatMessage(labels.os)}</Tab>
|
||||||
|
<Tab id="device">{formatMessage(labels.devices)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="browser">
|
||||||
|
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="os">
|
||||||
|
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="device">
|
||||||
|
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
<GridRow layout="two" {...rowProps}>
|
||||||
|
<Panel noPadding>
|
||||||
|
<WorldMap websiteId={pixelId} />
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.location)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="country">{formatMessage(labels.countries)}</Tab>
|
||||||
|
<Tab id="region">{formatMessage(labels.regions)}</Tab>
|
||||||
|
<Tab id="city">{formatMessage(labels.cities)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="country">
|
||||||
|
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="region">
|
||||||
|
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="city">
|
||||||
|
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/(main)/pixels/[pixelId]/page.tsx
Normal file
12
src/app/(main)/pixels/[pixelId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { PixelPage } from './PixelPage';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
|
export default async function ({ params }: { params: Promise<{ pixelId: string }> }) {
|
||||||
|
const { pixelId } = await params;
|
||||||
|
|
||||||
|
return <PixelPage pixelId={pixelId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Pixel',
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { TeamProvider } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
import { TeamProvider } from '@/app/(main)/teams/TeamProvider';
|
||||||
import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
|
import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
|
||||||
|
|
||||||
export function TeamSettingsPage({ teamId }: { teamId: string }) {
|
export function TeamSettingsPage({ teamId }: { teamId: string }) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column } from '@umami/react-zen';
|
||||||
import { WebsiteProvider } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||||
import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
|
import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
|
||||||
import { WebsiteSettingsHeader } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader';
|
import { WebsiteSettingsHeader } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,7 @@ import {
|
||||||
useToast,
|
useToast,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { getRandomChars } from '@/lib/crypto';
|
import { getRandomChars } from '@/lib/crypto';
|
||||||
import { useContext } from 'react';
|
import { useApi, useMessages, useModified, useTeam } from '@/components/hooks';
|
||||||
import { useApi, useMessages, useModified } from '@/components/hooks';
|
|
||||||
import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
|
||||||
|
|
||||||
const generateId = () => `team_${getRandomChars(16)}`;
|
const generateId = () => `team_${getRandomChars(16)}`;
|
||||||
|
|
||||||
|
|
@ -23,7 +21,7 @@ export function TeamEditForm({
|
||||||
allowEdit?: boolean;
|
allowEdit?: boolean;
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const team = useContext(TeamContext);
|
const team = useTeam();
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const { post, useMutation } = useApi();
|
const { post, useMutation } = useApi();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useContext, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
|
import { Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
|
||||||
import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks';
|
||||||
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
|
|
||||||
|
|
||||||
import { ROLES } from '@/lib/constants';
|
import { ROLES } from '@/lib/constants';
|
||||||
import { Users } from '@/components/icons';
|
import { Users } from '@/components/icons';
|
||||||
|
|
@ -14,7 +13,7 @@ import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
||||||
export function TeamSettings({ teamId }: { teamId: string }) {
|
export function TeamSettings({ teamId }: { teamId: string }) {
|
||||||
const team = useContext(TeamContext);
|
const team = useTeam();
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { user } = useLoginQuery();
|
const { user } = useLoginQuery();
|
||||||
const { query, pathname } = useNavigation();
|
const { query, pathname } = useNavigation();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export function UTMPage({ websiteId }: { websiteId: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} allowCompare={false} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<UTM websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
<UTM websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export function WebsiteChart({
|
||||||
compareMode,
|
compareMode,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
compareMode?: string;
|
compareMode?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { dateRange, dateCompare } = useDateRange(websiteId);
|
const { dateRange, dateCompare } = useDateRange(websiteId);
|
||||||
const { startDate, endDate, unit, value } = dateRange;
|
const { startDate, endDate, unit, value } = dateRange;
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
import { Grid, Heading, Column, Row, NavMenu, NavMenuItem, Text } from '@umami/react-zen';
|
|
||||||
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
|
||||||
import { BrowsersTable } from '@/components/metrics/BrowsersTable';
|
|
||||||
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
|
||||||
import { CitiesTable } from '@/components/metrics/CitiesTable';
|
|
||||||
import { CountriesTable } from '@/components/metrics/CountriesTable';
|
|
||||||
import { DevicesTable } from '@/components/metrics/DevicesTable';
|
|
||||||
import { EventsTable } from '@/components/metrics/EventsTable';
|
|
||||||
import { LanguagesTable } from '@/components/metrics/LanguagesTable';
|
|
||||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
|
||||||
import { OSTable } from '@/components/metrics/OSTable';
|
|
||||||
import { PagesTable } from '@/components/metrics/PagesTable';
|
|
||||||
import { QueryParametersTable } from '@/components/metrics/QueryParametersTable';
|
|
||||||
import { ReferrersTable } from '@/components/metrics/ReferrersTable';
|
|
||||||
import { RegionsTable } from '@/components/metrics/RegionsTable';
|
|
||||||
import { ScreenTable } from '@/components/metrics/ScreenTable';
|
|
||||||
import { TagsTable } from '@/components/metrics/TagsTable';
|
|
||||||
import { getCompareDate } from '@/lib/date';
|
|
||||||
import { formatNumber } from '@/lib/format';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
|
||||||
import { DateDisplay } from '@/components/common/DateDisplay';
|
|
||||||
|
|
||||||
const views = {
|
|
||||||
path: PagesTable,
|
|
||||||
title: PagesTable,
|
|
||||||
referrer: ReferrersTable,
|
|
||||||
browser: BrowsersTable,
|
|
||||||
os: OSTable,
|
|
||||||
device: DevicesTable,
|
|
||||||
screen: ScreenTable,
|
|
||||||
country: CountriesTable,
|
|
||||||
region: RegionsTable,
|
|
||||||
city: CitiesTable,
|
|
||||||
language: LanguagesTable,
|
|
||||||
event: EventsTable,
|
|
||||||
query: QueryParametersTable,
|
|
||||||
tag: TagsTable,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WebsiteCompareTables({ websiteId }: { websiteId: string }) {
|
|
||||||
const [data] = useState([]);
|
|
||||||
const { dateRange, dateCompare } = useDateRange(websiteId);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const {
|
|
||||||
updateParams,
|
|
||||||
query: { view },
|
|
||||||
} = useNavigation();
|
|
||||||
const Component: typeof MetricsTable = views[view || 'path'] || (() => null);
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
id: 'path',
|
|
||||||
label: formatMessage(labels.pages),
|
|
||||||
path: updateParams({ view: 'path' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'referrer',
|
|
||||||
label: formatMessage(labels.referrers),
|
|
||||||
path: updateParams({ view: 'referrer' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'browser',
|
|
||||||
label: formatMessage(labels.browsers),
|
|
||||||
path: updateParams({ view: 'browser' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'os',
|
|
||||||
label: formatMessage(labels.os),
|
|
||||||
path: updateParams({ view: 'os' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'device',
|
|
||||||
label: formatMessage(labels.devices),
|
|
||||||
path: updateParams({ view: 'device' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'country',
|
|
||||||
label: formatMessage(labels.countries),
|
|
||||||
path: updateParams({ view: 'country' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'region',
|
|
||||||
label: formatMessage(labels.regions),
|
|
||||||
path: updateParams({ view: 'region' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'city',
|
|
||||||
label: formatMessage(labels.cities),
|
|
||||||
path: updateParams({ view: 'city' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'language',
|
|
||||||
label: formatMessage(labels.languages),
|
|
||||||
path: updateParams({ view: 'language' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'screen',
|
|
||||||
label: formatMessage(labels.screens),
|
|
||||||
path: updateParams({ view: 'screen' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'event',
|
|
||||||
label: formatMessage(labels.events),
|
|
||||||
path: updateParams({ view: 'event' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'query',
|
|
||||||
label: formatMessage(labels.queryParameters),
|
|
||||||
path: updateParams({ view: 'query' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hostname',
|
|
||||||
label: formatMessage(labels.hostname),
|
|
||||||
path: updateParams({ view: 'hostname' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tag',
|
|
||||||
label: formatMessage(labels.tags),
|
|
||||||
path: updateParams({ view: 'tag' }),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderChange = ({ x, y }) => {
|
|
||||||
const prev = data.find(d => d.x === x)?.y;
|
|
||||||
const value = y - prev;
|
|
||||||
const change = Math.abs(((y - prev) / prev) * 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
!isNaN(change) && (
|
|
||||||
<Row alignItems="center" marginRight="3">
|
|
||||||
<ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel>
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { startDate, endDate } = getCompareDate(
|
|
||||||
dateCompare,
|
|
||||||
dateRange.startDate,
|
|
||||||
dateRange.endDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
startAt: startDate.getTime(),
|
|
||||||
endAt: endDate.getTime(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel>
|
|
||||||
<Grid columns={{ xs: '1fr', lg: '200px 1fr 1fr' }} gap="6">
|
|
||||||
<NavMenu>
|
|
||||||
{items.map(({ id, label }) => {
|
|
||||||
return (
|
|
||||||
<NavMenuItem key={id}>
|
|
||||||
<Text>{label}</Text>
|
|
||||||
</NavMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</NavMenu>
|
|
||||||
<Column border="left" paddingLeft="6" gap="6">
|
|
||||||
<Row alignItems="center" justifyContent="space-between">
|
|
||||||
<Heading size="1">{formatMessage(labels.previous)}</Heading>
|
|
||||||
<DateDisplay startDate={startDate} endDate={endDate} />
|
|
||||||
</Row>
|
|
||||||
<Component websiteId={websiteId} limit={20} showMore={false} params={params} />
|
|
||||||
</Column>
|
|
||||||
<Column border="left" paddingLeft="6" gap="6">
|
|
||||||
<Row alignItems="center" justifyContent="space-between">
|
|
||||||
<Heading size="1"> {formatMessage(labels.current)}</Heading>
|
|
||||||
<DateDisplay startDate={dateRange.startDate} endDate={dateRange.endDate} />
|
|
||||||
</Row>
|
|
||||||
<Component
|
|
||||||
websiteId={websiteId}
|
|
||||||
limit={20}
|
|
||||||
showMore={false}
|
|
||||||
renderChange={renderChange}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
</Grid>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -10,15 +10,15 @@ export function WebsiteControls({
|
||||||
allowFilter = true,
|
allowFilter = true,
|
||||||
allowDateFilter = true,
|
allowDateFilter = true,
|
||||||
allowMonthFilter,
|
allowMonthFilter,
|
||||||
allowCompare,
|
|
||||||
allowDownload = false,
|
allowDownload = false,
|
||||||
|
allowCompare = false,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
allowFilter?: boolean;
|
allowFilter?: boolean;
|
||||||
allowCompare?: boolean;
|
|
||||||
allowDateFilter?: boolean;
|
allowDateFilter?: boolean;
|
||||||
allowMonthFilter?: boolean;
|
allowMonthFilter?: boolean;
|
||||||
allowDownload?: boolean;
|
allowDownload?: boolean;
|
||||||
|
allowCompare?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,7 @@
|
||||||
import { Grid, Column, NavMenu, NavMenuItem } from '@umami/react-zen';
|
import { Grid, Column } from '@umami/react-zen';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { BrowsersTable } from '@/components/metrics/BrowsersTable';
|
import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable';
|
||||||
import { CitiesTable } from '@/components/metrics/CitiesTable';
|
import { SideMenu } from '@/components/common/SideMenu';
|
||||||
import { CountriesTable } from '@/components/metrics/CountriesTable';
|
|
||||||
import { DevicesTable } from '@/components/metrics/DevicesTable';
|
|
||||||
import { EventsTable } from '@/components/metrics/EventsTable';
|
|
||||||
import { HostnamesTable } from '@/components/metrics/HostnamesTable';
|
|
||||||
import { LanguagesTable } from '@/components/metrics/LanguagesTable';
|
|
||||||
import { OSTable } from '@/components/metrics/OSTable';
|
|
||||||
import { PagesTable } from '@/components/metrics/PagesTable';
|
|
||||||
import { QueryParametersTable } from '@/components/metrics/QueryParametersTable';
|
|
||||||
import { ReferrersTable } from '@/components/metrics/ReferrersTable';
|
|
||||||
import { RegionsTable } from '@/components/metrics/RegionsTable';
|
|
||||||
import { ScreenTable } from '@/components/metrics/ScreenTable';
|
|
||||||
import { TagsTable } from '@/components/metrics/TagsTable';
|
|
||||||
import { ChannelsTable } from '@/components/metrics/ChannelsTable';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const views = {
|
|
||||||
path: PagesTable,
|
|
||||||
entry: PagesTable,
|
|
||||||
exit: PagesTable,
|
|
||||||
title: PagesTable,
|
|
||||||
referrer: ReferrersTable,
|
|
||||||
grouped: ReferrersTable,
|
|
||||||
hostname: HostnamesTable,
|
|
||||||
browser: BrowsersTable,
|
|
||||||
os: OSTable,
|
|
||||||
device: DevicesTable,
|
|
||||||
screen: ScreenTable,
|
|
||||||
country: CountriesTable,
|
|
||||||
region: RegionsTable,
|
|
||||||
city: CitiesTable,
|
|
||||||
language: LanguagesTable,
|
|
||||||
event: EventsTable,
|
|
||||||
query: QueryParametersTable,
|
|
||||||
tag: TagsTable,
|
|
||||||
channel: ChannelsTable,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WebsiteExpandedView({
|
export function WebsiteExpandedView({
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -54,106 +18,137 @@ export function WebsiteExpandedView({
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
id: 'path',
|
|
||||||
label: formatMessage(labels.pages),
|
label: formatMessage(labels.pages),
|
||||||
path: updateParams({ view: 'path' }),
|
items: [
|
||||||
|
{
|
||||||
|
id: 'path',
|
||||||
|
label: formatMessage(labels.path),
|
||||||
|
path: updateParams({ view: 'path' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry',
|
||||||
|
label: formatMessage(labels.entry),
|
||||||
|
path: updateParams({ view: 'entry' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exit',
|
||||||
|
label: formatMessage(labels.exit),
|
||||||
|
path: updateParams({ view: 'exit' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
label: formatMessage(labels.title),
|
||||||
|
path: updateParams({ view: 'title' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'query',
|
||||||
|
label: formatMessage(labels.query),
|
||||||
|
path: updateParams({ view: 'query' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'referrer',
|
label: formatMessage(labels.sources),
|
||||||
label: formatMessage(labels.referrers),
|
items: [
|
||||||
path: updateParams({ view: 'referrer' }),
|
{
|
||||||
|
id: 'referrer',
|
||||||
|
label: formatMessage(labels.referrers),
|
||||||
|
path: updateParams({ view: 'referrer' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'channel',
|
||||||
|
label: formatMessage(labels.channels),
|
||||||
|
path: updateParams({ view: 'channel' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'domain',
|
||||||
|
label: formatMessage(labels.domain),
|
||||||
|
path: updateParams({ view: 'domain' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'channel',
|
label: formatMessage(labels.location),
|
||||||
label: formatMessage(labels.channels),
|
items: [
|
||||||
path: updateParams({ view: 'channel' }),
|
{
|
||||||
|
id: 'country',
|
||||||
|
label: formatMessage(labels.countries),
|
||||||
|
path: updateParams({ view: 'country' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'region',
|
||||||
|
label: formatMessage(labels.regions),
|
||||||
|
path: updateParams({ view: 'region' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'city',
|
||||||
|
label: formatMessage(labels.cities),
|
||||||
|
path: updateParams({ view: 'city' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'browser',
|
label: formatMessage(labels.environment),
|
||||||
label: formatMessage(labels.browsers),
|
items: [
|
||||||
path: updateParams({ view: 'browser' }),
|
{
|
||||||
|
id: 'browser',
|
||||||
|
label: formatMessage(labels.browsers),
|
||||||
|
path: updateParams({ view: 'browser' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'os',
|
||||||
|
label: formatMessage(labels.os),
|
||||||
|
path: updateParams({ view: 'os' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device',
|
||||||
|
label: formatMessage(labels.devices),
|
||||||
|
path: updateParams({ view: 'device' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'language',
|
||||||
|
label: formatMessage(labels.languages),
|
||||||
|
path: updateParams({ view: 'language' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'screen',
|
||||||
|
label: formatMessage(labels.screens),
|
||||||
|
path: updateParams({ view: 'screen' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'os',
|
label: formatMessage(labels.other),
|
||||||
label: formatMessage(labels.os),
|
items: [
|
||||||
path: updateParams({ view: 'os' }),
|
{
|
||||||
},
|
id: 'event',
|
||||||
{
|
label: formatMessage(labels.events),
|
||||||
id: 'device',
|
path: updateParams({ view: 'event' }),
|
||||||
label: formatMessage(labels.devices),
|
},
|
||||||
path: updateParams({ view: 'device' }),
|
{
|
||||||
},
|
id: 'hostname',
|
||||||
{
|
label: formatMessage(labels.hostname),
|
||||||
id: 'country',
|
path: updateParams({ view: 'hostname' }),
|
||||||
label: formatMessage(labels.countries),
|
},
|
||||||
path: updateParams({ view: 'country' }),
|
{
|
||||||
},
|
id: 'tag',
|
||||||
{
|
label: formatMessage(labels.tags),
|
||||||
id: 'region',
|
path: updateParams({ view: 'tag' }),
|
||||||
label: formatMessage(labels.regions),
|
},
|
||||||
path: updateParams({ view: 'region' }),
|
],
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'city',
|
|
||||||
label: formatMessage(labels.cities),
|
|
||||||
path: updateParams({ view: 'city' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'language',
|
|
||||||
label: formatMessage(labels.languages),
|
|
||||||
path: updateParams({ view: 'language' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'screen',
|
|
||||||
label: formatMessage(labels.screens),
|
|
||||||
path: updateParams({ view: 'screen' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'event',
|
|
||||||
label: formatMessage(labels.events),
|
|
||||||
path: updateParams({ view: 'event' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'query',
|
|
||||||
label: formatMessage(labels.queryParameters),
|
|
||||||
path: updateParams({ view: 'query' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hostname',
|
|
||||||
label: formatMessage(labels.hostname),
|
|
||||||
path: updateParams({ view: 'hostname' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tag',
|
|
||||||
label: formatMessage(labels.tags),
|
|
||||||
path: updateParams({ view: 'tag' }),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DetailsComponent = views[view] || (() => null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid columns="auto 1fr" gap="6" height="100%" overflow="hidden">
|
<Grid columns="auto 1fr" gap="6" height="100%" overflow="hidden">
|
||||||
<Column gap="6" width="200px" border="right" paddingRight="3">
|
<Column gap="6" border="right" paddingRight="3">
|
||||||
<NavMenu position="sticky" top="0">
|
<SideMenu items={items} selectedKey={view} />
|
||||||
{items.map(({ id, label, path }) => {
|
|
||||||
return (
|
|
||||||
<Link key={id} href={path}>
|
|
||||||
<NavMenuItem isSelected={id === view}>{label}</NavMenuItem>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</NavMenu>
|
|
||||||
</Column>
|
</Column>
|
||||||
<Column overflow="hidden">
|
<Column overflow="hidden">
|
||||||
<DetailsComponent
|
<MetricsExpandedTable
|
||||||
|
title={formatMessage(labels[view])}
|
||||||
|
type={view}
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
animate={false}
|
|
||||||
virtualize={true}
|
|
||||||
itemCount={25}
|
|
||||||
allowFilter={true}
|
|
||||||
allowSearch={true}
|
|
||||||
isExpanded={true}
|
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { Button, Icon, Text, Row, DialogTrigger, Dialog, Modal } from '@umami/react-zen';
|
import { Button, Icon, Text, Row, DialogTrigger, Dialog, Modal } from '@umami/react-zen';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { useWebsite } from '@/components/hooks/useWebsite';
|
|
||||||
import { Share, Edit } from '@/components/icons';
|
import { Share, Edit } from '@/components/icons';
|
||||||
import { Favicon } from '@/components/common/Favicon';
|
import { Favicon } from '@/components/common/Favicon';
|
||||||
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
|
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
|
||||||
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
|
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
|
||||||
import { LinkButton } from '@/components/common/LinkButton';
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
|
|
||||||
export function WebsiteHeader() {
|
export function WebsiteHeader() {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Column, Grid } from '@umami/react-zen';
|
import { Column, Grid } from '@umami/react-zen';
|
||||||
import { WebsiteProvider } from './WebsiteProvider';
|
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||||
import { PageBody } from '@/components/common/PageBody';
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
import { WebsiteHeader } from './WebsiteHeader';
|
import { WebsiteHeader } from './WebsiteHeader';
|
||||||
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
|
import { WebsiteNav } from './WebsiteNav';
|
||||||
|
|
||||||
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Network,
|
Network,
|
||||||
ChartPie,
|
ChartPie,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Compare,
|
||||||
} from '@/components/icons';
|
} from '@/components/icons';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { SideMenu } from '@/components/common/SideMenu';
|
import { SideMenu } from '@/components/common/SideMenu';
|
||||||
|
|
@ -22,7 +23,8 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { pathname, renderUrl, teamId } = useNavigation();
|
const { pathname, renderUrl, teamId } = useNavigation();
|
||||||
|
|
||||||
const renderPath = (path: string) => renderUrl(`/websites/${websiteId}${path}`);
|
const renderPath = (path: string) =>
|
||||||
|
renderUrl(`/websites/${websiteId}${path}`, { event: undefined });
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +54,12 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
||||||
icon: <Clock />,
|
icon: <Clock />,
|
||||||
path: renderPath('/realtime'),
|
path: renderPath('/realtime'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'compare',
|
||||||
|
label: formatMessage(labels.compare),
|
||||||
|
icon: <Compare />,
|
||||||
|
path: renderPath('/compare'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'breakdown',
|
id: 'breakdown',
|
||||||
label: formatMessage(labels.breakdown),
|
label: formatMessage(labels.breakdown),
|
||||||
|
|
@ -132,8 +140,8 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectedKey =
|
const selectedKey =
|
||||||
items.flatMap(e => e.items).find(({ path }) => path && pathname.endsWith(path))?.id ||
|
items.flatMap(e => e.items).find(({ path }) => path && pathname.endsWith(path.split('?')[0]))
|
||||||
'overview';
|
?.id || 'overview';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideMenu items={items} selectedKey={selectedKey} allowMinimize={false}>
|
<SideMenu items={items} selectedKey={selectedKey} allowMinimize={false}>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import { Panel } from '@/components/common/Panel';
|
||||||
import { WebsiteChart } from './WebsiteChart';
|
import { WebsiteChart } from './WebsiteChart';
|
||||||
import { WebsiteExpandedView } from './WebsiteExpandedView';
|
import { WebsiteExpandedView } from './WebsiteExpandedView';
|
||||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
||||||
import { WebsiteTableView } from './WebsiteTableView';
|
import { WebsitePanels } from './WebsitePanels';
|
||||||
import { WebsiteControls } from './WebsiteControls';
|
import { WebsiteControls } from './WebsiteControls';
|
||||||
|
|
||||||
export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
|
export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||||
const {
|
const {
|
||||||
router,
|
router,
|
||||||
query: { view, compare },
|
query: { view, compare },
|
||||||
|
|
@ -27,12 +27,12 @@ export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} allowCompare={true} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||||
<Panel>
|
<Panel minHeight="520px">
|
||||||
<WebsiteChart websiteId={websiteId} compareMode={compare} />
|
<WebsiteChart websiteId={websiteId} compareMode={compare} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<WebsiteTableView websiteId={websiteId} />
|
<WebsitePanels websiteId={websiteId} />
|
||||||
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
||||||
<Dialog style={{ maxWidth: 1320, width: '100vw', height: 'calc(100vh - 40px)' }}>
|
<Dialog style={{ maxWidth: 1320, width: '100vw', height: 'calc(100vh - 40px)' }}>
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
114
src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
Normal file
114
src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { Grid, Tabs, Tab, TabList, TabPanel, Heading, Row } from '@umami/react-zen';
|
||||||
|
import { GridRow } from '@/components/common/GridRow';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||||
|
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||||
|
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const tableProps = {
|
||||||
|
websiteId,
|
||||||
|
limit: 10,
|
||||||
|
allowDownload: false,
|
||||||
|
showMore: true,
|
||||||
|
metric: formatMessage(labels.visitors),
|
||||||
|
};
|
||||||
|
const rowProps = { minHeight: 570 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid gap="3">
|
||||||
|
<GridRow layout="two" {...rowProps}>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.pages)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="path">{formatMessage(labels.path)}</Tab>
|
||||||
|
<Tab id="entry">{formatMessage(labels.entry)}</Tab>
|
||||||
|
<Tab id="exit">{formatMessage(labels.exit)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="path">
|
||||||
|
<MetricsTable type="path" title={formatMessage(labels.path)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="entry">
|
||||||
|
<MetricsTable type="entry" title={formatMessage(labels.path)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="exit">
|
||||||
|
<MetricsTable type="exit" title={formatMessage(labels.path)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.sources)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
|
||||||
|
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="referrer">
|
||||||
|
<MetricsTable
|
||||||
|
type="referrer"
|
||||||
|
title={formatMessage(labels.referrer)}
|
||||||
|
{...tableProps}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="channel">
|
||||||
|
<MetricsTable type="channel" title={formatMessage(labels.channel)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
<GridRow layout="two-one" {...rowProps}>
|
||||||
|
<Panel gridColumn="span 2" noPadding>
|
||||||
|
<WorldMap websiteId={websiteId} />
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.location)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="country">{formatMessage(labels.countries)}</Tab>
|
||||||
|
<Tab id="region">{formatMessage(labels.regions)}</Tab>
|
||||||
|
<Tab id="city">{formatMessage(labels.cities)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="country">
|
||||||
|
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="region">
|
||||||
|
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="city">
|
||||||
|
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
<GridRow layout="two" {...rowProps}>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.environment)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
|
||||||
|
<Tab id="os">{formatMessage(labels.os)}</Tab>
|
||||||
|
<Tab id="device">{formatMessage(labels.devices)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="browser">
|
||||||
|
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="os">
|
||||||
|
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="device">
|
||||||
|
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.traffic)}</Heading>
|
||||||
|
<Row border="bottom" marginBottom="4" />
|
||||||
|
<WeeklyTraffic websiteId={websiteId} />
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { Grid } from '@umami/react-zen';
|
|
||||||
import { GridRow } from '@/components/common/GridRow';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
|
||||||
import { PagesTable } from '@/components/metrics/PagesTable';
|
|
||||||
import { ReferrersTable } from '@/components/metrics/ReferrersTable';
|
|
||||||
import { BrowsersTable } from '@/components/metrics/BrowsersTable';
|
|
||||||
import { OSTable } from '@/components/metrics/OSTable';
|
|
||||||
import { DevicesTable } from '@/components/metrics/DevicesTable';
|
|
||||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
|
||||||
import { CountriesTable } from '@/components/metrics/CountriesTable';
|
|
||||||
|
|
||||||
export function WebsiteTableView({ websiteId }: { websiteId: string }) {
|
|
||||||
const props = { websiteId, limit: 10, allowDownload: false };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Grid gap="3">
|
|
||||||
<GridRow layout="two">
|
|
||||||
<Panel>
|
|
||||||
<PagesTable {...props} />
|
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<ReferrersTable {...props} />
|
|
||||||
</Panel>
|
|
||||||
</GridRow>
|
|
||||||
<GridRow layout="three">
|
|
||||||
<Panel>
|
|
||||||
<BrowsersTable {...props} />
|
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<OSTable {...props} />
|
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<DevicesTable {...props} />
|
|
||||||
</Panel>
|
|
||||||
</GridRow>
|
|
||||||
<GridRow layout="two-one">
|
|
||||||
<Panel gridColumn="span 2" noPadding>
|
|
||||||
<WorldMap websiteId={websiteId} />
|
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<CountriesTable {...props} />
|
|
||||||
</Panel>
|
|
||||||
</GridRow>
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Tabs, TabList, Tab, Icon, Text, Row } from '@umami/react-zen';
|
import { Tabs, TabList, Tab, Icon, Text, Row } from '@umami/react-zen';
|
||||||
import { useWebsite } from '@/components/hooks/useWebsite';
|
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
|
||||||
import { Clock, Eye, Lightning, User, ChartPie } from '@/components/icons';
|
import { Clock, Eye, Lightning, User, ChartPie } from '@/components/icons';
|
||||||
|
|
||||||
export function WebsiteTabs() {
|
export function WebsiteTabs() {
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ import { DateDistance } from '@/components/common/DateDistance';
|
||||||
import { filtersObjectToArray } from '@/lib/params';
|
import { filtersObjectToArray } from '@/lib/params';
|
||||||
import { CohortEditButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditButton';
|
import { CohortEditButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditButton';
|
||||||
import { CohortDeleteButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton';
|
import { CohortDeleteButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export function CohortsTable({ data = [] }) {
|
export function CohortsTable({ data = [] }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { websiteId } = useNavigation();
|
const { websiteId, renderUrl } = useNavigation();
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return <Empty />;
|
return <Empty />;
|
||||||
|
|
@ -16,7 +17,11 @@ export function CohortsTable({ data = [] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable data={data}>
|
<DataTable data={data}>
|
||||||
<DataColumn id="name" label={formatMessage(labels.name)} />
|
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||||
|
{(row: any) => (
|
||||||
|
<Link href={renderUrl(`/websites/${websiteId}?cohort=${row.id}`)}>{row.name}</Link>
|
||||||
|
)}
|
||||||
|
</DataColumn>
|
||||||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
|
|
|
||||||
20
src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
Normal file
20
src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
'use client';
|
||||||
|
import { Column } from '@umami/react-zen';
|
||||||
|
import { CompareTables } from './CompareTables';
|
||||||
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||||
|
|
||||||
|
export function ComparePage({ websiteId }: { websiteId: string }) {
|
||||||
|
return (
|
||||||
|
<Column gap>
|
||||||
|
<WebsiteControls websiteId={websiteId} allowCompare={true} />
|
||||||
|
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||||
|
<Panel minHeight="520px">
|
||||||
|
<WebsiteChart websiteId={websiteId} compareMode={true} />
|
||||||
|
</Panel>
|
||||||
|
<CompareTables websiteId={websiteId} />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
Normal file
169
src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Grid, Heading, Column, Row, Select, ListItem } from '@umami/react-zen';
|
||||||
|
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
||||||
|
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { DateDisplay } from '@/components/common/DateDisplay';
|
||||||
|
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
||||||
|
import { getCompareDate } from '@/lib/date';
|
||||||
|
import { formatNumber } from '@/lib/format';
|
||||||
|
|
||||||
|
export function CompareTables({ websiteId }: { websiteId: string }) {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const { dateRange, dateCompare } = useDateRange(websiteId);
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const {
|
||||||
|
router,
|
||||||
|
updateParams,
|
||||||
|
query: { view = 'path' },
|
||||||
|
} = useNavigation();
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: 'path',
|
||||||
|
label: formatMessage(labels.path),
|
||||||
|
path: updateParams({ view: 'path' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'referrer',
|
||||||
|
label: formatMessage(labels.referrers),
|
||||||
|
path: updateParams({ view: 'referrer' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'browser',
|
||||||
|
label: formatMessage(labels.browsers),
|
||||||
|
path: updateParams({ view: 'browser' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'os',
|
||||||
|
label: formatMessage(labels.os),
|
||||||
|
path: updateParams({ view: 'os' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device',
|
||||||
|
label: formatMessage(labels.devices),
|
||||||
|
path: updateParams({ view: 'device' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'country',
|
||||||
|
label: formatMessage(labels.countries),
|
||||||
|
path: updateParams({ view: 'country' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'region',
|
||||||
|
label: formatMessage(labels.regions),
|
||||||
|
path: updateParams({ view: 'region' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'city',
|
||||||
|
label: formatMessage(labels.cities),
|
||||||
|
path: updateParams({ view: 'city' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'language',
|
||||||
|
label: formatMessage(labels.languages),
|
||||||
|
path: updateParams({ view: 'language' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'screen',
|
||||||
|
label: formatMessage(labels.screens),
|
||||||
|
path: updateParams({ view: 'screen' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event',
|
||||||
|
label: formatMessage(labels.events),
|
||||||
|
path: updateParams({ view: 'event' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hostname',
|
||||||
|
label: formatMessage(labels.hostname),
|
||||||
|
path: updateParams({ view: 'hostname' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tag',
|
||||||
|
label: formatMessage(labels.tags),
|
||||||
|
path: updateParams({ view: 'tag' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderChange = props => {
|
||||||
|
const { label: x, count: y } = props;
|
||||||
|
const prev = data.find(d => d.x === x)?.y;
|
||||||
|
const value = y - prev;
|
||||||
|
const change = Math.abs(((y - prev) / prev) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
!isNaN(change) && (
|
||||||
|
<Row alignItems="center" marginRight="3">
|
||||||
|
<ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = id => {
|
||||||
|
router.push(updateParams({ view: id }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { startDate, endDate } = getCompareDate(
|
||||||
|
dateCompare,
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
startAt: startDate.getTime(),
|
||||||
|
endAt: endDate.getTime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row width="300px">
|
||||||
|
<Select
|
||||||
|
items={items}
|
||||||
|
label={formatMessage(labels.compare)}
|
||||||
|
value={view}
|
||||||
|
defaultValue={view}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
{items.map(({ id, label }) => (
|
||||||
|
<ListItem key={id} id={id}>
|
||||||
|
{label}
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Row>
|
||||||
|
<Panel>
|
||||||
|
<Grid columns={{ xs: '1fr', lg: '1fr 1fr' }} gap="6">
|
||||||
|
<Column gap="6">
|
||||||
|
<Row alignItems="center" justifyContent="space-between">
|
||||||
|
<Heading size="2">{formatMessage(labels.previous)}</Heading>
|
||||||
|
<DateDisplay startDate={startDate} endDate={endDate} />
|
||||||
|
</Row>
|
||||||
|
<MetricsTable
|
||||||
|
websiteId={websiteId}
|
||||||
|
type={view}
|
||||||
|
limit={20}
|
||||||
|
showMore={false}
|
||||||
|
params={params}
|
||||||
|
onDataLoad={setData}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column border="left" paddingLeft="6" gap="6">
|
||||||
|
<Row alignItems="center" justifyContent="space-between">
|
||||||
|
<Heading size="2"> {formatMessage(labels.current)}</Heading>
|
||||||
|
<DateDisplay startDate={dateRange.startDate} endDate={dateRange.endDate} />
|
||||||
|
</Row>
|
||||||
|
<MetricsTable
|
||||||
|
websiteId={websiteId}
|
||||||
|
type={view}
|
||||||
|
limit={20}
|
||||||
|
showMore={false}
|
||||||
|
renderChange={renderChange}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/(main)/websites/[websiteId]/compare/page.tsx
Normal file
12
src/app/(main)/websites/[websiteId]/compare/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { ComparePage } from './ComparePage';
|
||||||
|
|
||||||
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
return <ComparePage websiteId={websiteId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Compare',
|
||||||
|
};
|
||||||
|
|
@ -100,9 +100,9 @@ const EventValues = ({ websiteId, eventName, propertyName }) => {
|
||||||
const tableData = useMemo(() => {
|
const tableData = useMemo(() => {
|
||||||
if (!propertyName || !values || propertySum === 0) return [];
|
if (!propertyName || !values || propertySum === 0) return [];
|
||||||
return values.map(({ value, total }) => ({
|
return values.map(({ value, total }) => ({
|
||||||
x: value,
|
label: value,
|
||||||
y: total,
|
count: total,
|
||||||
z: 100 * (total / propertySum),
|
percent: 100 * (total / propertySum),
|
||||||
}));
|
}));
|
||||||
}, [propertyName, values, propertySum]);
|
}, [propertyName, values, propertySum]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
|
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
|
||||||
import { EventsTable } from '@/components/metrics/EventsTable';
|
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||||
import { useState, Key } from 'react';
|
import { useState, Key } from 'react';
|
||||||
import { EventsDataTable } from './EventsDataTable';
|
import { EventsDataTable } from './EventsDataTable';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
@ -13,14 +13,9 @@ import { getItem, setItem } from '@/lib/storage';
|
||||||
const KEY_NAME = 'umami.events.tab';
|
const KEY_NAME = 'umami.events.tab';
|
||||||
|
|
||||||
export function EventsPage({ websiteId }) {
|
export function EventsPage({ websiteId }) {
|
||||||
const [label, setLabel] = useState(null);
|
|
||||||
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
|
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
const handleLabelClick = (value: string) => {
|
|
||||||
setLabel(value !== label ? value : '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (value: Key) => {
|
const handleSelect = (value: Key) => {
|
||||||
setItem(KEY_NAME, value);
|
setItem(KEY_NAME, value);
|
||||||
setTab(value);
|
setTab(value);
|
||||||
|
|
@ -42,14 +37,13 @@ export function EventsPage({ websiteId }) {
|
||||||
<TabPanel id="chart">
|
<TabPanel id="chart">
|
||||||
<Column gap="6">
|
<Column gap="6">
|
||||||
<Column border="bottom" paddingBottom="6">
|
<Column border="bottom" paddingBottom="6">
|
||||||
<EventsChart websiteId={websiteId} focusLabel={label} />
|
<EventsChart websiteId={websiteId} />
|
||||||
</Column>
|
</Column>
|
||||||
<EventsTable
|
<MetricsTable
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
type="event"
|
type="event"
|
||||||
title={formatMessage(labels.events)}
|
title={formatMessage(labels.events)}
|
||||||
metric={formatMessage(labels.actions)}
|
metric={formatMessage(labels.count)}
|
||||||
onLabelClick={handleLabelClick}
|
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { WebsiteDetailsPage } from './WebsiteDetailsPage';
|
import { WebsitePage } from './WebsitePage';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
export default async function WebsitePage({ params }: { params: Promise<{ websiteId: string }> }) {
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
|
|
||||||
return <WebsiteDetailsPage websiteId={websiteId} />;
|
return <WebsitePage websiteId={websiteId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { useFormat } from '@/components//hooks/useFormat';
|
import { useFormat } from '@/components//hooks/useFormat';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||||
import { useCountryNames, useLocale, useMessages, useTimezone } from '@/components/hooks';
|
import {
|
||||||
|
useCountryNames,
|
||||||
|
useLocale,
|
||||||
|
useMessages,
|
||||||
|
useTimezone,
|
||||||
|
useWebsite,
|
||||||
|
} from '@/components/hooks';
|
||||||
import { Eye, Visitor, Bolt } from '@/components/icons';
|
import { Eye, Visitor, Bolt } from '@/components/icons';
|
||||||
import { BROWSERS, OS_NAMES } from '@/lib/constants';
|
import { BROWSERS, OS_NAMES } from '@/lib/constants';
|
||||||
import { stringToColor } from '@/lib/format';
|
import { stringToColor } from '@/lib/format';
|
||||||
import { RealtimeData } from '@/lib/types';
|
import { useMemo, useState } from 'react';
|
||||||
import { useContext, useMemo, useState } from 'react';
|
|
||||||
import { Icon, SearchField, StatusLight, Text } from '@umami/react-zen';
|
import { Icon, SearchField, StatusLight, Text } from '@umami/react-zen';
|
||||||
import { FixedSizeList } from 'react-window';
|
import { FixedSizeList } from 'react-window';
|
||||||
import { WebsiteContext } from '../WebsiteProvider';
|
|
||||||
import styles from './RealtimeLog.module.css';
|
import styles from './RealtimeLog.module.css';
|
||||||
|
|
||||||
const TYPE_ALL = 'all';
|
const TYPE_ALL = 'all';
|
||||||
|
|
@ -23,8 +27,8 @@ const icons = {
|
||||||
[TYPE_EVENT]: <Bolt />,
|
[TYPE_EVENT]: <Bolt />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RealtimeLog({ data }: { data: RealtimeData }) {
|
export function RealtimeLog({ data }: { data: any }) {
|
||||||
const website = useContext(WebsiteContext);
|
const website = useWebsite();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const { formatValue } = useFormat();
|
const { formatValue } = useFormat();
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
import { useContext, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Row } from '@umami/react-zen';
|
import { Row } from '@umami/react-zen';
|
||||||
import thenby from 'thenby';
|
import thenby from 'thenby';
|
||||||
import { percentFilter } from '@/lib/filters';
|
import { percentFilter } from '@/lib/filters';
|
||||||
import { ListTable } from '@/components/metrics/ListTable';
|
import { ListTable } from '@/components/metrics/ListTable';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages, useWebsite } from '@/components/hooks';
|
||||||
import { RealtimeData } from '@/lib/types';
|
|
||||||
import { WebsiteContext } from '../WebsiteProvider';
|
|
||||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||||
|
|
||||||
const FILTER_REFERRERS = 'filter-referrers';
|
const FILTER_REFERRERS = 'filter-referrers';
|
||||||
const FILTER_PAGES = 'filter-pages';
|
const FILTER_PAGES = 'filter-pages';
|
||||||
|
|
||||||
export function RealtimeUrls({ data }: { data: RealtimeData }) {
|
export function RealtimeUrls({ data }: { data: any }) {
|
||||||
const website = useContext(WebsiteContext);
|
const website = useWebsite();
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { referrers, urls } = data || {};
|
const { referrers, urls } = data || {};
|
||||||
const [filter, setFilter] = useState(FILTER_REFERRERS);
|
const [filter, setFilter] = useState(FILTER_REFERRERS);
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ import {
|
||||||
import { subMonths, endOfDay } from 'date-fns';
|
import { subMonths, endOfDay } from 'date-fns';
|
||||||
import { FieldFilters } from '@/components/input/FieldFilters';
|
import { FieldFilters } from '@/components/input/FieldFilters';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useApi, useMessages, useModified, useWebsiteSegmentQuery } from '@/components/hooks';
|
import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks';
|
||||||
import { filtersArrayToObject } from '@/lib/params';
|
import { filtersArrayToObject } from '@/lib/params';
|
||||||
|
import { messages } from '@/components/messages';
|
||||||
|
|
||||||
export function SegmentEditForm({
|
export function SegmentEditForm({
|
||||||
segmentId,
|
segmentId,
|
||||||
|
|
@ -32,24 +33,23 @@ export function SegmentEditForm({
|
||||||
const { data } = useWebsiteSegmentQuery(websiteId, segmentId);
|
const { data } = useWebsiteSegmentQuery(websiteId, segmentId);
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const [currentFilters, setCurrentFilters] = useState(filters);
|
const [currentFilters, setCurrentFilters] = useState(filters);
|
||||||
const { touch } = useModified();
|
|
||||||
const startDate = subMonths(endOfDay(new Date()), 6);
|
const startDate = subMonths(endOfDay(new Date()), 6);
|
||||||
const endDate = endOfDay(new Date());
|
const endDate = endOfDay(new Date());
|
||||||
|
|
||||||
const { post, useMutation } = useApi();
|
const { mutate, error, isPending, touch, toast } = useUpdateQuery(
|
||||||
const { mutate, error, isPending } = useMutation({
|
`/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`,
|
||||||
mutationFn: (data: any) =>
|
{
|
||||||
post(`/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`, {
|
...data,
|
||||||
...data,
|
type: 'segment',
|
||||||
type: 'segment',
|
},
|
||||||
}),
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
mutate(
|
mutate(
|
||||||
{ ...data, parameters: filtersArrayToObject(currentFilters) },
|
{ ...data, parameters: filtersArrayToObject(currentFilters) },
|
||||||
{
|
{
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
toast(formatMessage(messages.saved));
|
||||||
touch('segments');
|
touch('segments');
|
||||||
onSave?.();
|
onSave?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ import { DateDistance } from '@/components/common/DateDistance';
|
||||||
import { filtersObjectToArray } from '@/lib/params';
|
import { filtersObjectToArray } from '@/lib/params';
|
||||||
import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton';
|
import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton';
|
||||||
import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton';
|
import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export function SegmentsTable({ data = [] }) {
|
export function SegmentsTable({ data = [] }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { websiteId } = useNavigation();
|
const { websiteId, renderUrl } = useNavigation();
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return <Empty />;
|
return <Empty />;
|
||||||
|
|
@ -16,7 +17,11 @@ export function SegmentsTable({ data = [] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable data={data}>
|
<DataTable data={data}>
|
||||||
<DataColumn id="name" label={formatMessage(labels.name)} />
|
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||||
|
{(row: any) => (
|
||||||
|
<Link href={renderUrl(`/websites/${websiteId}?segment=${row.id}`)}>{row.name}</Link>
|
||||||
|
)}
|
||||||
|
</DataColumn>
|
||||||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,9 @@ const SessionValues = ({ websiteId, propertyName }) => {
|
||||||
const tableData = useMemo(() => {
|
const tableData = useMemo(() => {
|
||||||
if (!propertyName || !data || propertySum === 0) return [];
|
if (!propertyName || !data || propertySum === 0) return [];
|
||||||
return data.map(({ value, total }) => ({
|
return data.map(({ value, total }) => ({
|
||||||
x: value,
|
label: value,
|
||||||
y: total,
|
count: total,
|
||||||
z: 100 * (total / propertySum),
|
percent: 100 * (total / propertySum),
|
||||||
}));
|
}));
|
||||||
}, [propertyName, data, propertySum]);
|
}, [propertyName, data, propertySum]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useContext } from 'react';
|
|
||||||
import {
|
import {
|
||||||
FormSubmitButton,
|
FormSubmitButton,
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -7,12 +6,11 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useApi, useMessages, useModified } from '@/components/hooks';
|
import { useApi, useMessages, useModified, useWebsite } from '@/components/hooks';
|
||||||
import { DOMAIN_REGEX } from '@/lib/constants';
|
import { DOMAIN_REGEX } from '@/lib/constants';
|
||||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
|
||||||
|
|
||||||
export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
|
export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
|
||||||
const website = useContext(WebsiteContext);
|
const website = useWebsite();
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const { post, useMutation } = useApi();
|
const { post, useMutation } = useApi();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
import { useContext } from 'react';
|
|
||||||
import { Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
|
import { Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
|
||||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
import { useMessages, useWebsite } from '@/components/hooks';
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { WebsiteShareForm } from './WebsiteShareForm';
|
import { WebsiteShareForm } from './WebsiteShareForm';
|
||||||
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
||||||
import { WebsiteData } from './WebsiteData';
|
import { WebsiteData } from './WebsiteData';
|
||||||
import { WebsiteEditForm } from './WebsiteEditForm';
|
import { WebsiteEditForm } from './WebsiteEditForm';
|
||||||
|
|
||||||
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
|
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
|
||||||
const website = useContext(WebsiteContext);
|
const website = useWebsite();
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useContext } from 'react';
|
|
||||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { Globe } from '@/components/icons';
|
import { Globe } from '@/components/icons';
|
||||||
|
import { useWebsite } from '@/components/hooks';
|
||||||
|
|
||||||
export function WebsiteSettingsHeader() {
|
export function WebsiteSettingsHeader() {
|
||||||
const website = useContext(WebsiteContext);
|
const website = useWebsite();
|
||||||
|
|
||||||
return <PageHeader title={website?.name} icon={<Globe />} />;
|
return <PageHeader title={website?.name} icon={<Globe />} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Key, useContext, useState } from 'react';
|
import { Key, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -10,8 +10,13 @@ import {
|
||||||
ListItem,
|
ListItem,
|
||||||
Text,
|
Text,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useApi, useLoginQuery, useMessages, useUserTeamsQuery } from '@/components/hooks';
|
import {
|
||||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
useLoginQuery,
|
||||||
|
useMessages,
|
||||||
|
useUpdateQuery,
|
||||||
|
useUserTeamsQuery,
|
||||||
|
useWebsite,
|
||||||
|
} from '@/components/hooks';
|
||||||
import { ROLES } from '@/lib/constants';
|
import { ROLES } from '@/lib/constants';
|
||||||
|
|
||||||
export function WebsiteTransferForm({
|
export function WebsiteTransferForm({
|
||||||
|
|
@ -24,13 +29,10 @@ export function WebsiteTransferForm({
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useLoginQuery();
|
const { user } = useLoginQuery();
|
||||||
const website = useContext(WebsiteContext);
|
const website = useWebsite();
|
||||||
const [teamId, setTeamId] = useState<string>(null);
|
const [teamId, setTeamId] = useState<string>(null);
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const { post, useMutation } = useApi();
|
const { mutate, error, isPending } = useUpdateQuery(`/websites/${websiteId}/transfer`);
|
||||||
const { mutate, error } = useMutation({
|
|
||||||
mutationFn: (data: any) => post(`/websites/${websiteId}/transfer`, data),
|
|
||||||
});
|
|
||||||
const { data: teams, isLoading } = useUserTeamsQuery(user.id);
|
const { data: teams, isLoading } = useUserTeamsQuery(user.id);
|
||||||
const isTeamWebsite = !!website?.teamId;
|
const isTeamWebsite = !!website?.teamId;
|
||||||
|
|
||||||
|
|
@ -87,7 +89,11 @@ export function WebsiteTransferForm({
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
<FormSubmitButton variant="primary" isDisabled={!isTeamWebsite && !teamId}>
|
<FormSubmitButton
|
||||||
|
variant="primary"
|
||||||
|
isPending={isPending}
|
||||||
|
isDisabled={!isTeamWebsite && !teamId}
|
||||||
|
>
|
||||||
{formatMessage(labels.transfer)}
|
{formatMessage(labels.transfer)}
|
||||||
</FormSubmitButton>
|
</FormSubmitButton>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { canViewWebsite } from '@/validations';
|
import { canViewWebsite } from '@/validations';
|
||||||
import { pagingParams, timezoneParam } from '@/lib/schema';
|
import { pagingParams, timezoneParam } from '@/lib/schema';
|
||||||
import { getWebsiteSessionsWeekly } from '@/queries';
|
import { getWeeklyTraffic } from '@/queries';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -30,7 +30,7 @@ export async function GET(
|
||||||
|
|
||||||
const filters = await getQueryFilters(query, websiteId);
|
const filters = await getQueryFilters(query, websiteId);
|
||||||
|
|
||||||
const data = await getWebsiteSessionsWeekly(websiteId, filters);
|
const data = await getWeeklyTraffic(websiteId, filters);
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { WebsiteProvider } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||||
import { WebsiteDetailsPage } from '@/app/(main)/websites/[websiteId]/WebsiteDetailsPage';
|
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
|
||||||
import { useShareTokenQuery } from '@/components/hooks';
|
import { useShareTokenQuery } from '@/components/hooks';
|
||||||
import { PageBody } from '@/components/common/PageBody';
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
|
|
@ -17,7 +17,7 @@ export function SharePage({ shareId }) {
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<Header />
|
<Header />
|
||||||
<WebsiteProvider websiteId={shareToken.websiteId}>
|
<WebsiteProvider websiteId={shareToken.websiteId}>
|
||||||
<WebsiteDetailsPage websiteId={shareToken.websiteId} />
|
<WebsitePage websiteId={shareToken.websiteId} />
|
||||||
</WebsiteProvider>
|
</WebsiteProvider>
|
||||||
<Footer />
|
<Footer />
|
||||||
</PageBody>
|
</PageBody>
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,49 @@
|
||||||
import { ReactNode } from 'react';
|
import { HTMLAttributes, ReactNode, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Icon } from '@umami/react-zen';
|
import { Icon, Row, Text } from '@umami/react-zen';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { ExternalLink } from '@/components/icons';
|
import { ExternalLink } from '@/components/icons';
|
||||||
import styles from './FilterLink.module.css';
|
|
||||||
|
|
||||||
export interface FilterLinkProps {
|
export interface FilterLinkProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
id: string;
|
type: string;
|
||||||
value: string;
|
value: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
externalUrl?: string;
|
externalUrl?: string;
|
||||||
className?: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterLink({
|
export function FilterLink({ type, value, label, externalUrl, icon }: FilterLinkProps) {
|
||||||
id,
|
const [showLink, setShowLink] = useState(false);
|
||||||
value,
|
|
||||||
label,
|
|
||||||
externalUrl,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: FilterLinkProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { updateParams, query } = useNavigation();
|
const { updateParams, query } = useNavigation();
|
||||||
const active = query[id] !== undefined;
|
const active = query[type] !== undefined;
|
||||||
const selected = query[id] === value;
|
const selected = query[type] === value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Row
|
||||||
className={classNames(styles.row, className, {
|
alignItems="center"
|
||||||
[styles.inactive]: active && !selected,
|
gap
|
||||||
[styles.active]: active && selected,
|
fontWeight={active && selected ? 'bold' : undefined}
|
||||||
})}
|
color={active && !selected ? 'muted' : undefined}
|
||||||
|
onMouseOver={() => setShowLink(true)}
|
||||||
|
onMouseOut={() => setShowLink(false)}
|
||||||
>
|
>
|
||||||
{children}
|
{icon}
|
||||||
{!value && `(${label || formatMessage(labels.unknown)})`}
|
{!value && `(${label || formatMessage(labels.unknown)})`}
|
||||||
{value && (
|
{value && (
|
||||||
<Link href={updateParams({ [id]: `eq.${value}` })} className={styles.label} replace>
|
<Text title={label || value} truncate>
|
||||||
{label || value}
|
<Link href={updateParams({ [type]: `eq.${value}` })} replace>
|
||||||
</Link>
|
{label || value}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
{externalUrl && (
|
{externalUrl && showLink && (
|
||||||
<a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
|
<a href={externalUrl} target="_blank" rel="noreferrer noopener">
|
||||||
<Icon className={styles.icon}>
|
<Icon color="muted">
|
||||||
<ExternalLink />
|
<ExternalLink />
|
||||||
</Icon>
|
</Icon>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function LoadingPanel({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Show loading spinner only if no data exists */}
|
{/* Show loading spinner only if no data exists */}
|
||||||
{(isLoading || isFetching) && !data && (
|
{(isLoading || isFetching) && (
|
||||||
<Column position="relative" height="100%" {...props}>
|
<Column position="relative" height="100%" {...props}>
|
||||||
<Loading icon={loadingIcon} position="page" />
|
<Loading icon={loadingIcon} position="page" />
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Heading, Icon, Row, RowProps, Text } from '@umami/react-zen';
|
import { Heading, Icon, Row, RowProps, Text, Column } from '@umami/react-zen';
|
||||||
|
|
||||||
export function PageHeader({
|
export function PageHeader({
|
||||||
title,
|
title,
|
||||||
|
|
@ -26,11 +26,17 @@ export function PageHeader({
|
||||||
width="100%"
|
width="100%"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Row alignItems="center" gap="3">
|
<Column>
|
||||||
{icon && <Icon size="md">{icon}</Icon>}
|
<Row alignItems="center" gap="3">
|
||||||
{title && <Heading size="4">{title}</Heading>}
|
{icon && (
|
||||||
|
<Icon size="md" color="muted">
|
||||||
|
{icon}
|
||||||
|
</Icon>
|
||||||
|
)}
|
||||||
|
{title && <Heading size="4">{title}</Heading>}
|
||||||
|
</Row>
|
||||||
{description && <Text color="muted">{description}</Text>}
|
{description && <Text color="muted">{description}</Text>}
|
||||||
</Row>
|
</Column>
|
||||||
<Row justifyContent="flex-end">{children}</Row>
|
<Row justifyContent="flex-end">{children}</Row>
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
Heading,
|
Heading,
|
||||||
|
|
@ -7,15 +8,16 @@ import {
|
||||||
Row,
|
Row,
|
||||||
Column,
|
Column,
|
||||||
NavMenuGroup,
|
NavMenuGroup,
|
||||||
|
NavMenuProps,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export interface SideMenuProps {
|
export interface SideMenuProps extends NavMenuProps {
|
||||||
items: { label: string; items: { id: string; label: string; icon?: any; path: string }[] }[];
|
items: { label: string; items: { id: string; label: string; icon?: any; path: string }[] }[];
|
||||||
title?: string;
|
title?: string;
|
||||||
selectedKey?: string;
|
selectedKey?: string;
|
||||||
allowMinimize?: boolean;
|
allowMinimize?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SideMenu({
|
export function SideMenu({
|
||||||
|
|
@ -24,6 +26,7 @@ export function SideMenu({
|
||||||
selectedKey,
|
selectedKey,
|
||||||
allowMinimize,
|
allowMinimize,
|
||||||
children,
|
children,
|
||||||
|
...props
|
||||||
}: SideMenuProps) {
|
}: SideMenuProps) {
|
||||||
return (
|
return (
|
||||||
<Column
|
<Column
|
||||||
|
|
@ -42,12 +45,12 @@ export function SideMenu({
|
||||||
<Heading size="1">{title}</Heading>
|
<Heading size="1">{title}</Heading>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
<NavMenu muteItems={false} gap="6">
|
<NavMenu muteItems={false} gap="6" {...props}>
|
||||||
{items?.map(({ label, items }) => {
|
{items?.map(({ label, items }, index) => {
|
||||||
return (
|
return (
|
||||||
<NavMenuGroup
|
<NavMenuGroup
|
||||||
title={label}
|
title={label}
|
||||||
key={label}
|
key={`${label}${index}`}
|
||||||
gap="1"
|
gap="1"
|
||||||
allowMinimize={allowMinimize}
|
allowMinimize={allowMinimize}
|
||||||
marginBottom="3"
|
marginBottom="3"
|
||||||
|
|
|
||||||
6
src/components/hooks/context/useLink.ts
Normal file
6
src/components/hooks/context/useLink.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { LinkContext } from '@/app/(main)/links/LinkProvider';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export function useLink() {
|
||||||
|
return useContext(LinkContext);
|
||||||
|
}
|
||||||
6
src/components/hooks/context/usePixel.ts
Normal file
6
src/components/hooks/context/usePixel.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { PixelContext } from '@/app/(main)/pixels/PixelProvider';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export function usePixel() {
|
||||||
|
return useContext(PixelContext);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
import { TeamContext } from '@/app/(main)/teams/TeamProvider';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
export function useTeam() {
|
export function useTeam() {
|
||||||
6
src/components/hooks/context/useUser.ts
Normal file
6
src/components/hooks/context/useUser.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
|
export function useUser() {
|
||||||
|
return useContext(UserContext);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
import { WebsiteContext } from '@/app/(main)/websites/WebsiteProvider';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
export function useWebsite() {
|
export function useWebsite() {
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
// Context hooks
|
||||||
|
export * from './context/useLink';
|
||||||
|
export * from './context/usePixel';
|
||||||
|
export * from './context/useTeam';
|
||||||
|
export * from './context/useUser';
|
||||||
|
export * from './context/useWebsite';
|
||||||
|
|
||||||
// Query hooks
|
// Query hooks
|
||||||
export * from './queries/useActiveUsersQuery';
|
export * from './queries/useActiveUsersQuery';
|
||||||
export * from './queries/useDeleteQuery';
|
export * from './queries/useDeleteQuery';
|
||||||
|
|
@ -43,7 +50,7 @@ export * from './queries/useWebsiteSegmentsQuery';
|
||||||
export * from './queries/useWebsiteSessionQuery';
|
export * from './queries/useWebsiteSessionQuery';
|
||||||
export * from './queries/useWebsiteSessionStatsQuery';
|
export * from './queries/useWebsiteSessionStatsQuery';
|
||||||
export * from './queries/useWebsiteSessionsQuery';
|
export * from './queries/useWebsiteSessionsQuery';
|
||||||
export * from './queries/useWebsiteSessionsWeeklyQuery';
|
export * from './queries/useWeeklyTrafficQuery';
|
||||||
export * from './queries/useWebsiteStatsQuery';
|
export * from './queries/useWebsiteStatsQuery';
|
||||||
export * from './queries/useWebsiteValuesQuery';
|
export * from './queries/useWebsiteValuesQuery';
|
||||||
export * from './queries/useWebsitesQuery';
|
export * from './queries/useWebsitesQuery';
|
||||||
|
|
@ -70,7 +77,6 @@ export * from './useNavigation';
|
||||||
export * from './usePagedQuery';
|
export * from './usePagedQuery';
|
||||||
export * from './usePageParameters';
|
export * from './usePageParameters';
|
||||||
export * from './useRegionNames';
|
export * from './useRegionNames';
|
||||||
|
export * from './useSlug';
|
||||||
export * from './useSticky';
|
export * from './useSticky';
|
||||||
export * from './useTeam';
|
|
||||||
export * from './useTimezone';
|
export * from './useTimezone';
|
||||||
export * from './useWebsite';
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { useApi, useModified } from '@/components/hooks';
|
import { useApi } from '../useApi';
|
||||||
|
import { useModified } from '../useModified';
|
||||||
|
import { useToast } from '@umami/react-zen';
|
||||||
|
|
||||||
export function useUpdateQuery(path: string, params?: Record<string, any>) {
|
export function useUpdateQuery(path: string, params?: Record<string, any>) {
|
||||||
const { post, useMutation } = useApi();
|
const { post, useMutation } = useApi();
|
||||||
|
|
@ -6,6 +8,7 @@ export function useUpdateQuery(path: string, params?: Record<string, any>) {
|
||||||
mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }),
|
mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }),
|
||||||
});
|
});
|
||||||
const { touch } = useModified();
|
const { touch } = useModified();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
return { mutate, isPending, error, touch };
|
return { mutate, isPending, error, touch, toast };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,7 @@ import { useModified } from '../useModified';
|
||||||
import { useDateParameters } from '../useDateParameters';
|
import { useDateParameters } from '../useDateParameters';
|
||||||
import { useFilterParameters } from '@/components/hooks/useFilterParameters';
|
import { useFilterParameters } from '@/components/hooks/useFilterParameters';
|
||||||
|
|
||||||
export function useWebsiteSessionsWeeklyQuery(
|
export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string, string | number>) {
|
||||||
websiteId: string,
|
|
||||||
params?: Record<string, string | number>,
|
|
||||||
) {
|
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { modified } = useModified(`sessions`);
|
const { modified } = useModified(`sessions`);
|
||||||
const date = useDateParameters(websiteId);
|
const date = useDateParameters(websiteId);
|
||||||
|
|
@ -5,10 +5,8 @@ export function useFields() {
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
|
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
|
||||||
// { name: 'cohort', type: 'string', label: formatMessage(labels.cohort) },
|
|
||||||
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
|
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
|
||||||
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
|
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
|
||||||
//{ name: 'query', type: 'string', label: formatMessage(labels.query) },
|
|
||||||
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
|
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
|
||||||
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
|
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
|
||||||
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
|
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
|
||||||
|
|
@ -17,6 +15,7 @@ export function useFields() {
|
||||||
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
|
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
|
||||||
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
|
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
|
||||||
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) },
|
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) },
|
||||||
|
{ name: 'event', type: 'string', label: formatMessage(labels.event) },
|
||||||
];
|
];
|
||||||
|
|
||||||
return { fields };
|
return { fields };
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ export function useRegionNames(locale: string) {
|
||||||
return regions[regionCode];
|
return regions[regionCode];
|
||||||
}
|
}
|
||||||
|
|
||||||
const region = regionCode.includes('-') ? regionCode : `${countryCode}-${regionCode}`;
|
if (!regionCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = regionCode?.includes('-') ? regionCode : `${countryCode}-${regionCode}`;
|
||||||
|
|
||||||
return regions[region] ? `${regions[region]}, ${countryNames[countryCode]}` : region;
|
return regions[region] ? `${regions[region]}, ${countryNames[countryCode]}` : region;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
14
src/components/hooks/useSlug.ts
Normal file
14
src/components/hooks/useSlug.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { useConfig } from '@/components/hooks/useConfig';
|
||||||
|
import { LINKS_URL, PIXELS_URL } from '@/lib/constants';
|
||||||
|
|
||||||
|
export function useSlug(type: 'link' | 'pixel') {
|
||||||
|
const { linksUrl, pixelsUrl } = useConfig();
|
||||||
|
|
||||||
|
const hostUrl = type === 'link' ? linksUrl || LINKS_URL : pixelsUrl || PIXELS_URL;
|
||||||
|
|
||||||
|
const getSlugUrl = (slug: string) => {
|
||||||
|
return `${hostUrl}/${slug}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getSlugUrl, hostUrl };
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ export {
|
||||||
FileJson,
|
FileJson,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Grid2X2,
|
Grid2X2 as Pixel,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Link,
|
Link,
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,11 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
|
||||||
router,
|
router,
|
||||||
updateParams,
|
updateParams,
|
||||||
replaceParams,
|
replaceParams,
|
||||||
query: { segment },
|
query: { segment, cohort },
|
||||||
} = useNavigation();
|
} = useNavigation();
|
||||||
const { filters, operatorLabels } = useFilters();
|
const { filters, operatorLabels } = useFilters();
|
||||||
const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment);
|
const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment);
|
||||||
|
const canSave = filters.length > 0 && !segment && !cohort;
|
||||||
|
|
||||||
const handleCloseFilter = (param: string) => {
|
const handleCloseFilter = (param: string) => {
|
||||||
router.push(updateParams({ [param]: undefined }));
|
router.push(updateParams({ [param]: undefined }));
|
||||||
|
|
@ -78,16 +79,18 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center">
|
<Row alignItems="center">
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<TooltipTrigger delay={0}>
|
{canSave && (
|
||||||
<Button variant="zero">
|
<TooltipTrigger delay={0}>
|
||||||
<Icon>
|
<Button variant="zero">
|
||||||
<Bookmark />
|
<Icon>
|
||||||
</Icon>
|
<Bookmark />
|
||||||
</Button>
|
</Icon>
|
||||||
<Tooltip>
|
</Button>
|
||||||
<Text>{formatMessage(labels.saveSegment)}</Text>
|
<Tooltip>
|
||||||
</Tooltip>
|
<Text>{formatMessage(labels.saveSegment)}</Text>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
)}
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog title={formatMessage(labels.segment)} style={{ width: 400 }}>
|
<Dialog title={formatMessage(labels.segment)} style={{ width: 400 }}>
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,30 @@
|
||||||
import {
|
import { Button, Icon, Row, Text, Select, ListItem } from '@umami/react-zen';
|
||||||
Button,
|
|
||||||
Icon,
|
|
||||||
Row,
|
|
||||||
Text,
|
|
||||||
Select,
|
|
||||||
ListItem,
|
|
||||||
TooltipTrigger,
|
|
||||||
Tooltip,
|
|
||||||
} from '@umami/react-zen';
|
|
||||||
import { isAfter } from 'date-fns';
|
import { isAfter } from 'date-fns';
|
||||||
import { Chevron, Close, Compare } from '@/components/icons';
|
import { Chevron } from '@/components/icons';
|
||||||
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { DateFilter } from './DateFilter';
|
import { DateFilter } from './DateFilter';
|
||||||
|
|
||||||
|
export interface WebsiteDateFilterProps {
|
||||||
|
websiteId: string;
|
||||||
|
compare?: string;
|
||||||
|
showAllTime?: boolean;
|
||||||
|
showButtons?: boolean;
|
||||||
|
allowCompare?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function WebsiteDateFilter({
|
export function WebsiteDateFilter({
|
||||||
websiteId,
|
websiteId,
|
||||||
showAllTime = true,
|
showAllTime = true,
|
||||||
showButtons = true,
|
showButtons = true,
|
||||||
allowCompare,
|
allowCompare,
|
||||||
}: {
|
}: WebsiteDateFilterProps) {
|
||||||
websiteId: string;
|
|
||||||
compare?: string;
|
|
||||||
showAllTime?: boolean;
|
|
||||||
showButtons?: boolean;
|
|
||||||
allowCompare?: boolean;
|
|
||||||
}) {
|
|
||||||
const { dateRange } = useDateRange(websiteId);
|
const { dateRange } = useDateRange(websiteId);
|
||||||
const { value, startDate, endDate } = dateRange;
|
const { value, startDate, endDate } = dateRange;
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const {
|
const {
|
||||||
router,
|
router,
|
||||||
updateParams,
|
updateParams,
|
||||||
query: { compare, offset = 0 },
|
query: { compare = 'prev', offset = 0 },
|
||||||
} = useNavigation();
|
} = useNavigation();
|
||||||
const isAllTime = value === 'all';
|
const isAllTime = value === 'all';
|
||||||
const isCustomRange = value.startsWith('range');
|
const isCustomRange = value.startsWith('range');
|
||||||
|
|
@ -50,10 +43,6 @@ export function WebsiteDateFilter({
|
||||||
router.push(updateParams({ compare }));
|
router.push(updateParams({ compare }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompare = () => {
|
|
||||||
router.push(updateParams({ compare: compare ? undefined : 'prev' }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gap>
|
<Row gap>
|
||||||
{showButtons && !isAllTime && !isCustomRange && (
|
{showButtons && !isAllTime && !isCustomRange && (
|
||||||
|
|
@ -78,7 +67,7 @@ export function WebsiteDateFilter({
|
||||||
showAllTime={showAllTime}
|
showAllTime={showAllTime}
|
||||||
renderDate={+offset !== 0}
|
renderDate={+offset !== 0}
|
||||||
/>
|
/>
|
||||||
{!isAllTime && compare && (
|
{allowCompare && !isAllTime && (
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap>
|
||||||
<Text weight="bold">VS</Text>
|
<Text weight="bold">VS</Text>
|
||||||
<Row width="200px">
|
<Row width="200px">
|
||||||
|
|
@ -93,14 +82,6 @@ export function WebsiteDateFilter({
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
{!isAllTime && allowCompare && (
|
|
||||||
<TooltipTrigger delay={0}>
|
|
||||||
<Button variant="quiet" onPress={handleCompare}>
|
|
||||||
<Icon fillColor>{compare ? <Close /> : <Compare />}</Icon>
|
|
||||||
</Button>
|
|
||||||
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
|
|
||||||
</TooltipTrigger>
|
|
||||||
)}
|
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,10 @@ export const labels = defineMessages({
|
||||||
refresh: { id: 'label.refresh', defaultMessage: 'Refresh' },
|
refresh: { id: 'label.refresh', defaultMessage: 'Refresh' },
|
||||||
page: { id: 'label.page', defaultMessage: 'Page' },
|
page: { id: 'label.page', defaultMessage: 'Page' },
|
||||||
pages: { id: 'label.pages', defaultMessage: 'Pages' },
|
pages: { id: 'label.pages', defaultMessage: 'Pages' },
|
||||||
entry: { id: 'label.entry', defaultMessage: 'Entry path' },
|
entry: { id: 'label.entry', defaultMessage: 'Entry' },
|
||||||
exit: { id: 'label.exit', defaultMessage: 'Exit path' },
|
exit: { id: 'label.exit', defaultMessage: 'Exit' },
|
||||||
referrers: { id: 'label.referrers', defaultMessage: 'Referrers' },
|
referrers: { id: 'label.referrers', defaultMessage: 'Referrers' },
|
||||||
|
screen: { id: 'label.screen', defaultMessage: 'Screen' },
|
||||||
screens: { id: 'label.screens', defaultMessage: 'Screens' },
|
screens: { id: 'label.screens', defaultMessage: 'Screens' },
|
||||||
browsers: { id: 'label.browsers', defaultMessage: 'Browsers' },
|
browsers: { id: 'label.browsers', defaultMessage: 'Browsers' },
|
||||||
os: { id: 'label.os', defaultMessage: 'OS' },
|
os: { id: 'label.os', defaultMessage: 'OS' },
|
||||||
|
|
@ -301,6 +302,7 @@ export const labels = defineMessages({
|
||||||
lastSeen: { id: 'label.last-seen', defaultMessage: 'Last seen' },
|
lastSeen: { id: 'label.last-seen', defaultMessage: 'Last seen' },
|
||||||
firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' },
|
firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' },
|
||||||
properties: { id: 'label.properties', defaultMessage: 'Properties' },
|
properties: { id: 'label.properties', defaultMessage: 'Properties' },
|
||||||
|
channel: { id: 'label.channel', defaultMessage: 'Channel' },
|
||||||
channels: { id: 'label.channels', defaultMessage: 'Channels' },
|
channels: { id: 'label.channels', defaultMessage: 'Channels' },
|
||||||
sources: { id: 'label.sources', defaultMessage: 'Sources' },
|
sources: { id: 'label.sources', defaultMessage: 'Sources' },
|
||||||
medium: { id: 'label.medium', defaultMessage: 'Medium' },
|
medium: { id: 'label.medium', defaultMessage: 'Medium' },
|
||||||
|
|
@ -354,11 +356,12 @@ export const labels = defineMessages({
|
||||||
destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' },
|
destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' },
|
||||||
audience: { id: 'label.audience', defaultMessage: 'Audience' },
|
audience: { id: 'label.audience', defaultMessage: 'Audience' },
|
||||||
invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
|
invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
|
||||||
|
environment: { id: 'label.environment', defaultMessage: 'Environment' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
error: { id: 'message.error', defaultMessage: 'Something went wrong.' },
|
error: { id: 'message.error', defaultMessage: 'Something went wrong.' },
|
||||||
saved: { id: 'message.saved', defaultMessage: 'Saved.' },
|
saved: { id: 'message.saved', defaultMessage: 'Saved successfully.' },
|
||||||
noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
|
noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
|
||||||
userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
|
userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
|
||||||
noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' },
|
noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' },
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { FilterLink } from '@/components/common/FilterLink';
|
|
||||||
import { MetricsTable, MetricsTableProps } from '@/components/metrics/MetricsTable';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { useFormat } from '@/components/hooks';
|
|
||||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
|
||||||
|
|
||||||
export function BrowsersTable(props: MetricsTableProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { formatBrowser } = useFormat();
|
|
||||||
|
|
||||||
function renderLink({ x: browser }) {
|
|
||||||
return (
|
|
||||||
<FilterLink id="browser" value={browser} label={formatBrowser(browser)}>
|
|
||||||
<TypeIcon type="browser" value={browser} />
|
|
||||||
</FilterLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.browsers)}
|
|
||||||
type="browser"
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { MetricsTable, MetricsTableProps } from '@/components/metrics/MetricsTable';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
|
|
||||||
export function ChannelsTable(props: MetricsTableProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const renderLabel = ({ x }) => {
|
|
||||||
return formatMessage(labels[x]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.channels)}
|
|
||||||
type="channel"
|
|
||||||
renderLabel={renderLabel}
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
|
||||||
import { emptyFilter } from '@/lib/filters';
|
|
||||||
import { FilterLink } from '@/components/common/FilterLink';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { useFormat } from '@/components/hooks';
|
|
||||||
|
|
||||||
export function CitiesTable(props: MetricsTableProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { formatCity } = useFormat();
|
|
||||||
|
|
||||||
const renderLink = ({ x: city, country }) => {
|
|
||||||
return (
|
|
||||||
<FilterLink id="city" value={city} label={formatCity(city, country)}>
|
|
||||||
{country && (
|
|
||||||
<img
|
|
||||||
src={`${process.env.basePath || ''}/images/country/${
|
|
||||||
country?.toLowerCase() || 'xx'
|
|
||||||
}.png`}
|
|
||||||
alt={country}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FilterLink>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.cities)}
|
|
||||||
type="city"
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
dataFilter={emptyFilter}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
searchFormattedValues={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -3,17 +3,22 @@ import { useCountryNames } from '@/components/hooks';
|
||||||
import { useLocale, useMessages, useFormat } from '@/components/hooks';
|
import { useLocale, useMessages, useFormat } from '@/components/hooks';
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||||
|
import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable';
|
||||||
|
|
||||||
export function CountriesTable({ ...props }: MetricsTableProps) {
|
export interface CountriesTableProps extends MetricsTableProps {
|
||||||
|
isExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountriesTable({ isExpanded, ...props }: CountriesTableProps) {
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const { countryNames } = useCountryNames(locale);
|
const { countryNames } = useCountryNames(locale);
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { formatCountry } = useFormat();
|
const { formatCountry } = useFormat();
|
||||||
|
|
||||||
const renderLink = ({ x: code }) => {
|
const renderLabel = ({ label: code }) => {
|
||||||
return (
|
return (
|
||||||
<FilterLink
|
<FilterLink
|
||||||
id="country"
|
type="country"
|
||||||
value={(countryNames[code] && code) || code}
|
value={(countryNames[code] && code) || code}
|
||||||
label={formatCountry(code)}
|
label={formatCountry(code)}
|
||||||
>
|
>
|
||||||
|
|
@ -22,13 +27,15 @@ export function CountriesTable({ ...props }: MetricsTableProps) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Component = isExpanded ? MetricsExpandedTable : MetricsTable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MetricsTable
|
<Component
|
||||||
{...props}
|
{...props}
|
||||||
title={formatMessage(labels.countries)}
|
title={formatMessage(labels.countries)}
|
||||||
type="country"
|
type="country"
|
||||||
metric={formatMessage(labels.visitors)}
|
metric={formatMessage(labels.visitors)}
|
||||||
renderLabel={renderLink}
|
renderLabel={renderLabel}
|
||||||
searchFormattedValues={true}
|
searchFormattedValues={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
|
||||||
import { FilterLink } from '@/components/common/FilterLink';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { useFormat } from '@/components/hooks';
|
|
||||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
|
||||||
|
|
||||||
export function DevicesTable(props: MetricsTableProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { formatDevice } = useFormat();
|
|
||||||
|
|
||||||
function renderLink({ x: device }) {
|
|
||||||
return (
|
|
||||||
<FilterLink id="device" value={labels[device] && device} label={formatDevice(device)}>
|
|
||||||
<TypeIcon type="device" value={device?.toLowerCase()} />
|
|
||||||
</FilterLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.devices)}
|
|
||||||
type="device"
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
searchFormattedValues={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
|
|
||||||
export interface EventsTableProps extends MetricsTableProps {
|
|
||||||
onLabelClick?: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const renderLabel = ({ x: label }) => {
|
|
||||||
if (onLabelClick) {
|
|
||||||
return (
|
|
||||||
<div onClick={() => onLabelClick(label)} style={{ cursor: 'pointer' }}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return label;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.events)}
|
|
||||||
type="event"
|
|
||||||
metric={formatMessage(labels.actions)}
|
|
||||||
renderLabel={renderLabel}
|
|
||||||
allowDownload={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
|
||||||
import { FilterLink } from '@/components/common/FilterLink';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { Flexbox } from '@umami/react-zen';
|
|
||||||
|
|
||||||
export function HostnamesTable(props: MetricsTableProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const renderLink = ({ x: hostname }) => {
|
|
||||||
return (
|
|
||||||
<Flexbox alignItems="center">
|
|
||||||
<FilterLink
|
|
||||||
id="hostname"
|
|
||||||
value={hostname}
|
|
||||||
externalUrl={`https://${hostname}`}
|
|
||||||
label={!hostname && formatMessage(labels.none)}
|
|
||||||
/>
|
|
||||||
</Flexbox>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.hostname)}
|
|
||||||
type="hostname"
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
|
||||||
import { useLocale } from '@/components/hooks';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { useFormat } from '@/components/hooks';
|
|
||||||
|
|
||||||
export function LanguagesTable(props: MetricsTableProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { locale } = useLocale();
|
|
||||||
const { formatLanguage } = useFormat();
|
|
||||||
|
|
||||||
const renderLabel = ({ x }) => {
|
|
||||||
return <div className={locale}>{formatLanguage(x)}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.languages)}
|
|
||||||
type="language"
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
renderLabel={renderLabel}
|
|
||||||
searchFormattedValues={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
.truncate {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
width: 300px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useMessages } from '@/components/hooks';
|
||||||
import { formatShortTime } from '@/lib/format';
|
import { formatShortTime } from '@/lib/format';
|
||||||
import { DataColumn, DataTable } from '@umami/react-zen';
|
import { DataColumn, DataTable } from '@umami/react-zen';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import styles from './ListExpandedTable.module.css';
|
|
||||||
|
|
||||||
export interface ListExpandedTableProps {
|
export interface ListExpandedTableProps {
|
||||||
data?: any[];
|
data?: any[];
|
||||||
|
|
@ -15,7 +14,7 @@ export function ListExpandedTable({ data = [], title, renderLabel }: ListExpande
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable data={data}>
|
<DataTable data={data}>
|
||||||
<DataColumn id="label" label={title} align="start" className={styles.truncate}>
|
<DataColumn id="label" label={title} align="start">
|
||||||
{row =>
|
{row =>
|
||||||
renderLabel
|
renderLabel
|
||||||
? renderLabel({ x: row?.['name'], country: row?.['country'] }, Number(row.id))
|
? renderLabel({ x: row?.['name'], country: row?.['country'] }, Number(row.id))
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,19 @@ import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
||||||
|
|
||||||
const ITEM_SIZE = 30;
|
const ITEM_SIZE = 30;
|
||||||
|
|
||||||
|
interface ListData {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListTableProps {
|
export interface ListTableProps {
|
||||||
data?: any[];
|
data?: ListData[];
|
||||||
title?: string;
|
title?: string;
|
||||||
metric?: string;
|
metric?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
renderLabel?: (row: any, index: number) => ReactNode;
|
renderLabel?: (data: ListData, index: number) => ReactNode;
|
||||||
renderChange?: (row: any, index: number) => ReactNode;
|
renderChange?: (data: ListData, index: number) => ReactNode;
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
virtualize?: boolean;
|
virtualize?: boolean;
|
||||||
showPercentage?: boolean;
|
showPercentage?: boolean;
|
||||||
|
|
@ -37,14 +43,14 @@ export function ListTable({
|
||||||
}: ListTableProps) {
|
}: ListTableProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
const getRow = (row: { x: any; y: any; z: any }, index: number) => {
|
const getRow = (row: ListData, index: number) => {
|
||||||
const { x: label, y: value, z: percent } = row || {};
|
const { label, count, percent } = row;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedRow
|
<AnimatedRow
|
||||||
key={label}
|
key={`${label}${index}`}
|
||||||
label={renderLabel ? renderLabel(row, index) : (label ?? formatMessage(labels.unknown))}
|
label={renderLabel ? renderLabel(row, index) : (label ?? formatMessage(labels.unknown))}
|
||||||
value={value}
|
value={count}
|
||||||
percent={percent}
|
percent={percent}
|
||||||
animate={animate && !virtualize}
|
animate={animate && !virtualize}
|
||||||
showPercentage={showPercentage}
|
showPercentage={showPercentage}
|
||||||
|
|
|
||||||
155
src/components/metrics/MetricLabel.tsx
Normal file
155
src/components/metrics/MetricLabel.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { Row } from '@umami/react-zen';
|
||||||
|
import {
|
||||||
|
useCountryNames,
|
||||||
|
useLocale,
|
||||||
|
useMessages,
|
||||||
|
useRegionNames,
|
||||||
|
useFormat,
|
||||||
|
} from '@/components/hooks';
|
||||||
|
import { FilterLink } from '@/components/common/FilterLink';
|
||||||
|
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||||
|
import { Favicon } from '@/components/common/Favicon';
|
||||||
|
import { GROUPED_DOMAINS } from '@/lib/constants';
|
||||||
|
|
||||||
|
export interface MetricLabelProps {
|
||||||
|
type: string;
|
||||||
|
data: any;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricLabel({ type, data }: MetricLabelProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { formatValue, formatCity } = useFormat();
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const { countryNames } = useCountryNames(locale);
|
||||||
|
const { getRegionName } = useRegionNames(locale);
|
||||||
|
|
||||||
|
const { label, country, domain } = data;
|
||||||
|
const isType = ['browser', 'country', 'device', 'os'].includes(type);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'browser':
|
||||||
|
return (
|
||||||
|
<FilterLink
|
||||||
|
type="browser"
|
||||||
|
value={label}
|
||||||
|
label={formatValue(label, 'browser')}
|
||||||
|
icon={<TypeIcon type="browser" value={label} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'channel':
|
||||||
|
return formatMessage(labels[label]);
|
||||||
|
|
||||||
|
case 'city':
|
||||||
|
return (
|
||||||
|
<FilterLink
|
||||||
|
type="city"
|
||||||
|
value={label}
|
||||||
|
label={formatCity(label, country)}
|
||||||
|
icon={
|
||||||
|
country && (
|
||||||
|
<img
|
||||||
|
src={`${process.env.basePath || ''}/images/country/${
|
||||||
|
country?.toLowerCase() || 'xx'
|
||||||
|
}.png`}
|
||||||
|
alt={country}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'region':
|
||||||
|
return (
|
||||||
|
<FilterLink
|
||||||
|
type="region"
|
||||||
|
value={label}
|
||||||
|
label={getRegionName(label, country)}
|
||||||
|
icon={<TypeIcon type="country" value={country} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'country':
|
||||||
|
return (
|
||||||
|
<FilterLink
|
||||||
|
type="country"
|
||||||
|
value={(countryNames[label] && label) || label}
|
||||||
|
label={formatValue(label, 'country')}
|
||||||
|
icon={<TypeIcon type="country" value={label} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'path':
|
||||||
|
case 'entry':
|
||||||
|
case 'exit':
|
||||||
|
return (
|
||||||
|
<FilterLink
|
||||||
|
type={type === 'entry' || type === 'exit' ? 'path' : type}
|
||||||
|
value={label}
|
||||||
|
label={!label && formatMessage(labels.none)}
|
||||||
|
externalUrl={
|
||||||
|
domain ? `${domain?.startsWith('http') ? domain : `https://${domain}`}${label}` : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'device':
|
||||||
|
return (
|
||||||
|
<FilterLink
|
||||||
|
type="device"
|
||||||
|
value={labels[label] && label}
|
||||||
|
label={formatValue(label, 'device')}
|
||||||
|
icon={<TypeIcon type="device" value={label?.toLowerCase()} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'referrer':
|
||||||
|
return (
|
||||||
|
<FilterLink
|
||||||
|
type="referrer"
|
||||||
|
value={label}
|
||||||
|
externalUrl={`https://${label}`}
|
||||||
|
label={!label && formatMessage(labels.none)}
|
||||||
|
icon={<Favicon domain={label} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'domain':
|
||||||
|
if (label === 'Other') {
|
||||||
|
return `(${formatMessage(labels.other)})`;
|
||||||
|
} else {
|
||||||
|
const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row alignItems="center" gap="3">
|
||||||
|
<Favicon domain={label} />
|
||||||
|
{name}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'language':
|
||||||
|
return formatValue(label, 'language');
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<FilterLink
|
||||||
|
type={type}
|
||||||
|
value={label}
|
||||||
|
icon={
|
||||||
|
isType && (
|
||||||
|
<TypeIcon
|
||||||
|
type={type as 'browser' | 'country' | 'device' | 'os'}
|
||||||
|
value={label?.toLowerCase()?.replaceAll(/\W/g, '-')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/components/metrics/MetricsExpandedTable.tsx
Normal file
110
src/components/metrics/MetricsExpandedTable.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
import { Button, Column, DataColumn, DataTable, Icon, Row, SearchField } from '@umami/react-zen';
|
||||||
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
import { useMessages, useWebsiteExpandedMetricsQuery } from '@/components/hooks';
|
||||||
|
import { Close } from '@/components/icons';
|
||||||
|
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||||
|
import { formatShortTime } from '@/lib/format';
|
||||||
|
import { MetricLabel } from '@/components/metrics/MetricLabel';
|
||||||
|
|
||||||
|
export interface MetricsExpandedTableProps {
|
||||||
|
websiteId: string;
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
dataFilter?: (data: any) => any;
|
||||||
|
onSearch?: (search: string) => void;
|
||||||
|
params?: { [key: string]: any };
|
||||||
|
allowSearch?: boolean;
|
||||||
|
allowDownload?: boolean;
|
||||||
|
renderLabel?: (row: any, index: number) => ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricsExpandedTable({
|
||||||
|
websiteId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
params,
|
||||||
|
allowSearch = true,
|
||||||
|
allowDownload = true,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: MetricsExpandedTableProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const isType = ['browser', 'country', 'device', 'os'].includes(type);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, {
|
||||||
|
type,
|
||||||
|
search: isType ? undefined : search,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.map(({ name, ...props }) => ({ label: name, ...props }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row alignItems="center" paddingBottom="3">
|
||||||
|
{allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
|
||||||
|
<Row justifyContent="flex-end" flexGrow={1} gap>
|
||||||
|
{children}
|
||||||
|
{allowDownload && <DownloadButton filename={type} data={data} />}
|
||||||
|
{onClose && (
|
||||||
|
<Button onPress={onClose} variant="quiet">
|
||||||
|
<Icon>
|
||||||
|
<Close />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
<LoadingPanel
|
||||||
|
data={data}
|
||||||
|
isFetching={isFetching}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden">
|
||||||
|
{items && (
|
||||||
|
<DataTable data={items}>
|
||||||
|
<DataColumn id="label" label={title} width="2fr" align="start">
|
||||||
|
{row => (
|
||||||
|
<Row overflow="hidden">
|
||||||
|
<MetricLabel type={type} data={row} />
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
|
||||||
|
{row => row?.['visitors']?.toLocaleString()}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
|
||||||
|
{row => row?.['visits']?.toLocaleString()}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="pageviews" label={formatMessage(labels.views)} align="end">
|
||||||
|
{row => row?.['pageviews']?.toLocaleString()}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
|
||||||
|
{row => {
|
||||||
|
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
|
||||||
|
return Math.round(+n) + '%';
|
||||||
|
}}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn
|
||||||
|
id="visitDuration"
|
||||||
|
label={formatMessage(labels.visitDuration)}
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
{row => {
|
||||||
|
const n = (row?.['totaltime'] / row?.['visits']) * 100;
|
||||||
|
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
|
||||||
|
}}
|
||||||
|
</DataColumn>
|
||||||
|
</DataTable>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
</LoadingPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,37 +1,21 @@
|
||||||
import { ReactNode, useMemo, useState } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { Button, Column, Icon, Row, SearchField, Text, Grid } from '@umami/react-zen';
|
import { Icon, Row, Text } from '@umami/react-zen';
|
||||||
import { LinkButton } from '@/components/common/LinkButton';
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import {
|
import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks';
|
||||||
useFormat,
|
import { Maximize } from '@/components/icons';
|
||||||
useMessages,
|
|
||||||
useNavigation,
|
|
||||||
useWebsiteExpandedMetricsQuery,
|
|
||||||
useWebsiteMetricsQuery,
|
|
||||||
} from '@/components/hooks';
|
|
||||||
import { Close, Maximize } from '@/components/icons';
|
|
||||||
import { DownloadButton } from '@/components/input/DownloadButton';
|
|
||||||
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
|
|
||||||
import { percentFilter } from '@/lib/filters';
|
import { percentFilter } from '@/lib/filters';
|
||||||
|
|
||||||
import { ListExpandedTable, ListExpandedTableProps } from './ListExpandedTable';
|
|
||||||
import { ListTable, ListTableProps } from './ListTable';
|
import { ListTable, ListTableProps } from './ListTable';
|
||||||
|
import { MetricLabel } from '@/components/metrics/MetricLabel';
|
||||||
|
|
||||||
export interface MetricsTableProps extends ListTableProps {
|
export interface MetricsTableProps extends ListTableProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
type?: string;
|
type: string;
|
||||||
dataFilter?: (data: any) => any;
|
dataFilter?: (data: any) => any;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
delay?: number;
|
|
||||||
onSearch?: (search: string) => void;
|
|
||||||
allowSearch?: boolean;
|
|
||||||
searchFormattedValues?: boolean;
|
|
||||||
showMore?: boolean;
|
showMore?: boolean;
|
||||||
params?: { [key: string]: any };
|
params?: Record<string, any>;
|
||||||
allowDownload?: boolean;
|
onDataLoad?: (data: any) => void;
|
||||||
isExpanded?: boolean;
|
|
||||||
onClose?: () => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MetricsTable({
|
export function MetricsTable({
|
||||||
|
|
@ -39,50 +23,18 @@ export function MetricsTable({
|
||||||
type,
|
type,
|
||||||
dataFilter,
|
dataFilter,
|
||||||
limit,
|
limit,
|
||||||
delay = null,
|
showMore = false,
|
||||||
allowSearch = false,
|
|
||||||
searchFormattedValues = false,
|
|
||||||
showMore = true,
|
|
||||||
params,
|
params,
|
||||||
allowDownload = true,
|
onDataLoad,
|
||||||
isExpanded = false,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
...props
|
...props
|
||||||
}: MetricsTableProps) {
|
}: MetricsTableProps) {
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const { formatValue } = useFormat();
|
|
||||||
const { updateParams } = useNavigation();
|
const { updateParams } = useNavigation();
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(websiteId, {
|
||||||
const expandedQuery = useWebsiteExpandedMetricsQuery(
|
type,
|
||||||
websiteId,
|
limit,
|
||||||
{
|
...params,
|
||||||
type,
|
});
|
||||||
search: searchFormattedValues ? undefined : search,
|
|
||||||
...params,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
|
|
||||||
enabled: isExpanded,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = useWebsiteMetricsQuery(
|
|
||||||
websiteId,
|
|
||||||
{
|
|
||||||
type,
|
|
||||||
limit,
|
|
||||||
search: searchFormattedValues ? undefined : search,
|
|
||||||
...params,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
|
|
||||||
enabled: !isExpanded,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error } = isExpanded ? expandedQuery : query;
|
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|
@ -98,59 +50,42 @@ export function MetricsTable({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchFormattedValues && search) {
|
|
||||||
items = items.filter(({ x, ...data }) => {
|
|
||||||
const value = formatValue(x, type, data);
|
|
||||||
|
|
||||||
return value?.toLowerCase().includes(search.toLowerCase());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
items = percentFilter(items);
|
items = percentFilter(items);
|
||||||
|
|
||||||
return items;
|
return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props }));
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}, [data, dataFilter, search, limit, formatValue, type]);
|
}, [data, dataFilter, limit, type]);
|
||||||
|
|
||||||
const downloadData = isExpanded ? data : filteredData;
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
onDataLoad?.(data);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const renderLabel = (row: any) => {
|
||||||
|
return <MetricLabel type={type} data={row} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
|
<LoadingPanel
|
||||||
<Grid rows="40px 1fr" height="100%" overflow="hidden" gap>
|
data={data}
|
||||||
<Row alignItems="center">
|
isFetching={isFetching}
|
||||||
{allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
|
isLoading={isLoading}
|
||||||
<Row justifyContent="flex-end" flexGrow={1} gap>
|
error={error}
|
||||||
{children}
|
height="100%"
|
||||||
{allowDownload && <DownloadButton filename={type} data={downloadData} />}
|
>
|
||||||
{onClose && (
|
{data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
|
||||||
<Button onPress={onClose} variant="quiet">
|
{showMore && limit && (
|
||||||
<Icon>
|
<Row justifyContent="center">
|
||||||
<Close />
|
<LinkButton href={updateParams({ view: type })} variant="quiet">
|
||||||
</Icon>
|
<Icon size="sm">
|
||||||
</Button>
|
<Maximize />
|
||||||
)}
|
</Icon>
|
||||||
</Row>
|
<Text>{formatMessage(labels.more)}</Text>
|
||||||
|
</LinkButton>
|
||||||
</Row>
|
</Row>
|
||||||
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden">
|
)}
|
||||||
{data &&
|
|
||||||
(isExpanded ? (
|
|
||||||
<ListExpandedTable {...(props as ListExpandedTableProps)} data={data} />
|
|
||||||
) : (
|
|
||||||
<ListTable {...(props as ListTableProps)} data={filteredData} />
|
|
||||||
))}
|
|
||||||
{showMore && limit && (
|
|
||||||
<Row justifyContent="center">
|
|
||||||
<LinkButton href={updateParams({ view: type })} variant="quiet">
|
|
||||||
<Icon size="sm">
|
|
||||||
<Maximize />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.more)}</Text>
|
|
||||||
</LinkButton>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
</Column>
|
|
||||||
</Grid>
|
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
|
||||||
import { FilterLink } from '@/components/common/FilterLink';
|
|
||||||
import { useMessages, useFormat } from '@/components/hooks';
|
|
||||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
|
||||||
|
|
||||||
export function OSTable(props: MetricsTableProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { formatOS } = useFormat();
|
|
||||||
|
|
||||||
function renderLink({ x: os }) {
|
|
||||||
return (
|
|
||||||
<FilterLink id="os" value={os} label={formatOS(os)}>
|
|
||||||
<TypeIcon type="os" value={os?.toLowerCase()?.replaceAll(/\W/g, '-')} />
|
|
||||||
</FilterLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
type="os"
|
|
||||||
title={formatMessage(labels.os)}
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
|
||||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
|
||||||
import { FilterLink } from '@/components/common/FilterLink';
|
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
|
||||||
import { emptyFilter } from '@/lib/filters';
|
|
||||||
import { useContext } from 'react';
|
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
|
||||||
|
|
||||||
export interface PagesTableProps extends MetricsTableProps {
|
|
||||||
allowFilter?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PagesTable({ allowFilter, ...props }: PagesTableProps) {
|
|
||||||
const {
|
|
||||||
router,
|
|
||||||
updateParams,
|
|
||||||
query: { view = 'path' },
|
|
||||||
} = useNavigation();
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { domain } = useContext(WebsiteContext);
|
|
||||||
|
|
||||||
const handleChange = (id: any) => {
|
|
||||||
router.push(updateParams({ view: id }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttons = [
|
|
||||||
{
|
|
||||||
id: 'path',
|
|
||||||
label: formatMessage(labels.path),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'entry',
|
|
||||||
label: formatMessage(labels.entry),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'exit',
|
|
||||||
label: formatMessage(labels.exit),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'title',
|
|
||||||
label: formatMessage(labels.title),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderLink = ({ x }) => {
|
|
||||||
return (
|
|
||||||
<FilterLink
|
|
||||||
id={view === 'entry' || view === 'exit' ? 'path' : view}
|
|
||||||
value={x}
|
|
||||||
label={!x && formatMessage(labels.none)}
|
|
||||||
externalUrl={
|
|
||||||
view !== 'title'
|
|
||||||
? `${domain.startsWith('http') ? domain : `https://${domain}`}${x}`
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.pages)}
|
|
||||||
type={view}
|
|
||||||
metric={formatMessage(labels.visitors)}
|
|
||||||
dataFilter={emptyFilter}
|
|
||||||
renderLabel={renderLink}
|
|
||||||
>
|
|
||||||
{allowFilter && <FilterButtons items={buttons} value={view} onChange={handleChange} />}
|
|
||||||
</MetricsTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
.item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
line-height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.param {
|
|
||||||
padding: 0 8px;
|
|
||||||
color: var(--primary-color);
|
|
||||||
background: var(--blue100);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { useState } from 'react';
|
|
||||||
import { Row, Text } from '@umami/react-zen';
|
|
||||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
|
||||||
import { emptyFilter, paramFilter } from '@/lib/filters';
|
|
||||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
|
|
||||||
const FILTER_COMBINED = 'filter-combined';
|
|
||||||
const FILTER_RAW = 'filter-raw';
|
|
||||||
|
|
||||||
const filters = {
|
|
||||||
[FILTER_RAW]: emptyFilter,
|
|
||||||
[FILTER_COMBINED]: [emptyFilter, paramFilter],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function QueryParametersTable({
|
|
||||||
allowFilter,
|
|
||||||
...props
|
|
||||||
}: { allowFilter: boolean } & MetricsTableProps) {
|
|
||||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
const buttons = [
|
|
||||||
{
|
|
||||||
id: FILTER_COMBINED,
|
|
||||||
label: formatMessage(labels.filterCombined),
|
|
||||||
},
|
|
||||||
{ id: FILTER_RAW, label: formatMessage(labels.filterRaw) },
|
|
||||||
];
|
|
||||||
|
|
||||||
const renderLabel = ({ x, p, v }) => {
|
|
||||||
return (
|
|
||||||
<Row alignItems="center" maxWidth="600px" gap>
|
|
||||||
{filter === FILTER_RAW ? (
|
|
||||||
<Text truncate title={x}>
|
|
||||||
{x}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Text color="primary" weight="bold">
|
|
||||||
{p}
|
|
||||||
</Text>
|
|
||||||
<Text truncate title={v}>
|
|
||||||
{v}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MetricsTable
|
|
||||||
{...props}
|
|
||||||
title={formatMessage(labels.query)}
|
|
||||||
type="query"
|
|
||||||
metric={formatMessage(labels.views)}
|
|
||||||
dataFilter={filters[filter]}
|
|
||||||
renderLabel={renderLabel}
|
|
||||||
delay={0}
|
|
||||||
isExpanded={false}
|
|
||||||
>
|
|
||||||
{allowFilter && <FilterButtons items={buttons} value={filter} onChange={setFilter} />}
|
|
||||||
</MetricsTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue