Compare commits

...

91 commits

Author SHA1 Message Date
Mike Cao
60271779de Fixed Docker build.
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-12-03 23:53:22 -08:00
Mike Cao
33e927ed1f Bump version 3.0.2.
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-03 23:01:22 -08:00
Mike Cao
2993db14f0 Updated README. 2025-12-03 19:37:51 -08:00
Mike Cao
1483241494 Merge branch 'dev' of https://github.com/umami-software/umami into dev
Some checks are pending
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-03 18:39:45 -08:00
Mike Cao
c427c6f547 Fixed replica logic. 2025-12-03 17:05:14 -08:00
Mike Cao
33cb195fd0 Merge remote-tracking branch 'origin/dev' into dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
# Conflicts:
#	package.json
#	pnpm-lock.yaml
#	src/app/(main)/links/[linkId]/LinkHeader.tsx
#	src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
2025-12-03 15:13:50 -08:00
Mike Cao
64767b1896 Updated next. Fixed link RSC fetch. 2025-12-03 15:11:40 -08:00
Francis Cao
be1b787789 Merge branch 'dependabot/npm_and_yarn/next-15.5.7' of https://github.com/umami-software/umami into dev 2025-12-03 15:00:47 -08:00
Francis Cao
dae7327ed3 Fix date range increment function
Closes #3828
2025-12-03 14:47:56 -08:00
dependabot[bot]
a06490af74
Bump next from 15.5.3 to 15.5.7
Bumps [next](https://github.com/vercel/next.js) from 15.5.3 to 15.5.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.3...v15.5.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 19:53:10 +00:00
Francis Cao
65f657dd23 Revert "prisma schema boards + varchar length increase"
Some checks are pending
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
This reverts commit cb034a1371.
2025-12-03 11:08:54 -08:00
Francis Cao
6b584338e3 fix PageHeader type errors 2025-12-03 11:06:03 -08:00
Mike Cao
41d2a24f9d Merge branch 'master' into dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
# Conflicts:
#	src/components/common/PageHeader.tsx
#	src/components/metrics/ActiveUsers.tsx
2025-12-03 00:17:44 -08:00
Mike Cao
1ae13513d2 Merge branch 'dev' of https://github.com/umami-software/umami into dev 2025-12-03 00:16:22 -08:00
Mike Cao
9a269ab811
Merge pull request #3805 from prince0xdev/feat/mobile-navigation-improvement
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Improve mobile navigation: clickable “online” badge & page title
2025-12-03 00:16:16 -08:00
Mike Cao
32aee652a5
Merge pull request #3824 from IndraGunawan/disable-prefetch-view-link
fix: disable prefetch for Links view button
2025-12-03 00:14:11 -08:00
Mike Cao
16cae691f6 Don't prefetch links/pixels. Closes #3814 2025-12-03 00:03:56 -08:00
Mike Cao
1390e09400 Merge branch 'dev' of https://github.com/umami-software/umami into dev 2025-12-03 00:01:36 -08:00
Mike Cao
23ff20a10b
Merge pull request #3809 from RaenonX/master
Allow `browser` / `os` / `device` override in `payload` & return `cache` from `/api/batch`
2025-12-03 00:01:30 -08:00
Mike Cao
58acee8d25 Merge branch 'dev' of https://github.com/umami-software/umami into dev 2025-12-02 23:32:53 -08:00
Mike Cao
a0940d78a7 Updated packages. 2025-12-02 23:32:44 -08:00
Indra Gunawan
89b985652a fix: disable prefetch for Links view button 2025-12-03 15:31:54 +08:00
Mike Cao
7b3be59c8d
Merge pull request #3819 from Lokimorty/seed-sample-data
feat(dev): add sample data generator script
2025-12-02 23:30:55 -08:00
Mike Cao
b08413ebea
Merge branch 'dev' into seed-sample-data 2025-12-02 23:30:47 -08:00
Francis Cao
f47e1072d9 remove locale code from SharePage
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-02 16:10:11 -08:00
Francis Cao
22f1b7d7c9 add theme and locale url parameter options to share page
Closes #3754
2025-12-02 15:57:45 -08:00
Francis Cao
b0f38b266b truncate large legend labels
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Closes #3813
2025-12-02 14:44:10 -08:00
Mike Cao
e7cb613cec Merge remote-tracking branch 'origin/dev' into dev 2025-12-02 13:47:01 -08:00
Mike Cao
170821e2f9 Populate teams on login.
Closes #3796
2025-12-02 13:45:49 -08:00
Francis Cao
935517ce3a add cascading deletes to revenue/segment table
Closes #3798
2025-12-02 09:41:46 -08:00
Arthur Sepiol
c481bc5dcc chore: exclude seed scripts from Docker builds 2025-12-02 20:25:25 +03:00
Arthur Sepiol
b7807ed466 feat(dev): add sample data generator script
Adds a CLI tool to generate realistic analytics data for local development and testing.
Creates two demo websites with varying traffic patterns and realistic user behavior distributions.
2025-12-02 13:43:59 +03:00
Mike Cao
f5896f071b Handle user account redirect.
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-01 23:33:57 -08:00
Mike Cao
06251e1317
Merge pull request #3803 from imsyedabdullah/3802-team-to-user-switch
Issue#3802 - Team to user switch fixed
2025-12-01 23:15:00 -08:00
Mike Cao
9a2827b50d
Merge pull request #3811 from IndraGunawan/support-uuidv7
add support for UUID v7
2025-12-01 23:11:23 -08:00
Mike Cao
b0c1f9041d
Merge pull request #3760 from umami-software/dependabot/npm_and_yarn/js-yaml-3.14.2
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Bump js-yaml from 3.14.1 to 3.14.2
2025-12-01 15:25:48 -08:00
Francis Cao
cb034a1371 prisma schema boards + varchar length increase
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-01 11:34:43 -08:00
Mike Cao
4b954fbc7c
Merge pull request #3806 from prince0xdev/feat/thumbnail-team-memory
Add favicon icons and auto-redirect to last selected team
2025-12-01 10:43:58 -08:00
Mike Cao
f5d6d0ebaf
Merge branch 'dev' into feat/thumbnail-team-memory 2025-12-01 10:43:48 -08:00
Mike Cao
3071ee8b88
Merge branch 'dev' into 3802-team-to-user-switch 2025-12-01 10:12:19 -08:00
Indra Gunawan
b16b98ffe8
Merge branch 'dev' into support-uuidv7 2025-12-01 17:02:26 +08:00
Indra Gunawan
4d70c3baf1 add support for UUID v7 2025-12-01 16:48:58 +08:00
RaenonX
c86ea1a74f
Updated /api/batch to return cache 2025-12-01 04:06:17 +08:00
RaenonX
805bc57bbb
Added browser / os / device override in payload 2025-12-01 04:06:17 +08:00
RaenonX
92a7355ce3
Fixed /api/batch request recreation failure 2025-12-01 04:06:10 +08:00
Mike Cao
875c03bca1
Merge pull request #3801 from Lokimorty/skip-realtime-anim
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
fix: skip realtime chart animation when data unchanged
2025-11-29 23:31:36 -08:00
Prince EKPINSE
e5a5aeecb5 feat(#3748): redirect user to last selected team on login 2025-11-29 16:54:45 +01:00
Prince EKPINSE
046cb6ef62 feat(#3748): restore favicon icons in websites list 2025-11-29 16:43:21 +01:00
Prince EKPINSE
beb2bc0a06 feat: improve mobile navigation with clickable page elements (#3770) 2025-11-29 13:53:32 +01:00
Prince EKPINSE
776e404c6f fix: [#3778] update 'Edit' word to support translation 2025-11-29 12:40:22 +01:00
Mike Cao
3379cc6e89 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	pnpm-lock.yaml
2025-11-28 00:34:12 -08:00
Mike Cao
d7fd22645c Fixed nav menus. 2025-11-28 00:33:53 -08:00
Syed Abdullah
50bfee3328 Moved the redirect to else statement 2025-11-28 06:30:22 +00:00
Syed Abdullah
a645dc7ba5 Issue#3802 - Team to user switch fixed 2025-11-28 06:10:19 +00:00
Arthur Sepiol
f5b5f159ec fix: skip realtime chart animation when data unchanged 2025-11-28 02:17:16 +03:00
Francis Cao
3cc2c5b7a8 downgrade prisma until 3.1 release
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-26 11:09:21 -08:00
Francis Cao
5c9403f748 Merge branch 'analytics' of https://github.com/umami-software/umami into dev 2025-11-26 11:03:50 -08:00
Francis Cao
ac6ed9d762 add case-insensitivity to currency comparison
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-26 11:01:58 -08:00
Mike Cao
5ea2a8659c
Merge pull request #3755 from Maxime-J/docker-prisma-engine
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Permit Docker use in read-only mode and/or offline env.
2025-11-25 21:33:37 -08:00
Mike Cao
a19b92a5cb
Merge pull request #3756 from Abrar74774/master
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
docs: remove underlines between badges in README.md
2025-11-25 21:31:35 -08:00
Mike Cao
dcc1ae1864
Merge pull request #3787 from RaenonX/master
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Fixed `/api/batch` request recreation failure
2025-11-25 16:08:02 -08:00
Mike Cao
5a44e6deb2
Merge pull request #3791 from travzhang/master
fix: fix SQL syntax error in getPageviewExpandedMetrics query
2025-11-25 16:05:58 -08:00
Travis Zhang
67e1af7e55
fix: fix SQL syntax error in getPageviewExpandedMetrics query 2025-11-25 21:13:35 +08:00
RaenonX
2b34cbeb37
Fixed /api/batch request recreation failure 2025-11-24 08:07:31 +08:00
Mike Cao
0ddec97bf0 Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-22 22:43:39 -08:00
Mike Cao
fa8d8055df Reformatted files with biome. 2025-11-22 22:42:42 -08:00
Francis Cao
67981db524 Remove no longer applicable CloudFlare header test
Closes #3773
2025-11-22 22:28:24 -08:00
Francis Cao
099c8bf1b4 Revert "remove relationMode from schema file"
This reverts commit bc737268b6.
2025-11-22 22:24:52 -08:00
Mike Cao
d51f0641a6 Merge branch 'dev' of https://github.com/umami-software/umami into dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
# Conflicts:
#	prisma/schema.prisma
2025-11-22 16:40:42 -08:00
Mike Cao
b9d52af215 Updated prisma. 2025-11-21 21:35:11 -08:00
Francis Cao
94321192b8 Allow view-only to still join team
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
Closes #3764
2025-11-21 14:43:58 -08:00
Francis Cao
ffa97d34e2 Fix Revenue country sums error
Closes #3769
2025-11-21 14:34:12 -08:00
Francis Cao
bc737268b6 remove relationMode from schema file 2025-11-21 13:43:54 -08:00
Mike Cao
b45971da33 Merge remote-tracking branch 'origin/dev' into dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-20 20:56:29 -08:00
Mike Cao
15fda927ce Switched to biome. 2025-11-20 20:56:16 -08:00
Francis Cao
af958b6462 fix DateDistance null logic
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-20 11:24:59 -08:00
Mike Cao
aaa1f9dc58 Merge branch 'dev'
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-18 10:27:02 -08:00
Mike Cao
abc1b50ad0 Reordered IP headers.
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-18 10:25:08 -08:00
Mike Cao
24b017cad8
Merge pull request #3765 from umami-software/dev
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
v3.0.1
2025-11-17 22:39:48 -08:00
Mike Cao
ef3f7274e3 Remember last team.
Some checks are pending
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-17 19:12:25 -08:00
dependabot[bot]
e6586c60b1
Bump js-yaml from 3.14.1 to 3.14.2
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.14.1 to 3.14.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/3.14.1...3.14.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 3.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-17 18:44:11 +00:00
Abrar74774
d34ad1d07f docs: remove underlines between bandges in README.md 2025-11-15 23:34:26 +03:00
Maxime-J
46388f8b41 Include prisma engine in Docker image 2025-11-15 15:32:06 +01:00
Mike Cao
1852acc333 Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-11-14 15:46:59 -08:00
Mike Cao
cb63e49a9b Fixed triggered event lookup. Closes #3742. 2025-11-14 15:42:23 -08:00
Mike Cao
d382ad2975
Merge pull request #3682 from rkoh-rq/patch-1
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
fix: quote "event" reserved keyword in journey queries
2025-11-14 11:44:31 -08:00
Mike Cao
b1dc690e2f
Merge branch 'dev' into patch-1 2025-11-14 11:44:20 -08:00
Francis Cao
cc8254985b Increase resetWebsite timeout. fix retention bug returning decimal day_number in CH.
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Has been cancelled
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
Closes #3698
2025-11-14 09:11:26 -08:00
Francis Cao
a3f32b036d revert getDateStringSQL for CH 2025-11-14 08:10:13 -08:00
Mike Cao
5ded9abbfe Added data-fetch-credentials attribute. Closes #3644
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-11-13 19:42:04 -08:00
rkoh-rq
3cb7fa34b0
fix: quote "event" reserved keyword in journey queries
Fixes PostgreSQL syntax error by quoting the "event" column alias. This was causing the journey query to fail.

"event" is a reserved keyword in PostgreSQL. Added double quotes to treat it as an identifier rather than a keyword.

Changes:
- Quote "event" in PostgreSQL
- Quote "event" in ClickHouse for consistency
2025-11-04 11:00:33 +08:00
646 changed files with 5544 additions and 4879 deletions

View file

@ -7,3 +7,5 @@ node_modules
.idea
.env
.env.*
scripts/seed
scripts/seed-data.ts

View file

@ -1 +0,0 @@
/src/generated/

View file

@ -1,51 +0,0 @@
{
"env": {
"browser": true,
"es2020": true,
"node": true,
"jquery": true,
"jest": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 11,
"sourceType": "module"
},
"extends": [
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:import/errors",
"plugin:import/typescript",
"plugin:css-modules/recommended",
"plugin:cypress/recommended",
"prettier",
"next"
],
"plugins": ["@typescript-eslint", "prettier", "promise", "css-modules", "cypress"],
"rules": {
"no-console": "error",
"react/display-name": "off",
"react-hooks/exhaustive-deps": "off",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"import/no-anonymous-default-export": "off",
"import/no-named-as-default": "off",
"css-modules/no-unused-class": "off",
"@next/next/no-img-element": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }],
"@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }],
"@typescript-eslint/triple-slash-reference": "off"
},
"globals": {
"React": "writable"
}
}

View file

@ -1,2 +0,0 @@
/public/script.js
/src/generated/

View file

@ -1,7 +0,0 @@
{
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all"
}

View file

@ -1,5 +1,7 @@
ARG NODE_IMAGE_VERSION="22-alpine"
# Install dependencies only when needed
FROM node:22-alpine AS deps
FROM node:${NODE_IMAGE_VERSION} AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
@ -8,26 +10,25 @@ RUN npm install -g pnpm
RUN pnpm install --frozen-lockfile
# Rebuild the source code only when needed
FROM node:22-alpine AS builder
FROM node:${NODE_IMAGE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY docker/middleware.ts ./src
ARG DATABASE_TYPE
ARG BASE_PATH
ENV DATABASE_TYPE=$DATABASE_TYPE
ENV BASE_PATH=$BASE_PATH
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL="postgresql://user:pass@localhost:5432/dummy"
RUN npm run build-docker
# Production image, copy all the files and run next
FROM node:22-alpine AS runner
FROM node:${NODE_IMAGE_VERSION} AS runner
WORKDIR /app
ARG PRISMA_VERSION="6.19.0"
ARG NODE_OPTIONS
ENV NODE_ENV=production
@ -36,16 +37,14 @@ ENV NODE_OPTIONS=$NODE_OPTIONS
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN npm install -g pnpm
RUN set -x \
&& apk add --no-cache curl
&& apk add --no-cache curl \
&& npm install -g pnpm
# Script dependencies
RUN pnpm add npm-run-all dotenv chalk semver prisma@6.18.0 @prisma/adapter-pg@6.18.0
# Permissions for prisma
RUN chown -R nextjs:nodejs node_modules/.pnpm/
RUN pnpm --allow-build='@prisma/engines' add npm-run-all dotenv chalk semver \
prisma@${PRISMA_VERSION} \
@prisma/adapter-pg@${PRISMA_VERSION}
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder /app/prisma ./prisma

View file

@ -9,18 +9,10 @@
</p>
<p align="center">
<a href="https://github.com/umami-software/umami/releases">
<img src="https://img.shields.io/github/release/umami-software/umami.svg" alt="GitHub Release" />
</a>
<a href="https://github.com/umami-software/umami/blob/master/LICENSE">
<img src="https://img.shields.io/github/license/umami-software/umami.svg" alt="MIT License" />
</a>
<a href="https://github.com/umami-software/umami/actions">
<img src="https://img.shields.io/github/actions/workflow/status/umami-software/umami/ci.yml" alt="Build Status" />
</a>
<a href="https://analytics.umami.is/share/LGazGOecbDtaIwDr/umami.is" style="text-decoration: none;">
<img src="https://img.shields.io/badge/Try%20Demo%20Now-Click%20Here-brightgreen" alt="Umami Demo" />
</a>
<a href="https://github.com/umami-software/umami/releases"><img src="https://img.shields.io/github/release/umami-software/umami.svg" alt="GitHub Release" /></a>
<a href="https://github.com/umami-software/umami/blob/master/LICENSE"><img src="https://img.shields.io/github/license/umami-software/umami.svg" alt="MIT License" /></a>
<a href="https://github.com/umami-software/umami/actions"><img src="https://img.shields.io/github/actions/workflow/status/umami-software/umami/ci.yml" alt="Build Status" /></a>
<a href="https://analytics.umami.is/share/LGazGOecbDtaIwDr/umami.is" style="text-decoration: none;"><img src="https://img.shields.io/badge/Try%20Demo%20Now-Click%20Here-brightgreen" alt="Umami Demo" /></a>
</p>
---
@ -35,10 +27,10 @@ A detailed getting started guide can be found at [umami.is/docs](https://umami.i
### Requirements
- A server with Node.js version 18.18 or newer
- A database. Umami supports [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases.
- A server with Node.js version 18.18+.
- A PostgreSQL database version v12.14+.
### Get the Source Code and Install Packages
### Get the source code and install packages
```bash
git clone https://github.com/umami-software/umami.git
@ -66,7 +58,7 @@ postgresql://username:mypassword@localhost:5432/mydb
pnpm run build
```
_The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**._
The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.
### Start the Application
@ -74,37 +66,36 @@ _The build step will create tables in your database if you are installing for th
pnpm run start
```
_By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly._
By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.
---
## 🐳 Installing with Docker
To build the Umami container and start up a Postgres database, run:
Umami provides Docker images as well as a Docker compose file for easy deployment.
```bash
docker compose up -d
```
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
Docker image:
```bash
docker pull docker.umami.is/umami-software/umami:latest
```
Docker compose to run Umami with a Postgres database, run:
```bash
docker compose up -d
```
---
## 🔄 Getting Updates
> [!WARNING]
> If you are updating from Umami V2, image "postgresql-latest" is deprecated. You must change it to "latest".
> e.g., rename `docker.umami.is/umami-software/umami:postgresql-latest` to `docker.umami.is/umami-software/umami:latest`.
To get the latest features, simply do a pull, install any new dependencies, and rebuild:
```bash
git pull
pnpm install
pnpm run build
pnpm build
```
To update the Docker image, simply pull the new images and rebuild:
@ -119,18 +110,10 @@ docker compose up --force-recreate -d
## 🛟 Support
<p align="center">
<a href="https://github.com/umami-software/umami">
<img src="https://img.shields.io/badge/GitHub--blue?style=social&logo=github" alt="GitHub" />
</a>
<a href="https://twitter.com/umami_software">
<img src="https://img.shields.io/badge/Twitter--blue?style=social&logo=twitter" alt="Twitter" />
</a>
<a href="https://linkedin.com/company/umami-software">
<img src="https://img.shields.io/badge/LinkedIn--blue?style=social&logo=linkedin" alt="LinkedIn" />
</a>
<a href="https://umami.is/discord">
<img src="https://img.shields.io/badge/Discord--blue?style=social&logo=discord" alt="Discord" />
</a>
<a href="https://github.com/umami-software/umami"><img src="https://img.shields.io/badge/GitHub--blue?style=social&logo=github" alt="GitHub" /></a>
<a href="https://twitter.com/umami_software"><img src="https://img.shields.io/badge/Twitter--blue?style=social&logo=twitter" alt="Twitter" /></a>
<a href="https://linkedin.com/company/umami-software"><img src="https://img.shields.io/badge/LinkedIn--blue?style=social&logo=linkedin" alt="LinkedIn" /></a>
<a href="https://umami.is/discord"><img src="https://img.shields.io/badge/Discord--blue?style=social&logo=discord" alt="Discord" /></a>
</p>
[release-shield]: https://img.shields.io/github/release/umami-software/umami.svg

65
biome.json Normal file
View file

@ -0,0 +1,65 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
},
"formatter": {
"enabled": true,
"lineWidth": 100,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": "off",
"correctness": {
"useExhaustiveDependencies": "off"
},
"style": {
"noDescendingSpecificity": "off"
},
"complexity": {
"noImportantStyles": "off"
},
"suspicious": {
"noArrayIndexKey": "off",
"noExplicitAny": "off",
"noImplicitAnyLet": "off"
},
"performance": {
"noImgElement": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"arrowParentheses": "asNeeded"
}
},
"css": {
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

View file

@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from 'next/server';
import { type NextRequest, NextResponse } from 'next/server';
export const config = {
matcher: '/:path*',

View file

@ -1,5 +1,5 @@
import 'dotenv/config';
import pkg from './package.json' assert { type: 'json' };
import pkg from './package.json' with { type: 'json' };
const TRACKER_SCRIPT = '/script.js';

View file

@ -1,6 +1,6 @@
{
"name": "umami",
"version": "3.0.0",
"version": "3.0.2",
"description": "A modern, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT",
@ -42,24 +42,19 @@
"download-country-names": "node scripts/download-country-names.js",
"download-language-names": "node scripts/download-language-names.js",
"change-password": "node scripts/change-password.js",
"lint": "next lint --quiet",
"prepare": "node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky install",
"postbuild": "node scripts/postbuild.js",
"test": "jest",
"cypress-open": "cypress open cypress run",
"cypress-run": "cypress run 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"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": [
"prettier --write",
"eslint"
],
"**/*.css": [
"stylelint --fix",
"prettier --write"
],
"**/*.json": [
"prettier --write"
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
"cacheDirectories": [
@ -77,8 +72,8 @@
"@prisma/extension-read-replicas": "^0.4.1",
"@react-spring/web": "^10.0.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.90.5",
"@umami/react-zen": "^0.206.0",
"@tanstack/react-query": "^5.90.11",
"@umami/react-zen": "^0.211.0",
"@umami/redis-client": "^0.29.0",
"bcryptjs": "^3.0.2",
"chalk": "^5.6.2",
@ -95,10 +90,9 @@
"detect-browser": "^5.2.0",
"dotenv": "^17.2.3",
"esbuild": "^0.25.11",
"eslint-plugin-promise": "^6.1.1",
"fs-extra": "^11.3.2",
"immer": "^10.2.0",
"ipaddr.js": "^2.0.1",
"ipaddr.js": "^2.3.0",
"is-ci": "^3.0.1",
"is-docker": "^3.0.0",
"is-localhost-ip": "^2.0.0",
@ -108,15 +102,15 @@
"kafkajs": "^2.1.0",
"lucide-react": "^0.543.0",
"maxmind": "^5.0.0",
"next": "15.5.3",
"next": "^15.5.7",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"papaparse": "^5.5.3",
"pg": "^8.16.3",
"prisma": "^6.18.0",
"pure-rand": "^7.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-error-boundary": "^4.0.4",
"react-intl": "^7.1.14",
"react-simple-maps": "^2.3.0",
@ -128,12 +122,13 @@
"thenby": "^1.3.4",
"ua-parser-js": "^2.0.6",
"uuid": "^11.1.0",
"zod": "^4.1.12",
"zustand": "^5.0.8"
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@formatjs/cli": "^4.2.29",
"@netlify/plugin-nextjs": "^5.14.4",
"@netlify/plugin-nextjs": "^5.15.1",
"@rollup/plugin-alias": "^5.0.0",
"@rollup/plugin-commonjs": "^25.0.4",
"@rollup/plugin-json": "^6.0.0",
@ -143,23 +138,12 @@
"@rollup/plugin-typescript": "^12.3.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.2",
"@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"cross-env": "^10.1.0",
"cypress": "^13.6.6",
"eslint": "^8.33.0",
"eslint-config-next": "^14.2.33",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^5.5.3",
"extract-react-intl-messages": "^4.1.1",
"husky": "^9.1.7",
"jest": "^29.7.0",
@ -168,12 +152,11 @@
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-import": "^15.1.0",
"postcss-preset-env": "7.8.3",
"prettier": "^3.6.2",
"prompts": "2.4.2",
"rollup": "^4.52.5",
"rollup-plugin-copy": "^3.4.0",
"rollup-plugin-delete": "^3.0.1",
"rollup-plugin-dts": "^6.2.3",
"rollup-plugin-dts": "^6.3.0",
"rollup-plugin-node-externals": "^8.1.1",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
@ -182,9 +165,10 @@
"stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^14.0.0",
"tar": "^6.1.2",
"ts-jest": "^29.4.5",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.1",
"tsup": "^8.5.0",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
}
}

4978
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

8
prisma.config.ts Normal file
View file

@ -0,0 +1,8 @@
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
export default defineConfig({
datasource: {
url: env('DATABASE_URL'),
},
});

View file

@ -1,19 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -3,8 +3,8 @@ import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import https from 'https';
import zlib from 'zlib';
import tar from 'tar';
import zlib from 'zlib';
if (process.env.VERCEL && !process.env.BUILD_GEO) {
console.log('Vercel environment detected. Skipping geo setup.');
@ -50,7 +50,9 @@ const downloadDirect = (url, originalUrl) =>
https.get(url, res => {
// Follow redirects
if (res.statusCode === 301 || res.statusCode === 302) {
downloadDirect(res.headers.location, originalUrl || url).then(resolve).catch(reject);
downloadDirect(res.headers.location, originalUrl || url)
.then(resolve)
.catch(reject);
return;
}
@ -78,27 +80,29 @@ if (isDirectMmdb) {
process.exit(1);
});
} else {
downloadCompressed(url).then(
res =>
new Promise((resolve, reject) => {
res.on('entry', entry => {
if (entry.path.endsWith('.mmdb')) {
const filename = path.join(dest, path.basename(entry.path));
entry.pipe(fs.createWriteStream(filename));
downloadCompressed(url)
.then(
res =>
new Promise((resolve, reject) => {
res.on('entry', entry => {
if (entry.path.endsWith('.mmdb')) {
const filename = path.join(dest, path.basename(entry.path));
entry.pipe(fs.createWriteStream(filename));
console.log('Saved geo database:', filename);
}
});
console.log('Saved geo database:', filename);
}
});
res.on('error', e => {
reject(e);
});
res.on('finish', () => {
resolve();
});
}),
).catch(e => {
console.error('Failed to download geo database:', e);
process.exit(1);
});
res.on('error', e => {
reject(e);
});
res.on('finish', () => {
resolve();
});
}),
)
.catch(e => {
console.error('Failed to download geo database:', e);
process.exit(1);
});
}

View file

@ -1,10 +1,10 @@
/* eslint-disable no-console */
import 'dotenv/config';
import { execSync } from 'node:child_process';
import { PrismaPg } from '@prisma/adapter-pg';
import chalk from 'chalk';
import semver from 'semver';
import { PrismaClient } from '../generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';
const MIN_VERSION = '9.4.0';

View file

@ -1,8 +1,9 @@
/* eslint-disable no-console */
import fs from 'fs-extra';
import path from 'node:path';
import https from 'https';
import chalk from 'chalk';
import fs from 'fs-extra';
import https from 'https';
const src = path.resolve(process.cwd(), 'src/lang');
const dest = path.resolve(process.cwd(), 'public/intl/country');

View file

@ -1,8 +1,9 @@
/* eslint-disable no-console */
import fs from 'fs-extra';
import path from 'node:path';
import https from 'https';
import chalk from 'chalk';
import fs from 'fs-extra';
import https from 'https';
const src = path.resolve(process.cwd(), 'src/lang');
const dest = path.resolve(process.cwd(), 'public/intl/language');

View file

@ -1,6 +1,6 @@
import path from 'node:path';
import fs from 'fs-extra';
import del from 'del';
import fs from 'fs-extra';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);

View file

@ -1,8 +1,8 @@
/* eslint-disable no-console */
import fs from 'node:fs';
import path from 'node:path';
import prettier from 'prettier';
import { createRequire } from 'module';
import prettier from 'prettier';
const require = createRequire(import.meta.url);

121
scripts/seed-data.ts Normal file
View file

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

View file

@ -0,0 +1,80 @@
import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js';
export type DeviceType = 'desktop' | 'mobile' | 'tablet';
const deviceWeights: WeightedOption<DeviceType>[] = [
{ value: 'desktop', weight: 0.55 },
{ value: 'mobile', weight: 0.4 },
{ value: 'tablet', weight: 0.05 },
];
const browsersByDevice: Record<DeviceType, WeightedOption<string>[]> = {
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<DeviceType, WeightedOption<string>[]> = {
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<DeviceType, string[]> = {
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 };
}

View file

@ -0,0 +1,144 @@
import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js';
interface GeoLocation {
country: string;
region: string;
city: string;
}
const countryWeights: WeightedOption<string>[] = [
{ 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<string, { region: string; city: string }[]> = {
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<string>[] = [
{ 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);
}

View file

@ -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<ReferrerType>[] = [
{ 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;
}

View file

@ -0,0 +1,69 @@
import { weightedRandom, randomInt, type WeightedOption } from '../utils.js';
const hourlyWeights: WeightedOption<number>[] = [
{ 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<number>[] = [
{ 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);
}

View file

@ -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<string, string[] | number[]>;
}
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 };
}

View file

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

View file

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

378
scripts/seed/index.ts Normal file
View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<string> {
const websiteId = uuid();
await prisma.website.create({
data: {
id: websiteId,
name,
domain,
userId: adminUserId,
createdBy: adminUserId,
},
});
return websiteId;
}
async function clearDemoData(prisma: PrismaClient): Promise<void> {
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<typeof getBlogSiteConfig>;
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<SeedResult> {
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();
}
}

108
scripts/seed/sites/blog.ts Normal file
View file

@ -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<string[]>[] = blogJourneys.map(j => ({
value: j.pages,
weight: j.weight,
}));
return weightedRandom(journeyWeights);
}
export const BLOG_SESSIONS_PER_DAY = 3; // ~90 sessions per month

185
scripts/seed/sites/saas.ts Normal file
View file

@ -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<string[]>[] = saasJourneys.map(j => ({
value: j.pages,
weight: j.weight,
}));
return weightedRandom(journeyWeights);
}
export const SAAS_SESSIONS_PER_DAY = 500;

85
scripts/seed/utils.ts Normal file
View file

@ -0,0 +1,85 @@
import { v4 as uuidv4 } from 'uuid';
export interface WeightedOption<T> {
value: T;
weight: number;
}
export function weightedRandom<T>(options: WeightedOption<T>[]): 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<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)];
}
export function shuffleArray<T>(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)}%`;
}

View file

@ -1,11 +1,11 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { notFound } from '@/lib/response';
import redis from '@/lib/redis';
import { findPixel } from '@/queries/prisma';
import { Pixel } from '@/generated/prisma/client';
import { POST } from '@/app/api/send/route';
import type { Pixel } from '@/generated/prisma/client';
import redis from '@/lib/redis';
import { notFound } from '@/lib/response';
import { findPixel } from '@/queries/prisma';
const image = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw', 'base64');

View file

@ -1,11 +1,11 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { POST } from '@/app/api/send/route';
import type { Link } from '@/generated/prisma/client';
import redis from '@/lib/redis';
import { notFound } from '@/lib/response';
import { findLink } from '@/queries/prisma';
import { POST } from '@/app/api/send/route';
import { Link } from '@/generated/prisma/client';
import redis from '@/lib/redis';
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;

View file

@ -1,22 +1,35 @@
'use client';
import { Grid, Loading, Column, Row } from '@umami/react-zen';
import { Column, Grid, Loading, Row } from '@umami/react-zen';
import Script from 'next/script';
import { UpdateNotice } from './UpdateNotice';
import { SideNav } from '@/app/(main)/SideNav';
import { useLoginQuery, useConfig, useNavigation } from '@/components/hooks';
import { useEffect } from 'react';
import { MobileNav } from '@/app/(main)/MobileNav';
import { SideNav } from '@/app/(main)/SideNav';
import { useConfig, useLoginQuery, useNavigation } from '@/components/hooks';
import { LAST_TEAM_CONFIG } from '@/lib/constants';
import { removeItem, setItem } from '@/lib/storage';
import { UpdateNotice } from './UpdateNotice';
export function App({ children }) {
const { user, isLoading, error } = useLoginQuery();
const config = useConfig();
const { pathname } = useNavigation();
const { pathname, teamId } = useNavigation();
useEffect(() => {
if (teamId) {
setItem(LAST_TEAM_CONFIG, teamId);
} else {
removeItem(LAST_TEAM_CONFIG);
}
}, [teamId]);
if (isLoading || !config) {
return <Loading placement="absolute" />;
}
if (error) {
window.location.href = `${process.env.basePath || ''}/login`;
window.location.href = config.cloudMode
? `${process.env.cloudUrl}/login`
: `${process.env.basePath || ''}/login`;
return null;
}

View file

@ -1,11 +1,11 @@
import { Grid, IconLabel, NavMenu, NavMenuItem, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { useMessages, useNavigation } from '@/components/hooks';
import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { NavButton } from '@/components/input/NavButton';
import { Logo } from '@/components/svg';
import { Grid, IconLabel, NavMenu, NavMenuItem, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import { AdminNav } from './admin/AdminNav';
import { SettingsNav } from './settings/SettingsNav';

View file

@ -1,20 +1,20 @@
import { Key } from 'react';
import Link from 'next/link';
import {
Sidebar,
SidebarSection,
SidebarItem,
SidebarHeader,
Row,
SidebarProps,
Sidebar,
SidebarHeader,
SidebarItem,
type SidebarProps,
SidebarSection,
ThemeButton,
} from '@umami/react-zen';
import { Globe, LinkIcon, Grid2x2, PanelLeft } from '@/components/icons';
import { Logo } from '@/components/svg';
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
import Link from 'next/link';
import type { Key } from 'react';
import { useGlobalState, useMessages, useNavigation } from '@/components/hooks';
import { Globe, Grid2x2, LinkIcon, PanelLeft } from '@/components/icons';
import { LanguageButton } from '@/components/input/LanguageButton';
import { NavButton } from '@/components/input/NavButton';
import { PanelButton } from '@/components/input/PanelButton';
import { LanguageButton } from '@/components/input/LanguageButton';
import { Logo } from '@/components/svg';
export function SideNav(props: SidebarProps) {
const { formatMessage, labels } = useMessages();

View file

@ -1,4 +1,4 @@
import { ThemeButton, Row } from '@umami/react-zen';
import { Row, ThemeButton } from '@umami/react-zen';
import { LanguageButton } from '@/components/input/LanguageButton';
import { ProfileButton } from '@/components/input/ProfileButton';

View file

@ -1,10 +1,10 @@
import { useEffect, useCallback, useState } from 'react';
import { Button, AlertBanner, Column, Row } from '@umami/react-zen';
import { setItem } from '@/lib/storage';
import { useVersion, checkVersion } from '@/store/version';
import { REPO_URL, VERSION_CHECK } from '@/lib/constants';
import { useMessages } from '@/components/hooks';
import { AlertBanner, Button, Column, Row } from '@umami/react-zen';
import { usePathname } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useMessages } from '@/components/hooks';
import { REPO_URL, VERSION_CHECK } from '@/lib/constants';
import { setItem } from '@/lib/storage';
import { checkVersion, useVersion } from '@/store/version';
export function UpdateNotice({ user, config }) {
const { formatMessage, labels, messages } = useMessages();

View file

@ -1,8 +1,8 @@
'use client';
import { Column, Grid } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { PageBody } from '@/components/common/PageBody';
import { useLoginQuery } from '@/components/hooks';
import { Column, Grid } from '@umami/react-zen';
import { ReactNode } from 'react';
import { AdminNav } from './AdminNav';
export function AdminLayout({ children }: { children: ReactNode }) {
@ -21,6 +21,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
border="right"
backgroundColor
marginRight="2"
padding="3"
>
<AdminNav />
</Column>

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { AdminLayout } from './AdminLayout';
export default function ({ children }) {

View file

@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { DataGrid } from '@/components/common/DataGrid';
import { useTeamsQuery } from '@/components/hooks';
import { AdminTeamsTable } from './AdminTeamsTable';
import { ReactNode } from 'react';
export function AdminTeamsDataTable({
showActions,

View file

@ -1,9 +1,9 @@
'use client';
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
import { Column } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages();

View file

@ -1,11 +1,11 @@
import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import { useState } from 'react';
import { DateDistance } from '@/components/common/DateDistance';
import { useMessages } from '@/components/hooks';
import { Edit, Trash } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen';
import { TeamDeleteForm } from '../../teams/[teamId]/TeamDeleteForm';
import Link from 'next/link';
import { useState } from 'react';
export function AdminTeamsTable({
data = [],

View file

@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { AdminTeamPage } from './AdminTeamPage';
import { Metadata } from 'next';
export default async function ({ params }: { params: Promise<{ teamId: string }> }) {
const { teamId } = await params;

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { AdminTeamsPage } from './AdminTeamsPage';
export default function () {

View file

@ -1,7 +1,7 @@
import { Button, Icon, Text, Modal, DialogTrigger, Dialog, useToast } from '@umami/react-zen';
import { UserAddForm } from './UserAddForm';
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { UserAddForm } from './UserAddForm';
export function UserAddButton({ onSave }: { onSave?: () => void }) {
const { formatMessage, labels, messages } = useMessages();

View file

@ -1,13 +1,13 @@
import {
Select,
ListItem,
Form,
FormField,
FormButtons,
FormSubmitButton,
TextField,
PasswordField,
Button,
Form,
FormButtons,
FormField,
FormSubmitButton,
ListItem,
PasswordField,
Select,
TextField,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { ROLES } from '@/lib/constants';

View file

@ -1,5 +1,5 @@
import { Button, Icon, Modal, DialogTrigger, Dialog, Text } from '@umami/react-zen';
import { useMessages, useLoginQuery } from '@/components/hooks';
import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
import { useLoginQuery, useMessages } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { UserDeleteForm } from './UserDeleteForm';

View file

@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { DataGrid } from '@/components/common/DataGrid';
import { useUsersQuery } from '@/components/hooks';
import { UsersTable } from './UsersTable';
import { ReactNode } from 'react';
export function UsersDataTable({ showActions }: { showActions?: boolean; children?: ReactNode }) {
const queryResult = useUsersQuery();

View file

@ -1,10 +1,10 @@
'use client';
import { UsersDataTable } from './UsersDataTable';
import { Column } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { UserAddButton } from './UserAddButton';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { UserAddButton } from './UserAddButton';
import { UsersDataTable } from './UsersDataTable';
export function UsersPage() {
const { formatMessage, labels } = useMessages();

View file

@ -1,13 +1,12 @@
import { useState } from 'react';
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal } from '@umami/react-zen';
import { DataColumn, DataTable, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import { ROLES } from '@/lib/constants';
import { Trash } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { UserDeleteForm } from './UserDeleteForm';
import { useState } from 'react';
import { DateDistance } from '@/components/common/DateDistance';
import { useMessages } from '@/components/hooks';
import { Edit, Trash } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { ROLES } from '@/lib/constants';
import { UserDeleteForm } from './UserDeleteForm';
export function UsersTable({
data = [],

View file

@ -1,12 +1,12 @@
import {
Select,
ListItem,
Form,
FormField,
FormButtons,
TextField,
FormField,
FormSubmitButton,
ListItem,
PasswordField,
Select,
TextField,
} from '@umami/react-zen';
import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
@ -30,7 +30,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
};
return (
<Form onSubmit={handleSubmit} error={getMessage(error?.['code'])} values={user}>
<Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}>
<FormField name="username" label={formatMessage(labels.username)}>
<TextField data-test="input-username" />
</FormField>
@ -50,7 +50,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
label={formatMessage(labels.role)}
rules={{ required: formatMessage(labels.required) }}
>
<Select defaultSelectedKey={user.role}>
<Select defaultValue={user.role}>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)}
</ListItem>

View file

@ -1,6 +1,6 @@
import { User } from '@/components/icons';
import { PageHeader } from '@/components/common/PageHeader';
import { useUser } from '@/components/hooks';
import { User } from '@/components/icons';
export function UserHeader() {
const user = useUser();

View file

@ -1,9 +1,9 @@
'use client';
import { Column } from '@umami/react-zen';
import { UserSettings } from './UserSettings';
import { UserProvider } from './UserProvider';
import { UserHeader } from '@/app/(main)/admin/users/[userId]/UserHeader';
import { Panel } from '@/components/common/Panel';
import { UserProvider } from './UserProvider';
import { UserSettings } from './UserSettings';
export function UserPage({ userId }: { userId: string }) {
return (

View file

@ -1,7 +1,7 @@
import { createContext, ReactNode } from 'react';
import { Loading } from '@umami/react-zen';
import { User } from '@/generated/prisma/client';
import { createContext, type ReactNode } from 'react';
import { useUserQuery } from '@/components/hooks/queries/useUserQuery';
import type { User } from '@/generated/prisma/client';
export const UserContext = createContext<User>(null);

View file

@ -1,6 +1,6 @@
import { Column, Tabs, Tab, TabList, TabPanel } from '@umami/react-zen';
import { UserEditForm } from './UserEditForm';
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { UserEditForm } from './UserEditForm';
import { UserWebsites } from './UserWebsites';
export function UserSettings({ userId }: { userId: string }) {

View file

@ -1,6 +1,6 @@
import { WebsitesTable } from '@/app/(main)/websites/WebsitesTable';
import { DataGrid } from '@/components/common/DataGrid';
import { useUserWebsitesQuery } from '@/components/hooks';
import { WebsitesTable } from '@/app/(main)/websites/WebsitesTable';
export function UserWebsites({ userId }) {
const queryResult = useUserWebsitesQuery({ userId });

View file

@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { UserPage } from './UserPage';
import { Metadata } from 'next';
export default async function ({ params }: { params: Promise<{ userId: string }> }) {
const { userId } = await params;

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { UsersPage } from './UsersPage';
export default function () {

View file

@ -1,9 +1,9 @@
'use client';
import { AdminWebsitesDataTable } from './AdminWebsitesDataTable';
import { Column } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { AdminWebsitesDataTable } from './AdminWebsitesDataTable';
export function AdminWebsitesPage() {
const { formatMessage, labels } = useMessages();

View file

@ -1,12 +1,11 @@
import { useState } from 'react';
import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen';
import Link from 'next/link';
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal, Dialog } from '@umami/react-zen';
import { Trash, Users } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { DateDistance } from '@/components/common/DateDistance';
import { useState } from 'react';
import { WebsiteDeleteForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
import { DateDistance } from '@/components/common/DateDistance';
import { useMessages } from '@/components/hooks';
import { Edit, Trash, Users } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
const { formatMessage, labels } = useMessages();

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { AdminWebsitesPage } from './AdminWebsitesPage';
export default function () {

View file

@ -1,5 +1,5 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified, useNavigation } from '@/components/hooks';
import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen';
import { Plus } from '@/components/icons';
import { BoardAddForm } from './BoardAddForm';

View file

@ -1,5 +1,5 @@
import { Form, FormField, FormSubmitButton, Row, TextField, Button } from '@umami/react-zen';
import { useUpdateQuery, useMessages } from '@/components/hooks';
import { Button, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { DOMAIN_REGEX } from '@/lib/constants';
export function BoardAddForm({

View file

@ -1,7 +1,7 @@
'use client';
import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { BoardAddButton } from './BoardAddButton';
export function BoardsPage() {

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { Board } from './Board';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { BoardsPage } from './BoardsPage';
export default function () {

View file

@ -1,25 +1,25 @@
'use client';
import { Button, Grid, Column, Heading } from '@umami/react-zen';
import { Button, Column, Grid, Heading } from '@umami/react-zen';
import Link from 'next/link';
import Script from 'next/script';
import { Panel } from '@/components/common/Panel';
import { PageBody } from '@/components/common/PageBody';
import { EventsChart } from '@/components/metrics/EventsChart';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { useWebsiteQuery } from '@/components/hooks';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useWebsiteQuery } from '@/components/hooks';
import { EventsChart } from '@/components/metrics/EventsChart';
export function TestConsolePage({ websiteId }: { websiteId: string }) {
const { data } = useWebsiteQuery(websiteId);
function handleRunScript() {
window['umami'].track(props => ({
window.umami.track(props => ({
...props,
url: '/page-view',
referrer: 'https://www.google.com',
}));
window['umami'].track('track-event-no-data');
window['umami'].track('track-event-with-data', {
window.umami.track('track-event-no-data');
window.umami.track('track-event-with-data', {
test: 'test-data',
boolean: true,
booleanError: 'true',
@ -40,32 +40,32 @@ export function TestConsolePage({ websiteId }: { websiteId: string }) {
}
function handleRunRevenue() {
window['umami'].track(props => ({
window.umami.track(props => ({
...props,
url: '/checkout-cart',
referrer: 'https://www.google.com',
}));
window['umami'].track('checkout-cart', {
window.umami.track('checkout-cart', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'USD',
});
window['umami'].track('affiliate-link', {
window.umami.track('affiliate-link', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'USD',
});
window['umami'].track('promotion-link', {
window.umami.track('promotion-link', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'USD',
});
window['umami'].track('checkout-cart', {
window.umami.track('checkout-cart', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'EUR',
});
window['umami'].track('promotion-link', {
window.umami.track('promotion-link', {
revenue: parseFloat((Math.random() * 1000).toFixed(2)),
currency: 'EUR',
});
window['umami'].track('affiliate-link', {
window.umami.track('affiliate-link', {
item1: {
productIdentity: 'ABC424',
revenue: parseFloat((Math.random() * 10000).toFixed(2)),
@ -80,7 +80,7 @@ export function TestConsolePage({ websiteId }: { websiteId: string }) {
}
function handleRunIdentify() {
window['umami'].identify({
window.umami.identify({
userId: 123,
name: 'brian',
number: Math.random() * 100,

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { TestConsolePage } from './TestConsolePage';
async function getEnabled() {

View file

@ -1,8 +1,8 @@
'use client';
import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages } from '@/components/hooks';
import { PageBody } from '@/components/common/PageBody';
export function DashboardPage() {
const { formatMessage, labels } = useMessages();

View file

@ -1,4 +1,4 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { DashboardPage } from './DashboardPage';
export default async function () {

View file

@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { Suspense } from 'react';
import { Metadata } from 'next';
import { App } from './App';
export default function ({ children }) {

View file

@ -1,7 +1,7 @@
import { useMessages } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { LinkEditForm } from './LinkEditForm';
import { DialogButton } from '@/components/input/DialogButton';
import { LinkEditForm } from './LinkEditForm';
export function LinkAddButton({ teamId }: { teamId?: string }) {
const { formatMessage, labels } = useMessages();

View file

@ -1,8 +1,8 @@
import { Trash } from '@/components/icons';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { messages } from '@/components/messages';
import { useDeleteQuery, useMessages } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
export function LinkDeleteButton({
linkId,

View file

@ -1,7 +1,7 @@
import { Edit } from '@/components/icons';
import { LinkEditForm } from './LinkEditForm';
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { LinkEditForm } from './LinkEditForm';
export function LinkEditButton({ linkId }: { linkId: string }) {
const { formatMessage, labels } = useMessages();

View file

@ -1,22 +1,21 @@
import { useState, useEffect } from 'react';
import {
Button,
Column,
Form,
FormField,
FormSubmitButton,
Icon,
Label,
Loading,
Row,
TextField,
Button,
Label,
Column,
Icon,
Loading,
} from '@umami/react-zen';
import { useConfig, useLinkQuery } from '@/components/hooks';
import { useMessages } from '@/components/hooks';
import { RefreshCw } from '@/components/icons';
import { getRandomChars } from '@/lib/generate';
import { useEffect, useState } from 'react';
import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { RefreshCw } from '@/components/icons';
import { LINKS_URL } from '@/lib/constants';
import { getRandomChars } from '@/lib/generate';
import { isValidUrl } from '@/lib/url';
const generateId = () => getRandomChars(9);

View file

@ -1,8 +1,8 @@
'use client';
import { createContext, ReactNode } from 'react';
import { Loading } from '@umami/react-zen';
import { Link } from '@/generated/prisma/client';
import { createContext, type ReactNode } from 'react';
import { useLinkQuery } from '@/components/hooks/queries/useLinkQuery';
import type { Link } from '@/generated/prisma/client';
export const LinkContext = createContext<Link>(null);

View file

@ -1,6 +1,6 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useLinksQuery, useNavigation } from '@/components/hooks';
import { LinksTable } from './LinksTable';
import { DataGrid } from '@/components/common/DataGrid';
export function LinksDataTable() {
const { teamId } = useNavigation();

View file

@ -1,11 +1,11 @@
'use client';
import { PageBody } from '@/components/common/PageBody';
import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { LinkAddButton } from './LinkAddButton';
import { useMessages, useNavigation } from '@/components/hooks';
import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks';
import { LinkAddButton } from './LinkAddButton';
export function LinksPage() {
const { formatMessage, labels } = useMessages();

View file

@ -1,10 +1,10 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import Link from 'next/link';
import { DataTable, DataColumn, Row, DataTableProps } from '@umami/react-zen';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { LinkEditButton } from './LinkEditButton';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { LinkDeleteButton } from './LinkDeleteButton';
import { LinkEditButton } from './LinkEditButton';
export function LinksTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages();
@ -21,7 +21,11 @@ export function LinksTable(props: DataTableProps) {
<DataColumn id="slug" label={formatMessage(labels.link)}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
<DataColumn id="url" label={formatMessage(labels.destinationUrl)}>

View file

@ -1,9 +1,9 @@
import { Column, Row } from '@umami/react-zen';
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { ExportButton } from '@/components/input/ExportButton';
import { FilterBar } from '@/components/input/FilterBar';
import { MonthFilter } from '@/components/input/MonthFilter';
import { ExportButton } from '@/components/input/ExportButton';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
export function LinkControls({
linkId: websiteId,

View file

@ -1,8 +1,8 @@
import { useLink, useMessages, useSlug } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
import { Icon, Text } from '@umami/react-zen';
import { ExternalLink, Link } from '@/components/icons';
import { IconLabel } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useLink, useMessages, useSlug } from '@/components/hooks';
import { ExternalLink, Link } from '@/components/icons';
export function LinkHeader() {
const { formatMessage, labels } = useMessages();
@ -10,12 +10,9 @@ export function LinkHeader() {
const link = useLink();
return (
<PageHeader title={link.name} description={link.url} icon={<Link />} marginBottom="3">
<LinkButton href={getSlugUrl(link.slug)} target="_blank">
<Icon>
<ExternalLink />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
<PageHeader title={link.name} description={link.url} icon={<Link />}>
<LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor>
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
</LinkButton>
</PageHeader>
);

View file

@ -1,9 +1,9 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange, useMessages } from '@/components/hooks';
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { formatLongNumber } from '@/lib/format';
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
import { LoadingPanel } from '@/components/common/LoadingPanel';
export function LinkMetricsBar({
linkId,

View file

@ -1,14 +1,14 @@
'use client';
import { PageBody } from '@/components/common/PageBody';
import { LinkProvider } from '@/app/(main)/links/LinkProvider';
import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader';
import { Panel } from '@/components/common/Panel';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
import { Column, Grid } from '@umami/react-zen';
import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader';
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
import { LinkProvider } from '@/app/(main)/links/LinkProvider';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { PageBody } from '@/components/common/PageBody';
import { Panel } from '@/components/common/Panel';
const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];

View file

@ -1,9 +1,9 @@
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
import { Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { GridRow } from '@/components/common/GridRow';
import { Panel } from '@/components/common/Panel';
import { WorldMap } from '@/components/metrics/WorldMap';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { useMessages } from '@/components/hooks';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WorldMap } from '@/components/metrics/WorldMap';
export function LinkPanels({ linkId }: { linkId: string }) {
const { formatMessage, labels } = useMessages();

View file

@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { LinkPage } from './LinkPage';
import { Metadata } from 'next';
export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
const { linkId } = await params;

View file

@ -1,5 +1,5 @@
import type { Metadata } from 'next';
import { LinksPage } from './LinksPage';
import { Metadata } from 'next';
export default function () {
return <LinksPage />;

View file

@ -1,7 +1,7 @@
import { useMessages } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { PixelEditForm } from './PixelEditForm';
import { DialogButton } from '@/components/input/DialogButton';
import { PixelEditForm } from './PixelEditForm';
export function PixelAddButton({ teamId }: { teamId?: string }) {
const { formatMessage, labels } = useMessages();

View file

@ -1,8 +1,8 @@
import { Trash } from '@/components/icons';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { messages } from '@/components/messages';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
export function PixelDeleteButton({
pixelId,

View file

@ -1,7 +1,7 @@
import { Edit } from '@/components/icons';
import { PixelEditForm } from './PixelEditForm';
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { PixelEditForm } from './PixelEditForm';
export function PixelEditButton({ pixelId }: { pixelId: string }) {
const { formatMessage, labels } = useMessages();

View file

@ -1,22 +1,21 @@
import {
Button,
Column,
Form,
FormField,
FormSubmitButton,
Icon,
Label,
Loading,
Row,
TextField,
Button,
Label,
Column,
Icon,
Loading,
} from '@umami/react-zen';
import { useConfig, usePixelQuery } from '@/components/hooks';
import { useMessages } from '@/components/hooks';
import { RefreshCw } from '@/components/icons';
import { getRandomChars } from '@/lib/generate';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { useEffect, useState } from 'react';
import { useConfig, useMessages, usePixelQuery } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { RefreshCw } from '@/components/icons';
import { PIXELS_URL } from '@/lib/constants';
import { getRandomChars } from '@/lib/generate';
const generateId = () => getRandomChars(9);

View file

@ -1,8 +1,8 @@
'use client';
import { createContext, ReactNode } from 'react';
import { Loading } from '@umami/react-zen';
import { Pixel } from '@/generated/prisma/client';
import { createContext, type ReactNode } from 'react';
import { usePixelQuery } from '@/components/hooks/queries/usePixelQuery';
import type { Pixel } from '@/generated/prisma/client';
export const PixelContext = createContext<Pixel>(null);

View file

@ -1,6 +1,6 @@
import { usePixelsQuery, useNavigation } from '@/components/hooks';
import { PixelsTable } from './PixelsTable';
import { DataGrid } from '@/components/common/DataGrid';
import { useNavigation, usePixelsQuery } from '@/components/hooks';
import { PixelsTable } from './PixelsTable';
export function PixelsDataTable() {
const { teamId } = useNavigation();

View file

@ -1,11 +1,11 @@
'use client';
import { PageBody } from '@/components/common/PageBody';
import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader';
import { PixelAddButton } from './PixelAddButton';
import { useMessages, useNavigation } from '@/components/hooks';
import { PixelsDataTable } from './PixelsDataTable';
import { Panel } from '@/components/common/Panel';
import { useMessages, useNavigation } from '@/components/hooks';
import { PixelAddButton } from './PixelAddButton';
import { PixelsDataTable } from './PixelsDataTable';
export function PixelsPage() {
const { formatMessage, labels } = useMessages();

View file

@ -1,10 +1,10 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import Link from 'next/link';
import { DataTable, DataColumn, Row, DataTableProps } from '@umami/react-zen';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { DateDistance } from '@/components/common/DateDistance';
import { PixelEditButton } from './PixelEditButton';
import { PixelDeleteButton } from './PixelDeleteButton';
import { ExternalLink } from '@/components/common/ExternalLink';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { PixelDeleteButton } from './PixelDeleteButton';
import { PixelEditButton } from './PixelEditButton';
export function PixelsTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages();
@ -21,7 +21,11 @@ export function PixelsTable(props: DataTableProps) {
<DataColumn id="url" label="URL">
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>

View file

@ -1,9 +1,9 @@
import { Column, Row } from '@umami/react-zen';
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { ExportButton } from '@/components/input/ExportButton';
import { FilterBar } from '@/components/input/FilterBar';
import { MonthFilter } from '@/components/input/MonthFilter';
import { ExportButton } from '@/components/input/ExportButton';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
export function PixelControls({
pixelId: websiteId,

View file

@ -1,8 +1,8 @@
import { usePixel, useMessages, useSlug } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
import { Icon, Text } from '@umami/react-zen';
import { ExternalLink, Grid2x2 } from '@/components/icons';
import { IconLabel } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, usePixel, useSlug } from '@/components/hooks';
import { ExternalLink, Grid2x2 } from '@/components/icons';
export function PixelHeader() {
const { formatMessage, labels } = useMessages();
@ -10,12 +10,9 @@ export function PixelHeader() {
const pixel = usePixel();
return (
<PageHeader title={pixel.name} icon={<Grid2x2 />} marginBottom="3">
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false}>
<Icon>
<ExternalLink />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
<PageHeader title={pixel.name} icon={<Grid2x2 />}>
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor>
<IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
</LinkButton>
</PageHeader>
);

View file

@ -1,9 +1,9 @@
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange, useMessages } from '@/components/hooks';
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { formatLongNumber } from '@/lib/format';
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
import { LoadingPanel } from '@/components/common/LoadingPanel';
export function PixelMetricsBar({
pixelId,

Some files were not shown because too many files have changed in this diff Show more