diff --git a/.dockerignore b/.dockerignore index 71cdb8b9..61cb85b9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,5 @@ node_modules .idea .env .env.* +scripts/seed +scripts/seed-data.ts diff --git a/package.json b/package.json index 077b74d3..dc701dfb 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "test": "jest", "cypress-open": "cypress open cypress run", "cypress-run": "cypress run cypress run", + "seed-data": "tsx scripts/seed-data.ts", "lint": "biome lint .", "format": "biome format --write .", "check": "biome check --write" @@ -72,7 +73,7 @@ "@react-spring/web": "^10.0.3", "@svgr/cli": "^8.1.0", "@tanstack/react-query": "^5.90.5", - "@umami/react-zen": "^0.210.0", + "@umami/react-zen": "^0.211.0", "@umami/redis-client": "^0.29.0", "bcryptjs": "^3.0.2", "chalk": "^5.6.2", @@ -167,6 +168,7 @@ "ts-jest": "^29.4.5", "ts-node": "^10.9.1", "tsup": "^8.5.0", + "tsx": "^4.19.0", "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e19b4029..be25301f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,8 +45,8 @@ importers: specifier: ^5.90.5 version: 5.90.10(react@19.2.0) '@umami/react-zen': - specifier: ^0.210.0 - version: 0.210.0(@babel/core@7.28.3)(@types/react@19.2.6)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) + specifier: ^0.211.0 + version: 0.211.0(@babel/core@7.28.3)(@types/react@19.2.6)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) '@umami/redis-client': specifier: ^0.29.0 version: 0.29.0 @@ -323,7 +323,10 @@ importers: version: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) tsup: specifier: ^8.5.0 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + tsx: + specifier: ^4.19.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1693,8 +1696,8 @@ packages: '@next/env@15.5.3': resolution: {integrity: sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==} - '@next/env@15.5.6': - resolution: {integrity: sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q==} + '@next/env@16.0.6': + resolution: {integrity: sha512-PFTK/G/vM3UJwK5XDYMFOqt8QW42mmhSgdKDapOlCqBUAOfJN2dyOnASR/xUR/JRrro0pLohh/zOJ77xUQWQAg==} '@next/swc-darwin-arm64@15.5.3': resolution: {integrity: sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==} @@ -1702,8 +1705,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@15.5.6': - resolution: {integrity: sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==} + '@next/swc-darwin-arm64@16.0.6': + resolution: {integrity: sha512-AGzKiPlDiui+9JcPRHLI4V9WFTTcKukhJTfK9qu3e0tz+Y/88B7vo5yZoO7UaikplJEHORzG3QaBFQfkjhnL0Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1714,8 +1717,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@15.5.6': - resolution: {integrity: sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==} + '@next/swc-darwin-x64@16.0.6': + resolution: {integrity: sha512-LlLLNrK9WCIUkq2GciWDcquXYIf7vLxX8XE49gz7EncssZGL1vlHwgmURiJsUZAvk0HM1a8qb1ABDezsjAE/jw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1726,8 +1729,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@15.5.6': - resolution: {integrity: sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==} + '@next/swc-linux-arm64-gnu@16.0.6': + resolution: {integrity: sha512-r04NzmLSGGfG8EPXKVK72N5zDNnq9pa9el78LhdtqIC3zqKh74QfKHnk24DoK4PEs6eY7sIK/CnNpt30oc59kg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1738,8 +1741,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.6': - resolution: {integrity: sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==} + '@next/swc-linux-arm64-musl@16.0.6': + resolution: {integrity: sha512-hfB/QV0hA7lbD1OJxp52wVDlpffUMfyxUB5ysZbb/pBC5iuhyLcEKSVQo56PFUUmUQzbMsAtUu6k2Gh9bBtWXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1750,8 +1753,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.6': - resolution: {integrity: sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==} + '@next/swc-linux-x64-gnu@16.0.6': + resolution: {integrity: sha512-PZJushBgfvKhJBy01yXMdgL+l5XKr7uSn5jhOQXQXiH3iPT2M9iG64yHpPNGIKitKrHJInwmhPVGogZBAJOCPw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1762,8 +1765,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.6': - resolution: {integrity: sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ==} + '@next/swc-linux-x64-musl@16.0.6': + resolution: {integrity: sha512-LqY76IojrH9yS5fyATjLzlOIOgwyzBuNRqXwVxcGfZ58DWNQSyfnLGlfF6shAEqjwlDNLh4Z+P0rnOI87Y9jEw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1774,8 +1777,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@15.5.6': - resolution: {integrity: sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==} + '@next/swc-win32-arm64-msvc@16.0.6': + resolution: {integrity: sha512-eIfSNNqAkj0tqKRf0u7BVjqylJCuabSrxnpSENY3YKApqwDMeAqYPmnOwmVe6DDl3Lvkbe7cJAyP6i9hQ5PmmQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -1786,8 +1789,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.6': - resolution: {integrity: sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==} + '@next/swc-win32-x64-msvc@16.0.6': + resolution: {integrity: sha512-QGs18P4OKdK9y2F3Th42+KGnwsc2iaThOe6jxQgP62kslUU4W+g6AzI6bdIn/pslhSfxjAMU5SjakfT5Fyo/xA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2947,8 +2950,8 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@umami/react-zen@0.210.0': - resolution: {integrity: sha512-nQ8EfrSleuXMPBVabr6rDoH2VS0ca41A3V2OCQbG4HqgLJ5+Mj8gHT/aLqUz5EKNBAmMy0/XxPNAgsHwwoxrCQ==} + '@umami/react-zen@0.211.0': + resolution: {integrity: sha512-e9dfsmMYpClYU/xQ+nwFo4ktAJc6eth4k6lpdD4j47FD5PaMfSY1FK1qJ7yq/JVN0Ydomc8cuWBDZbHpG4sQmQ==} '@umami/redis-client@0.29.0': resolution: {integrity: sha512-Jaqh++jskqDB7ny75pfC02OvKp1JTS4asGDsFrRL3qy8sxL3PAl9+/mybCJe4/6vWrXDJKqpgkSfUDJq2bFjyw==} @@ -3277,8 +3280,8 @@ packages: caniuse-lite@1.0.30001741: resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} - caniuse-lite@1.0.30001756: - resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==} + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -4176,6 +4179,9 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + getos@3.2.1: resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} @@ -4194,6 +4200,10 @@ packages: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} hasBin: true + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -5037,6 +5047,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5044,13 +5058,13 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lucide-react@0.511.0: - resolution: {integrity: sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==} + lucide-react@0.543.0: + resolution: {integrity: sha512-fpVfuOQO0V3HBaOA1stIiP/A2fPCXHIleRZL16Mx3HmjTYwNSbimhnFBygs2CAfU1geexMX5ItUcWBGUaqw5CA==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@0.543.0: - resolution: {integrity: sha512-fpVfuOQO0V3HBaOA1stIiP/A2fPCXHIleRZL16Mx3HmjTYwNSbimhnFBygs2CAfU1geexMX5ItUcWBGUaqw5CA==} + lucide-react@0.555.0: + resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5161,6 +5175,10 @@ packages: resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} engines: {node: 20 || >=22} + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -5249,9 +5267,9 @@ packages: sass: optional: true - next@15.5.6: - resolution: {integrity: sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + next@16.0.6: + resolution: {integrity: sha512-2zOZ/4FdaAp5hfCU/RnzARlZzBsjaTZ/XjNQmuyYLluAPM7kcrbIkdeO2SL0Ysd1vnrSgU+GwugfeWX1cUCgCg==} + engines: {node: '>=20.9.0'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -5451,6 +5469,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + path-type@3.0.0: resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} engines: {node: '>=4'} @@ -6119,8 +6141,8 @@ packages: peerDependencies: react: '>=16.13.1' - react-hook-form@7.66.1: - resolution: {integrity: sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==} + react-hook-form@7.67.0: + resolution: {integrity: sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -6271,6 +6293,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -6883,6 +6908,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -8450,54 +8480,54 @@ snapshots: '@next/env@15.5.3': {} - '@next/env@15.5.6': {} + '@next/env@16.0.6': {} '@next/swc-darwin-arm64@15.5.3': optional: true - '@next/swc-darwin-arm64@15.5.6': + '@next/swc-darwin-arm64@16.0.6': optional: true '@next/swc-darwin-x64@15.5.3': optional: true - '@next/swc-darwin-x64@15.5.6': + '@next/swc-darwin-x64@16.0.6': optional: true '@next/swc-linux-arm64-gnu@15.5.3': optional: true - '@next/swc-linux-arm64-gnu@15.5.6': + '@next/swc-linux-arm64-gnu@16.0.6': optional: true '@next/swc-linux-arm64-musl@15.5.3': optional: true - '@next/swc-linux-arm64-musl@15.5.6': + '@next/swc-linux-arm64-musl@16.0.6': optional: true '@next/swc-linux-x64-gnu@15.5.3': optional: true - '@next/swc-linux-x64-gnu@15.5.6': + '@next/swc-linux-x64-gnu@16.0.6': optional: true '@next/swc-linux-x64-musl@15.5.3': optional: true - '@next/swc-linux-x64-musl@15.5.6': + '@next/swc-linux-x64-musl@16.0.6': optional: true '@next/swc-win32-arm64-msvc@15.5.3': optional: true - '@next/swc-win32-arm64-msvc@15.5.6': + '@next/swc-win32-arm64-msvc@16.0.6': optional: true '@next/swc-win32-x64-msvc@15.5.3': optional: true - '@next/swc-win32-x64-msvc@15.5.6': + '@next/swc-win32-x64-msvc@16.0.6': optional: true '@nodelib/fs.scandir@2.1.5': @@ -10087,21 +10117,21 @@ snapshots: '@types/node': 24.10.1 optional: true - '@umami/react-zen@0.210.0(@babel/core@7.28.3)(@types/react@19.2.6)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))': + '@umami/react-zen@0.211.0(@babel/core@7.28.3)(@types/react@19.2.6)(babel-plugin-react-compiler@19.1.0-rc.2)(immer@10.2.0)(use-sync-external-store@1.6.0(react@19.2.0))': dependencies: '@fontsource/jetbrains-mono': 5.2.8 '@internationalized/date': 3.10.0 '@react-aria/focus': 3.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@react-spring/web': 9.7.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) classnames: 2.5.1 - glob: 10.5.0 + glob: 13.0.0 highlight.js: 11.11.1 - lucide-react: 0.511.0(react@19.2.0) - next: 15.5.6(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + lucide-react: 0.555.0(react@19.2.0) + next: 16.0.6(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-aria-components: 1.13.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-dom: 19.2.0(react@19.2.0) - react-hook-form: 7.66.1(react@19.2.0) + react-hook-form: 7.67.0(react@19.2.0) react-icons: 5.5.0(react@19.2.0) thenby: 1.3.4 zustand: 5.0.8(@types/react@19.2.6)(immer@10.2.0)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)) @@ -10507,7 +10537,7 @@ snapshots: caniuse-lite@1.0.30001741: {} - caniuse-lite@1.0.30001756: {} + caniuse-lite@1.0.30001759: {} caseless@0.12.0: {} @@ -11586,6 +11616,10 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + getos@3.2.1: dependencies: async: 3.2.6 @@ -11616,6 +11650,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -12646,6 +12686,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.4: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -12654,11 +12696,11 @@ snapshots: dependencies: yallist: 4.0.0 - lucide-react@0.511.0(react@19.2.0): + lucide-react@0.543.0(react@19.2.0): dependencies: react: 19.2.0 - lucide-react@0.543.0(react@19.2.0): + lucide-react@0.555.0(react@19.2.0): dependencies: react: 19.2.0 @@ -12769,6 +12811,10 @@ snapshots: dependencies: '@isaacs/brace-expansion': 5.0.0 + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -12853,24 +12899,24 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.5.6(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.6(@babel/core@7.28.3)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: - '@next/env': 15.5.6 + '@next/env': 16.0.6 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001756 + caniuse-lite: 1.0.30001759 postcss: 8.4.31 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) styled-jsx: 5.1.6(@babel/core@7.28.3)(react@19.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.6 - '@next/swc-darwin-x64': 15.5.6 - '@next/swc-linux-arm64-gnu': 15.5.6 - '@next/swc-linux-arm64-musl': 15.5.6 - '@next/swc-linux-x64-gnu': 15.5.6 - '@next/swc-linux-x64-musl': 15.5.6 - '@next/swc-win32-arm64-msvc': 15.5.6 - '@next/swc-win32-x64-msvc': 15.5.6 + '@next/swc-darwin-arm64': 16.0.6 + '@next/swc-darwin-x64': 16.0.6 + '@next/swc-linux-arm64-gnu': 16.0.6 + '@next/swc-linux-arm64-musl': 16.0.6 + '@next/swc-linux-x64-gnu': 16.0.6 + '@next/swc-linux-x64-musl': 16.0.6 + '@next/swc-win32-arm64-msvc': 16.0.6 + '@next/swc-win32-x64-msvc': 16.0.6 babel-plugin-react-compiler: 19.1.0-rc.2 sharp: 0.34.5 transitivePeerDependencies: @@ -13055,6 +13101,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.4 + minipass: 7.1.2 + path-type@3.0.0: dependencies: pify: 3.0.0 @@ -13286,12 +13337,13 @@ snapshots: postcss: 8.5.6 ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.8.1): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.6 + tsx: 4.21.0 yaml: 2.8.1 postcss-logical@5.0.4(postcss@8.5.6): @@ -13750,7 +13802,7 @@ snapshots: '@babel/runtime': 7.28.3 react: 19.2.0 - react-hook-form@7.66.1(react@19.2.0): + react-hook-form@7.67.0(react@19.2.0): dependencies: react: 19.2.0 @@ -13952,6 +14004,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} resolve@1.22.10: @@ -14704,7 +14758,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.27.0) cac: 6.7.14 @@ -14715,7 +14769,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(yaml@2.8.1) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1) resolve-from: 5.0.0 rollup: 4.53.3 source-map: 0.7.6 @@ -14732,6 +14786,13 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.0 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 diff --git a/scripts/seed-data.ts b/scripts/seed-data.ts new file mode 100644 index 00000000..82a0564c --- /dev/null +++ b/scripts/seed-data.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +/** + * Umami Sample Data Generator + * + * Generates realistic analytics data for local development and testing. + * Creates two demo websites: + * - Demo Blog: Low traffic (~100 sessions/month) + * - Demo SaaS: Average traffic (~500 sessions/day) + * + * Usage: + * npm run seed-data # Generate 30 days of data + * npm run seed-data -- --days 90 # Generate 90 days of data + * npm run seed-data -- --clear # Clear existing demo data first + * npm run seed-data -- --verbose # Show detailed progress + */ + +import { seed, type SeedConfig } from './seed/index.js'; + +function parseArgs(): SeedConfig { + const args = process.argv.slice(2); + + const config: SeedConfig = { + days: 30, + clear: false, + verbose: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--days' && args[i + 1]) { + config.days = parseInt(args[i + 1], 10); + if (isNaN(config.days) || config.days < 1) { + console.error('Error: --days must be a positive integer'); + process.exit(1); + } + i++; + } else if (arg === '--clear') { + config.clear = true; + } else if (arg === '--verbose' || arg === '-v') { + config.verbose = true; + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else if (arg.startsWith('--days=')) { + config.days = parseInt(arg.split('=')[1], 10); + if (isNaN(config.days) || config.days < 1) { + console.error('Error: --days must be a positive integer'); + process.exit(1); + } + } + } + + return config; +} + +function printHelp(): void { + console.log(` +Umami Sample Data Generator + +Generates realistic analytics data for local development and testing. + +Usage: + npm run seed-data [options] + +Options: + --days Number of days of data to generate (default: 30) + --clear Clear existing demo data before generating + --verbose, -v Show detailed progress + --help, -h Show this help message + +Examples: + npm run seed-data # Generate 30 days of data + npm run seed-data -- --days 90 # Generate 90 days of data + npm run seed-data -- --clear # Clear existing demo data first + npm run seed-data -- --days 7 -v # Generate 7 days with verbose output + +Generated Sites: + - Demo Blog: Low traffic (~90 sessions/month) + - Demo SaaS: Average traffic (~500 sessions/day) with revenue tracking + +Note: + This script is blocked from running in production environments + (NODE_ENV=production or cloud platforms like Vercel/Netlify/Railway). +`); +} + +function checkEnvironment(): void { + const nodeEnv = process.env.NODE_ENV; + + if (nodeEnv === 'production') { + console.error('\nError: seed-data cannot run in production environment.'); + console.error('This script is only for local development and testing.\n'); + process.exit(1); + } + + if (process.env.VERCEL || process.env.NETLIFY || process.env.RAILWAY_ENVIRONMENT) { + console.error('\nError: seed-data cannot run in cloud environments.'); + console.error('This script is only for local development and testing.\n'); + process.exit(1); + } +} + +async function main(): Promise { + console.log('\nUmami Sample Data Generator\n'); + + checkEnvironment(); + + const config = parseArgs(); + + try { + await seed(config); + } catch (error) { + console.error('\nError generating seed data:', error); + process.exit(1); + } +} + +main(); diff --git a/scripts/seed/distributions/devices.ts b/scripts/seed/distributions/devices.ts new file mode 100644 index 00000000..9d8b8c00 --- /dev/null +++ b/scripts/seed/distributions/devices.ts @@ -0,0 +1,80 @@ +import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js'; + +export type DeviceType = 'desktop' | 'mobile' | 'tablet'; + +const deviceWeights: WeightedOption[] = [ + { value: 'desktop', weight: 0.55 }, + { value: 'mobile', weight: 0.4 }, + { value: 'tablet', weight: 0.05 }, +]; + +const browsersByDevice: Record[]> = { + desktop: [ + { value: 'Chrome', weight: 0.65 }, + { value: 'Safari', weight: 0.12 }, + { value: 'Firefox', weight: 0.1 }, + { value: 'Edge', weight: 0.1 }, + { value: 'Opera', weight: 0.03 }, + ], + mobile: [ + { value: 'Chrome', weight: 0.55 }, + { value: 'Safari', weight: 0.35 }, + { value: 'Samsung', weight: 0.05 }, + { value: 'Firefox', weight: 0.03 }, + { value: 'Opera', weight: 0.02 }, + ], + tablet: [ + { value: 'Safari', weight: 0.6 }, + { value: 'Chrome', weight: 0.35 }, + { value: 'Firefox', weight: 0.05 }, + ], +}; + +const osByDevice: Record[]> = { + desktop: [ + { value: 'Windows 10', weight: 0.5 }, + { value: 'Mac OS', weight: 0.3 }, + { value: 'Linux', weight: 0.12 }, + { value: 'Chrome OS', weight: 0.05 }, + { value: 'Windows 11', weight: 0.03 }, + ], + mobile: [ + { value: 'iOS', weight: 0.45 }, + { value: 'Android', weight: 0.55 }, + ], + tablet: [ + { value: 'iOS', weight: 0.75 }, + { value: 'Android', weight: 0.25 }, + ], +}; + +const screensByDevice: Record = { + desktop: [ + '1920x1080', + '2560x1440', + '1366x768', + '1440x900', + '3840x2160', + '1536x864', + '1680x1050', + '2560x1080', + ], + mobile: ['390x844', '414x896', '375x812', '360x800', '428x926', '393x873', '412x915', '360x780'], + tablet: ['1024x768', '768x1024', '834x1194', '820x1180', '810x1080', '800x1280'], +}; + +export interface DeviceInfo { + device: DeviceType; + browser: string; + os: string; + screen: string; +} + +export function getRandomDevice(): DeviceInfo { + const device = weightedRandom(deviceWeights); + const browser = weightedRandom(browsersByDevice[device]); + const os = weightedRandom(osByDevice[device]); + const screen = pickRandom(screensByDevice[device]); + + return { device, browser, os, screen }; +} diff --git a/scripts/seed/distributions/geographic.ts b/scripts/seed/distributions/geographic.ts new file mode 100644 index 00000000..ba6ebae3 --- /dev/null +++ b/scripts/seed/distributions/geographic.ts @@ -0,0 +1,144 @@ +import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js'; + +interface GeoLocation { + country: string; + region: string; + city: string; +} + +const countryWeights: WeightedOption[] = [ + { value: 'US', weight: 0.35 }, + { value: 'GB', weight: 0.08 }, + { value: 'DE', weight: 0.06 }, + { value: 'FR', weight: 0.05 }, + { value: 'CA', weight: 0.04 }, + { value: 'AU', weight: 0.03 }, + { value: 'IN', weight: 0.08 }, + { value: 'BR', weight: 0.04 }, + { value: 'JP', weight: 0.03 }, + { value: 'NL', weight: 0.02 }, + { value: 'ES', weight: 0.02 }, + { value: 'IT', weight: 0.02 }, + { value: 'PL', weight: 0.02 }, + { value: 'SE', weight: 0.01 }, + { value: 'MX', weight: 0.02 }, + { value: 'KR', weight: 0.02 }, + { value: 'SG', weight: 0.01 }, + { value: 'ID', weight: 0.02 }, + { value: 'PH', weight: 0.01 }, + { value: 'TH', weight: 0.01 }, + { value: 'VN', weight: 0.01 }, + { value: 'RU', weight: 0.02 }, + { value: 'UA', weight: 0.01 }, + { value: 'ZA', weight: 0.01 }, + { value: 'NG', weight: 0.01 }, +]; + +const regionsByCountry: Record = { + US: [ + { region: 'CA', city: 'San Francisco' }, + { region: 'CA', city: 'Los Angeles' }, + { region: 'NY', city: 'New York' }, + { region: 'TX', city: 'Austin' }, + { region: 'TX', city: 'Houston' }, + { region: 'WA', city: 'Seattle' }, + { region: 'IL', city: 'Chicago' }, + { region: 'MA', city: 'Boston' }, + { region: 'CO', city: 'Denver' }, + { region: 'GA', city: 'Atlanta' }, + { region: 'FL', city: 'Miami' }, + { region: 'PA', city: 'Philadelphia' }, + ], + GB: [ + { region: 'ENG', city: 'London' }, + { region: 'ENG', city: 'Manchester' }, + { region: 'ENG', city: 'Birmingham' }, + { region: 'SCT', city: 'Edinburgh' }, + { region: 'ENG', city: 'Bristol' }, + ], + DE: [ + { region: 'BE', city: 'Berlin' }, + { region: 'BY', city: 'Munich' }, + { region: 'HH', city: 'Hamburg' }, + { region: 'HE', city: 'Frankfurt' }, + { region: 'NW', city: 'Cologne' }, + ], + FR: [ + { region: 'IDF', city: 'Paris' }, + { region: 'ARA', city: 'Lyon' }, + { region: 'PAC', city: 'Marseille' }, + { region: 'OCC', city: 'Toulouse' }, + ], + CA: [ + { region: 'ON', city: 'Toronto' }, + { region: 'BC', city: 'Vancouver' }, + { region: 'QC', city: 'Montreal' }, + { region: 'AB', city: 'Calgary' }, + ], + AU: [ + { region: 'NSW', city: 'Sydney' }, + { region: 'VIC', city: 'Melbourne' }, + { region: 'QLD', city: 'Brisbane' }, + { region: 'WA', city: 'Perth' }, + ], + IN: [ + { region: 'MH', city: 'Mumbai' }, + { region: 'KA', city: 'Bangalore' }, + { region: 'DL', city: 'New Delhi' }, + { region: 'TN', city: 'Chennai' }, + { region: 'TG', city: 'Hyderabad' }, + ], + BR: [ + { region: 'SP', city: 'Sao Paulo' }, + { region: 'RJ', city: 'Rio de Janeiro' }, + { region: 'MG', city: 'Belo Horizonte' }, + ], + JP: [ + { region: '13', city: 'Tokyo' }, + { region: '27', city: 'Osaka' }, + { region: '23', city: 'Nagoya' }, + ], + NL: [ + { region: 'NH', city: 'Amsterdam' }, + { region: 'ZH', city: 'Rotterdam' }, + { region: 'ZH', city: 'The Hague' }, + ], +}; + +const defaultRegions = [{ region: '', city: '' }]; + +export function getRandomGeo(): GeoLocation { + const country = weightedRandom(countryWeights); + const regions = regionsByCountry[country] || defaultRegions; + const { region, city } = pickRandom(regions); + + return { country, region, city }; +} + +const languages: WeightedOption[] = [ + { value: 'en-US', weight: 0.4 }, + { value: 'en-GB', weight: 0.08 }, + { value: 'de-DE', weight: 0.06 }, + { value: 'fr-FR', weight: 0.05 }, + { value: 'es-ES', weight: 0.05 }, + { value: 'pt-BR', weight: 0.04 }, + { value: 'ja-JP', weight: 0.03 }, + { value: 'zh-CN', weight: 0.05 }, + { value: 'ko-KR', weight: 0.02 }, + { value: 'ru-RU', weight: 0.02 }, + { value: 'it-IT', weight: 0.02 }, + { value: 'nl-NL', weight: 0.02 }, + { value: 'pl-PL', weight: 0.02 }, + { value: 'hi-IN', weight: 0.04 }, + { value: 'ar-SA', weight: 0.02 }, + { value: 'tr-TR', weight: 0.02 }, + { value: 'vi-VN', weight: 0.01 }, + { value: 'th-TH', weight: 0.01 }, + { value: 'id-ID', weight: 0.02 }, + { value: 'sv-SE', weight: 0.01 }, + { value: 'da-DK', weight: 0.01 }, +]; + +export function getRandomLanguage(): string { + return weightedRandom(languages); +} diff --git a/scripts/seed/distributions/referrers.ts b/scripts/seed/distributions/referrers.ts new file mode 100644 index 00000000..5b3f2c45 --- /dev/null +++ b/scripts/seed/distributions/referrers.ts @@ -0,0 +1,163 @@ +import { weightedRandom, pickRandom, randomInt, type WeightedOption } from '../utils.js'; + +export type ReferrerType = 'direct' | 'organic' | 'social' | 'paid' | 'referral'; + +export interface ReferrerInfo { + type: ReferrerType; + domain: string | null; + path: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmContent: string | null; + utmTerm: string | null; + gclid: string | null; + fbclid: string | null; +} + +const referrerTypeWeights: WeightedOption[] = [ + { value: 'direct', weight: 0.4 }, + { value: 'organic', weight: 0.25 }, + { value: 'social', weight: 0.15 }, + { value: 'paid', weight: 0.1 }, + { value: 'referral', weight: 0.1 }, +]; + +const searchEngines = [ + { domain: 'google.com', path: '/search' }, + { domain: 'bing.com', path: '/search' }, + { domain: 'duckduckgo.com', path: '/' }, + { domain: 'yahoo.com', path: '/search' }, + { domain: 'baidu.com', path: '/s' }, +]; + +const socialPlatforms = [ + { domain: 'twitter.com', path: null }, + { domain: 'x.com', path: null }, + { domain: 'linkedin.com', path: '/feed' }, + { domain: 'facebook.com', path: null }, + { domain: 'reddit.com', path: '/r/programming' }, + { domain: 'news.ycombinator.com', path: '/item' }, + { domain: 'threads.net', path: null }, + { domain: 'bsky.app', path: null }, +]; + +const referralSites = [ + { domain: 'medium.com', path: '/@author/article' }, + { domain: 'dev.to', path: '/post' }, + { domain: 'hashnode.com', path: '/blog' }, + { domain: 'techcrunch.com', path: '/article' }, + { domain: 'producthunt.com', path: '/posts' }, + { domain: 'indiehackers.com', path: '/post' }, +]; + +interface PaidCampaign { + source: string; + medium: string; + campaign: string; + useGclid?: boolean; + useFbclid?: boolean; +} + +const paidCampaigns: PaidCampaign[] = [ + { source: 'google', medium: 'cpc', campaign: 'brand_search', useGclid: true }, + { source: 'google', medium: 'cpc', campaign: 'product_awareness', useGclid: true }, + { source: 'facebook', medium: 'paid_social', campaign: 'retargeting', useFbclid: true }, + { source: 'facebook', medium: 'paid_social', campaign: 'lookalike', useFbclid: true }, + { source: 'linkedin', medium: 'cpc', campaign: 'b2b_targeting' }, + { source: 'twitter', medium: 'paid_social', campaign: 'launch_promo' }, +]; + +const organicCampaigns = [ + { source: 'newsletter', medium: 'email', campaign: 'weekly_digest' }, + { source: 'newsletter', medium: 'email', campaign: 'product_update' }, + { source: 'partner', medium: 'referral', campaign: 'integration_launch' }, +]; + +function generateClickId(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < 32; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export function getRandomReferrer(): ReferrerInfo { + const type = weightedRandom(referrerTypeWeights); + + const result: ReferrerInfo = { + type, + domain: null, + path: null, + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmContent: null, + utmTerm: null, + gclid: null, + fbclid: null, + }; + + switch (type) { + case 'direct': + // No referrer data + break; + + case 'organic': { + const engine = pickRandom(searchEngines); + result.domain = engine.domain; + result.path = engine.path; + break; + } + + case 'social': { + const platform = pickRandom(socialPlatforms); + result.domain = platform.domain; + result.path = platform.path; + + // Some social traffic has UTM params + if (Math.random() < 0.3) { + result.utmSource = platform.domain.replace('.com', '').replace('.net', ''); + result.utmMedium = 'social'; + } + break; + } + + case 'paid': { + const campaign = pickRandom(paidCampaigns); + result.utmSource = campaign.source; + result.utmMedium = campaign.medium; + result.utmCampaign = campaign.campaign; + result.utmContent = `ad_${randomInt(1, 5)}`; + + if (campaign.useGclid) { + result.gclid = generateClickId(); + result.domain = 'google.com'; + result.path = '/search'; + } else if (campaign.useFbclid) { + result.fbclid = generateClickId(); + result.domain = 'facebook.com'; + result.path = null; + } + break; + } + + case 'referral': { + // Mix of pure referrals and organic campaigns + if (Math.random() < 0.6) { + const site = pickRandom(referralSites); + result.domain = site.domain; + result.path = site.path; + } else { + const campaign = pickRandom(organicCampaigns); + result.utmSource = campaign.source; + result.utmMedium = campaign.medium; + result.utmCampaign = campaign.campaign; + } + break; + } + } + + return result; +} diff --git a/scripts/seed/distributions/temporal.ts b/scripts/seed/distributions/temporal.ts new file mode 100644 index 00000000..da0409a9 --- /dev/null +++ b/scripts/seed/distributions/temporal.ts @@ -0,0 +1,69 @@ +import { weightedRandom, randomInt, type WeightedOption } from '../utils.js'; + +const hourlyWeights: WeightedOption[] = [ + { value: 0, weight: 0.02 }, + { value: 1, weight: 0.01 }, + { value: 2, weight: 0.01 }, + { value: 3, weight: 0.01 }, + { value: 4, weight: 0.01 }, + { value: 5, weight: 0.02 }, + { value: 6, weight: 0.03 }, + { value: 7, weight: 0.05 }, + { value: 8, weight: 0.07 }, + { value: 9, weight: 0.08 }, + { value: 10, weight: 0.09 }, + { value: 11, weight: 0.08 }, + { value: 12, weight: 0.07 }, + { value: 13, weight: 0.08 }, + { value: 14, weight: 0.09 }, + { value: 15, weight: 0.08 }, + { value: 16, weight: 0.07 }, + { value: 17, weight: 0.06 }, + { value: 18, weight: 0.05 }, + { value: 19, weight: 0.04 }, + { value: 20, weight: 0.03 }, + { value: 21, weight: 0.03 }, + { value: 22, weight: 0.02 }, + { value: 23, weight: 0.02 }, +]; + +const dayOfWeekWeights: WeightedOption[] = [ + { value: 0, weight: 0.08 }, // Sunday + { value: 1, weight: 0.16 }, // Monday + { value: 2, weight: 0.17 }, // Tuesday + { value: 3, weight: 0.17 }, // Wednesday + { value: 4, weight: 0.16 }, // Thursday + { value: 5, weight: 0.15 }, // Friday + { value: 6, weight: 0.11 }, // Saturday +]; + +export function getWeightedHour(): number { + return weightedRandom(hourlyWeights); +} + +export function getDayOfWeekMultiplier(dayOfWeek: number): number { + const weight = dayOfWeekWeights.find(d => d.value === dayOfWeek)?.weight ?? 0.14; + return weight / 0.14; // Normalize around 1.0 +} + +export function generateTimestampForDay(day: Date): Date { + const hour = getWeightedHour(); + const minute = randomInt(0, 59); + const second = randomInt(0, 59); + const millisecond = randomInt(0, 999); + + const timestamp = new Date(day); + timestamp.setHours(hour, minute, second, millisecond); + + return timestamp; +} + +export function getSessionCountForDay(baseCount: number, day: Date): number { + const dayOfWeek = day.getDay(); + const multiplier = getDayOfWeekMultiplier(dayOfWeek); + + // Add some random variance (±20%) + const variance = 0.8 + Math.random() * 0.4; + + return Math.round(baseCount * multiplier * variance); +} diff --git a/scripts/seed/generators/events.ts b/scripts/seed/generators/events.ts new file mode 100644 index 00000000..72429062 --- /dev/null +++ b/scripts/seed/generators/events.ts @@ -0,0 +1,191 @@ +import { uuid, addSeconds, randomInt } from '../utils.js'; +import { getRandomReferrer } from '../distributions/referrers.js'; +import type { SessionData } from './sessions.js'; + +export const EVENT_TYPE = { + pageView: 1, + customEvent: 2, +} as const; + +export interface PageConfig { + path: string; + title: string; + weight: number; + avgTimeOnPage: number; +} + +export interface CustomEventConfig { + name: string; + weight: number; + pages?: string[]; + data?: Record; +} + +export interface JourneyConfig { + pages: string[]; + weight: number; +} + +export interface EventData { + id: string; + websiteId: string; + sessionId: string; + visitId: string; + eventType: number; + urlPath: string; + urlQuery: string | null; + pageTitle: string | null; + hostname: string; + referrerDomain: string | null; + referrerPath: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmContent: string | null; + utmTerm: string | null; + gclid: string | null; + fbclid: string | null; + eventName: string | null; + tag: string | null; + createdAt: Date; +} + +export interface EventDataEntry { + id: string; + websiteId: string; + websiteEventId: string; + dataKey: string; + stringValue: string | null; + numberValue: number | null; + dateValue: Date | null; + dataType: number; + createdAt: Date; +} + +export interface SiteConfig { + hostname: string; + pages: PageConfig[]; + journeys: JourneyConfig[]; + customEvents: CustomEventConfig[]; +} + +function getPageTitle(pages: PageConfig[], path: string): string | null { + const page = pages.find(p => p.path === path); + return page?.title ?? null; +} + +function getPageTimeOnPage(pages: PageConfig[], path: string): number { + const page = pages.find(p => p.path === path); + return page?.avgTimeOnPage ?? 30; +} + +export function generateEventsForSession( + session: SessionData, + siteConfig: SiteConfig, + journey: string[], +): { events: EventData[]; eventDataEntries: EventDataEntry[] } { + const events: EventData[] = []; + const eventDataEntries: EventDataEntry[] = []; + const visitId = uuid(); + + let currentTime = session.createdAt; + const referrer = getRandomReferrer(); + + for (let i = 0; i < journey.length; i++) { + const pagePath = journey[i]; + const isFirstPage = i === 0; + + const eventId = uuid(); + const pageTitle = getPageTitle(siteConfig.pages, pagePath); + + events.push({ + id: eventId, + websiteId: session.websiteId, + sessionId: session.id, + visitId, + eventType: EVENT_TYPE.pageView, + urlPath: pagePath, + urlQuery: null, + pageTitle, + hostname: siteConfig.hostname, + referrerDomain: isFirstPage ? referrer.domain : null, + referrerPath: isFirstPage ? referrer.path : null, + utmSource: isFirstPage ? referrer.utmSource : null, + utmMedium: isFirstPage ? referrer.utmMedium : null, + utmCampaign: isFirstPage ? referrer.utmCampaign : null, + utmContent: isFirstPage ? referrer.utmContent : null, + utmTerm: isFirstPage ? referrer.utmTerm : null, + gclid: isFirstPage ? referrer.gclid : null, + fbclid: isFirstPage ? referrer.fbclid : null, + eventName: null, + tag: null, + createdAt: currentTime, + }); + + // Check for custom events on this page + for (const customEvent of siteConfig.customEvents) { + // Check if this event can occur on this page + if (customEvent.pages && !customEvent.pages.includes(pagePath)) { + continue; + } + + // Random chance based on weight + if (Math.random() < customEvent.weight) { + currentTime = addSeconds(currentTime, randomInt(2, 15)); + + const customEventId = uuid(); + events.push({ + id: customEventId, + websiteId: session.websiteId, + sessionId: session.id, + visitId, + eventType: EVENT_TYPE.customEvent, + urlPath: pagePath, + urlQuery: null, + pageTitle, + hostname: siteConfig.hostname, + referrerDomain: null, + referrerPath: null, + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmContent: null, + utmTerm: null, + gclid: null, + fbclid: null, + eventName: customEvent.name, + tag: null, + createdAt: currentTime, + }); + + // Generate event data if configured + if (customEvent.data) { + for (const [key, values] of Object.entries(customEvent.data)) { + const value = values[Math.floor(Math.random() * values.length)]; + const isNumber = typeof value === 'number'; + + eventDataEntries.push({ + id: uuid(), + websiteId: session.websiteId, + websiteEventId: customEventId, + dataKey: key, + stringValue: isNumber ? null : String(value), + numberValue: isNumber ? value : null, + dateValue: null, + dataType: isNumber ? 2 : 1, // 1 = string, 2 = number + createdAt: currentTime, + }); + } + } + } + } + + // Time spent on page before navigating + const timeOnPage = getPageTimeOnPage(siteConfig.pages, pagePath); + const variance = Math.floor(timeOnPage * 0.5); + const actualTime = timeOnPage + randomInt(-variance, variance); + currentTime = addSeconds(currentTime, Math.max(5, actualTime)); + } + + return { events, eventDataEntries }; +} diff --git a/scripts/seed/generators/revenue.ts b/scripts/seed/generators/revenue.ts new file mode 100644 index 00000000..deea9e6b --- /dev/null +++ b/scripts/seed/generators/revenue.ts @@ -0,0 +1,65 @@ +import { uuid, randomFloat } from '../utils.js'; +import type { EventData } from './events.js'; + +export interface RevenueConfig { + eventName: string; + minAmount: number; + maxAmount: number; + currency: string; + weight: number; +} + +export interface RevenueData { + id: string; + websiteId: string; + sessionId: string; + eventId: string; + eventName: string; + currency: string; + revenue: number; + createdAt: Date; +} + +export function generateRevenue(event: EventData, config: RevenueConfig): RevenueData | null { + if (event.eventName !== config.eventName) { + return null; + } + + if (Math.random() > config.weight) { + return null; + } + + const revenue = randomFloat(config.minAmount, config.maxAmount); + + return { + id: uuid(), + websiteId: event.websiteId, + sessionId: event.sessionId, + eventId: event.id, + eventName: event.eventName!, + currency: config.currency, + revenue: Math.round(revenue * 100) / 100, // Round to 2 decimal places + createdAt: event.createdAt, + }; +} + +export function generateRevenueForEvents( + events: EventData[], + configs: RevenueConfig[], +): RevenueData[] { + const revenueEntries: RevenueData[] = []; + + for (const event of events) { + if (!event.eventName) continue; + + for (const config of configs) { + const revenue = generateRevenue(event, config); + if (revenue) { + revenueEntries.push(revenue); + break; // Only one revenue per event + } + } + } + + return revenueEntries; +} diff --git a/scripts/seed/generators/sessions.ts b/scripts/seed/generators/sessions.ts new file mode 100644 index 00000000..1370511f --- /dev/null +++ b/scripts/seed/generators/sessions.ts @@ -0,0 +1,52 @@ +import { uuid } from '../utils.js'; +import { getRandomDevice } from '../distributions/devices.js'; +import { getRandomGeo, getRandomLanguage } from '../distributions/geographic.js'; +import { generateTimestampForDay } from '../distributions/temporal.js'; + +export interface SessionData { + id: string; + websiteId: string; + browser: string; + os: string; + device: string; + screen: string; + language: string; + country: string; + region: string; + city: string; + createdAt: Date; +} + +export function createSession(websiteId: string, day: Date): SessionData { + const deviceInfo = getRandomDevice(); + const geo = getRandomGeo(); + const language = getRandomLanguage(); + const createdAt = generateTimestampForDay(day); + + return { + id: uuid(), + websiteId, + browser: deviceInfo.browser, + os: deviceInfo.os, + device: deviceInfo.device, + screen: deviceInfo.screen, + language, + country: geo.country, + region: geo.region, + city: geo.city, + createdAt, + }; +} + +export function createSessions(websiteId: string, day: Date, count: number): SessionData[] { + const sessions: SessionData[] = []; + + for (let i = 0; i < count; i++) { + sessions.push(createSession(websiteId, day)); + } + + // Sort by createdAt to maintain chronological order + sessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + return sessions; +} diff --git a/scripts/seed/index.ts b/scripts/seed/index.ts new file mode 100644 index 00000000..5b9de8de --- /dev/null +++ b/scripts/seed/index.ts @@ -0,0 +1,378 @@ +/* eslint-disable no-console */ +import 'dotenv/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient, Prisma } from '../../src/generated/prisma/client.js'; +import { uuid, generateDatesBetween, subDays, formatNumber, progressBar } from './utils.js'; +import { createSessions, type SessionData } from './generators/sessions.js'; +import { + generateEventsForSession, + type EventData, + type EventDataEntry, +} from './generators/events.js'; +import { + generateRevenueForEvents, + type RevenueData, + type RevenueConfig, +} from './generators/revenue.js'; +import { getSessionCountForDay } from './distributions/temporal.js'; +import { + BLOG_WEBSITE_NAME, + BLOG_WEBSITE_DOMAIN, + BLOG_SESSIONS_PER_DAY, + getBlogSiteConfig, + getBlogJourney, +} from './sites/blog.js'; +import { + SAAS_WEBSITE_NAME, + SAAS_WEBSITE_DOMAIN, + SAAS_SESSIONS_PER_DAY, + getSaasSiteConfig, + getSaasJourney, + saasRevenueConfigs, +} from './sites/saas.js'; + +const BATCH_SIZE = 1000; + +type SessionCreateInput = Prisma.SessionCreateManyInput; +type WebsiteEventCreateInput = Prisma.WebsiteEventCreateManyInput; +type EventDataCreateInput = Prisma.EventDataCreateManyInput; +type RevenueCreateInput = Prisma.RevenueCreateManyInput; + +export interface SeedConfig { + days: number; + clear: boolean; + verbose: boolean; +} + +export interface SeedResult { + websites: number; + sessions: number; + events: number; + eventData: number; + revenue: number; +} + +async function batchInsertSessions( + prisma: PrismaClient, + data: SessionCreateInput[], + verbose: boolean, +): Promise { + for (let i = 0; i < data.length; i += BATCH_SIZE) { + const batch = data.slice(i, i + BATCH_SIZE); + await prisma.session.createMany({ data: batch, skipDuplicates: true }); + if (verbose) { + console.log( + ` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} session records`, + ); + } + } +} + +async function batchInsertEvents( + prisma: PrismaClient, + data: WebsiteEventCreateInput[], + verbose: boolean, +): Promise { + for (let i = 0; i < data.length; i += BATCH_SIZE) { + const batch = data.slice(i, i + BATCH_SIZE); + await prisma.websiteEvent.createMany({ data: batch, skipDuplicates: true }); + if (verbose) { + console.log( + ` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} event records`, + ); + } + } +} + +async function batchInsertEventData( + prisma: PrismaClient, + data: EventDataCreateInput[], + verbose: boolean, +): Promise { + for (let i = 0; i < data.length; i += BATCH_SIZE) { + const batch = data.slice(i, i + BATCH_SIZE); + await prisma.eventData.createMany({ data: batch, skipDuplicates: true }); + if (verbose) { + console.log( + ` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} eventData records`, + ); + } + } +} + +async function batchInsertRevenue( + prisma: PrismaClient, + data: RevenueCreateInput[], + verbose: boolean, +): Promise { + for (let i = 0; i < data.length; i += BATCH_SIZE) { + const batch = data.slice(i, i + BATCH_SIZE); + await prisma.revenue.createMany({ data: batch, skipDuplicates: true }); + if (verbose) { + console.log( + ` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} revenue records`, + ); + } + } +} + +async function findAdminUser(prisma: PrismaClient): Promise { + const adminUser = await prisma.user.findFirst({ + where: { role: 'admin' }, + select: { id: true }, + }); + + if (!adminUser) { + throw new Error( + 'No admin user found in the database.\n' + + 'Please ensure you have run the initial setup and created an admin user.\n' + + 'The default admin user is created during first build (username: admin, password: umami).', + ); + } + + return adminUser.id; +} + +async function createWebsite( + prisma: PrismaClient, + name: string, + domain: string, + adminUserId: string, +): Promise { + const websiteId = uuid(); + + await prisma.website.create({ + data: { + id: websiteId, + name, + domain, + userId: adminUserId, + createdBy: adminUserId, + }, + }); + + return websiteId; +} + +async function clearDemoData(prisma: PrismaClient): Promise { + console.log('Clearing existing demo data...'); + + const demoWebsites = await prisma.website.findMany({ + where: { + OR: [{ name: BLOG_WEBSITE_NAME }, { name: SAAS_WEBSITE_NAME }], + }, + select: { id: true }, + }); + + const websiteIds = demoWebsites.map(w => w.id); + + if (websiteIds.length === 0) { + console.log(' No existing demo websites found'); + return; + } + + console.log(` Found ${websiteIds.length} demo website(s)`); + + // Delete in correct order due to foreign key constraints + await prisma.revenue.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.eventData.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.sessionData.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.websiteEvent.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.session.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.segment.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.report.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.website.deleteMany({ where: { id: { in: websiteIds } } }); + + console.log(' Cleared existing demo data'); +} + +interface SiteGeneratorConfig { + name: string; + domain: string; + sessionsPerDay: number; + getSiteConfig: () => ReturnType; + getJourney: () => string[]; + revenueConfigs?: RevenueConfig[]; +} + +async function generateSiteData( + prisma: PrismaClient, + config: SiteGeneratorConfig, + days: Date[], + adminUserId: string, + verbose: boolean, +): Promise<{ sessions: number; events: number; eventData: number; revenue: number }> { + console.log(`\nGenerating data for ${config.name}...`); + + const websiteId = await createWebsite(prisma, config.name, config.domain, adminUserId); + console.log(` Created website: ${config.name} (${websiteId})`); + + const siteConfig = config.getSiteConfig(); + + const allSessions: SessionData[] = []; + const allEvents: EventData[] = []; + const allEventData: EventDataEntry[] = []; + const allRevenue: RevenueData[] = []; + + for (let dayIndex = 0; dayIndex < days.length; dayIndex++) { + const day = days[dayIndex]; + const sessionCount = getSessionCountForDay(config.sessionsPerDay, day); + const sessions = createSessions(websiteId, day, sessionCount); + + for (const session of sessions) { + const journey = config.getJourney(); + const { events, eventDataEntries } = generateEventsForSession(session, siteConfig, journey); + + allSessions.push(session); + allEvents.push(...events); + allEventData.push(...eventDataEntries); + + if (config.revenueConfigs) { + const revenueEntries = generateRevenueForEvents(events, config.revenueConfigs); + allRevenue.push(...revenueEntries); + } + } + + // Show progress (every day in verbose mode, otherwise every 2 days) + const shouldShowProgress = verbose || dayIndex % 2 === 0 || dayIndex === days.length - 1; + if (shouldShowProgress) { + process.stdout.write( + `\r ${progressBar(dayIndex + 1, days.length)} Day ${dayIndex + 1}/${days.length}`, + ); + } + } + + console.log(''); // New line after progress bar + + // Batch insert all data + console.log(` Inserting ${formatNumber(allSessions.length)} sessions...`); + await batchInsertSessions(prisma, allSessions as SessionCreateInput[], verbose); + + console.log(` Inserting ${formatNumber(allEvents.length)} events...`); + await batchInsertEvents(prisma, allEvents as WebsiteEventCreateInput[], verbose); + + if (allEventData.length > 0) { + console.log(` Inserting ${formatNumber(allEventData.length)} event data entries...`); + await batchInsertEventData(prisma, allEventData as EventDataCreateInput[], verbose); + } + + if (allRevenue.length > 0) { + console.log(` Inserting ${formatNumber(allRevenue.length)} revenue entries...`); + await batchInsertRevenue(prisma, allRevenue as RevenueCreateInput[], verbose); + } + + return { + sessions: allSessions.length, + events: allEvents.length, + eventData: allEventData.length, + revenue: allRevenue.length, + }; +} + +function createPrismaClient(): PrismaClient { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error( + 'DATABASE_URL environment variable is not set.\n' + + 'Please set DATABASE_URL in your .env file or environment.\n' + + 'Example: DATABASE_URL=postgresql://user:password@localhost:5432/umami', + ); + } + + let schema: string | undefined; + try { + const connectionUrl = new URL(url); + schema = connectionUrl.searchParams.get('schema') ?? undefined; + } catch { + throw new Error( + 'DATABASE_URL is not a valid URL.\n' + + 'Expected format: postgresql://user:password@host:port/database\n' + + `Received: ${url.substring(0, 30)}...`, + ); + } + + const adapter = new PrismaPg({ connectionString: url }, { schema }); + + return new PrismaClient({ + adapter, + errorFormat: 'pretty', + }); +} + +export async function seed(config: SeedConfig): Promise { + const prisma = createPrismaClient(); + + try { + const endDate = new Date(); + const startDate = subDays(endDate, config.days); + const days = generateDatesBetween(startDate, endDate); + + console.log(`\nSeed Configuration:`); + console.log( + ` Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`, + ); + console.log(` Days: ${days.length}`); + console.log(` Clear existing: ${config.clear}`); + + if (config.clear) { + await clearDemoData(prisma); + } + + // Find admin user to own the demo websites + const adminUserId = await findAdminUser(prisma); + console.log(` Using admin user: ${adminUserId}`); + + // Generate Blog site (low traffic) + const blogResults = await generateSiteData( + prisma, + { + name: BLOG_WEBSITE_NAME, + domain: BLOG_WEBSITE_DOMAIN, + sessionsPerDay: BLOG_SESSIONS_PER_DAY, + getSiteConfig: getBlogSiteConfig, + getJourney: getBlogJourney, + }, + days, + adminUserId, + config.verbose, + ); + + // Generate SaaS site (high traffic) + const saasResults = await generateSiteData( + prisma, + { + name: SAAS_WEBSITE_NAME, + domain: SAAS_WEBSITE_DOMAIN, + sessionsPerDay: SAAS_SESSIONS_PER_DAY, + getSiteConfig: getSaasSiteConfig, + getJourney: getSaasJourney, + revenueConfigs: saasRevenueConfigs, + }, + days, + adminUserId, + config.verbose, + ); + + const result: SeedResult = { + websites: 2, + sessions: blogResults.sessions + saasResults.sessions, + events: blogResults.events + saasResults.events, + eventData: blogResults.eventData + saasResults.eventData, + revenue: blogResults.revenue + saasResults.revenue, + }; + + console.log(`\n${'─'.repeat(50)}`); + console.log(`Seed Complete!`); + console.log(`${'─'.repeat(50)}`); + console.log(` Websites: ${formatNumber(result.websites)}`); + console.log(` Sessions: ${formatNumber(result.sessions)}`); + console.log(` Events: ${formatNumber(result.events)}`); + console.log(` Event Data: ${formatNumber(result.eventData)}`); + console.log(` Revenue: ${formatNumber(result.revenue)}`); + console.log(`${'─'.repeat(50)}\n`); + + return result; + } finally { + await prisma.$disconnect(); + } +} diff --git a/scripts/seed/sites/blog.ts b/scripts/seed/sites/blog.ts new file mode 100644 index 00000000..e60b8b95 --- /dev/null +++ b/scripts/seed/sites/blog.ts @@ -0,0 +1,108 @@ +import { weightedRandom, type WeightedOption } from '../utils.js'; +import type { + SiteConfig, + JourneyConfig, + PageConfig, + CustomEventConfig, +} from '../generators/events.js'; + +export const BLOG_WEBSITE_NAME = 'Demo Blog'; +export const BLOG_WEBSITE_DOMAIN = 'blog.example.com'; + +const blogPosts = [ + 'getting-started-with-analytics', + 'privacy-first-tracking', + 'understanding-your-visitors', + 'improving-page-performance', + 'seo-best-practices', + 'content-marketing-guide', + 'building-audience-trust', + 'data-driven-decisions', +]; + +export const blogPages: PageConfig[] = [ + { path: '/', title: 'Demo Blog - Home', weight: 0.25, avgTimeOnPage: 30 }, + { path: '/blog', title: 'Blog Posts', weight: 0.2, avgTimeOnPage: 45 }, + { path: '/about', title: 'About Us', weight: 0.1, avgTimeOnPage: 60 }, + { path: '/contact', title: 'Contact', weight: 0.05, avgTimeOnPage: 45 }, + ...blogPosts.map(slug => ({ + path: `/blog/${slug}`, + title: slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '), + weight: 0.05, + avgTimeOnPage: 180, + })), +]; + +export const blogJourneys: JourneyConfig[] = [ + // Direct to blog post (organic search) + { pages: ['/blog/getting-started-with-analytics'], weight: 0.15 }, + { pages: ['/blog/privacy-first-tracking'], weight: 0.12 }, + { pages: ['/blog/understanding-your-visitors'], weight: 0.1 }, + + // Homepage bounces + { pages: ['/'], weight: 0.15 }, + + // Homepage to blog listing + { pages: ['/', '/blog'], weight: 0.1 }, + + // Homepage to blog post + { pages: ['/', '/blog', '/blog/seo-best-practices'], weight: 0.08 }, + { pages: ['/', '/blog', '/blog/content-marketing-guide'], weight: 0.08 }, + + // About page visits + { pages: ['/', '/about'], weight: 0.07 }, + { pages: ['/', '/about', '/contact'], weight: 0.05 }, + + // Blog post to another + { pages: ['/blog/improving-page-performance', '/blog/data-driven-decisions'], weight: 0.05 }, + + // Longer sessions + { pages: ['/', '/blog', '/blog/building-audience-trust', '/about'], weight: 0.05 }, +]; + +export const blogCustomEvents: CustomEventConfig[] = [ + { + name: 'newsletter_signup', + weight: 0.03, + pages: ['/', '/blog'], + }, + { + name: 'share_click', + weight: 0.05, + pages: blogPosts.map(slug => `/blog/${slug}`), + data: { + platform: ['twitter', 'linkedin', 'facebook', 'copy_link'], + }, + }, + { + name: 'scroll_depth', + weight: 0.2, + pages: blogPosts.map(slug => `/blog/${slug}`), + data: { + depth: [25, 50, 75, 100], + }, + }, +]; + +export function getBlogSiteConfig(): SiteConfig { + return { + hostname: BLOG_WEBSITE_DOMAIN, + pages: blogPages, + journeys: blogJourneys, + customEvents: blogCustomEvents, + }; +} + +export function getBlogJourney(): string[] { + const journeyWeights: WeightedOption[] = blogJourneys.map(j => ({ + value: j.pages, + weight: j.weight, + })); + + return weightedRandom(journeyWeights); +} + +export const BLOG_SESSIONS_PER_DAY = 3; // ~90 sessions per month diff --git a/scripts/seed/sites/saas.ts b/scripts/seed/sites/saas.ts new file mode 100644 index 00000000..133895af --- /dev/null +++ b/scripts/seed/sites/saas.ts @@ -0,0 +1,185 @@ +import { weightedRandom, type WeightedOption } from '../utils.js'; +import type { + SiteConfig, + JourneyConfig, + PageConfig, + CustomEventConfig, +} from '../generators/events.js'; +import type { RevenueConfig } from '../generators/revenue.js'; + +export const SAAS_WEBSITE_NAME = 'Demo SaaS'; +export const SAAS_WEBSITE_DOMAIN = 'app.example.com'; + +const docsSections = [ + 'getting-started', + 'installation', + 'configuration', + 'api-reference', + 'integrations', +]; + +const blogPosts = [ + 'announcing-v2', + 'customer-success-story', + 'product-roadmap', + 'security-best-practices', +]; + +export const saasPages: PageConfig[] = [ + { path: '/', title: 'Demo SaaS - Analytics Made Simple', weight: 0.2, avgTimeOnPage: 45 }, + { path: '/features', title: 'Features', weight: 0.15, avgTimeOnPage: 90 }, + { path: '/pricing', title: 'Pricing', weight: 0.15, avgTimeOnPage: 120 }, + { path: '/docs', title: 'Documentation', weight: 0.1, avgTimeOnPage: 60 }, + { path: '/blog', title: 'Blog', weight: 0.05, avgTimeOnPage: 45 }, + { path: '/signup', title: 'Sign Up', weight: 0.08, avgTimeOnPage: 90 }, + { path: '/login', title: 'Login', weight: 0.05, avgTimeOnPage: 30 }, + { path: '/demo', title: 'Request Demo', weight: 0.05, avgTimeOnPage: 60 }, + ...docsSections.map(slug => ({ + path: `/docs/${slug}`, + title: `Docs: ${slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ')}`, + weight: 0.02, + avgTimeOnPage: 180, + })), + ...blogPosts.map(slug => ({ + path: `/blog/${slug}`, + title: slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '), + weight: 0.02, + avgTimeOnPage: 150, + })), +]; + +export const saasJourneys: JourneyConfig[] = [ + // Conversion funnel + { pages: ['/', '/features', '/pricing', '/signup'], weight: 0.12 }, + { pages: ['/', '/pricing', '/signup'], weight: 0.1 }, + { pages: ['/pricing', '/signup'], weight: 0.08 }, + + // Feature exploration + { pages: ['/', '/features'], weight: 0.1 }, + { pages: ['/', '/features', '/pricing'], weight: 0.08 }, + + // Documentation users + { pages: ['/docs', '/docs/getting-started'], weight: 0.08 }, + { pages: ['/docs/getting-started', '/docs/installation', '/docs/configuration'], weight: 0.06 }, + { pages: ['/docs/api-reference'], weight: 0.05 }, + + // Blog readers + { pages: ['/blog/announcing-v2'], weight: 0.05 }, + { pages: ['/blog/customer-success-story'], weight: 0.04 }, + + // Returning users + { pages: ['/login'], weight: 0.08 }, + + // Bounces + { pages: ['/'], weight: 0.08 }, + { pages: ['/pricing'], weight: 0.05 }, + + // Demo requests + { pages: ['/', '/demo'], weight: 0.03 }, +]; + +export const saasCustomEvents: CustomEventConfig[] = [ + { + name: 'signup_started', + weight: 0.6, + pages: ['/signup'], + data: { + plan: ['free', 'pro', 'enterprise'], + }, + }, + { + name: 'signup_completed', + weight: 0.3, + pages: ['/signup'], + data: { + plan: ['free', 'pro', 'enterprise'], + method: ['email', 'google', 'github'], + }, + }, + { + name: 'purchase', + weight: 0.15, + pages: ['/signup', '/pricing'], + data: { + plan: ['pro', 'enterprise'], + billing: ['monthly', 'annual'], + revenue: [29, 49, 99, 299], + currency: ['USD'], + }, + }, + { + name: 'demo_requested', + weight: 0.5, + pages: ['/demo'], + data: { + company_size: ['1-10', '11-50', '51-200', '200+'], + }, + }, + { + name: 'feature_viewed', + weight: 0.3, + pages: ['/features'], + data: { + feature: ['analytics', 'reports', 'api', 'integrations', 'privacy'], + }, + }, + { + name: 'cta_click', + weight: 0.15, + pages: ['/', '/features', '/pricing'], + data: { + button: ['hero_signup', 'nav_signup', 'pricing_cta', 'footer_cta'], + }, + }, + { + name: 'docs_search', + weight: 0.2, + pages: ['/docs', ...docsSections.map(s => `/docs/${s}`)], + data: { + query_type: ['api', 'setup', 'integration', 'troubleshooting'], + }, + }, +]; + +export const saasRevenueConfigs: RevenueConfig[] = [ + { + eventName: 'purchase', + minAmount: 29, + maxAmount: 29, + currency: 'USD', + weight: 0.7, // 70% Pro plan + }, + { + eventName: 'purchase', + minAmount: 299, + maxAmount: 299, + currency: 'USD', + weight: 0.3, // 30% Enterprise + }, +]; + +export function getSaasSiteConfig(): SiteConfig { + return { + hostname: SAAS_WEBSITE_DOMAIN, + pages: saasPages, + journeys: saasJourneys, + customEvents: saasCustomEvents, + }; +} + +export function getSaasJourney(): string[] { + const journeyWeights: WeightedOption[] = saasJourneys.map(j => ({ + value: j.pages, + weight: j.weight, + })); + + return weightedRandom(journeyWeights); +} + +export const SAAS_SESSIONS_PER_DAY = 500; diff --git a/scripts/seed/utils.ts b/scripts/seed/utils.ts new file mode 100644 index 00000000..7b44261e --- /dev/null +++ b/scripts/seed/utils.ts @@ -0,0 +1,85 @@ +import { v4 as uuidv4 } from 'uuid'; + +export interface WeightedOption { + value: T; + weight: number; +} + +export function weightedRandom(options: WeightedOption[]): T { + const totalWeight = options.reduce((sum, opt) => sum + opt.weight, 0); + let random = Math.random() * totalWeight; + + for (const option of options) { + random -= option.weight; + if (random <= 0) { + return option.value; + } + } + + return options[options.length - 1].value; +} + +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function randomFloat(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +export function pickRandom(array: T[]): T { + return array[Math.floor(Math.random() * array.length)]; +} + +export function shuffleArray(array: T[]): T[] { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +export function uuid(): string { + return uuidv4(); +} + +export function generateDatesBetween(startDate: Date, endDate: Date): Date[] { + const dates: Date[] = []; + const current = new Date(startDate); + current.setHours(0, 0, 0, 0); + + while (current <= endDate) { + dates.push(new Date(current)); + current.setDate(current.getDate() + 1); + } + + return dates; +} + +export function addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +} + +export function addMinutes(date: Date, minutes: number): Date { + return new Date(date.getTime() + minutes * 60 * 1000); +} + +export function addSeconds(date: Date, seconds: number): Date { + return new Date(date.getTime() + seconds * 1000); +} + +export function subDays(date: Date, days: number): Date { + return new Date(date.getTime() - days * 24 * 60 * 60 * 1000); +} + +export function formatNumber(num: number): string { + return num.toLocaleString(); +} + +export function progressBar(current: number, total: number, width = 30): string { + const percent = current / total; + const filled = Math.round(width * percent); + const empty = width - filled; + return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${Math.round(percent * 100)}%`; +} diff --git a/src/app/(main)/links/LinksTable.tsx b/src/app/(main)/links/LinksTable.tsx index 2ce4ba49..a3b4a86a 100644 --- a/src/app/(main)/links/LinksTable.tsx +++ b/src/app/(main)/links/LinksTable.tsx @@ -21,7 +21,11 @@ export function LinksTable(props: DataTableProps) { {({ slug }: any) => { const url = getSlugUrl(slug); - return {url}; + return ( + + {url} + + ); }} diff --git a/src/app/(main)/links/[linkId]/LinkHeader.tsx b/src/app/(main)/links/[linkId]/LinkHeader.tsx index b7c70f07..33f0c242 100644 --- a/src/app/(main)/links/[linkId]/LinkHeader.tsx +++ b/src/app/(main)/links/[linkId]/LinkHeader.tsx @@ -11,7 +11,7 @@ export function LinkHeader() { return ( } marginBottom="3"> - + diff --git a/src/app/(main)/pixels/PixelsTable.tsx b/src/app/(main)/pixels/PixelsTable.tsx index 48f2121b..48a84589 100644 --- a/src/app/(main)/pixels/PixelsTable.tsx +++ b/src/app/(main)/pixels/PixelsTable.tsx @@ -21,7 +21,11 @@ export function PixelsTable(props: DataTableProps) { {({ slug }: any) => { const url = getSlugUrl(slug); - return {url}; + return ( + + {url} + + ); }} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx index 7db22912..e7a92516 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx @@ -13,12 +13,19 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) { const { renderUrl, pathname } = useNavigation(); const isSettings = pathname.endsWith('/settings'); + const { formatMessage, labels } = useMessages(); + if (isSettings) { return null; } return ( - } marginBottom="3"> + } + marginBottom="3" + titleHref={renderUrl(`/websites/${website.id}`, false)} + > @@ -29,7 +36,7 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) { - Edit + {formatMessage(labels.edit)} )} diff --git a/src/app/api/batch/route.ts b/src/app/api/batch/route.ts index aae14c96..46e8b3c3 100644 --- a/src/app/api/batch/route.ts +++ b/src/app/api/batch/route.ts @@ -17,6 +17,7 @@ export async function POST(request: Request) { const errors = []; let index = 0; + let cache = null; for (const data of body) { // Recreate a fresh Request since `new Request(request)` will have the following error: // > Cannot read private member #state from an object whose class did not declare it @@ -33,9 +34,12 @@ export async function POST(request: Request) { }); const response = await send.POST(newRequest); + const responseJson = await response.json(); if (!response.ok) { - errors.push({ index, response: await response.json() }); + errors.push({ index, response: responseJson }); + } else { + cache ??= responseJson.cache; } index++; @@ -46,6 +50,7 @@ export async function POST(request: Request) { processed: body.length - errors.length, errors: errors.length, details: errors, + cache, }); } catch (e) { return serverError(e); diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index d1a7b90b..a0becc2a 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -41,6 +41,9 @@ const schema = z.object({ userAgent: z.string().optional(), timestamp: z.coerce.number().int().optional(), id: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + device: z.string().optional(), }) .refine( data => { diff --git a/src/components/common/ExternalLink.tsx b/src/components/common/ExternalLink.tsx index 6b6dbb99..dec0d16f 100644 --- a/src/components/common/ExternalLink.tsx +++ b/src/components/common/ExternalLink.tsx @@ -1,8 +1,13 @@ import { Icon, Row, Text } from '@umami/react-zen'; -import Link from 'next/link'; +import Link, { type LinkProps } from 'next/link'; +import type { ReactNode } from 'react'; import { ExternalLink as LinkIcon } from '@/components/icons'; -export function ExternalLink({ href, children, ...props }) { +export function ExternalLink({ + href, + children, + ...props +}: LinkProps & { href: string; children: ReactNode }) { return ( diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx index b4605150..6d8c122a 100644 --- a/src/components/common/PageHeader.tsx +++ b/src/components/common/PageHeader.tsx @@ -1,5 +1,6 @@ import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen'; import type { ReactNode } from 'react'; +import { LinkButton } from './LinkButton'; export function PageHeader({ title, @@ -7,6 +8,7 @@ export function PageHeader({ label, icon, showBorder = true, + titleHref, children, }: { title: string; @@ -14,6 +16,7 @@ export function PageHeader({ label?: ReactNode; icon?: ReactNode; showBorder?: boolean; + titleHref?: string; allowEdit?: boolean; className?: string; children?: ReactNode; @@ -33,7 +36,13 @@ export function PageHeader({ {icon} )} - {title && {title}} + {title && titleHref ? ( + + {title} + + ) : ( + title && {title} + )} {description && ( diff --git a/src/components/metrics/ActiveUsers.tsx b/src/components/metrics/ActiveUsers.tsx index a7f6e604..a4bc7da2 100644 --- a/src/components/metrics/ActiveUsers.tsx +++ b/src/components/metrics/ActiveUsers.tsx @@ -1,5 +1,6 @@ import { StatusLight, Text } from '@umami/react-zen'; import { useMemo } from 'react'; +import { LinkButton } from '@/components/common/LinkButton'; import { useActyiveUsersQuery, useMessages } from '@/components/hooks'; export function ActiveUsers({ @@ -27,10 +28,12 @@ export function ActiveUsers({ } return ( - - - {count} {formatMessage(labels.online)} - - + + + + {count} {formatMessage(labels.online)} + + + ); } diff --git a/src/lang/ar-SA.json b/src/lang/ar-SA.json index 6a60dd53..5b5cfa9f 100644 --- a/src/lang/ar-SA.json +++ b/src/lang/ar-SA.json @@ -289,6 +289,7 @@ "label.websites": "المواقع", "label.window": "النافذة", "label.yesterday": "الأمس", + "label.behavior": "السلوك", "message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.", "message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}", "message.bad-request": "Bad request", diff --git a/src/lang/be-BY.json b/src/lang/be-BY.json index 932ea557..1a866a92 100644 --- a/src/lang/be-BY.json +++ b/src/lang/be-BY.json @@ -20,6 +20,7 @@ "label.average": "Сярэдняе", "label.back": "Назад", "label.before": "Да", + "label.behavior": "Паводзіны", "label.boards": "Дошкі", "label.bounce-rate": "Паказчык адмоваў", "label.breakdown": "Разбіўка", diff --git a/src/lang/bg-BG.json b/src/lang/bg-BG.json index 0487498d..4b0effc8 100644 --- a/src/lang/bg-BG.json +++ b/src/lang/bg-BG.json @@ -20,6 +20,7 @@ "label.average": "Средно", "label.back": "Назад", "label.before": "Преди", + "label.behavior": "Поведение", "label.boards": "Дъски", "label.bounce-rate": "Kоефициент на отказ", "label.breakdown": "Разбивка", diff --git a/src/lang/bn-BD.json b/src/lang/bn-BD.json index 0260558b..9b9ad2f4 100644 --- a/src/lang/bn-BD.json +++ b/src/lang/bn-BD.json @@ -20,6 +20,7 @@ "label.average": "গড়", "label.back": "পেছনে", "label.before": "পূর্বে", + "label.behavior": "আচরণ", "label.boards": "বোর্ডসমূহ", "label.bounce-rate": "উপরে উঠার হার", "label.breakdown": "ভাঙ্গন", diff --git a/src/lang/bs-BA.json b/src/lang/bs-BA.json index 72d635d4..56848771 100644 --- a/src/lang/bs-BA.json +++ b/src/lang/bs-BA.json @@ -20,6 +20,7 @@ "label.average": "Prosjek", "label.back": "Nazad", "label.before": "Prije", + "label.behavior": "Ponašanje", "label.boards": "Ploče", "label.bounce-rate": "Stopa napuštanja", "label.breakdown": "Pregled po kategorijama", diff --git a/src/lang/ca-ES.json b/src/lang/ca-ES.json index 7ff46555..ab5444ce 100644 --- a/src/lang/ca-ES.json +++ b/src/lang/ca-ES.json @@ -20,6 +20,7 @@ "label.average": "Mitjana", "label.back": "Enrere", "label.before": "Abans", + "label.behavior": "Comportament", "label.boards": "Taulers", "label.bounce-rate": "Percentatge de rebot", "label.breakdown": "Desglossament", diff --git a/src/lang/cs-CZ.json b/src/lang/cs-CZ.json index a4dbc865..77d45a79 100644 --- a/src/lang/cs-CZ.json +++ b/src/lang/cs-CZ.json @@ -20,6 +20,7 @@ "label.average": "Průměr", "label.back": "Zpět", "label.before": "Před", + "label.behavior": "Chování", "label.boards": "Nástěnky", "label.bounce-rate": "Okamžité opuštění", "label.breakdown": "Rozpis", diff --git a/src/lang/da-DK.json b/src/lang/da-DK.json index 0078bcd3..f6c447ff 100644 --- a/src/lang/da-DK.json +++ b/src/lang/da-DK.json @@ -20,6 +20,7 @@ "label.average": "Gennemsnit", "label.back": "Tilbage", "label.before": "Før", + "label.behavior": "Adfærd", "label.boards": "Tavler", "label.bounce-rate": "Afvisningsprocent", "label.breakdown": "Opdeling", diff --git a/src/lang/de-CH.json b/src/lang/de-CH.json index a770949a..55734ebd 100644 --- a/src/lang/de-CH.json +++ b/src/lang/de-CH.json @@ -20,6 +20,7 @@ "label.average": "Durchschnitt", "label.back": "Zrugg", "label.before": "Vor", + "label.behavior": "Verhalte", "label.boards": "Boards", "label.bounce-rate": "Absprungsrate", "label.breakdown": "Uufschlüsselig", diff --git a/src/lang/de-DE.json b/src/lang/de-DE.json index 9bbedbf9..3436eb89 100644 --- a/src/lang/de-DE.json +++ b/src/lang/de-DE.json @@ -20,6 +20,7 @@ "label.average": "Durchschnitt", "label.back": "Zurück", "label.before": "Vor", + "label.behavior": "Verhalten", "label.boards": "Boards", "label.bounce-rate": "Absprungrate", "label.breakdown": "Aufschlüsselung", diff --git a/src/lang/el-GR.json b/src/lang/el-GR.json index df44a29d..720ff5ea 100644 --- a/src/lang/el-GR.json +++ b/src/lang/el-GR.json @@ -23,6 +23,7 @@ "label.boards": "Boards", "label.bounce-rate": "Ποσοστό αναπήδησης", "label.breakdown": "Breakdown", + "label.behavior": "Συμπεριφορά", "label.browser": "Browser", "label.browsers": "Προγράμματα περιήγησης", "label.campaigns": "Campaigns", diff --git a/src/lang/en-GB.json b/src/lang/en-GB.json index d2291495..7803dd68 100644 --- a/src/lang/en-GB.json +++ b/src/lang/en-GB.json @@ -20,6 +20,7 @@ "label.average": "Average", "label.back": "Back", "label.before": "Before", + "label.behavior": "Behavior", "label.boards": "Boards", "label.bounce-rate": "Bounce rate", "label.breakdown": "Breakdown", diff --git a/src/lang/en-US.json b/src/lang/en-US.json index 60627cca..3e588f50 100644 --- a/src/lang/en-US.json +++ b/src/lang/en-US.json @@ -289,6 +289,7 @@ "label.websites": "Websites", "label.window": "Window", "label.yesterday": "Yesterday", + "label.behavior": "Behavior", "message.action-confirmation": "Type {confirmation} in the box below to confirm.", "message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}", "message.bad-request": "Bad request", diff --git a/src/lang/es-ES.json b/src/lang/es-ES.json index 3e74ed35..e3a4d38d 100644 --- a/src/lang/es-ES.json +++ b/src/lang/es-ES.json @@ -290,6 +290,7 @@ "label.websites": "Sitios web", "label.window": "Ventana", "label.yesterday": "Ayer", + "label.behavior": "Comportamiento", "message.action-confirmation": "Escriba {confirmation} en el cuadro a continuación para confirmar.", "message.active-users": "{x} {x, plural, one {activo} other {activos}}", "message.bad-request": "Bad request", diff --git a/src/lang/fa-IR.json b/src/lang/fa-IR.json index 4e912fce..96b3da9b 100644 --- a/src/lang/fa-IR.json +++ b/src/lang/fa-IR.json @@ -20,6 +20,7 @@ "label.average": "میانگین", "label.back": "بازگشت", "label.before": "قبل از", + "label.behavior": "رفتار", "label.boards": "بردها", "label.bounce-rate": "نرخ ریزش", "label.breakdown": "تفکیک", diff --git a/src/lang/fi-FI.json b/src/lang/fi-FI.json index 06f66d6c..daaa62f0 100644 --- a/src/lang/fi-FI.json +++ b/src/lang/fi-FI.json @@ -289,6 +289,7 @@ "label.websites": "Verkkosivut", "label.window": "Window", "label.yesterday": "Yesterday", + "label.behavior": "Behavior", "message.action-confirmation": "Type {confirmation} in the box below to confirm.", "message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}", "message.bad-request": "Bad request", diff --git a/src/lang/fo-FO.json b/src/lang/fo-FO.json index e811ab5e..6fca4258 100644 --- a/src/lang/fo-FO.json +++ b/src/lang/fo-FO.json @@ -20,6 +20,7 @@ "label.average": "Miðal", "label.back": "Aftur", "label.before": "Áðrenn", + "label.behavior": "Atferð", "label.boards": "Borð", "label.bounce-rate": "Bounce prosenttal", "label.breakdown": "Sundurgreining", diff --git a/src/lang/fr-FR.json b/src/lang/fr-FR.json index 517633af..cd6a96bc 100644 --- a/src/lang/fr-FR.json +++ b/src/lang/fr-FR.json @@ -289,6 +289,9 @@ "label.websites": "Sites", "label.window": "Fenêtre", "label.yesterday": "Hier", + "label.behavior": "Comportement", + "label.traffic": "Trafic", + "label.segments": "Segments", "message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.", "message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement", "message.bad-request": "Bad request", @@ -315,13 +318,13 @@ "message.no-teams": "Vous n'avez pas créé d'équipe.", "message.no-users": "Aucun utilisateur.", "message.no-websites-configured": "Vous n'avez pas configuré de site.", - "message.not-found": "Not found", - "message.nothing-selected": "Nothing selected.", + "message.not-found": "Non trouvé!", + "message.nothing-selected": "Rien n'est sélectionné.", "message.page-not-found": "Page non trouvée.", "message.reset-website": "Pour réinitialiser ce site, taper {confirmation} ci-dessous pour confirmer.", "message.reset-website-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.", "message.saved": "Enregistré.", - "message.sever-error": "Server error", + "message.sever-error": "Erreur serveur", "message.share-url": "Les statistiques de votre site sont accessibles publiquement sur cette URL :", "message.team-already-member": "Vous êtes déjà membre de cette équipe.", "message.team-not-found": "Équipe non trouvée.", @@ -331,7 +334,7 @@ "message.transfer-user-website-to-team": "Choisir l'équipe à laquelle transférer ce site.", "message.transfer-website": "Transférer la propriété du site sur votre compte ou à une autre équipe.", "message.triggered-event": "Évènement déclenché", - "message.unauthorized": "Unauthorized", + "message.unauthorized": "Non authorisé!", "message.user-deleted": "Utilisateur supprimé.", "message.viewed-page": "Page vue", "message.visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}" diff --git a/src/lang/ga-ES.json b/src/lang/ga-ES.json index bce46d3b..20826005 100644 --- a/src/lang/ga-ES.json +++ b/src/lang/ga-ES.json @@ -289,6 +289,7 @@ "label.websites": "Sitios web", "label.window": "Ventá", "label.yesterday": "Onte", + "label.behavior": "Comportamento", "message.action-confirmation": "Escribe {confirmation} na caixa de embaixo para confirmar.", "message.active-users": "{x} actual {x, plural, one {visitante} other {visitantes}}", "message.bad-request": "Bad request", diff --git a/src/lang/he-IL.json b/src/lang/he-IL.json index 2345dc44..2d115c8d 100644 --- a/src/lang/he-IL.json +++ b/src/lang/he-IL.json @@ -278,6 +278,7 @@ "label.value": "Value", "label.view": "View", "label.view-details": "פרטים נוספים", + "label.behavior": "התנהגות", "label.view-only": "View only", "label.views": "צפיות", "label.views-per-visit": "Views per visit", diff --git a/src/lang/hi-IN.json b/src/lang/hi-IN.json index 513d6742..54cac301 100644 --- a/src/lang/hi-IN.json +++ b/src/lang/hi-IN.json @@ -20,6 +20,7 @@ "label.average": "औसत", "label.back": "पीछे", "label.before": "पहले", + "label.behavior": "व्यवहार", "label.boards": "बोर्ड्स", "label.bounce-rate": "उछाल दर", "label.breakdown": "विभाजन", diff --git a/src/lang/hr-HR.json b/src/lang/hr-HR.json index c587e92e..141ad3fd 100644 --- a/src/lang/hr-HR.json +++ b/src/lang/hr-HR.json @@ -20,6 +20,7 @@ "label.average": "Prosjek", "label.back": "Natrag ", "label.before": "Prije", + "label.behavior": "Ponašanje", "label.boards": "Ploče", "label.bounce-rate": "Stopa napuštanja", "label.breakdown": "Raspad", diff --git a/src/lang/hu-HU.json b/src/lang/hu-HU.json index 67bafa61..1666b7a1 100644 --- a/src/lang/hu-HU.json +++ b/src/lang/hu-HU.json @@ -20,6 +20,7 @@ "label.average": "Átlag", "label.back": "Vissza", "label.before": "Előtt", + "label.behavior": "Viselkedés", "label.boards": "Táblák", "label.bounce-rate": "Visszafordulási arány", "label.breakdown": "Bontás", diff --git a/src/lang/id-ID.json b/src/lang/id-ID.json index e7a26470..30a64b6c 100644 --- a/src/lang/id-ID.json +++ b/src/lang/id-ID.json @@ -20,6 +20,7 @@ "label.average": "Rata-rata", "label.back": "Kembali", "label.before": "Sebelum", + "label.behavior": "Perilaku", "label.boards": "Papan", "label.bounce-rate": "Rasio pentalan", "label.breakdown": "Rincian", diff --git a/src/lang/it-IT.json b/src/lang/it-IT.json index 019b4de5..40cb5ecd 100644 --- a/src/lang/it-IT.json +++ b/src/lang/it-IT.json @@ -20,6 +20,7 @@ "label.average": "Media", "label.back": "Indietro", "label.before": "Prima", + "label.behavior": "Comportamento", "label.boards": "Bacheche", "label.bounce-rate": "Frequenza di rimbalzo", "label.breakdown": "Dettaglio", diff --git a/src/lang/ja-JP.json b/src/lang/ja-JP.json index 2c6cc2f9..7d2bf403 100644 --- a/src/lang/ja-JP.json +++ b/src/lang/ja-JP.json @@ -20,6 +20,7 @@ "label.average": "平均", "label.back": "戻る", "label.before": "直前", + "label.behavior": "行動", "label.boards": "ボード", "label.bounce-rate": "直帰率", "label.breakdown": "故障", diff --git a/src/lang/km-KH.json b/src/lang/km-KH.json index bae63986..087e24dc 100644 --- a/src/lang/km-KH.json +++ b/src/lang/km-KH.json @@ -20,6 +20,7 @@ "label.average": "ជាមធ្យម", "label.back": "ថយក្រោយ", "label.before": "មុន", + "label.behavior": "អាកប្បកិរិយា", "label.boards": "ក្តារ", "label.bounce-rate": "ចំនួនវិលត្រឡប់", "label.breakdown": "បំបែកលម្អិត", diff --git a/src/lang/ko-KR.json b/src/lang/ko-KR.json index 5826b1a4..977eea4e 100644 --- a/src/lang/ko-KR.json +++ b/src/lang/ko-KR.json @@ -20,6 +20,7 @@ "label.average": "평균", "label.back": "뒤로", "label.before": "이전", + "label.behavior": "행동", "label.boards": "보드", "label.bounce-rate": "이탈률", "label.breakdown": "세부 사항", diff --git a/src/lang/lt-LT.json b/src/lang/lt-LT.json index 2fc89c2c..772fa34e 100644 --- a/src/lang/lt-LT.json +++ b/src/lang/lt-LT.json @@ -20,6 +20,7 @@ "label.average": "Vidurkis", "label.back": "Atgal", "label.before": "Prieš", + "label.behavior": "Elgsena", "label.boards": "Lentos", "label.bounce-rate": "Atmetimo rodiklis", "label.breakdown": "Išskaidymas", diff --git a/src/lang/mn-MN.json b/src/lang/mn-MN.json index 22e37bc4..e9c649db 100644 --- a/src/lang/mn-MN.json +++ b/src/lang/mn-MN.json @@ -20,6 +20,7 @@ "label.average": "Дундаж", "label.back": "Буцах", "label.before": "Өмнө", + "label.behavior": "Зан төлөв", "label.boards": "Самбарууд", "label.bounce-rate": "Нэг хуудас үзээд гарсан", "label.breakdown": "Задаргаа", diff --git a/src/lang/ms-MY.json b/src/lang/ms-MY.json index eb5976bd..32abd085 100644 --- a/src/lang/ms-MY.json +++ b/src/lang/ms-MY.json @@ -20,6 +20,7 @@ "label.average": "Average", "label.back": "Kembali", "label.before": "Before", + "label.behavior": "Behavior", "label.boards": "Boards", "label.bounce-rate": "Kadar lantunan", "label.breakdown": "Breakdown", diff --git a/src/lang/my-MM.json b/src/lang/my-MM.json index 77f8e040..156b0c26 100644 --- a/src/lang/my-MM.json +++ b/src/lang/my-MM.json @@ -20,6 +20,7 @@ "label.average": "ပျမ်းမျှ", "label.back": "နောက်သို့", "label.before": "မတိုင်မီ", + "label.behavior": "အပြုအမူ", "label.boards": "Boards", "label.bounce-rate": "Bounce နှုန်း", "label.breakdown": "ခွဲခြမ်းစိတ်ဖြာမှု", diff --git a/src/lang/nb-NO.json b/src/lang/nb-NO.json index fc422256..adb4468e 100644 --- a/src/lang/nb-NO.json +++ b/src/lang/nb-NO.json @@ -20,6 +20,7 @@ "label.average": "Gjennomsnnitt", "label.back": "Tilbake", "label.before": "Før", + "label.behavior": "Atferd", "label.boards": "Tavler", "label.bounce-rate": "Avvisningsfrekvens", "label.breakdown": "Nedbrytning", diff --git a/src/lang/nl-NL.json b/src/lang/nl-NL.json index e48fa89e..1ec5c020 100644 --- a/src/lang/nl-NL.json +++ b/src/lang/nl-NL.json @@ -20,6 +20,7 @@ "label.average": "Gemiddelde", "label.back": "Terug", "label.before": "Voor", + "label.behavior": "Gedrag", "label.boards": "Borden", "label.bounce-rate": "Bouncepercentage", "label.breakdown": "Opsplitsen", diff --git a/src/lang/pl-PL.json b/src/lang/pl-PL.json index 11c76763..0c8b0004 100644 --- a/src/lang/pl-PL.json +++ b/src/lang/pl-PL.json @@ -20,6 +20,7 @@ "label.average": "Średnia", "label.back": "Powrót", "label.before": "Przed", + "label.behavior": "Zachowanie", "label.boards": "Tablice", "label.bounce-rate": "Współczynnik odrzuceń", "label.breakdown": "Rozbicie", diff --git a/src/lang/pt-BR.json b/src/lang/pt-BR.json index 5ccd4869..c34c9ab0 100644 --- a/src/lang/pt-BR.json +++ b/src/lang/pt-BR.json @@ -20,6 +20,7 @@ "label.average": "Média", "label.back": "Voltar", "label.before": "Antes", + "label.behavior": "Comportamento", "label.boards": "Quadros", "label.bounce-rate": "Taxa de rejeição", "label.breakdown": "Detalhamento", diff --git a/src/lang/pt-PT.json b/src/lang/pt-PT.json index ba5b3b08..86734cb5 100644 --- a/src/lang/pt-PT.json +++ b/src/lang/pt-PT.json @@ -20,6 +20,7 @@ "label.average": "Média", "label.back": "Voltar", "label.before": "Antes", + "label.behavior": "Comportamento", "label.boards": "Quadros", "label.bounce-rate": "Taxa de rejeição", "label.breakdown": "Detalhamento", diff --git a/src/lang/ro-RO.json b/src/lang/ro-RO.json index 54707048..78633304 100644 --- a/src/lang/ro-RO.json +++ b/src/lang/ro-RO.json @@ -20,6 +20,7 @@ "label.average": "Mediu", "label.back": "Înapoi", "label.before": "Înainte", + "label.behavior": "Comportament", "label.boards": "Panouri", "label.bounce-rate": "Rata de respingere", "label.breakdown": "Detaliat", diff --git a/src/lang/ru-RU.json b/src/lang/ru-RU.json index e63e66ab..96d0538f 100644 --- a/src/lang/ru-RU.json +++ b/src/lang/ru-RU.json @@ -20,6 +20,7 @@ "label.average": "Средний", "label.back": "Назад", "label.before": "До", + "label.behavior": "Поведение", "label.boards": "Доски", "label.bounce-rate": "Отказы", "label.breakdown": "Авария", diff --git a/src/lang/si-LK.json b/src/lang/si-LK.json index 34de41de..3e6aff86 100644 --- a/src/lang/si-LK.json +++ b/src/lang/si-LK.json @@ -20,6 +20,7 @@ "label.average": "Average", "label.back": "ආපසු", "label.before": "Before", + "label.behavior": "අචරණය", "label.boards": "Boards", "label.bounce-rate": "Bounce rate", "label.breakdown": "Breakdown", diff --git a/src/lang/sk-SK.json b/src/lang/sk-SK.json index ed182331..297d5e34 100644 --- a/src/lang/sk-SK.json +++ b/src/lang/sk-SK.json @@ -20,6 +20,7 @@ "label.average": "Priemer", "label.back": "Späť", "label.before": "Pred", + "label.behavior": "Správanie", "label.boards": "Tabule", "label.bounce-rate": "Okamžité opustenie", "label.breakdown": "Rozpis", diff --git a/src/lang/sl-SI.json b/src/lang/sl-SI.json index 5834881f..3dd3226f 100644 --- a/src/lang/sl-SI.json +++ b/src/lang/sl-SI.json @@ -20,6 +20,7 @@ "label.average": "Povprečno", "label.back": "Nazaj", "label.before": "Pred", + "label.behavior": "Obnašanje", "label.boards": "Table", "label.bounce-rate": "Odbojna stopnja", "label.breakdown": "Razčlenitev", diff --git a/src/lang/sv-SE.json b/src/lang/sv-SE.json index 18d216f2..1f456b0e 100644 --- a/src/lang/sv-SE.json +++ b/src/lang/sv-SE.json @@ -20,6 +20,7 @@ "label.average": "Genomsnitt", "label.back": "Tillbaka", "label.before": "Före", + "label.behavior": "Beteende", "label.boards": "Anslagstavlor", "label.bounce-rate": "Avvisningsfrekvens", "label.breakdown": "Analys", diff --git a/src/lang/ta-IN.json b/src/lang/ta-IN.json index ad992605..9e33d7b6 100644 --- a/src/lang/ta-IN.json +++ b/src/lang/ta-IN.json @@ -21,6 +21,7 @@ "label.back": "பின்னால்", "label.before": "Before", "label.boards": "Boards", + "label.behavior": "நடத்தை", "label.bounce-rate": "துள்ளல் விகிதம்", "label.breakdown": "Breakdown", "label.browser": "Browser", diff --git a/src/lang/th-TH.json b/src/lang/th-TH.json index 97df9fe3..b94ca905 100644 --- a/src/lang/th-TH.json +++ b/src/lang/th-TH.json @@ -20,6 +20,7 @@ "label.average": "Average", "label.back": "ย้อนกลับ", "label.before": "Before", + "label.behavior": "พฤติกรรม", "label.boards": "Boards", "label.bounce-rate": "อัตราตีกลับ", "label.breakdown": "Breakdown", diff --git a/src/lang/tr-TR.json b/src/lang/tr-TR.json index 4f8af0b3..3a2dce4b 100644 --- a/src/lang/tr-TR.json +++ b/src/lang/tr-TR.json @@ -20,6 +20,7 @@ "label.average": "Ortalama", "label.back": "Geri", "label.before": "Önce", + "label.behavior": "Davranış", "label.boards": "Panolar", "label.bounce-rate": "Tek sayfa ziyaret oranı", "label.breakdown": "Dağılım", diff --git a/src/lang/uk-UA.json b/src/lang/uk-UA.json index 39c08db9..768015be 100644 --- a/src/lang/uk-UA.json +++ b/src/lang/uk-UA.json @@ -20,6 +20,7 @@ "label.average": "Середній", "label.back": "Назад", "label.before": "До", + "label.behavior": "Поведінка", "label.boards": "Дошки", "label.bounce-rate": "Показник відмов", "label.breakdown": "Розподіл", diff --git a/src/lang/ur-PK.json b/src/lang/ur-PK.json index 66f33b9f..5cc31212 100644 --- a/src/lang/ur-PK.json +++ b/src/lang/ur-PK.json @@ -20,6 +20,7 @@ "label.average": "Average", "label.back": "پیچھے", "label.before": "Before", + "label.behavior": "رویے", "label.boards": "Boards", "label.bounce-rate": "اچھال کی شرح", "label.breakdown": "Breakdown", diff --git a/src/lang/uz-UZ.json b/src/lang/uz-UZ.json index e8a574c7..cf58945f 100644 --- a/src/lang/uz-UZ.json +++ b/src/lang/uz-UZ.json @@ -15,6 +15,7 @@ "label.average": "Oʻrtacha", "label.back": "Orqaga", "label.before": "Oldin", + "label.behavior": "Xulq-atvor", "label.bounce-rate": "Chiqib ketish darajasi", "label.breakdown": "Tahlil", "label.browser": "Brauzer", diff --git a/src/lang/vi-VN.json b/src/lang/vi-VN.json index 5c9a78a2..fc0a8c13 100644 --- a/src/lang/vi-VN.json +++ b/src/lang/vi-VN.json @@ -15,6 +15,7 @@ "label.average": "Trung bình", "label.back": "Quay lại", "label.before": "Trước đó", + "label.behavior": "Hành vi", "label.bounce-rate": "Tỷ lệ thoát trang", "label.breakdown": "Phân tích chi tiết", "label.browser": "Trình duyệt", diff --git a/src/lang/zh-CN.json b/src/lang/zh-CN.json index 894ac35c..c6f01dd5 100644 --- a/src/lang/zh-CN.json +++ b/src/lang/zh-CN.json @@ -20,6 +20,7 @@ "label.average": "平均", "label.back": "返回", "label.before": "之前", + "label.behavior": "行为", "label.boards": "看板", "label.bounce-rate": "跳出率", "label.breakdown": "故障", diff --git a/src/lang/zh-TW.json b/src/lang/zh-TW.json index 7b19cb25..030d11dc 100644 --- a/src/lang/zh-TW.json +++ b/src/lang/zh-TW.json @@ -20,6 +20,7 @@ "label.average": "平均", "label.back": "返回", "label.before": "之前", + "label.behavior": "行為", "label.boards": "看板", "label.bounce-rate": "跳出率", "label.breakdown": "細項分析", diff --git a/src/lib/detect.ts b/src/lib/detect.ts index f3301295..68cb6672 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -114,9 +114,9 @@ export async function getClientInfo(request: Request, payload: Record