diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml index 2404918b..d48567e6 100644 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -24,13 +24,13 @@ body: render: shell - type: input attributes: - label: Which Umami version are you using? (if relevant) + label: Which Umami version are you using? description: 'For example: 2.18.0, 2.15.1, 1.39.0, etc' - type: input attributes: - label: Which browser are you using? (if relevant) - description: 'For example: Chrome, Edge, Firefox, etc' + label: How are you deploying your application? + description: 'For example: Vercel, Railway, Docker, etc' - type: input attributes: - label: How are you deploying your application? (if relevant) - description: 'For example: Vercel, Railway, Docker, etc' + label: Which browser are you using? + description: 'For example: Chrome, Edge, Firefox, etc' diff --git a/.gitignore b/.gitignore index 753389d1..8b543f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ package-lock.json /dist /generated /src/generated +pm2.yml # misc .DS_Store @@ -30,6 +31,8 @@ package-lock.json *.log .vscode .tool-versions +.claude +nul # debug npm-debug.log* diff --git a/next.config.ts b/next.config.ts index 99dcca0d..1a4e2e0e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,6 +8,7 @@ const cloudMode = process.env.CLOUD_MODE || ''; const cloudUrl = process.env.CLOUD_URL || ''; const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || ''; const corsMaxAge = process.env.CORS_MAX_AGE || ''; +const defaultCurrency = process.env.DEFAULT_CURRENCY || ''; const defaultLocale = process.env.DEFAULT_LOCALE || ''; const forceSSL = process.env.FORCE_SSL || ''; const frameAncestors = process.env.ALLOWED_FRAME_URLS || ''; @@ -170,6 +171,7 @@ export default { cloudMode, cloudUrl, currentVersion: pkg.version, + defaultCurrency, defaultLocale, }, basePath, diff --git a/package.json b/package.json index a48e8c8e..8f0a5782 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "type": "module", "scripts": { - "dev": "next dev -p 3001 --turbo", + "dev": "next dev -p 3002 --turbo", "build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app", "start": "next start", "build-docker": "npm-run-all build-db build-tracker build-geo build-app", @@ -97,7 +97,7 @@ "is-docker": "^3.0.0", "is-localhost-ip": "^2.0.0", "isbot": "^5.1.31", - "jsonwebtoken": "^9.0.2", + "jsonwebtoken": "^9.0.3", "jszip": "^3.10.1", "kafkajs": "^2.1.0", "lucide-react": "^0.543.0", @@ -143,8 +143,8 @@ "@types/react-window": "^1.8.8", "babel-plugin-react-compiler": "19.1.0-rc.2", "cross-env": "^10.1.0", - "cypress": "^13.6.6", - "extract-react-intl-messages": "^4.1.1", + "cypress": "^15.8.0", + "extract-react-intl-messages": "^5.0.0", "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.6", @@ -164,7 +164,7 @@ "stylelint-config-css-modules": "^4.5.1", "stylelint-config-prettier": "^9.0.3", "stylelint-config-recommended": "^14.0.0", - "tar": "^6.1.2", + "tar": "^7.5.4", "ts-jest": "^29.4.6", "ts-node": "^10.9.1", "tsup": "^8.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3391f4fb..851abba3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,8 +117,8 @@ importers: specifier: ^5.1.31 version: 5.1.32 jsonwebtoken: - specifier: ^9.0.2 - version: 9.0.2 + specifier: ^9.0.3 + version: 9.0.3 jszip: specifier: ^3.10.1 version: 3.10.1 @@ -250,11 +250,11 @@ importers: specifier: ^10.1.0 version: 10.1.0 cypress: - specifier: ^13.6.6 - version: 13.17.0 + specifier: ^15.8.0 + version: 15.9.0 extract-react-intl-messages: - specifier: ^4.1.1 - version: 4.1.1(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)) + specifier: ^5.0.0 + version: 5.0.0(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -313,8 +313,8 @@ importers: specifier: ^14.0.0 version: 14.0.1(stylelint@15.11.0(typescript@5.9.3)) tar: - specifier: ^6.1.2 - version: 6.2.1 + specifier: ^7.5.4 + version: 7.5.4 ts-jest: specifier: ^29.4.6 version: 29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) @@ -566,10 +566,6 @@ packages: resolution: {integrity: sha512-co2spjR7wZoZ3Ck0H/jv76bpiuO3oJHtOmq9/gxFiod2DcT9NFg01u/hXcG8MJFnEJuMB6e3vGqS6IOnLwHqRw==} engines: {node: '>=16'} - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -687,8 +683,8 @@ packages: peerDependencies: postcss-selector-parser: ^6.0.13 - '@cypress/request@3.0.9': - resolution: {integrity: sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==} + '@cypress/request@3.0.10': + resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==} engines: {node: '>= 6'} '@cypress/xvfb@1.2.4': @@ -1442,6 +1438,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -2811,6 +2811,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tmp@0.2.6': + resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -2972,9 +2975,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3183,17 +3183,13 @@ packages: chart.js: '>=2.8.0' date-fns: '>=2.0.0' - check-more-types@2.24.0: - resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} - engines: {node: '>= 0.8.0'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} @@ -3224,8 +3220,8 @@ packages: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + cli-table3@0.6.1: + resolution: {integrity: sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==} engines: {node: 10.* || >= 12.*} cli-truncate@2.1.0: @@ -3280,6 +3276,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3478,9 +3478,9 @@ packages: resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==} engines: {node: '>=0.10.0'} - cypress@13.17.0: - resolution: {integrity: sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==} - engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} + cypress@15.9.0: + resolution: {integrity: sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==} + engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0} hasBin: true d3-array@2.12.1: @@ -3845,9 +3845,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extract-react-intl-messages@4.1.1: - resolution: {integrity: sha512-dPogci5X7HVtV7VbUxajH/1YgfNRaW2VtEiVidZ/31Tq8314uzOtzVMNo0IrAPD2E+H1wHoPiu/j565TZsyIZg==} - engines: {node: '>=10'} + extract-react-intl-messages@5.0.0: + resolution: {integrity: sha512-7K1aA3WxhhjBXsuZ2buZm5MLuPHjzkbErV2qqhf0m0K9RMqdwe6mYrOAMZ+1z1bfrngwQ2Iv44+RLjILO8qPdA==} + engines: {node: '>=20'} hasBin: true extract-zip@2.0.1: @@ -3906,6 +3906,10 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} + file-entry-cache@5.0.1: + resolution: {integrity: sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==} + engines: {node: '>=4'} + file-entry-cache@7.0.2: resolution: {integrity: sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==} engines: {node: '>=12.0.0'} @@ -3925,6 +3929,10 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flat-cache@2.0.1: + resolution: {integrity: sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==} + engines: {node: '>=4'} + flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3933,6 +3941,9 @@ packages: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true + flatted@2.0.2: + resolution: {integrity: sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -3974,10 +3985,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -4042,9 +4049,6 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} - getos@3.2.1: - resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} - getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} @@ -4147,6 +4151,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hasha@5.2.2: + resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} + engines: {node: '>=8'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4730,8 +4738,8 @@ packages: jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} - jsonwebtoken@9.0.2: - resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} jsprim@2.0.2: @@ -4741,11 +4749,11 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - jwa@1.4.2: - resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - jws@3.2.2: - resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} kafkajs@2.2.4: resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} @@ -4768,10 +4776,6 @@ packages: known-css-properties@0.36.0: resolution: {integrity: sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==} - lazy-ass@1.6.0: - resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} - engines: {node: '> 0.8'} - leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4865,10 +4869,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.pick@4.4.0: - resolution: {integrity: sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q==} - deprecated: This package is deprecated. Use destructuring assignment syntax instead. - lodash.truncate@4.4.2: resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} @@ -5054,21 +5054,17 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - minizlib@2.1.2: - resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} - engines: {node: '>= 8'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} @@ -5952,8 +5948,8 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -6177,6 +6173,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -6603,14 +6604,19 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + systeminformation@5.30.5: + resolution: {integrity: sha512-DpWmpCckhwR3hG+6udb6/aQB7PpiqVnvSljrjbKxNSvTRsGsg7NVE3/vouoYf96xgwMxXFKcS4Ux+cnkFwYM7A==} + engines: {node: '>=8.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + tar@7.5.4: + resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==} + engines: {node: '>=18'} terser@5.43.1: resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==} @@ -6995,6 +7001,10 @@ packages: resolution: {integrity: sha512-PxiShnxf0IlnQuMYOPPhPkhExoCQuTUNPOa/2JWCYTmBquU9njyyDuwRKN26IZBlp4yn1nt+Agh2HOOBl+55HQ==} engines: {node: '>=8.3'} + write@1.0.3: + resolution: {integrity: sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==} + engines: {node: '>=4'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -7009,6 +7019,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -7305,9 +7319,6 @@ snapshots: dependencies: '@clickhouse/client-common': 1.14.0 - '@colors/colors@1.5.0': - optional: true - '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -7405,7 +7416,7 @@ snapshots: dependencies: postcss-selector-parser: 6.1.2 - '@cypress/request@3.0.9': + '@cypress/request@3.0.10': dependencies: aws-sign2: 0.7.0 aws4: 1.13.2 @@ -7420,7 +7431,7 @@ snapshots: json-stringify-safe: 5.0.1 mime-types: 2.1.35 performance-now: 2.1.0 - qs: 6.14.0 + qs: 6.14.1 safe-buffer: 5.2.1 tough-cookie: 5.1.2 tunnel-agent: 0.6.0 @@ -8011,6 +8022,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -9864,6 +9879,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tmp@0.2.6': {} + '@types/use-sync-external-store@0.0.6': {} '@types/yargs-parser@21.0.3': {} @@ -10070,8 +10087,6 @@ snapshots: async-function@1.0.0: {} - async@3.2.6: {} - asynckit@0.4.0: {} at-least-node@1.0.0: {} @@ -10323,13 +10338,11 @@ snapshots: chart.js: 4.5.1 date-fns: 2.30.0 - check-more-types@2.24.0: {} - chokidar@4.0.3: dependencies: readdirp: 4.1.2 - chownr@2.0.0: {} + chownr@3.0.0: {} ci-info@3.9.0: {} @@ -10353,11 +10366,11 @@ snapshots: dependencies: restore-cursor: 5.1.0 - cli-table3@0.6.5: + cli-table3@0.6.1: dependencies: string-width: 4.2.3 optionalDependencies: - '@colors/colors': 1.5.0 + colors: 1.4.0 cli-truncate@2.1.0: dependencies: @@ -10403,6 +10416,9 @@ snapshots: colorette@2.0.20: {} + colors@1.4.0: + optional: true + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -10618,22 +10634,22 @@ snapshots: dependencies: array-find-index: 1.0.2 - cypress@13.17.0: + cypress@15.9.0: dependencies: - '@cypress/request': 3.0.9 + '@cypress/request': 3.0.10 '@cypress/xvfb': 1.2.4(supports-color@8.1.1) '@types/sinonjs__fake-timers': 8.1.1 '@types/sizzle': 2.3.9 + '@types/tmp': 0.2.6 arch: 2.2.0 blob-util: 2.0.2 bluebird: 3.7.2 buffer: 5.7.1 cachedir: 2.4.0 chalk: 4.1.2 - check-more-types: 2.24.0 ci-info: 4.3.0 cli-cursor: 3.1.0 - cli-table3: 0.6.5 + cli-table3: 0.6.1 commander: 6.2.1 common-tags: 1.8.2 dayjs: 1.11.13 @@ -10645,9 +10661,8 @@ snapshots: extract-zip: 2.0.1(supports-color@8.1.1) figures: 3.2.0 fs-extra: 9.1.0 - getos: 3.2.1 + hasha: 5.2.2 is-installed-globally: 0.4.0 - lazy-ass: 1.6.0 listr2: 3.14.0(enquirer@2.4.1) lodash: 4.17.21 log-symbols: 4.1.0 @@ -10657,8 +10672,8 @@ snapshots: process: 0.11.10 proxy-from-env: 1.0.0 request-progress: 3.0.0 - semver: 7.7.3 supports-color: 8.1.1 + systeminformation: 5.30.5 tmp: 0.2.5 tree-kill: 1.2.2 untildify: 4.0.0 @@ -11131,17 +11146,17 @@ snapshots: extend@3.0.2: {} - extract-react-intl-messages@4.1.1(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)): + extract-react-intl-messages@5.0.0(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)): dependencies: '@babel/core': 7.28.3 babel-plugin-react-intl: 7.9.4(ts-jest@29.4.6(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@30.0.5)(babel-jest@29.7.0(@babel/core@7.28.3))(esbuild@0.25.12)(jest-util@30.0.5)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3)) + file-entry-cache: 5.0.1 flat: 5.0.2 glob: 7.2.3 js-yaml: 3.14.1 load-json-file: 6.2.0 lodash.merge: 4.6.2 lodash.mergewith: 4.6.2 - lodash.pick: 4.4.0 meow: 6.1.1 mkdirp: 1.0.4 pify: 5.0.0 @@ -11209,6 +11224,10 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + file-entry-cache@5.0.1: + dependencies: + flat-cache: 2.0.1 + file-entry-cache@7.0.2: dependencies: flat-cache: 3.2.0 @@ -11233,6 +11252,12 @@ snapshots: mlly: 1.8.0 rollup: 4.53.3 + flat-cache@2.0.1: + dependencies: + flatted: 2.0.2 + rimraf: 2.6.3 + write: 1.0.3 + flat-cache@3.2.0: dependencies: flatted: 3.3.3 @@ -11241,6 +11266,8 @@ snapshots: flat@5.0.2: {} + flatted@2.0.2: {} + flatted@3.3.3: {} for-each@0.3.5: @@ -11293,10 +11320,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -11363,10 +11386,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - getos@3.2.1: - dependencies: - async: 3.2.6 - getpass@0.1.7: dependencies: assert-plus: 1.0.0 @@ -11501,6 +11520,11 @@ snapshots: dependencies: has-symbols: 1.1.0 + hasha@5.2.2: + dependencies: + is-stream: 2.0.1 + type-fest: 0.8.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -12236,9 +12260,9 @@ snapshots: jsonify@0.0.1: {} - jsonwebtoken@9.0.2: + jsonwebtoken@9.0.3: dependencies: - jws: 3.2.2 + jws: 4.0.1 lodash.includes: 4.3.0 lodash.isboolean: 3.0.3 lodash.isinteger: 4.0.4 @@ -12263,15 +12287,15 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 - jwa@1.4.2: + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jws@3.2.2: + jws@4.0.1: dependencies: - jwa: 1.4.2 + jwa: 2.0.1 safe-buffer: 5.2.1 kafkajs@2.2.4: {} @@ -12289,8 +12313,6 @@ snapshots: known-css-properties@0.36.0: optional: true - lazy-ass@1.6.0: {} - leven@3.1.0: {} lie@3.3.0: @@ -12383,8 +12405,6 @@ snapshots: lodash.once@4.1.1: {} - lodash.pick@4.4.0: {} - lodash.truncate@4.4.2: {} lodash.uniq@4.5.0: {} @@ -12575,18 +12595,15 @@ snapshots: minimist@1.2.8: {} - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - - minipass@5.0.0: {} - minipass@7.1.2: {} - minizlib@2.1.2: + minizlib@3.1.0: dependencies: - minipass: 3.3.6 - yallist: 4.0.0 + minipass: 7.1.2 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 mkdirp@1.0.4: {} @@ -13431,7 +13448,7 @@ snapshots: pure-rand@7.0.1: {} - qs@6.14.0: + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -13769,6 +13786,10 @@ snapshots: rfdc@1.4.1: {} + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -14336,6 +14357,8 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 + systeminformation@5.30.5: {} + table@6.9.0: dependencies: ajv: 8.17.1 @@ -14344,14 +14367,13 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tar@6.2.1: + tar@7.5.4: dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.1.0 + yallist: 5.0.0 terser@5.43.1: dependencies: @@ -14758,6 +14780,10 @@ snapshots: sort-keys: 4.2.0 write-file-atomic: 3.0.3 + write@1.0.3: + dependencies: + mkdirp: 0.5.6 + xtend@4.0.2: {} y18n@5.0.8: {} @@ -14766,6 +14792,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml@1.10.2: {} yaml@2.8.1: {} diff --git a/prisma/migrations/15_add_share/migration.sql b/prisma/migrations/15_add_share/migration.sql new file mode 100644 index 00000000..89aece1e --- /dev/null +++ b/prisma/migrations/15_add_share/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "share" ( + "share_id" UUID NOT NULL, + "entity_id" UUID NOT NULL, + "name" VARCHAR(200) NOT NULL, + "share_type" INTEGER NOT NULL, + "slug" VARCHAR(100) NOT NULL, + "parameters" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "share_pkey" PRIMARY KEY ("share_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug"); + +-- CreateIndex +CREATE INDEX "share_entity_id_idx" ON "share"("entity_id"); + +-- MigrateData +INSERT INTO "share" (share_id, entity_id, name, share_type, slug, parameters, created_at) +SELECT gen_random_uuid(), + website_id, + name, + 1, + share_id, + '{"overview":true}'::jsonb, + now() +FROM "website" +WHERE share_id IS NOT NULL; + +-- DropIndex +DROP INDEX "website_share_id_idx"; + +-- DropIndex +DROP INDEX "website_share_id_key"; + +-- AlterTable +ALTER TABLE "website" DROP COLUMN "share_id"; diff --git a/prisma/migrations/16_boards/migration.sql b/prisma/migrations/16_boards/migration.sql new file mode 100644 index 00000000..ad8ee172 --- /dev/null +++ b/prisma/migrations/16_boards/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "board" ( + "board_id" UUID NOT NULL, + "type" VARCHAR(50) NOT NULL, + "name" VARCHAR(200) NOT NULL, + "description" VARCHAR(500) NOT NULL, + "parameters" JSONB NOT NULL, + "slug" VARCHAR(100) NOT NULL, + "user_id" UUID, + "team_id" UUID, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "board_pkey" PRIMARY KEY ("board_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "board_slug_key" ON "board"("slug"); + +-- CreateIndex +CREATE INDEX "board_slug_idx" ON "board"("slug"); + +-- CreateIndex +CREATE INDEX "board_user_id_idx" ON "board"("user_id"); + +-- CreateIndex +CREATE INDEX "board_team_id_idx" ON "board"("team_id"); + +-- CreateIndex +CREATE INDEX "board_created_at_idx" ON "board"("created_at"); \ No newline at end of file diff --git a/prisma/migrations/17_remove_duplicate_key/migration.sql b/prisma/migrations/17_remove_duplicate_key/migration.sql new file mode 100644 index 00000000..75f7191e --- /dev/null +++ b/prisma/migrations/17_remove_duplicate_key/migration.sql @@ -0,0 +1,29 @@ +-- DropIndex +DROP INDEX "link_link_id_key"; + +-- DropIndex +DROP INDEX "pixel_pixel_id_key"; + +-- DropIndex +DROP INDEX "report_report_id_key"; + +-- DropIndex +DROP INDEX "revenue_revenue_id_key"; + +-- DropIndex +DROP INDEX "segment_segment_id_key"; + +-- DropIndex +DROP INDEX "session_session_id_key"; + +-- DropIndex +DROP INDEX "team_team_id_key"; + +-- DropIndex +DROP INDEX "team_user_team_user_id_key"; + +-- DropIndex +DROP INDEX "user_user_id_key"; + +-- DropIndex +DROP INDEX "website_website_id_key"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aeb11648..e58ebd0b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,7 +11,7 @@ datasource db { } model User { - id String @id @unique @map("user_id") @db.Uuid + id String @id() @map("user_id") @db.Uuid username String @unique @db.VarChar(255) password String @db.VarChar(60) role String @map("role") @db.VarChar(50) @@ -27,12 +27,13 @@ model User { pixels Pixel[] @relation("user") teams TeamUser[] reports Report[] + boards Board[] @relation("user") @@map("user") } model Session { - id String @id @unique @map("session_id") @db.Uuid + id String @id() @map("session_id") @db.Uuid websiteId String @map("website_id") @db.Uuid browser String? @db.VarChar(20) os String? @db.VarChar(20) @@ -64,10 +65,9 @@ model Session { } model Website { - id String @id @unique @map("website_id") @db.Uuid + id String @id() @map("website_id") @db.Uuid name String @db.VarChar(100) domain String? @db.VarChar(500) - shareId String? @unique @map("share_id") @db.VarChar(50) resetAt DateTime? @map("reset_at") @db.Timestamptz(6) userId String? @map("user_id") @db.Uuid teamId String? @map("team_id") @db.Uuid @@ -88,7 +88,6 @@ model Website { @@index([userId]) @@index([teamId]) @@index([createdAt]) - @@index([shareId]) @@index([createdBy]) @@map("website") } @@ -187,7 +186,7 @@ model SessionData { } model Team { - id String @id() @unique() @map("team_id") @db.Uuid + id String @id() @map("team_id") @db.Uuid name String @db.VarChar(50) accessCode String? @unique @map("access_code") @db.VarChar(50) logoUrl String? @map("logo_url") @db.VarChar(2183) @@ -199,13 +198,14 @@ model Team { members TeamUser[] links Link[] pixels Pixel[] + boards Board[] @@index([accessCode]) @@map("team") } model TeamUser { - id String @id() @unique() @map("team_user_id") @db.Uuid + id String @id() @map("team_user_id") @db.Uuid teamId String @map("team_id") @db.Uuid userId String @map("user_id") @db.Uuid role String @db.VarChar(50) @@ -221,7 +221,7 @@ model TeamUser { } model Report { - id String @id() @unique() @map("report_id") @db.Uuid + id String @id() @map("report_id") @db.Uuid userId String @map("user_id") @db.Uuid websiteId String @map("website_id") @db.Uuid type String @db.VarChar(50) @@ -242,7 +242,7 @@ model Report { } model Segment { - id String @id() @unique() @map("segment_id") @db.Uuid + id String @id() @map("segment_id") @db.Uuid websiteId String @map("website_id") @db.Uuid type String @db.VarChar(50) name String @db.VarChar(200) @@ -257,7 +257,7 @@ model Segment { } model Revenue { - id String @id() @unique() @map("revenue_id") @db.Uuid + id String @id() @map("revenue_id") @db.Uuid websiteId String @map("website_id") @db.Uuid sessionId String @map("session_id") @db.Uuid eventId String @map("event_id") @db.Uuid @@ -277,7 +277,7 @@ model Revenue { } model Link { - id String @id() @unique() @map("link_id") @db.Uuid + id String @id() @map("link_id") @db.Uuid name String @db.VarChar(100) url String @db.VarChar(500) slug String @unique() @db.VarChar(100) @@ -298,7 +298,7 @@ model Link { } model Pixel { - id String @id() @unique() @map("pixel_id") @db.Uuid + id String @id() @map("pixel_id") @db.Uuid name String @db.VarChar(100) slug String @unique() @db.VarChar(100) userId String? @map("user_id") @db.Uuid @@ -316,3 +316,39 @@ model Pixel { @@index([createdAt]) @@map("pixel") } + +model Board { + id String @id() @map("board_id") @db.Uuid + type String @db.VarChar(50) + name String @db.VarChar(200) + description String @db.VarChar(500) + parameters Json + slug String @unique() @db.VarChar(100) + userId String? @map("user_id") @db.Uuid + teamId String? @map("team_id") @db.Uuid + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + + user User? @relation("user", fields: [userId], references: [id]) + team Team? @relation(fields: [teamId], references: [id]) + + @@index([slug]) + @@index([userId]) + @@index([teamId]) + @@index([createdAt]) + @@map("board") +} + +model Share { + id String @id() @map("share_id") @db.Uuid + entityId String @map("entity_id") @db.Uuid + name String @db.VarChar(200) + shareType Int @map("share_type") @db.Integer + slug String @unique() @db.VarChar(100) + parameters Json + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + + @@index([entityId]) + @@map("share") +} diff --git a/public/images/country/t1.png b/public/images/country/t1.png new file mode 100644 index 00000000..c45ed0fe Binary files /dev/null and b/public/images/country/t1.png differ diff --git a/public/intl/messages/zh-CN.json b/public/intl/messages/zh-CN.json index b3d2f3c0..a4ad51fa 100644 --- a/public/intl/messages/zh-CN.json +++ b/public/intl/messages/zh-CN.json @@ -5,6 +5,18 @@ "value": "访问代码" } ], + "label.account": [ + { + "type": 0, + "value": "账户" + } + ], + "label.action": [ + { + "type": 0, + "value": "行为" + } + ], "label.actions": [ { "type": 0, @@ -35,12 +47,24 @@ "value": "添加描述" } ], + "label.add-link": [ + { + "type": 0, + "value": "添加链接" + } + ], "label.add-member": [ { "type": 0, "value": "添加成员" } ], + "label.add-pixel": [ + { + "type": 0, + "value": "添加像素" + } + ], "label.add-step": [ { "type": 0, @@ -83,12 +107,24 @@ "value": "所有时间段" } ], + "label.analysis": [ + { + "type": 0, + "value": "分析" + } + ], "label.analytics": [ { "type": 0, "value": "分析" } ], + "label.application": [ + { + "type": 0, + "value": "应用" + } + ], "label.apply": [ { "type": 0, @@ -107,6 +143,12 @@ "value": "查看用户如何与您的营销互动,以及是什么促成了转化。" } ], + "label.audience": [ + { + "type": 0, + "value": "受众" + } + ], "label.average": [ { "type": 0, @@ -125,6 +167,12 @@ "value": "之前" } ], + "label.behavior": [ + { + "type": 0, + "value": "行为" + } + ], "label.boards": [ { "type": 0, @@ -173,12 +221,24 @@ "value": "修改密码" } ], + "label.channel": [ + { + "type": 0, + "value": "渠道" + } + ], "label.channels": [ { "type": 0, "value": "渠道" } ], + "label.chart": [ + { + "type": 0, + "value": "图表" + } + ], "label.cities": [ { "type": 0, @@ -203,6 +263,12 @@ "value": "队列" } ], + "label.cohorts": [ + { + "type": 0, + "value": "队列" + } + ], "label.compare": [ { "type": 0, @@ -317,6 +383,12 @@ "value": "创建者" } ], + "label.criteria": [ + { + "type": 0, + "value": "条件" + } + ], "label.currency": [ { "type": 0, @@ -419,6 +491,12 @@ "value": "台式机" } ], + "label.destination-url": [ + { + "type": 0, + "value": "目标URL" + } + ], "label.details": [ { "type": 0, @@ -455,6 +533,12 @@ "value": "唯一ID" } ], + "label.documentation": [ + { + "type": 0, + "value": "文档" + } + ], "label.does-not-contain": [ { "type": 0, @@ -479,6 +563,12 @@ "value": "域名" } ], + "label.download": [ + { + "type": 0, + "value": "下载" + } + ], "label.dropoff": [ { "type": 0, @@ -506,7 +596,7 @@ "label.email": [ { "type": 0, - "value": "Email" + "value": "邮箱" } ], "label.enable-share-url": [ @@ -527,6 +617,12 @@ "value": "入口 URL" } ], + "label.environment": [ + { + "type": 0, + "value": "环境" + } + ], "label.event": [ { "type": 0, @@ -671,6 +767,12 @@ "value": "分组" } ], + "label.growth": [ + { + "type": 0, + "value": "增长" + } + ], "label.hostname": [ { "type": 0, @@ -701,6 +803,12 @@ "value": "通过使用筛选器和划分时间段来更深入地研究数据。" } ], + "label.invalid-url": [ + { + "type": 0, + "value": "无效URL" + } + ], "label.is": [ { "type": 0, @@ -863,12 +971,24 @@ "value": "少于等于" } ], + "label.link": [ + { + "type": 0, + "value": "链接" + } + ], "label.links": [ { "type": 0, "value": "链接" } ], + "label.location": [ + { + "type": 0, + "value": "位置" + } + ], "label.login": [ { "type": 0, @@ -1020,7 +1140,7 @@ "label.online": [ { "type": 0, - "value": "Online" + "value": "在线" } ], "label.organic-search": [ @@ -1165,6 +1285,12 @@ "value": "路径" } ], + "label.pixel": [ + { + "type": 0, + "value": "像素" + } + ], "label.pixels": [ { "type": 0, @@ -1185,6 +1311,12 @@ "value": " 提供支持" } ], + "label.preferences": [ + { + "type": 0, + "value": "偏好" + } + ], "label.previous": [ { "type": 0, @@ -1209,6 +1341,12 @@ "value": "个人资料" } ], + "label.profiles": [ + { + "type": 0, + "value": "个人资料" + } + ], "label.properties": [ { "type": 0, @@ -1248,7 +1386,7 @@ "label.referral": [ { "type": 0, - "value": "Referral" + "value": "来源" } ], "label.referrer": [ @@ -1371,6 +1509,24 @@ "value": "保存" } ], + "label.save-cohort": [ + { + "type": 0, + "value": "保存为群组" + } + ], + "label.save-segment": [ + { + "type": 0, + "value": "保存为细分" + } + ], + "label.screen": [ + { + "type": 0, + "value": "屏幕" + } + ], "label.screens": [ { "type": 0, @@ -1383,6 +1539,18 @@ "value": "搜索" } ], + "label.segment": [ + { + "type": 0, + "value": "细分" + } + ], + "label.segments": [ + { + "type": 0, + "value": "细分" + } + ], "label.select": [ { "type": 0, @@ -1485,6 +1653,24 @@ "value": "总和" } ], + "label.support": [ + { + "type": 0, + "value": "支持" + } + ], + "label.switch-account": [ + { + "type": 0, + "value": "切换账户" + } + ], + "label.table": [ + { + "type": 0, + "value": "表格" + } + ], "label.tablet": [ { "type": 0, @@ -1635,6 +1821,12 @@ "value": "跟踪代码" } ], + "label.traffic": [ + { + "type": 0, + "value": "流量" + } + ], "label.transactions": [ { "type": 0, @@ -1846,7 +2038,7 @@ "message.bad-request": [ { "type": 0, - "value": "Bad request" + "value": "请求错误" } ], "message.collected-data": [ @@ -1946,7 +2138,7 @@ "message.forbidden": [ { "type": 0, - "value": "Forbidden" + "value": "禁止访问" } ], "message.go-to-settings": [ @@ -2046,13 +2238,13 @@ "message.not-found": [ { "type": 0, - "value": "Not found" + "value": "未找到" } ], "message.nothing-selected": [ { "type": 0, - "value": "Nothing selected." + "value": "未选择" } ], "message.page-not-found": [ @@ -2090,7 +2282,7 @@ "message.sever-error": [ { "type": 0, - "value": "Server error" + "value": "服务器错误" } ], "message.share-url": [ @@ -2158,7 +2350,7 @@ "message.unauthorized": [ { "type": 0, - "value": "Unauthorized" + "value": "未授权" } ], "message.user-deleted": [ diff --git a/public/iso-3166-2.json b/public/iso-3166-2.json index 347313d7..2b3b5a80 100644 --- a/public/iso-3166-2.json +++ b/public/iso-3166-2.json @@ -6,13 +6,13 @@ "AD-06": "Sant Julia de Loria", "AD-07": "Andorra la Vella", "AD-08": "Escaldes-Engordany", - "AE-AJ": "'Ajman", - "AE-AZ": "Abu Zaby", - "AE-DU": "Dubayy", - "AE-FU": "Al Fujayrah", - "AE-RK": "Ra's al Khaymah", - "AE-SH": "Ash Shariqah", - "AE-UQ": "Umm al Qaywayn", + "AE-AJ": "Ajman", + "AE-AZ": "Abu Dhabi", + "AE-DU": "Dubai", + "AE-FU": "Al Fujairah", + "AE-RK": "Ras al Khaimah", + "AE-SH": "Sharjah", + "AE-UQ": "Umm al Quwain", "AF-BAL": "Balkh", "AF-BAM": "Bamyan", "AF-BDG": "Badghis", diff --git a/scripts/build-geo.js b/scripts/build-geo.js index a83caa6c..e36b097c 100644 --- a/scripts/build-geo.js +++ b/scripts/build-geo.js @@ -3,7 +3,7 @@ import 'dotenv/config'; import fs from 'node:fs'; import path from 'node:path'; import https from 'https'; -import tar from 'tar'; +import { list } from 'tar'; import zlib from 'zlib'; if (process.env.VERCEL && !process.env.BUILD_GEO) { @@ -40,7 +40,7 @@ const isDirectMmdb = url.endsWith('.mmdb'); const downloadCompressed = url => new Promise(resolve => { https.get(url, res => { - resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t())); + resolve(res.pipe(zlib.createGunzip({})).pipe(list())); }); }); diff --git a/src/app/(main)/admin/teams/AdminTeamsPage.tsx b/src/app/(main)/admin/teams/AdminTeamsPage.tsx index 41e6f4af..7905f7f6 100644 --- a/src/app/(main)/admin/teams/AdminTeamsPage.tsx +++ b/src/app/(main)/admin/teams/AdminTeamsPage.tsx @@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; import { useMessages } from '@/components/hooks'; +import { TeamsAddButton } from '../../teams/TeamsAddButton'; import { AdminTeamsDataTable } from './AdminTeamsDataTable'; export function AdminTeamsPage() { const { formatMessage, labels } = useMessages(); + const handleSave = () => {}; + return ( - + + + diff --git a/src/app/(main)/admin/users/UserAddForm.tsx b/src/app/(main)/admin/users/UserAddForm.tsx index 6c365510..84b8399c 100644 --- a/src/app/(main)/admin/users/UserAddForm.tsx +++ b/src/app/(main)/admin/users/UserAddForm.tsx @@ -10,6 +10,7 @@ import { TextField, } from '@umami/react-zen'; import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { messages } from '@/components/messages'; import { ROLES } from '@/lib/constants'; export function UserAddForm({ onSave, onClose }) { @@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) { diff --git a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx index 28bf030f..68aa7f6e 100644 --- a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx +++ b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx @@ -30,7 +30,11 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () = }; return ( -
+ diff --git a/src/app/(main)/links/LinkDeleteButton.tsx b/src/app/(main)/links/LinkDeleteButton.tsx index 78f85f89..32ccbaf7 100644 --- a/src/app/(main)/links/LinkDeleteButton.tsx +++ b/src/app/(main)/links/LinkDeleteButton.tsx @@ -1,5 +1,5 @@ import { ConfirmationForm } from '@/components/common/ConfirmationForm'; -import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; import { Trash } from '@/components/icons'; import { DialogButton } from '@/components/input/DialogButton'; import { messages } from '@/components/messages'; @@ -15,7 +15,8 @@ export function LinkDeleteButton({ onSave?: () => void; }) { const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages(); - const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`); + const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`); + const { touch } = useModified(); const handleConfirm = async (close: () => void) => { await mutateAsync(null, { diff --git a/src/app/(main)/links/LinkEditForm.tsx b/src/app/(main)/links/LinkEditForm.tsx index 6c10c7f0..a6c0164d 100644 --- a/src/app/(main)/links/LinkEditForm.tsx +++ b/src/app/(main)/links/LinkEditForm.tsx @@ -4,13 +4,14 @@ import { Form, FormField, FormSubmitButton, + Grid, Icon, Label, Loading, Row, TextField, } from '@umami/react-zen'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useConfig, useLinkQuery, useMessages } from '@/components/hooks'; import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery'; import { RefreshCw } from '@/components/icons'; @@ -42,27 +43,20 @@ export function LinkEditForm({ const { linksUrl } = useConfig(); const hostUrl = linksUrl || LINKS_URL; const { data, isLoading } = useLinkQuery(linkId); - const [slug, setSlug] = useState(generateId()); + const [defaultSlug] = useState(generateId()); const handleSubmit = async (data: any) => { await mutateAsync(data, { onSuccess: async () => { toast(formatMessage(messages.saved)); touch('links'); + touch(`link:${linkId}`); onSave?.(); onClose?.(); }, }); }; - const handleSlug = () => { - const slug = generateId(); - - setSlug(slug); - - return slug; - }; - const checkUrl = (url: string) => { if (!isValidUrl(url)) { return formatMessage(labels.invalidUrl); @@ -70,19 +64,19 @@ export function LinkEditForm({ return true; }; - useEffect(() => { - if (data) { - setSlug(data.slug); - } - }, [data]); - if (linkId && isLoading) { return ; } return ( - - {({ setValue }) => { + + {({ setValue, watch }) => { + const slug = watch('slug'); + return ( <> - - - + + + + + + @@ -121,14 +125,6 @@ export function LinkEditForm({ allowCopy style={{ width: '100%' }} /> - diff --git a/src/app/(main)/links/LinksDataTable.tsx b/src/app/(main)/links/LinksDataTable.tsx index 0b3d660b..87da5984 100644 --- a/src/app/(main)/links/LinksDataTable.tsx +++ b/src/app/(main)/links/LinksDataTable.tsx @@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid'; import { useLinksQuery, useNavigation } from '@/components/hooks'; import { LinksTable } from './LinksTable'; -export function LinksDataTable() { +export function LinksDataTable({ showActions = false }: { showActions?: boolean }) { const { teamId } = useNavigation(); const query = useLinksQuery({ teamId }); return ( - {({ data }) => } + {({ data }) => } ); } diff --git a/src/app/(main)/links/LinksPage.tsx b/src/app/(main)/links/LinksPage.tsx index a6e4c7c4..cdaf8fce 100644 --- a/src/app/(main)/links/LinksPage.tsx +++ b/src/app/(main)/links/LinksPage.tsx @@ -4,21 +4,30 @@ import { LinksDataTable } from '@/app/(main)/links/LinksDataTable'; import { PageBody } from '@/components/common/PageBody'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useMessages, useNavigation } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; import { LinkAddButton } from './LinkAddButton'; export function LinksPage() { + const { user } = useLoginQuery(); const { formatMessage, labels } = useMessages(); const { teamId } = useNavigation(); + const { data } = useTeamMembersQuery(teamId); + + const showActions = + (teamId && + data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly) + .length > 0) || + (!teamId && user.role !== ROLES.viewOnly); return ( - + {showActions && } - + diff --git a/src/app/(main)/links/LinksTable.tsx b/src/app/(main)/links/LinksTable.tsx index a3b4a86a..62eb0fb8 100644 --- a/src/app/(main)/links/LinksTable.tsx +++ b/src/app/(main)/links/LinksTable.tsx @@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks'; import { LinkDeleteButton } from './LinkDeleteButton'; import { LinkEditButton } from './LinkEditButton'; -export function LinksTable(props: DataTableProps) { +export interface LinksTableProps extends DataTableProps { + showActions?: boolean; +} + +export function LinksTable({ showActions, ...props }: LinksTableProps) { const { formatMessage, labels } = useMessages(); const { websiteId, renderUrl } = useNavigation(); const { getSlugUrl } = useSlug('link'); @@ -36,16 +40,18 @@ export function LinksTable(props: DataTableProps) { {(row: any) => } - - {({ id, name }: any) => { - return ( - - - - - ); - }} - + {showActions && ( + + {({ id, name }: any) => { + return ( + + + + + ); + }} + + )} ); } diff --git a/src/app/(main)/links/[linkId]/page.tsx b/src/app/(main)/links/[linkId]/page.tsx index 4317ada2..3a5f5d7e 100644 --- a/src/app/(main)/links/[linkId]/page.tsx +++ b/src/app/(main)/links/[linkId]/page.tsx @@ -1,8 +1,14 @@ import type { Metadata } from 'next'; +import { getLink } from '@/queries/prisma'; import { LinkPage } from './LinkPage'; export default async function ({ params }: { params: Promise<{ linkId: string }> }) { const { linkId } = await params; + const link = await getLink(linkId); + + if (!link || link?.deletedAt) { + return null; + } return ; } diff --git a/src/app/(main)/pixels/PixelEditForm.tsx b/src/app/(main)/pixels/PixelEditForm.tsx index aedd3a3b..46241c1c 100644 --- a/src/app/(main)/pixels/PixelEditForm.tsx +++ b/src/app/(main)/pixels/PixelEditForm.tsx @@ -48,6 +48,7 @@ export function PixelEditForm({ onSuccess: async () => { toast(formatMessage(messages.saved)); touch('pixels'); + touch(`pixel:${pixelId}`); onSave?.(); onClose?.(); }, diff --git a/src/app/(main)/pixels/PixelsDataTable.tsx b/src/app/(main)/pixels/PixelsDataTable.tsx index 51b8c5a0..6a9a9162 100644 --- a/src/app/(main)/pixels/PixelsDataTable.tsx +++ b/src/app/(main)/pixels/PixelsDataTable.tsx @@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid'; import { useNavigation, usePixelsQuery } from '@/components/hooks'; import { PixelsTable } from './PixelsTable'; -export function PixelsDataTable() { +export function PixelsDataTable({ showActions = false }: { showActions?: boolean }) { const { teamId } = useNavigation(); const query = usePixelsQuery({ teamId }); return ( - {({ data }) => } + {({ data }) => } ); } diff --git a/src/app/(main)/pixels/PixelsPage.tsx b/src/app/(main)/pixels/PixelsPage.tsx index 4f6acefe..91ddcdcd 100644 --- a/src/app/(main)/pixels/PixelsPage.tsx +++ b/src/app/(main)/pixels/PixelsPage.tsx @@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen'; import { PageBody } from '@/components/common/PageBody'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useMessages, useNavigation } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; import { PixelAddButton } from './PixelAddButton'; import { PixelsDataTable } from './PixelsDataTable'; export function PixelsPage() { + const { user } = useLoginQuery(); const { formatMessage, labels } = useMessages(); const { teamId } = useNavigation(); + const { data } = useTeamMembersQuery(teamId); + + const showActions = + (teamId && + data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly) + .length > 0) || + (!teamId && user.role !== ROLES.viewOnly); return ( - + {showActions && } - + diff --git a/src/app/(main)/pixels/PixelsTable.tsx b/src/app/(main)/pixels/PixelsTable.tsx index 48a84589..018b40eb 100644 --- a/src/app/(main)/pixels/PixelsTable.tsx +++ b/src/app/(main)/pixels/PixelsTable.tsx @@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks'; import { PixelDeleteButton } from './PixelDeleteButton'; import { PixelEditButton } from './PixelEditButton'; -export function PixelsTable(props: DataTableProps) { +export interface PixelsTableProps extends DataTableProps { + showActions?: boolean; +} + +export function PixelsTable({ showActions, ...props }: PixelsTableProps) { const { formatMessage, labels } = useMessages(); const { renderUrl } = useNavigation(); const { getSlugUrl } = useSlug('pixel'); @@ -31,18 +35,20 @@ export function PixelsTable(props: DataTableProps) { {(row: any) => } - - {(row: any) => { - const { id, name } = row; + {showActions && ( + + {(row: any) => { + const { id, name } = row; - return ( - - - - - ); - }} - + return ( + + + + + ); + }} + + )} ); } diff --git a/src/app/(main)/pixels/[pixelId]/page.tsx b/src/app/(main)/pixels/[pixelId]/page.tsx index d1db92f3..e174c195 100644 --- a/src/app/(main)/pixels/[pixelId]/page.tsx +++ b/src/app/(main)/pixels/[pixelId]/page.tsx @@ -1,8 +1,14 @@ import type { Metadata } from 'next'; +import { getPixel } from '@/queries/prisma'; import { PixelPage } from './PixelPage'; export default async function ({ params }: { params: { pixelId: string } }) { const { pixelId } = await params; + const pixel = await getPixel(pixelId); + + if (!pixel || pixel?.deletedAt) { + return null; + } return ; } diff --git a/src/app/(main)/settings/preferences/PreferenceSettings.tsx b/src/app/(main)/settings/preferences/PreferenceSettings.tsx index a2890ce9..cc2d1b62 100644 --- a/src/app/(main)/settings/preferences/PreferenceSettings.tsx +++ b/src/app/(main)/settings/preferences/PreferenceSettings.tsx @@ -4,6 +4,7 @@ import { DateRangeSetting } from './DateRangeSetting'; import { LanguageSetting } from './LanguageSetting'; import { ThemeSetting } from './ThemeSetting'; import { TimezoneSetting } from './TimezoneSetting'; +import { VersionSetting } from './VersionSetting'; export function PreferenceSettings() { const { user } = useLoginQuery(); @@ -31,6 +32,10 @@ export function PreferenceSettings() { + + + + ); } diff --git a/src/app/(main)/settings/preferences/VersionSetting.tsx b/src/app/(main)/settings/preferences/VersionSetting.tsx new file mode 100644 index 00000000..afca1de6 --- /dev/null +++ b/src/app/(main)/settings/preferences/VersionSetting.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { Text } from '@umami/react-zen'; +import { CURRENT_VERSION } from '@/lib/constants'; + +export function VersionSetting() { + return {CURRENT_VERSION}; +} diff --git a/src/app/(main)/teams/TeamAddForm.tsx b/src/app/(main)/teams/TeamAddForm.tsx index c95259f4..3b827776 100644 --- a/src/app/(main)/teams/TeamAddForm.tsx +++ b/src/app/(main)/teams/TeamAddForm.tsx @@ -7,8 +7,17 @@ import { TextField, } from '@umami/react-zen'; import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { UserSelect } from '@/components/input/UserSelect'; -export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { +export function TeamAddForm({ + onSave, + onClose, + isAdmin, +}: { + onSave: () => void; + onClose: () => void; + isAdmin: boolean; +}) { const { formatMessage, labels, getErrorMessage } = useMessages(); const { mutateAsync, error, isPending } = useUpdateQuery('/teams'); @@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: + {isAdmin && ( + + + + )} + + {formatMessage(labels.save)} + + + + ); +} diff --git a/src/app/(main)/teams/TeamsAddButton.tsx b/src/app/(main)/teams/TeamsAddButton.tsx index 578a273a..13873088 100644 --- a/src/app/(main)/teams/TeamsAddButton.tsx +++ b/src/app/(main)/teams/TeamsAddButton.tsx @@ -4,7 +4,13 @@ import { Plus } from '@/components/icons'; import { messages } from '@/components/messages'; import { TeamAddForm } from './TeamAddForm'; -export function TeamsAddButton({ onSave }: { onSave?: () => void }) { +export function TeamsAddButton({ + onSave, + isAdmin = false, +}: { + onSave?: () => void; + isAdmin?: boolean; +}) { const { formatMessage, labels } = useMessages(); const { toast } = useToast(); const { touch } = useModified(); @@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) { - {({ close }) => } + {({ close }) => } diff --git a/src/app/(main)/teams/TeamsMemberAddButton.tsx b/src/app/(main)/teams/TeamsMemberAddButton.tsx new file mode 100644 index 00000000..f1bbf258 --- /dev/null +++ b/src/app/(main)/teams/TeamsMemberAddButton.tsx @@ -0,0 +1,40 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { messages } from '@/components/messages'; +import { TeamMemberAddForm } from './TeamMemberAddForm'; + +export function TeamsMemberAddButton({ + teamId, + onSave, +}: { + teamId: string; + onSave?: () => void; + isAdmin?: boolean; +}) { + const { formatMessage, labels } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('teams:members'); + onSave?.(); + }; + + return ( + + + + + {({ close }) => } + + + + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx index 3ddbe000..4bbb8905 100644 --- a/src/app/(main)/teams/[teamId]/TeamSettings.tsx +++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx @@ -1,10 +1,12 @@ -import { Column } from '@umami/react-zen'; +import { Column, Heading, Row } from '@umami/react-zen'; import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks'; import { Users } from '@/components/icons'; +import { labels } from '@/components/messages'; import { ROLES } from '@/lib/constants'; +import { TeamsMemberAddButton } from '../TeamsMemberAddButton'; import { TeamEditForm } from './TeamEditForm'; import { TeamManage } from './TeamManage'; import { TeamMembersDataTable } from './TeamMembersDataTable'; @@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) { const team: any = useTeam(); const { user } = useLoginQuery(); const { pathname } = useNavigation(); + const { formatMessage } = useMessages(); const isAdmin = pathname.includes('/admin'); @@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) { + + {formatMessage(labels.members)} + {isAdmin && } + {isTeamOwner && ( diff --git a/src/app/(main)/websites/WebsitesPage.tsx b/src/app/(main)/websites/WebsitesPage.tsx index 31de7047..6f3548a9 100644 --- a/src/app/(main)/websites/WebsitesPage.tsx +++ b/src/app/(main)/websites/WebsitesPage.tsx @@ -3,22 +3,31 @@ import { Column } from '@umami/react-zen'; import { PageBody } from '@/components/common/PageBody'; import { PageHeader } from '@/components/common/PageHeader'; import { Panel } from '@/components/common/Panel'; -import { useMessages, useNavigation } from '@/components/hooks'; +import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; import { WebsiteAddButton } from './WebsiteAddButton'; import { WebsitesDataTable } from './WebsitesDataTable'; export function WebsitesPage() { + const { user } = useLoginQuery(); const { teamId } = useNavigation(); const { formatMessage, labels } = useMessages(); + const { data } = useTeamMembersQuery(teamId); + + const showActions = + (teamId && + data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly) + .length > 0) || + (!teamId && user.role !== ROLES.viewOnly); return ( - + {showActions && } - + diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx index e336a3db..d81519d7 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx @@ -1,6 +1,6 @@ import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useMessages, useResultQuery } from '@/components/hooks'; +import { useMessages, useNavigation, useResultQuery } from '@/components/hooks'; import { File, User } from '@/components/icons'; import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ChangeLabel } from '@/components/metrics/ChangeLabel'; @@ -20,6 +20,8 @@ type FunnelResult = { export function Funnel({ id, name, type, parameters, websiteId }) { const { formatMessage, labels } = useMessages(); + const { pathname } = useNavigation(); + const isSharePage = pathname.includes('/share/'); const { data, error, isLoading } = useResultQuery(type, { websiteId, ...parameters, @@ -36,21 +38,22 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
- - - {({ close }) => { - return ( - - - - ); - }} - - + {!isSharePage && ( + + + {({ close }) => { + return ( + + + + ); + }} + + + )} {data?.map( ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx index 57bce52f..a56917b7 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx @@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Panel } from '@/components/common/Panel'; import { SectionHeader } from '@/components/common/SectionHeader'; -import { useDateRange, useReportsQuery } from '@/components/hooks'; +import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks'; import { Funnel } from './Funnel'; import { FunnelAddButton } from './FunnelAddButton'; @@ -13,13 +13,17 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) { const { dateRange: { startDate, endDate }, } = useDateRange(); + const { pathname } = useNavigation(); + const isSharePage = pathname.includes('/share/'); return ( - - - + {!isSharePage && ( + + + + )} {data && ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx index b6c4a11d..1d0b96e5 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx @@ -1,6 +1,6 @@ import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useMessages, useResultQuery } from '@/components/hooks'; +import { useMessages, useNavigation, useResultQuery } from '@/components/hooks'; import { File, User } from '@/components/icons'; import { ReportEditButton } from '@/components/input/ReportEditButton'; import { Lightning } from '@/components/svg'; @@ -25,6 +25,8 @@ export type GoalData = { num: number; total: number }; export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) { const { formatMessage, labels } = useMessages(); + const { pathname } = useNavigation(); + const isSharePage = pathname.includes('/share/'); const { data, error, isLoading, isFetching } = useResultQuery(type, { websiteId, startDate, @@ -45,21 +47,23 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate - - - {({ close }) => { - return ( - - - - ); - }} - - + {!isSharePage && ( + + + {({ close }) => { + return ( + + + + ); + }} + + + )} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx index ff7b49fb..fe4550d6 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx @@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Panel } from '@/components/common/Panel'; import { SectionHeader } from '@/components/common/SectionHeader'; -import { useDateRange, useReportsQuery } from '@/components/hooks'; +import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks'; import { Goal } from './Goal'; import { GoalAddButton } from './GoalAddButton'; @@ -13,13 +13,17 @@ export function GoalsPage({ websiteId }: { websiteId: string }) { const { dateRange: { startDate, endDate }, } = useDateRange(); + const { pathname } = useNavigation(); + const isSharePage = pathname.includes('/share/'); return ( - - - + {!isSharePage && ( + + + + )} {data && ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx index 3327a425..1b893d27 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx @@ -21,9 +21,15 @@ export interface JourneyProps { steps: number; startStep?: string; endStep?: string; + view: string; } -export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) { +const EVENT_TYPES = { + views: 1, + events: 2, +}; + +export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) { const [selectedNode, setSelectedNode] = useState(null); const [activeNode, setActiveNode] = useState(null); const { formatMessage, labels } = useMessages(); @@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) steps, startStep, endStep, + view, + eventType: EVENT_TYPES[view], }); useEscapeKey(() => setSelectedNode(null)); diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx index 14b8341d..f1a8976f 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx @@ -1,9 +1,10 @@ 'use client'; -import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; +import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen'; import { useState } from 'react'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { Panel } from '@/components/common/Panel'; import { useDateRange, useMessages } from '@/components/hooks'; +import { FilterButtons } from '@/components/input/FilterButtons'; import { Journey } from './Journey'; const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7]; @@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) { const { dateRange: { startDate, endDate }, } = useDateRange(); + const [view, setView] = useState('all'); const [steps, setSteps] = useState(DEFAULT_STEP); const [startStep, setStartStep] = useState(''); const [endStep, setEndStep] = useState(''); + const buttons = [ + { + id: 'all', + label: formatMessage(labels.all), + }, + { + id: 'views', + label: formatMessage(labels.views), + }, + { + id: 'events', + label: formatMessage(labels.events), + }, + ]; + return ( @@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) { /> + + + diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx index 0e782a16..faee8b9a 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx @@ -12,9 +12,10 @@ import { ListTable } from '@/components/metrics/ListTable'; import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricsBar } from '@/components/metrics/MetricsBar'; import { renderDateLabels } from '@/lib/charts'; -import { CHART_COLORS } from '@/lib/constants'; +import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants'; import { generateTimeSeries } from '@/lib/date'; import { formatLongCurrency, formatLongNumber } from '@/lib/format'; +import { getItem, setItem } from '@/lib/storage'; export interface RevenueProps { websiteId: string; @@ -24,7 +25,15 @@ export interface RevenueProps { } export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { - const [currency, setCurrency] = useState('USD'); + const [currency, setCurrency] = useState( + getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY, + ); + + const handleCurrencyChange = (value: string) => { + setCurrency(value); + setItem(CURRENCY_CONFIG, value); + }; + const { formatMessage, labels } = useMessages(); const { locale, dateLocale } = useLocale(); const { countryNames } = useCountryNames(locale); @@ -107,7 +116,7 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { return ( - + {data && ( diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx index b2ea2a83..896c733a 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx @@ -21,30 +21,28 @@ export function WebsiteChart({ const { pageviews, sessions, compare } = (data || {}) as any; const chartData = useMemo(() => { - if (data) { - const result = { - pageviews, - sessions, - }; + if (!data) { + return { pageviews: [], sessions: [] }; + } - if (compare) { - result.compare = { - pageviews: result.pageviews.map(({ x }, i) => ({ + return { + pageviews, + sessions, + ...(compare && { + compare: { + pageviews: pageviews.map(({ x }, i) => ({ x, y: compare.pageviews[i]?.y, d: compare.pageviews[i]?.x, })), - sessions: result.sessions.map(({ x }, i) => ({ + sessions: sessions.map(({ x }, i) => ({ x, y: compare.sessions[i]?.y, d: compare.sessions[i]?.x, })), - }; - } - - return result; - } - return { pageviews: [], sessions: [] }; + }, + }), + }; }, [data, startDate, endDate, unit]); return ( diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx index 29c3954f..4bac4ff6 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx @@ -169,6 +169,12 @@ export function WebsiteExpandedMenu({ path: updateParams({ view: 'hostname' }), icon: , }, + { + id: 'distinctId', + label: formatMessage(labels.distinctId), + path: updateParams({ view: 'distinctId' }), + icon: , + }, { id: 'tag', label: formatMessage(labels.tag), diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx index 7dd1d771..1dee8022 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx @@ -1,14 +1,18 @@ import { Icon, Row, Text } from '@umami/react-zen'; -import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm'; import { Favicon } from '@/components/common/Favicon'; import { LinkButton } from '@/components/common/LinkButton'; import { PageHeader } from '@/components/common/PageHeader'; import { useMessages, useNavigation, useWebsite } from '@/components/hooks'; -import { Edit, Share } from '@/components/icons'; -import { DialogButton } from '@/components/input/DialogButton'; +import { Edit } from '@/components/icons'; import { ActiveUsers } from '@/components/metrics/ActiveUsers'; -export function WebsiteHeader({ showActions }: { showActions?: boolean }) { +export function WebsiteHeader({ + showActions, + allowLink = true, +}: { + showActions?: boolean; + allowLink?: boolean; +}) { const website = useWebsite(); const { renderUrl, pathname } = useNavigation(); const isSettings = pathname.endsWith('/settings'); @@ -23,35 +27,20 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) { } - titleHref={renderUrl(`/websites/${website.id}`, false)} + titleHref={allowLink ? renderUrl(`/websites/${website.id}`, false) : undefined} > {showActions && ( - - - - - - - {formatMessage(labels.edit)} - - + + + + + {formatMessage(labels.edit)} + )} ); } - -const ShareButton = ({ websiteId, shareId }) => { - const { formatMessage, labels } = useMessages(); - - return ( - } label={formatMessage(labels.share)} width="800px"> - {({ close }) => { - return ; - }} - - ); -}; diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx index 30189534..132d3b14 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx @@ -10,7 +10,7 @@ import { } from '@umami/react-zen'; import { Fragment } from 'react'; import { useMessages, useNavigation } from '@/components/hooks'; -import { Edit, More, Share } from '@/components/icons'; +import { Edit, MoreHorizontal, Share } from '@/components/icons'; export function WebsiteMenu({ websiteId }: { websiteId: string }) { const { formatMessage, labels } = useMessages(); @@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) { diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx index 6c91ba6d..605ee385 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx @@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format'; export function WebsiteMetricsBar({ websiteId, + compareMode, }: { websiteId: string; showChange?: boolean; compareMode?: boolean; }) { - const { isAllTime } = useDateRange(); + const { isAllTime, dateCompare } = useDateRange(); const { formatMessage, labels, getErrorMessage } = useMessages(); - const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId); + const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({ + websiteId, + compare: compareMode ? dateCompare?.compare : undefined, + }); const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {}; diff --git a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx index ad05b706..9f72c303 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx @@ -29,6 +29,7 @@ export function WebsiteNav({ event: undefined, compare: undefined, view: undefined, + unit: undefined, }); const items = [ diff --git a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx index f587e112..5acc9e68 100644 --- a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx @@ -1,7 +1,8 @@ 'use client'; -import { Column } from '@umami/react-zen'; +import { Column, Row } from '@umami/react-zen'; import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; import { Panel } from '@/components/common/Panel'; +import { UnitFilter } from '@/components/input/UnitFilter'; import { WebsiteChart } from './WebsiteChart'; import { WebsiteControls } from './WebsiteControls'; import { WebsiteMetricsBar } from './WebsiteMetricsBar'; @@ -13,6 +14,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) { + + + diff --git a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx index bca8d244..32d641b0 100644 --- a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx +++ b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx @@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) { return ( - + diff --git a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx index 13c05160..4daf17fc 100644 --- a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx +++ b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx @@ -93,6 +93,11 @@ export function CompareTables({ websiteId }: { websiteId: string }) { label: formatMessage(labels.hostname), path: renderPath('hostname'), }, + { + id: 'distinctId', + label: formatMessage(labels.distinctId), + path: renderPath('distinctId'), + }, { id: 'tag', label: formatMessage(labels.tags), diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx index 55ec0403..f62d8a4c 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx @@ -1,12 +1,18 @@ 'use client'; import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; -import { type Key, useState } from 'react'; +import locale from 'date-fns/locale/af'; +import { type Key, useMemo, useState } from 'react'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Panel } from '@/components/common/Panel'; import { useMessages } from '@/components/hooks'; +import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery'; import { EventsChart } from '@/components/metrics/EventsChart'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { formatLongNumber } from '@/lib/format'; import { getItem, setItem } from '@/lib/storage'; import { EventProperties } from './EventProperties'; import { EventsDataTable } from './EventsDataTable'; @@ -15,16 +21,61 @@ const KEY_NAME = 'umami.events.tab'; export function EventsPage({ websiteId }) { const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); - const { formatMessage, labels } = useMessages(); + const { formatMessage, labels, getErrorMessage } = useMessages(); + const { data, isLoading, isFetching, error } = useEventStatsQuery({ + websiteId, + }); const handleSelect = (value: Key) => { setItem(KEY_NAME, value); setTab(value); }; + const metrics = useMemo(() => { + if (!data) return []; + + const { events, visitors, visits, uniqueEvents } = data || {}; + + return [ + { + value: visitors, + label: formatMessage(labels.visitors), + formatValue: formatLongNumber, + }, + { + value: visits, + label: formatMessage(labels.visits), + formatValue: formatLongNumber, + }, + { + value: events, + label: formatMessage(labels.events), + formatValue: formatLongNumber, + }, + { + value: uniqueEvents, + label: formatMessage(labels.uniqueEvents), + formatValue: formatLongNumber, + }, + ] as any; + }, [data, locale]); + return ( + + + {metrics?.map(({ label, value, formatValue }) => { + return ; + })} + + handleSelect(key)}> diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx index 7fb2eb41..41c2b1e8 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx @@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) { const { updateParams } = useNavigation(); const { formatValue } = useFormat(); + const renderLink = (label: string, hostname: string) => { + return ( + + {label} + + ); + }; + return ( @@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) { title={row.eventName || row.urlPath} truncate > - {row.eventName || row.urlPath} + {row.eventName || renderLink(row.urlPath, row.hostname)} {row.hasData > 0 && } diff --git a/src/app/(main)/websites/[websiteId]/layout.tsx b/src/app/(main)/websites/[websiteId]/layout.tsx index 67595e9d..b12ff950 100644 --- a/src/app/(main)/websites/[websiteId]/layout.tsx +++ b/src/app/(main)/websites/[websiteId]/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout'; +import { getWebsite } from '@/queries/prisma'; export default async function ({ children, @@ -9,6 +10,11 @@ export default async function ({ params: Promise<{ websiteId: string }>; }) { const { websiteId } = await params; + const website = await getWebsite(websiteId); + + if (!website || website?.deletedAt) { + return null; + } return {children}; } diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx index 10763618..9cbbd371 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx @@ -74,8 +74,9 @@ export function RealtimeLog({ data }: { data: any }) { os: string; country: string; device: string; + hostname: string; }) => { - const { __type, eventName, urlPath, browser, os, country, device } = log; + const { __type, eventName, urlPath, browser, os, country, device, hostname } = log; if (__type === TYPE_EVENT) { return ( @@ -86,7 +87,8 @@ export function RealtimeLog({ data }: { data: any }) { url: ( @@ -100,7 +102,12 @@ export function RealtimeLog({ data }: { data: any }) { if (__type === TYPE_PAGEVIEW) { return ( - + {urlPath} ); diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx index cbb28108..df0ef834 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx @@ -39,10 +39,23 @@ export function SessionActivity({ const { isMobile } = useMobile(); let lastDay = null; + const renderLink = (label: string, hostname: string) => { + return ( + + {label} + + ); + }; + return ( - {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { + {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hostname, hasData }) => { const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); lastDay = createdAt; @@ -61,7 +74,7 @@ export function SessionActivity({ : formatMessage(labels.viewedPage)} - {eventName || urlPath} + {eventName || renderLink(urlPath, hostname)} {hasData > 0 && } diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx new file mode 100644 index 00000000..35e96df3 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/ShareDeleteButton.tsx @@ -0,0 +1,57 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +export function ShareDeleteButton({ + shareId, + slug, + onSave, +}: { + shareId: string; + slug: string; + onSave?: () => void; +}) { + const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages(); + const { mutateAsync, isPending, error } = useDeleteQuery(`/share/id/${shareId}`); + const { touch } = useModified(); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('shares'); + onSave?.(); + close(); + }, + }); + }; + + return ( + } + title={formatMessage(labels.confirm)} + variant="quiet" + width="400px" + > + {({ close }) => ( + {slug}, + }} + /> + } + isLoading={isPending} + error={getErrorMessage(error)} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx new file mode 100644 index 00000000..df1c2e64 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/ShareEditButton.tsx @@ -0,0 +1,16 @@ +import { useMessages } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { ShareEditForm } from './ShareEditForm'; + +export function ShareEditButton({ shareId }: { shareId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + } title={formatMessage(labels.share)} variant="quiet" width="600px"> + {({ close }) => { + return ; + }} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx new file mode 100644 index 00000000..4b86247a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx @@ -0,0 +1,164 @@ +import { + Button, + Checkbox, + Column, + Form, + FormField, + FormSubmitButton, + Grid, + Label, + Loading, + Row, + Text, + TextField, +} from '@umami/react-zen'; +import { useEffect, useState } from 'react'; +import { useApi, useConfig, useMessages, useModified } from '@/components/hooks'; +import { SHARE_NAV_ITEMS } from './constants'; + +export function ShareEditForm({ + shareId, + websiteId, + onSave, + onClose, +}: { + shareId?: string; + websiteId?: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels, getErrorMessage } = useMessages(); + const { cloudMode } = useConfig(); + const { get, post } = useApi(); + const { touch } = useModified(); + const { modified } = useModified('shares'); + const [share, setShare] = useState(null); + const [isLoading, setIsLoading] = useState(!!shareId); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const isEditing = !!shareId; + + const getUrl = (slug: string) => { + if (cloudMode) { + return `${process.env.cloudUrl}/share/${slug}`; + } + return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`; + }; + + useEffect(() => { + if (!shareId) return; + + const loadShare = async () => { + setIsLoading(true); + try { + const data = await get(`/share/id/${shareId}`); + setShare(data); + } finally { + setIsLoading(false); + } + }; + loadShare(); + }, [shareId, modified]); + + const handleSubmit = async (data: any) => { + const parameters: Record = {}; + SHARE_NAV_ITEMS.forEach(section => { + section.items.forEach(item => { + parameters[item.id] = data[item.id] ?? false; + }); + }); + + setIsPending(true); + setError(null); + + try { + if (isEditing) { + await post(`/share/id/${shareId}`, { name: data.name, slug: share.slug, parameters }); + } else { + await post(`/websites/${websiteId}/shares`, { name: data.name, parameters }); + } + touch('shares'); + onSave?.(); + onClose?.(); + } catch (e) { + setError(e); + } finally { + setIsPending(false); + } + }; + + if (isLoading) { + return ; + } + + const url = isEditing ? getUrl(share?.slug || '') : null; + + // Build default values from share parameters + const defaultValues: Record = { + name: share?.name || '', + }; + SHARE_NAV_ITEMS.forEach(section => { + section.items.forEach(item => { + const defaultSelected = item.id === 'overview' || item.id === 'events'; + defaultValues[item.id] = share?.parameters?.[item.id] ?? defaultSelected; + }); + }); + + // Get all item ids for validation + const allItemIds = SHARE_NAV_ITEMS.flatMap(section => section.items.map(item => item.id)); + + return ( +
+ {({ watch }) => { + const values = watch(); + const hasSelection = allItemIds.some(id => values[id]); + + return ( + + {url && ( + + + + + )} + + + + + {SHARE_NAV_ITEMS.map(section => ( + + {formatMessage((labels as any)[section.section])} + + {section.items.map(item => ( + + {formatMessage((labels as any)[item.label])} + + ))} + + + ))} + + + {onClose && ( + + )} + + {formatMessage(labels.save)} + + + + ); + }} +
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx b/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx new file mode 100644 index 00000000..57701ac6 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx @@ -0,0 +1,46 @@ +import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen'; +import { DateDistance } from '@/components/common/DateDistance'; +import { ExternalLink } from '@/components/common/ExternalLink'; +import { useConfig, useMessages } from '@/components/hooks'; +import { ShareDeleteButton } from './ShareDeleteButton'; +import { ShareEditButton } from './ShareEditButton'; + +export function SharesTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { cloudMode } = useConfig(); + + const getUrl = (slug: string) => { + return `${cloudMode ? process.env.cloudUrl : window?.location.origin}${process.env.basePath || ''}/share/${slug}`; + }; + + return ( + + + {({ name }: any) => name} + + + {({ slug }: any) => { + const url = getUrl(slug); + return ( + + {url} + + ); + }} + + + {(row: any) => } + + + {({ id, slug }: any) => { + return ( + + + + + ); + }} + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx index 3970cdbd..d39c4531 100644 --- a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx @@ -1,14 +1,11 @@ import { Column } from '@umami/react-zen'; import { Panel } from '@/components/common/Panel'; -import { useWebsite } from '@/components/hooks'; import { WebsiteData } from './WebsiteData'; import { WebsiteEditForm } from './WebsiteEditForm'; import { WebsiteShareForm } from './WebsiteShareForm'; import { WebsiteTrackingCode } from './WebsiteTrackingCode'; export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) { - const website = useWebsite(); - return ( @@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal - + diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx index 56c6f436..8472ca97 100644 --- a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx @@ -1,93 +1,43 @@ -import { - Button, - Column, - Form, - FormButtons, - FormSubmitButton, - IconLabel, - Label, - Row, - Switch, - TextField, -} from '@umami/react-zen'; -import { RefreshCcw } from 'lucide-react'; -import { useState } from 'react'; -import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks'; -import { getRandomChars } from '@/lib/generate'; - -const generateId = () => getRandomChars(16); +import { Column, Heading, Row, Text } from '@umami/react-zen'; +import { Plus } from 'lucide-react'; +import { useMessages, useWebsiteSharesQuery } from '@/components/hooks'; +import { DialogButton } from '@/components/input/DialogButton'; +import { ShareEditForm } from './ShareEditForm'; +import { SharesTable } from './SharesTable'; export interface WebsiteShareFormProps { websiteId: string; - shareId?: string; - onSave?: () => void; - onClose?: () => void; } -export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) { - const { formatMessage, labels, messages, getErrorMessage } = useMessages(); - const [currentId, setCurrentId] = useState(shareId); - const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`); - const { cloudMode } = useConfig(); +export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) { + const { formatMessage, labels, messages } = useMessages(); + const { data, isLoading } = useWebsiteSharesQuery({ websiteId }); - const getUrl = (shareId: string) => { - if (cloudMode) { - return `${process.env.cloudUrl}/share/${shareId}`; - } - - return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`; - }; - - const url = getUrl(currentId); - - const handleGenerate = () => { - setCurrentId(generateId()); - }; - - const handleSwitch = () => { - setCurrentId(currentId ? null : generateId()); - }; - - const handleSave = async () => { - const data = { - shareId: currentId, - }; - await mutateAsync(data, { - onSuccess: async () => { - toast(formatMessage(messages.saved)); - touch(`website:${websiteId}`); - onSave?.(); - onClose?.(); - }, - }); - }; + const shares = data?.data || []; + const hasShares = shares.length > 0; return ( -
- - - {formatMessage(labels.enableShareUrl)} - - {currentId && ( - - - - - - - - - - )} - - - {onClose && } - {formatMessage(labels.save)} - - - -
+ + + {formatMessage(labels.share)} + } + label={formatMessage(labels.add)} + title={formatMessage(labels.share)} + variant="primary" + width="600px" + > + {({ close }) => } + + + {hasShares ? ( + <> + {formatMessage(messages.shareUrl)} + + + ) : ( + {formatMessage(messages.noDataAvailable)} + )} + ); } diff --git a/src/app/(main)/websites/[websiteId]/settings/constants.ts b/src/app/(main)/websites/[websiteId]/settings/constants.ts new file mode 100644 index 00000000..f4a3df80 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/constants.ts @@ -0,0 +1,30 @@ +export const SHARE_NAV_ITEMS = [ + { + section: 'traffic', + items: [ + { id: 'overview', label: 'overview' }, + { id: 'events', label: 'events' }, + { id: 'sessions', label: 'sessions' }, + { id: 'realtime', label: 'realtime' }, + { id: 'compare', label: 'compare' }, + { id: 'breakdown', label: 'breakdown' }, + ], + }, + { + section: 'behavior', + items: [ + { id: 'goals', label: 'goals' }, + { id: 'funnels', label: 'funnels' }, + { id: 'journeys', label: 'journeys' }, + { id: 'retention', label: 'retention' }, + ], + }, + { + section: 'growth', + items: [ + { id: 'utm', label: 'utm' }, + { id: 'revenue', label: 'revenue' }, + { id: 'attribution', label: 'attribution' }, + ], + }, +]; diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 7bf0a813..153f1f52 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,7 +1,14 @@ import redis from '@/lib/redis'; +import { parseRequest } from '@/lib/request'; import { ok } from '@/lib/response'; export async function POST(request: Request) { + const { error } = await parseRequest(request); + + if (error) { + return error(); + } + if (redis.enabled) { const token = request.headers.get('authorization')?.split(' ')?.[1]; diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts index bba3dde3..f8222869 100644 --- a/src/app/api/auth/sso/route.ts +++ b/src/app/api/auth/sso/route.ts @@ -1,7 +1,7 @@ import { saveAuth } from '@/lib/auth'; import redis from '@/lib/redis'; import { parseRequest } from '@/lib/request'; -import { json } from '@/lib/response'; +import { json, serverError } from '@/lib/response'; export async function POST(request: Request) { const { auth, error } = await parseRequest(request); @@ -10,9 +10,13 @@ export async function POST(request: Request) { return error(); } - if (redis.enabled) { - const token = await saveAuth({ userId: auth.user.id }, 86400); - - return json({ user: auth.user, token }); + if (!redis.enabled) { + return serverError({ + message: 'Redis is disabled', + }); } + + const token = await saveAuth({ userId: auth.user.id }, 86400); + + return json({ user: auth.user, token }); } diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index 4e40caa4..101a1224 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -17,5 +17,6 @@ export async function GET(request: Request) { telemetryDisabled: !!process.env.DISABLE_TELEMETRY, trackerScriptName: process.env.TRACKER_SCRIPT_NAME, updatesDisabled: !!process.env.DISABLE_UPDATES, + currentVersion: !!process.env.currentVersion, }); } diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts index 29e85319..b53d225d 100644 --- a/src/app/api/reports/journey/route.ts +++ b/src/app/api/reports/journey/route.ts @@ -12,11 +12,16 @@ export async function POST(request: Request) { } const { websiteId, parameters, filters } = body; + const { eventType } = parameters; if (!(await canViewWebsite(auth, websiteId))) { return unauthorized(); } + if (eventType) { + filters.eventType = eventType; + } + const queryFilters = await getQueryFilters(filters, websiteId); const data = await getJourney(websiteId, parameters, queryFilters); diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts deleted file mode 100644 index bef87c4f..00000000 --- a/src/app/api/share/[shareId]/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { secret } from '@/lib/crypto'; -import { createToken } from '@/lib/jwt'; -import { json, notFound } from '@/lib/response'; -import { getSharedWebsite } from '@/queries/prisma'; - -export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) { - const { shareId } = await params; - - const website = await getSharedWebsite(shareId); - - if (!website) { - return notFound(); - } - - const data = { websiteId: website.id }; - const token = createToken(data, secret()); - - return json({ ...data, token }); -} diff --git a/src/app/api/share/[slug]/route.ts b/src/app/api/share/[slug]/route.ts new file mode 100644 index 00000000..e7d5372f --- /dev/null +++ b/src/app/api/share/[slug]/route.ts @@ -0,0 +1,76 @@ +import { ROLES } from '@/lib/constants'; +import { secret } from '@/lib/crypto'; +import { createToken } from '@/lib/jwt'; +import prisma from '@/lib/prisma'; +import redis from '@/lib/redis'; +import { json, notFound } from '@/lib/response'; +import type { WhiteLabel } from '@/lib/types'; +import { getShareByCode, getWebsite } from '@/queries/prisma'; + +async function getAccountId(website: { userId?: string; teamId?: string }): Promise { + if (website.userId) { + return website.userId; + } + + if (website.teamId) { + const teamOwner = await prisma.client.teamUser.findFirst({ + where: { + teamId: website.teamId, + role: ROLES.teamOwner, + }, + select: { + userId: true, + }, + }); + + return teamOwner?.userId || null; + } + + return null; +} + +async function getWhiteLabel(accountId: string): Promise { + if (!redis.enabled) { + return null; + } + + const data = await redis.client.get(`white-label:${accountId}`); + + if (data) { + return data as WhiteLabel; + } + + return null; +} + +export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + const share = await getShareByCode(slug); + + if (!share) { + return notFound(); + } + + const website = await getWebsite(share.entityId); + + const data: Record = { + shareId: share.id, + websiteId: share.entityId, + parameters: share.parameters, + }; + + data.token = createToken(data, secret()); + + const accountId = await getAccountId(website); + + if (accountId) { + const whiteLabel = await getWhiteLabel(accountId); + + if (whiteLabel) { + data.whiteLabel = whiteLabel; + } + } + + return json(data); +} diff --git a/src/app/api/share/id/[shareId]/route.ts b/src/app/api/share/id/[shareId]/route.ts new file mode 100644 index 00000000..80da17b8 --- /dev/null +++ b/src/app/api/share/id/[shareId]/route.ts @@ -0,0 +1,82 @@ +import z from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { anyObjectParam } from '@/lib/schema'; +import { canDeleteEntity, canUpdateEntity, canViewEntity } from '@/permissions'; +import { deleteShare, getShare, updateShare } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { shareId } = await params; + + const share = await getShare(shareId); + + if (!(await canViewEntity(auth, share.entityId))) { + return unauthorized(); + } + + return json(share); +} + +export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) { + const schema = z.object({ + name: z.string().max(200), + slug: z.string().max(100), + parameters: anyObjectParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { shareId } = await params; + const { name, slug, parameters } = body; + + const share = await getShare(shareId); + + if (!share) { + return notFound(); + } + + if (!(await canUpdateEntity(auth, share.entityId))) { + return unauthorized(); + } + + const result = await updateShare(shareId, { + name, + slug, + parameters, + } as any); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ shareId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { shareId } = await params; + + const share = await getShare(shareId); + + if (!(await canDeleteEntity(auth, share.entityId))) { + return unauthorized(); + } + + await deleteShare(shareId); + + return ok(); +} diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts new file mode 100644 index 00000000..a772b4ab --- /dev/null +++ b/src/app/api/share/route.ts @@ -0,0 +1,39 @@ +import z from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { anyObjectParam } from '@/lib/schema'; +import { canUpdateEntity } from '@/permissions'; +import { createShare } from '@/queries/prisma'; + +export async function POST(request: Request) { + const schema = z.object({ + entityId: z.uuid(), + shareType: z.coerce.number().int(), + slug: z.string().max(100).optional(), + parameters: anyObjectParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { entityId, shareType, slug, parameters } = body; + + if (!(await canUpdateEntity(auth, entityId))) { + return unauthorized(); + } + + const share = await createShare({ + id: uuid(), + entityId, + shareType, + slug: slug || getRandomChars(16), + parameters, + }); + + return json(share); +} diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 53ef5923..c571f405 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -28,6 +28,7 @@ export async function GET(request: Request) { export async function POST(request: Request) { const schema = z.object({ name: z.string().max(50), + ownerId: z.uuid().optional(), }); const { auth, body, error } = await parseRequest(request, schema); @@ -40,7 +41,7 @@ export async function POST(request: Request) { return unauthorized(); } - const { name } = body; + const { name, ownerId } = body; const team = await createTeam( { @@ -48,7 +49,7 @@ export async function POST(request: Request) { name, accessCode: `team_${getRandomChars(16)}`, }, - auth.user.id, + ownerId ?? auth.user.id, ); return json(team); diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts index aade8aa8..e642fe3c 100644 --- a/src/app/api/users/[userId]/route.ts +++ b/src/app/api/users/[userId]/route.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { hashPassword } from '@/lib/password'; import { parseRequest } from '@/lib/request'; -import { badRequest, json, ok, unauthorized } from '@/lib/response'; +import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response'; import { userRoleParam } from '@/lib/schema'; import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions'; import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma'; @@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { const schema = z.object({ username: z.string().max(255).optional(), - password: z.string().max(255).optional(), + password: z.string().min(8).max(255).optional(), role: userRoleParam.optional(), }); @@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use const user = await getUser(userId); + if (!user) { + return notFound(); + } + const data: any = {}; if (password) { diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index dbb114cf..4335c33f 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto'; import { hashPassword } from '@/lib/password'; import { parseRequest } from '@/lib/request'; import { badRequest, json, unauthorized } from '@/lib/response'; +import { userRoleParam } from '@/lib/schema'; import { canCreateUser } from '@/permissions'; import { createUser, getUserByUsername } from '@/queries/prisma'; @@ -11,8 +12,8 @@ export async function POST(request: Request) { const schema = z.object({ id: z.uuid().optional(), username: z.string().max(255), - password: z.string(), - role: z.string().regex(/admin|user|view-only/i), + password: z.string().min(8).max(255), + role: userRoleParam, }); const { auth, body, error } = await parseRequest(request, schema); diff --git a/src/app/api/websites/[websiteId]/events/stats/route.ts b/src/app/api/websites/[websiteId]/events/stats/route.ts new file mode 100644 index 00000000..61e151d4 --- /dev/null +++ b/src/app/api/websites/[websiteId]/events/stats/route.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteEventStats } from '@/queries/sql/events/getWebsiteEventStats'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteEventStats(websiteId, filters); + + return json({ data }); +} diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts index b4c0e7e8..59f314d3 100644 --- a/src/app/api/websites/[websiteId]/route.ts +++ b/src/app/api/websites/[websiteId]/route.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; -import { SHARE_ID_REGEX } from '@/lib/constants'; import { parseRequest } from '@/lib/request'; -import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { json, ok, unauthorized } from '@/lib/response'; import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma'; @@ -33,7 +32,6 @@ export async function POST( const schema = z.object({ name: z.string().optional(), domain: z.string().optional(), - shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), }); const { auth, body, error } = await parseRequest(request, schema); @@ -43,23 +41,15 @@ export async function POST( } const { websiteId } = await params; - const { name, domain, shareId } = body; + const { name, domain } = body; if (!(await canUpdateWebsite(auth, websiteId))) { return unauthorized(); } - try { - const website = await updateWebsite(websiteId, { name, domain, shareId }); + const website = await updateWebsite(websiteId, { name, domain }); - return Response.json(website); - } catch (e: any) { - if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) { - return badRequest({ message: 'That share ID is already taken.' }); - } - - return serverError(e); - } + return Response.json(website); } export async function DELETE( diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts index 45927656..db34193e 100644 --- a/src/app/api/websites/[websiteId]/segments/route.ts +++ b/src/app/api/websites/[websiteId]/segments/route.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { uuid } from '@/lib/crypto'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; -import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema'; +import { searchParams, segmentParamSchema, segmentTypeParam } from '@/lib/schema'; import { canUpdateWebsite, canViewWebsite } from '@/permissions'; import { createSegment, getWebsiteSegments } from '@/queries/prisma'; @@ -42,7 +42,7 @@ export async function POST( const schema = z.object({ type: segmentTypeParam, name: z.string().max(200), - parameters: anyObjectParam, + parameters: segmentParamSchema, }); const { auth, body, error } = await parseRequest(request, schema); diff --git a/src/app/api/websites/[websiteId]/shares/route.ts b/src/app/api/websites/[websiteId]/shares/route.ts new file mode 100644 index 00000000..65d53771 --- /dev/null +++ b/src/app/api/websites/[websiteId]/shares/route.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { ENTITY_TYPE } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema'; +import { canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { createShare, getSharesByEntityId } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...filterParams, + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { page, pageSize, search } = query; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getSharesByEntityId(websiteId, { + page, + pageSize, + search, + }); + + return json(data); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + name: z.string().max(200), + parameters: anyObjectParam.optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { name, parameters } = body; + const shareParameters = parameters ?? {}; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const slug = getRandomChars(16); + + const share = await createShare({ + id: uuid(), + entityId: websiteId, + shareType: ENTITY_TYPE.website, + name, + slug, + parameters: shareParameters, + }); + + return json(share); +} diff --git a/src/app/api/websites/[websiteId]/stats/route.ts b/src/app/api/websites/[websiteId]/stats/route.ts index 07c8b969..9d21f4f5 100644 --- a/src/app/api/websites/[websiteId]/stats/route.ts +++ b/src/app/api/websites/[websiteId]/stats/route.ts @@ -31,7 +31,11 @@ export async function GET( const data = await getWebsiteStats(websiteId, filters); - const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate); + const { startDate, endDate } = getCompareDate( + filters.compare ?? 'prev', + filters.startDate, + filters.endDate, + ); const comparison = await getWebsiteStats(websiteId, { ...filters, diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts index e2b26c10..dd8e0ffd 100644 --- a/src/app/api/websites/route.ts +++ b/src/app/api/websites/route.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { uuid } from '@/lib/crypto'; -import redis from '@/lib/redis'; +import { fetchAccount } from '@/lib/load'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { pagingParams, searchParams } from '@/lib/schema'; @@ -52,7 +52,7 @@ export async function POST(request: Request) { const { id, name, domain, shareId, teamId } = body; if (process.env.CLOUD_MODE && !teamId) { - const account = await redis.client.get(`account:${auth.user.id}`); + const account = await fetchAccount(auth.user.id); if (!account?.hasSubscription) { const count = await getWebsiteCount(auth.user.id); diff --git a/src/app/share/[...shareId]/Footer.tsx b/src/app/share/[...shareId]/Footer.tsx deleted file mode 100644 index f2948628..00000000 --- a/src/app/share/[...shareId]/Footer.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Row, Text } from '@umami/react-zen'; -import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants'; - -export function Footer() { - return ( - - - umami {`v${CURRENT_VERSION}`} - - - ); -} diff --git a/src/app/share/[...shareId]/Header.tsx b/src/app/share/[...shareId]/Header.tsx deleted file mode 100644 index d7b7dcb4..00000000 --- a/src/app/share/[...shareId]/Header.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Icon, Row, Text, ThemeButton } from '@umami/react-zen'; -import { LanguageButton } from '@/components/input/LanguageButton'; -import { PreferencesButton } from '@/components/input/PreferencesButton'; -import { Logo } from '@/components/svg'; - -export function Header() { - return ( - - - - - - - umami - - - - - - - - - ); -} diff --git a/src/app/share/[...shareId]/ShareFooter.tsx b/src/app/share/[...shareId]/ShareFooter.tsx new file mode 100644 index 00000000..5348ac63 --- /dev/null +++ b/src/app/share/[...shareId]/ShareFooter.tsx @@ -0,0 +1,23 @@ +import { Row, Text } from '@umami/react-zen'; +import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants'; +import type { WhiteLabel } from '@/lib/types'; + +export function ShareFooter({ whiteLabel }: { whiteLabel?: WhiteLabel }) { + if (whiteLabel) { + return ( + + + {whiteLabel.name} + + + ); + } + + return ( + + + umami {`v${CURRENT_VERSION}`} + + + ); +} diff --git a/src/app/share/[...shareId]/ShareHeader.tsx b/src/app/share/[...shareId]/ShareHeader.tsx new file mode 100644 index 00000000..abd8511d --- /dev/null +++ b/src/app/share/[...shareId]/ShareHeader.tsx @@ -0,0 +1,33 @@ +import { Icon, Row, Text, ThemeButton } from '@umami/react-zen'; +import { LanguageButton } from '@/components/input/LanguageButton'; +import { PreferencesButton } from '@/components/input/PreferencesButton'; +import { Logo } from '@/components/svg'; +import type { WhiteLabel } from '@/lib/types'; + +export function ShareHeader({ whiteLabel }: { whiteLabel?: WhiteLabel }) { + const logoUrl = whiteLabel?.url || 'https://umami.is'; + const logoName = whiteLabel?.name || 'umami'; + const logoImage = whiteLabel?.image; + + return ( + + + + {logoImage ? ( + {logoName} + ) : ( + + + + )} + {logoName} + + + + + + + + + ); +} diff --git a/src/app/share/[...shareId]/ShareNav.tsx b/src/app/share/[...shareId]/ShareNav.tsx new file mode 100644 index 00000000..b494046d --- /dev/null +++ b/src/app/share/[...shareId]/ShareNav.tsx @@ -0,0 +1,143 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { SideMenu } from '@/components/common/SideMenu'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { AlignEndHorizontal, Clock, Eye, Sheet, Tag, User } from '@/components/icons'; +import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg'; + +export function ShareNav({ + shareId, + parameters, + onItemClick, +}: { + shareId: string; + parameters: Record; + onItemClick?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { pathname } = useNavigation(); + + const renderPath = (path: string) => `/share/${shareId}${path}`; + + const allItems = [ + { + section: 'traffic', + label: formatMessage(labels.traffic), + items: [ + { + id: 'overview', + label: formatMessage(labels.overview), + icon: , + path: renderPath(''), + }, + { + id: 'events', + label: formatMessage(labels.events), + icon: , + path: renderPath('/events'), + }, + { + id: 'sessions', + label: formatMessage(labels.sessions), + icon: , + path: renderPath('/sessions'), + }, + { + id: 'realtime', + label: formatMessage(labels.realtime), + icon: , + path: renderPath('/realtime'), + }, + { + id: 'compare', + label: formatMessage(labels.compare), + icon: , + path: renderPath('/compare'), + }, + { + id: 'breakdown', + label: formatMessage(labels.breakdown), + icon: , + path: renderPath('/breakdown'), + }, + ], + }, + { + section: 'behavior', + label: formatMessage(labels.behavior), + items: [ + { + id: 'goals', + label: formatMessage(labels.goals), + icon: , + path: renderPath('/goals'), + }, + { + id: 'funnels', + label: formatMessage(labels.funnels), + icon: , + path: renderPath('/funnels'), + }, + { + id: 'journeys', + label: formatMessage(labels.journeys), + icon: , + path: renderPath('/journeys'), + }, + { + id: 'retention', + label: formatMessage(labels.retention), + icon: , + path: renderPath('/retention'), + }, + ], + }, + { + section: 'growth', + label: formatMessage(labels.growth), + items: [ + { + id: 'utm', + label: formatMessage(labels.utm), + icon: , + path: renderPath('/utm'), + }, + { + id: 'revenue', + label: formatMessage(labels.revenue), + icon: , + path: renderPath('/revenue'), + }, + { + id: 'attribution', + label: formatMessage(labels.attribution), + icon: , + path: renderPath('/attribution'), + }, + ], + }, + ]; + + // Filter items based on parameters + const items = allItems + .map(section => ({ + label: section.label, + items: section.items.filter(item => parameters[item.id] !== false), + })) + .filter(section => section.items.length > 0); + + const selectedKey = items + .flatMap(e => e.items) + .find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id; + + return ( + + + + ); +} diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx index 7ed06673..91a8b298 100644 --- a/src/app/share/[...shareId]/SharePage.tsx +++ b/src/app/share/[...shareId]/SharePage.tsx @@ -1,17 +1,74 @@ 'use client'; -import { Column, useTheme } from '@umami/react-zen'; -import { useEffect } from 'react'; +import { Column, Grid, Row, useTheme } from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { useEffect, useMemo } from 'react'; +import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage'; +import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage'; +import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage'; +import { GoalsPage } from '@/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage'; +import { JourneysPage } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage'; +import { RetentionPage } from '@/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage'; +import { RevenuePage } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage'; +import { UTMPage } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage'; +import { ComparePage } from '@/app/(main)/websites/[websiteId]/compare/ComparePage'; +import { EventsPage } from '@/app/(main)/websites/[websiteId]/events/EventsPage'; +import { RealtimePage } from '@/app/(main)/websites/[websiteId]/realtime/RealtimePage'; +import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/SessionsPage'; import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader'; import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage'; import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; import { PageBody } from '@/components/common/PageBody'; import { useShareTokenQuery } from '@/components/hooks'; -import { Footer } from './Footer'; -import { Header } from './Header'; +import { MobileMenuButton } from '@/components/input/MobileMenuButton'; +import { ShareFooter } from './ShareFooter'; +import { ShareHeader } from './ShareHeader'; +import { ShareNav } from './ShareNav'; -export function SharePage({ shareId }) { +const PAGE_COMPONENTS: Record> = { + '': WebsitePage, + overview: WebsitePage, + events: EventsPage, + sessions: SessionsPage, + realtime: RealtimePage, + compare: ComparePage, + breakdown: BreakdownPage, + goals: GoalsPage, + funnels: FunnelsPage, + journeys: JourneysPage, + retention: RetentionPage, + utm: UTMPage, + revenue: RevenuePage, + attribution: AttributionPage, +}; + +// All section IDs that can be enabled/disabled via parameters +const ALL_SECTION_IDS = [ + 'overview', + 'events', + 'sessions', + 'realtime', + 'compare', + 'breakdown', + 'goals', + 'funnels', + 'journeys', + 'retention', + 'utm', + 'revenue', + 'attribution', +]; + +export function SharePage({ shareId, path = '' }: { shareId: string; path?: string }) { const { shareToken, isLoading } = useShareTokenQuery(shareId); const { setTheme } = useTheme(); + const router = useRouter(); + + // Calculate allowed sections + const allowedSections = useMemo(() => { + if (!shareToken?.parameters) return []; + const params = shareToken.parameters; + return ALL_SECTION_IDS.filter(id => params[id] !== false); + }, [shareToken?.parameters]); useEffect(() => { const url = new URL(window?.location?.href); @@ -22,20 +79,77 @@ export function SharePage({ shareId }) { } }, []); + // Redirect to the only allowed section if there's just one and we're on the base path + useEffect(() => { + if ( + allowedSections.length === 1 && + allowedSections[0] !== 'overview' && + (path === '' || path === 'overview') + ) { + router.replace(`/share/${shareId}/${allowedSections[0]}`); + } + }, [allowedSections, shareId, path, router]); + if (isLoading || !shareToken) { return null; } + const { websiteId, parameters = {}, whiteLabel } = shareToken; + + // Redirect to only allowed section - return null while redirecting + if ( + allowedSections.length === 1 && + allowedSections[0] !== 'overview' && + (path === '' || path === 'overview') + ) { + return null; + } + + // Check if the requested path is allowed + const pageKey = path || ''; + const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false; + + if (!isAllowed) { + return null; + } + + const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage; + return ( - -
- - - - -