This commit is contained in:
Francis Cao 2025-08-25 07:40:32 -07:00
commit 6c832bd0db
113 changed files with 1671 additions and 1335 deletions

View file

@ -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
View file

@ -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.

View file

@ -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
View file

@ -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

View file

@ -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>
)} )}

View file

@ -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 (

View file

@ -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();

View file

@ -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 />} />;
} }

View file

@ -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 }) {

View file

@ -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?.();

View 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>;
}

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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',
};

View file

@ -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?.();

View 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>;
}

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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',
};

View file

@ -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 }) {

View file

@ -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';

View file

@ -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();

View file

@ -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();

View file

@ -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>
); );

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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() {

View file

@ -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 (

View file

@ -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}>

View file

@ -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 }) => {

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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() {

View file

@ -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>

View 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>
);
}

View 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>
</>
);
}

View 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',
};

View file

@ -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]);

View file

@ -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>

View file

@ -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 = {

View file

@ -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();

View file

@ -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);

View file

@ -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?.();

View file

@ -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>

View file

@ -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]);

View file

@ -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();

View file

@ -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 (

View file

@ -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 />} />;
} }

View file

@ -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>

View file

@ -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);
} }

View file

@ -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>

View file

@ -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>
); );
} }

View file

@ -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>

View file

@ -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>
); );

View file

@ -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"

View file

@ -0,0 +1,6 @@
import { LinkContext } from '@/app/(main)/links/LinkProvider';
import { useContext } from 'react';
export function useLink() {
return useContext(LinkContext);
}

View file

@ -0,0 +1,6 @@
import { PixelContext } from '@/app/(main)/pixels/PixelProvider';
import { useContext } from 'react';
export function usePixel() {
return useContext(PixelContext);
}

View file

@ -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() {

View file

@ -0,0 +1,6 @@
import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider';
import { useContext } from 'react';
export function useUser() {
return useContext(UserContext);
}

View file

@ -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() {

View file

@ -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';

View file

@ -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 };
} }

View file

@ -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);

View file

@ -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 };

View file

@ -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;
}; };

View 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 };
}

View file

@ -17,7 +17,7 @@ export {
FileJson, FileJson,
FileText, FileText,
Globe, Globe,
Grid2X2, Grid2X2 as Pixel,
KeyRound, KeyRound,
LayoutDashboard, LayoutDashboard,
Link, Link,

View file

@ -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 }) => {

View file

@ -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>
); );
} }

View file

@ -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.' },

View file

@ -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}
/>
);
}

View file

@ -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)}
/>
);
}

View file

@ -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}
/>
);
}

View file

@ -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}
/> />
); );

View file

@ -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}
/>
);
}

View file

@ -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}
/>
);
}

View file

@ -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}
/>
</>
);
}

View file

@ -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}
/>
);
}

View file

@ -1,7 +0,0 @@
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 300px;
display: block;
}

View file

@ -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))

View file

@ -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}

View 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, '-')}
/>
)
}
/>
);
}
}

View 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>
</>
);
}

View file

@ -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>
); );
} }

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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