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