Compare commits

..

97 commits

Author SHA1 Message Date
Francis Cao
1498da2d02 fix FilterButton import error
Some checks failed
Node.js CI / build (push) Has been cancelled
2026-01-26 15:26:02 -08:00
Francis Cao
4867492ca3 Fix breakdown alias column not found bug 2026-01-26 13:52:46 -08:00
Francis Cao
29a373467a Revert "Merge pull request #3972 from IndraGunawan/fix-inconsistent-date-format"
This reverts commit 5f316a79e5, reversing
changes made to 7bb30443a8.
2026-01-26 11:42:09 -08:00
Mike Cao
5f316a79e5
Merge pull request #3972 from IndraGunawan/fix-inconsistent-date-format
fix inconsistent date format
2026-01-26 11:15:20 -08:00
Francis Cao
7bb30443a8 Add distinct ID to filters/expanded metrics. Closes #3861 2026-01-26 11:05:20 -08:00
Francis Cao
7f43a0d41a Improve team admin screen workflow for team/members. Closes  #2767 2026-01-26 10:00:31 -08:00
Francis Cao
128217c0f4 add event type filter button and implementation to journeys Close #2803
Some checks failed
Node.js CI / build (push) Has been cancelled
2026-01-24 10:09:10 -08:00
Francis Cao
355fa1e9a9 increase minimum unit for day. Last 6 months includes current month
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-23 11:42:12 -08:00
Francis Cao
a2f0066c42 clear unit param on nav, fix more import, fix NaN animation error 2026-01-23 11:19:19 -08:00
Francis Cao
e73432dd26 Add unit select and backend implementation. Fix compare for websitestats. Remove unused params from stats, metrics, weekly 2026-01-23 10:08:47 -08:00
Francis Cao
3f173889ea Fix "name" alias for relational query to be compatible with Postgres 12. Closes #3970
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-22 22:04:11 -08:00
Francis Cao
d70152be38 fix share data migration to '{"overview":true}'
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-22 19:52:55 -08:00
Francis Cao
7a89bbf19f render links in event, session activity, realtime activity using hostname instead of domain.
Closes #2861
2026-01-22 19:29:25 -08:00
Francis Cao
c88425332b fix tar import tar.t() to list() in build-geo 2026-01-22 18:24:43 -08:00
Francis Cao
09e1267cb0 fix dependabot vuln alerts 2026-01-22 18:13:38 -08:00
Francis Cao
fce1c13017
Merge pull request #3969 from umami-software/dependabot/npm_and_yarn/tar-7.5.4
Bump tar from 6.2.1 to 7.5.4
2026-01-22 17:49:29 -08:00
Francis Cao
3fc41bdd08 clean-up canViewWebsite, use getEntity 2026-01-22 16:37:53 -08:00
Francis Cao
f4eaa9fd0d update segmentParamSchema, add typing to getEntity 2026-01-22 16:32:06 -08:00
Francis Cao
d4ff7c8e3f tighten zod validation for segment parameters
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-22 12:53:44 -08:00
Francis Cao
6fd428683d update timezone zod validation to use timezoneParam 2026-01-22 12:02:08 -08:00
Francis Cao
a1efb2d86a check for explicit teamCreate permissions for global role in canCreateTeam 2026-01-22 11:53:11 -08:00
Francis Cao
8b2196b97a fix showDeleted reversed logic. Show non-deleted user as default query 2026-01-22 11:18:14 -08:00
Francis Cao
63d2bfe118 add name column to share table 2026-01-22 10:41:55 -08:00
Francis Cao
8f55ed9da9 security advisory fixes opened by kolega-ai-dev 2026-01-22 09:24:08 -08:00
Francis Cao
e5f794c329 add boards + duplicate key migration
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-21 09:42:24 -08:00
Indra Gunawan
14012ed68e fix inconsistent date format 2026-01-21 22:28:55 +08:00
Mike Cao
dd8888cfcf Update share form defaults to only select overview and events
Some checks are pending
Node.js CI / build (push) Waiting to run
Changed default selected items for new shares from all items to only
overview and events. Also fixed checkbox text size in create form.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 00:19:08 -08:00
Francis Cao
d47ee6e8e8 Merge branch 'dev' of https://github.com/umami-software/umami into dev
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-20 19:10:14 -08:00
Francis Cao
52f812d9e5 pass unit into revenue report 2026-01-20 19:09:23 -08:00
Mike Cao
f2c49845d0 Add filtered navigation to share pages
- Update share API to return websiteId and parameters
- Create ShareNav component that filters nav items based on parameters
- Update SharePage to include navigation sidebar and route to correct page
- Support all website pages: overview, events, sessions, realtime, compare,
  breakdown, goals, funnels, journeys, retention, utm, revenue, attribution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 18:12:33 -08:00
Mike Cao
ef3aec09be Add display options form for website shares
Allow users to select which navigation items to display when creating
or editing a share. Options include traffic, behavior, and growth
sections with checkboxes for each nav item (excluding segments/cohorts).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:23:15 -08:00
Mike Cao
0eb598c817 implement website share functionality using share table
- Add support for multiple share URLs per website with server-generated slugs
- Create shares API endpoint for listing and creating website shares
- Add SharesTable, ShareEditButton, ShareDeleteButton components
- Move share management to website settings, remove header share button
- Remove shareId from website update API (now uses separate share table)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:23:14 -08:00
dependabot[bot]
3fe7afbb12
Bump tar from 6.2.1 to 7.5.4
Bumps [tar](https://github.com/isaacs/node-tar) from 6.2.1 to 7.5.4.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.2.1...v7.5.4)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-21 01:20:18 +00:00
Francis Cao
3a498333a6 Merge branch 'analytics' of https://github.com/umami-software/umami into dev
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-19 12:51:38 -08:00
Francis Cao
cd6a28ea4a improve revenue joins and event_data joins to website_event
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
2026-01-19 12:46:21 -08:00
Mike Cao
63c9cf2e30
Merge pull request #3954 from maennenajere/dev
Some checks failed
Node.js CI / build (push) Has been cancelled
Update fi-FI.json
2026-01-16 22:15:10 -08:00
Francis Cao
520c91c621 fix share api routes
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-16 16:29:08 -08:00
Francis Cao
29f2c7b7d4 share api, queries, permissions, migration, entity lib
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-15 16:25:56 -08:00
Mike Cao
a270b0afea
Merge pull request #3956 from Nayrode/master
Some checks are pending
Node.js CI / build (push) Waiting to run
Fix autofill styling inconsistency in dark theme
2026-01-14 21:34:52 -08:00
Francis Cao
889a404650 share table schema + migration
Some checks are pending
Node.js CI / build (push) Waiting to run
2026-01-14 15:33:06 -08:00
Francis Cao
2bd0734162 Revert "refactor 6 month retention. use auth instead of cache:website". Fix share page retention bug.
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
This reverts commit 741c6039e6.
2026-01-14 11:06:33 -08:00
Francis Cao
b6013c3ee8 Revert "refactor 6 month retention. use auth instead of cache:website". Fix share page retention bug.
This reverts commit 741c6039e6.
2026-01-14 10:28:48 -08:00
Dorian TETU
ebfbc282ee fix: autofill background color 2026-01-13 16:39:39 +01:00
Jere Mäennenä
1a664d8719 Update fi-FI.json 2026-01-11 14:25:41 +02:00
Mike Cao
6d480d9c36
Merge pull request #3950 from AymanAlSuleihi/fix/compare-metrics
Some checks failed
Node.js CI / build (push) Has been cancelled
Fix metrics bar not updating on compare mode switch
2026-01-10 18:56:54 -08:00
AymanAlSuleihi
a049fbb5b0 Update compare parameter to use enum for valid values 2026-01-09 23:41:16 +00:00
AymanAlSuleihi
6420f2c813 Remove redundant compare param definition 2026-01-09 22:54:50 +00:00
AymanAlSuleihi
fbf03d6563 Fix metrics bar not updating on compare mode switch 2026-01-09 19:35:48 +00:00
Francis Cao
fbe031bfe9 update pixel/link edit form on save.
Some checks failed
Node.js CI / build (push) Has been cancelled
2026-01-07 09:30:59 -08:00
Mike Cao
5213e04f44
Merge pull request #3866 from RaenonX/master
Some checks are pending
Node.js CI / build (push) Waiting to run
Added custom slug for links
2026-01-06 18:23:28 -08:00
Mike Cao
17d24bf8e2
Merge pull request #3872 from GochoMugo/fix/denied-storage-access
fix: handle denied storage access
2026-01-06 18:03:16 -08:00
Mike Cao
767fda21cd
Merge pull request #3874 from maphubs/fix-session-error
fix prisma session race condition error
2026-01-06 18:01:09 -08:00
Mike Cao
c122fb718b
Merge pull request #3875 from FEgor04/master
fix(events): use correct key for event values
2026-01-06 17:50:24 -08:00
Mike Cao
f105a52fc2
Merge pull request #3884 from XahidEx/patch-2
Correct UAE emirate names in iso-3166-2.json
2026-01-06 17:48:29 -08:00
Mike Cao
5506314e54
Merge pull request #3891 from journry789/master
This resolves the issue of being unable to obtain the client's IP add…
2026-01-06 17:46:04 -08:00
Mike Cao
e90b2201ca
Merge pull request #3917 from Mintimate/master
feat: add EdgeOne CDN geolocation headers support
2026-01-06 17:32:48 -08:00
Mike Cao
d8dcf05a20
Merge pull request #3920 from PaiJi/chore/update-zh-CN-translation
chore(i18n): update zh-CN translation
2026-01-06 17:29:43 -08:00
Mike Cao
2e62a06aa4
Merge pull request #3926 from Yashh56/feat/default-currency
feat: Add default currency support and update currency handling in Revenue component
2026-01-06 17:27:47 -08:00
Mike Cao
75ae7528fb
Merge pull request #3929 from dyanakiev/master
chore(i18n): update bg-BG translation
2026-01-06 17:25:35 -08:00
Mike Cao
58880b6a5f
Merge pull request #3939 from AymanAlSuleihi/master
Fix double scrollbar in dropdown
2026-01-06 17:24:38 -08:00
AymanAlSuleihi
491716f4dd Fix double scrollbar in dropdown 2026-01-03 00:48:09 +00:00
Mike Cao
42d0594118
Merge pull request #3936 from sbozh/fix/compatible-event-name
Some checks failed
Node.js CI / build (push) Has been cancelled
Fixed PostgreSQL 12/13 syntax error in Journeys feature
2026-01-02 13:03:34 -08:00
Mike Cao
34c31ca63c
Merge pull request #3937 from AymanAlSuleihi/master
Add country flag image for Tor traffic (T1)
2026-01-02 13:02:35 -08:00
AymanAlSuleihi
dacf13475a Add country image t1.png for Tor 2026-01-01 15:01:04 +00:00
SBOZH
8286af1453 Fixed PostgreSQL 12/13 syntax error in Journeys feature 2026-01-01 15:53:20 +01:00
Francis Cao
34677bca8f
Merge pull request #3925 from Yashh56/feat/version
Some checks failed
Node.js CI / build (push) Has been cancelled
feat : Add version settings to display application version
2025-12-30 13:51:09 -08:00
Yash
b0aa6fd6ef feat: Add current version to API response 2025-12-26 22:13:23 +05:30
Dimitar Yanakiev
97c26bc075 Translated various labels and messages in Bulgarian. 2025-12-26 16:34:38 +02:00
Yash
4eddac21c7 feat: Add default currency support and update currency handling in Revenue component 2025-12-25 20:41:14 +05:30
Yash
5e3e6b3edd refactor: Simplify version display by removing API endpoint and using constant 2025-12-25 09:48:09 +05:30
Yash
612b00179b feat : Add version settings and API endpoint to display application version 2025-12-25 00:21:10 +05:30
JiPai
783098fadc chore(i18n): update zh-CN translation 2025-12-23 23:04:41 +08:00
JiPai
6859f00bf6 chore(i18n): update zh-CN translation 2025-12-23 22:39:33 +08:00
Francis Cao
b0ed4bddb6 change reset/delete website to interactive transaction with timeout
Some checks failed
Node.js CI / build (push) Has been cancelled
Closes #3905
2025-12-22 10:59:33 -08:00
Mintihuang
ad264f941d feat: add EdgeOne headers for geolocation detection​ 2025-12-22 11:35:28 +00:00
Mike Cao
4c0c9e6aa0
Merge pull request #3910 from kkhys/fix/ja-jp-breakdown
Some checks failed
Node.js CI / build (push) Has been cancelled
Fix Japanese translation for label 'breakdown'
2025-12-20 21:39:29 -08:00
kkhys
687318bd09
fix Japanese translation for label 'breakdown' 2025-12-20 11:58:25 +09:00
Francis Cao
912d2d544d Fix deleted website visibility bug
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 #3865
2025-12-19 14:21:42 -08:00
Francis Cao
3072f02f1b Add table alias to filterQuery. Closed  #3869
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-19 13:32:20 -08:00
Francis Cao
86d2672c47 fix shareId error
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-12-18 11:33:29 -08:00
Francis Cao
741c6039e6 refactor 6 month retention. use auth instead of cache:website 2025-12-18 10:35:52 -08:00
Francis Cao
37b6194c5f set 6 month retention for hobby users
Some checks are pending
Node.js CI / build (postgresql, 18.18, 10) (push) Waiting to run
2025-12-17 13:28:34 -08:00
Francis Cao
b75c15dc43 Merge branch 'analytics' 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
2025-12-17 09:35:51 -08:00
Francis Cao
d04fff65fe fix monthly truncation timezone issue
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-17 09:34:59 -08:00
journry789
437c168e6f This resolves the issue of being unable to obtain the client's IP address due to the IPv6 format. 2025-12-15 13:56:39 +08:00
Jahidul Islam
886544f297
Correct UAE emirate names in iso-3166-2.json
Updated names of UAE emirates for accuracy.
2025-12-13 16:14:27 +04:00
RaenonX
53dfc5e76a
Added pm2.yml in .gitignore 2025-12-12 23:04:45 +08:00
RaenonX
5fbef149d0
Added custom slug for links 2025-12-12 23:04:44 +08:00
Mike Cao
860e6390f1 Updated Docker build.
Some checks failed
Node.js CI / build (push) Has been cancelled
2025-12-11 20:16:56 -08:00
Mike Cao
9b0d1b092e Updated worfkflow. 2025-12-11 19:31:36 -08:00
Mike Cao
2a71cc721b Fixed CI build. 2025-12-11 18:25:23 -08:00
Mike Cao
7bea47d9e8 Bumped version v3.0.3. Updated workflow. 2025-12-11 18:04:10 -08:00
Mike Cao
e9cdabab5a Upgraded react and next. 2025-12-11 17:58:20 -08:00
Egor Fedorov
5b97fb908a
fix(events): use correct key for event values 2025-12-11 23:24:01 +03:00
Kristofor Carle
b088a2ee6e fix prisma session race condition error 2025-12-11 14:53:08 -05:00
GochoMugo
8cc571f548
fix: handle denied storage access 2025-12-11 08:53:13 +03:00
Francis Cao
59a16e719b update bug template
Some checks failed
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled
2025-12-09 10:35:10 -08:00
134 changed files with 3670 additions and 1780 deletions

View file

@ -24,13 +24,13 @@ body:
render: shell render: shell
- type: input - type: input
attributes: attributes:
label: Which Umami version are you using? (if relevant) label: Which Umami version are you using?
description: 'For example: 2.18.0, 2.15.1, 1.39.0, etc' description: 'For example: 2.18.0, 2.15.1, 1.39.0, etc'
- type: input - type: input
attributes: attributes:
label: Which browser are you using? (if relevant) label: How are you deploying your application?
description: 'For example: Chrome, Edge, Firefox, etc' description: 'For example: Vercel, Railway, Docker, etc'
- type: input - type: input
attributes: attributes:
label: How are you deploying your application? (if relevant) label: Which browser are you using?
description: 'For example: Vercel, Railway, Docker, etc' description: 'For example: Chrome, Edge, Firefox, etc'

View file

@ -3,13 +3,18 @@ name: Create docker images
on: on:
push: push:
tags: tags:
- 'v*.*.*' - "v*.*.*"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
description: 'Optional image version (e.g. 3.0.0, v3.0.0, or 3.0.0-beta.1)' description: "Optional image version (e.g. 3.0.0, v3.0.0, or 3.0.0-beta.1)"
required: false required: false
default: '' default: ""
include_latest:
description: "Include latest tag"
required: false
type: boolean
default: true
jobs: jobs:
build: build:
@ -22,6 +27,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@ -46,6 +54,7 @@ jobs:
INPUT="${{ github.event.inputs.version }}" INPUT="${{ github.event.inputs.version }}"
REF_TYPE="${{ github.ref_type }}" REF_TYPE="${{ github.ref_type }}"
REF_NAME="${{ github.ref_name }}" REF_NAME="${{ github.ref_name }}"
INCLUDE_LATEST="${{ github.event.inputs.include_latest }}"
# Determine version source # Determine version source
if [[ -n "$INPUT" ]]; then if [[ -n "$INPUT" ]]; then
@ -56,7 +65,8 @@ jobs:
VERSION="" VERSION=""
fi fi
TAGS="" GHCR_TAGS=""
DOCKER_TAGS=""
if [[ -n "$VERSION" ]]; then if [[ -n "$VERSION" ]]; then
MAJOR=$(echo "$VERSION" | cut -d. -f1) MAJOR=$(echo "$VERSION" | cut -d. -f1)
@ -64,37 +74,54 @@ jobs:
if [[ "$VERSION" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
# prerelease: only version tag # prerelease: only version tag
TAGS="$VERSION" GHCR_TAGS="ghcr.io/${{ github.repository }}:$VERSION"
DOCKER_TAGS="umamisoftware/umami:$VERSION"
else else
# stable release: version + hierarchy + latest # stable release: version + hierarchy
TAGS="$VERSION,${MAJOR}.${MINOR},${MAJOR},postgresql-latest,latest" GHCR_TAGS="ghcr.io/${{ github.repository }}:$VERSION"
GHCR_TAGS="$GHCR_TAGS,ghcr.io/${{ github.repository }}:${MAJOR}.${MINOR}"
GHCR_TAGS="$GHCR_TAGS,ghcr.io/${{ github.repository }}:${MAJOR}"
GHCR_TAGS="$GHCR_TAGS,ghcr.io/${{ github.repository }}:postgresql-latest"
DOCKER_TAGS="umamisoftware/umami:$VERSION"
DOCKER_TAGS="$DOCKER_TAGS,umamisoftware/umami:${MAJOR}.${MINOR}"
DOCKER_TAGS="$DOCKER_TAGS,umamisoftware/umami:${MAJOR}"
DOCKER_TAGS="$DOCKER_TAGS,umamisoftware/umami:postgresql-latest"
# Add latest tag based on trigger and input
if [[ "$REF_TYPE" == "tag" ]] || [[ "$INCLUDE_LATEST" == "true" ]]; then
GHCR_TAGS="$GHCR_TAGS,ghcr.io/${{ github.repository }}:latest"
DOCKER_TAGS="$DOCKER_TAGS,umamisoftware/umami:latest"
fi
fi fi
else else
# Non-tag build (e.g. from main branch) # Non-tag build (e.g. from main branch)
TAGS="${REF_NAME}" GHCR_TAGS="ghcr.io/${{ github.repository }}:${REF_NAME}"
DOCKER_TAGS="umamisoftware/umami:${REF_NAME}"
fi fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "ghcr_tags=$GHCR_TAGS" >> $GITHUB_OUTPUT
echo "Computed tags: $TAGS" echo "docker_tags=$DOCKER_TAGS" >> $GITHUB_OUTPUT
echo "Computed GHCR tags: $GHCR_TAGS"
echo "Computed Docker Hub tags: $DOCKER_TAGS"
- name: Build and push Docker image - name: Build and push to GHCR
run: | uses: docker/build-push-action@v5
TAGS="${{ steps.compute.outputs.tags }}" with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.compute.outputs.ghcr_tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Set image targets conditionally - name: Build and push to Docker Hub
if [[ "${{ github.repository }}" == "umami-software/umami" ]]; then if: github.repository == 'umami-software/umami'
IMAGES=("umamisoftware/umami" "ghcr.io/${{ github.repository }}") uses: docker/build-push-action@v5
else with:
IMAGES=("ghcr.io/${{ github.repository }}") context: .
fi platforms: linux/amd64,linux/arm64
push: true
for IMAGE in "${IMAGES[@]}"; do tags: ${{ steps.compute.outputs.docker_tags }}
echo "Building and pushing $IMAGE with tags: $TAGS" cache-from: type=gha
docker buildx build \ cache-to: type=gha,mode=max
--platform linux/amd64,linux/arm64 \
--push \
$(echo "$TAGS" | tr ',' '\n' | sed "s|^|--tag ${IMAGE}:|") \
--cache-from type=gha \
--cache-to type=gha,mode=max \
.
done

View file

@ -3,7 +3,7 @@ name: Node.js CI
on: [push] on: [push]
env: env:
DATABASE_TYPE: postgresql DATABASE_URL: "postgresql://user:pass@localhost:5432/dummy"
SKIP_DB_CHECK: 1 SKIP_DB_CHECK: 1
jobs: jobs:
@ -11,27 +11,18 @@ jobs:
if: github.repository == 'umami-software/umami' if: github.repository == 'umami-software/umami'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
include:
- node-version: 18.18
pnpm-version: 10
db-type: postgresql
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 # required so that setup-node will work - uses: pnpm/action-setup@v4
with: with:
version: ${{ matrix.pnpm-version }} version: 10
run_install: false run_install: false
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js 18.18
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: 18.18
cache: 'pnpm' cache: "pnpm"
env: - run: npm install --global pnpm
DATABASE_TYPE: ${{ matrix.db-type }} - run: pnpm install
- run: npm install --global pnpm - run: pnpm test
- run: pnpm install - run: pnpm build
- run: pnpm test
- run: pnpm build

91
.gitignore vendored
View file

@ -1,45 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
node_modules node_modules
.pnp .pnp
.pnp.js .pnp.js
.pnpm-store .pnpm-store
package-lock.json package-lock.json
# testing # testing
/coverage /coverage
# next.js # next.js
/.next /.next
/out /out
# production # production
/build /build
/public/script.js /public/script.js
/geo /geo
/dist /dist
/generated /generated
/src/generated /src/generated
pm2.yml
# misc
.DS_Store # misc
.idea .DS_Store
.yarn .idea
*.iml .yarn
*.log *.iml
.vscode *.log
.tool-versions .vscode
.tool-versions
# debug
npm-debug.log* # debug
yarn-debug.log* npm-debug.log*
yarn-error.log* yarn-debug.log*
yarn-error.log*
# local env files
.env # local env files
.env.* .env
*.env.* .env.*
*.env.*
*.dev.yml
*.dev.yml

View file

@ -8,6 +8,7 @@ const cloudMode = process.env.CLOUD_MODE || '';
const cloudUrl = process.env.CLOUD_URL || ''; const cloudUrl = process.env.CLOUD_URL || '';
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || ''; const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || '';
const corsMaxAge = process.env.CORS_MAX_AGE || ''; const corsMaxAge = process.env.CORS_MAX_AGE || '';
const defaultCurrency = process.env.DEFAULT_CURRENCY || '';
const defaultLocale = process.env.DEFAULT_LOCALE || ''; const defaultLocale = process.env.DEFAULT_LOCALE || '';
const forceSSL = process.env.FORCE_SSL || ''; const forceSSL = process.env.FORCE_SSL || '';
const frameAncestors = process.env.ALLOWED_FRAME_URLS || ''; const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
@ -170,6 +171,7 @@ export default {
cloudMode, cloudMode,
cloudUrl, cloudUrl,
currentVersion: pkg.version, currentVersion: pkg.version,
defaultCurrency,
defaultLocale, defaultLocale,
}, },
basePath, basePath,

View file

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "3.0.2", "version": "3.0.3",
"description": "A modern, privacy-focused alternative to Google Analytics.", "description": "A modern, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>", "author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT", "license": "MIT",
@ -97,20 +97,20 @@
"is-docker": "^3.0.0", "is-docker": "^3.0.0",
"is-localhost-ip": "^2.0.0", "is-localhost-ip": "^2.0.0",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"kafkajs": "^2.1.0", "kafkajs": "^2.1.0",
"lucide-react": "^0.543.0", "lucide-react": "^0.543.0",
"maxmind": "^5.0.0", "maxmind": "^5.0.0",
"next": "^15.5.7", "next": "^15.5.9",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"prisma": "^6.18.0", "prisma": "^6.18.0",
"pure-rand": "^7.0.1", "pure-rand": "^7.0.1",
"react": "^19.2.1", "react": "^19.2.3",
"react-dom": "^19.2.1", "react-dom": "^19.2.3",
"react-error-boundary": "^4.0.4", "react-error-boundary": "^4.0.4",
"react-intl": "^7.1.14", "react-intl": "^7.1.14",
"react-simple-maps": "^2.3.0", "react-simple-maps": "^2.3.0",
@ -143,8 +143,8 @@
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"babel-plugin-react-compiler": "19.1.0-rc.2", "babel-plugin-react-compiler": "19.1.0-rc.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"cypress": "^13.6.6", "cypress": "^15.8.0",
"extract-react-intl-messages": "^4.1.1", "extract-react-intl-messages": "^5.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"lint-staged": "^16.2.6", "lint-staged": "^16.2.6",
@ -164,7 +164,7 @@
"stylelint-config-css-modules": "^4.5.1", "stylelint-config-css-modules": "^4.5.1",
"stylelint-config-prettier": "^9.0.3", "stylelint-config-prettier": "^9.0.3",
"stylelint-config-recommended": "^14.0.0", "stylelint-config-recommended": "^14.0.0",
"tar": "^6.1.2", "tar": "^7.5.4",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsup": "^8.5.0", "tsup": "^8.5.0",

2223
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "share" (
"share_id" UUID NOT NULL,
"entity_id" UUID NOT NULL,
"name" VARCHAR(200) NOT NULL,
"share_type" INTEGER NOT NULL,
"slug" VARCHAR(100) NOT NULL,
"parameters" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "share_pkey" PRIMARY KEY ("share_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
-- CreateIndex
CREATE INDEX "share_entity_id_idx" ON "share"("entity_id");
-- MigrateData
INSERT INTO "share" (share_id, entity_id, name, share_type, slug, parameters, created_at)
SELECT gen_random_uuid(),
website_id,
name,
1,
share_id,
'{"overview":true}'::jsonb,
now()
FROM "website"
WHERE share_id IS NOT NULL;
-- DropIndex
DROP INDEX "website_share_id_idx";
-- DropIndex
DROP INDEX "website_share_id_key";
-- AlterTable
ALTER TABLE "website" DROP COLUMN "share_id";

View file

@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "board" (
"board_id" UUID NOT NULL,
"type" VARCHAR(50) NOT NULL,
"name" VARCHAR(200) NOT NULL,
"description" VARCHAR(500) NOT NULL,
"parameters" JSONB NOT NULL,
"slug" VARCHAR(100) NOT NULL,
"user_id" UUID,
"team_id" UUID,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
CONSTRAINT "board_pkey" PRIMARY KEY ("board_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "board_slug_key" ON "board"("slug");
-- CreateIndex
CREATE INDEX "board_slug_idx" ON "board"("slug");
-- CreateIndex
CREATE INDEX "board_user_id_idx" ON "board"("user_id");
-- CreateIndex
CREATE INDEX "board_team_id_idx" ON "board"("team_id");
-- CreateIndex
CREATE INDEX "board_created_at_idx" ON "board"("created_at");

View file

@ -0,0 +1,29 @@
-- DropIndex
DROP INDEX "link_link_id_key";
-- DropIndex
DROP INDEX "pixel_pixel_id_key";
-- DropIndex
DROP INDEX "report_report_id_key";
-- DropIndex
DROP INDEX "revenue_revenue_id_key";
-- DropIndex
DROP INDEX "segment_segment_id_key";
-- DropIndex
DROP INDEX "session_session_id_key";
-- DropIndex
DROP INDEX "team_team_id_key";
-- DropIndex
DROP INDEX "team_user_team_user_id_key";
-- DropIndex
DROP INDEX "user_user_id_key";
-- DropIndex
DROP INDEX "website_website_id_key";

View file

@ -11,7 +11,7 @@ datasource db {
} }
model User { model User {
id String @id @unique @map("user_id") @db.Uuid id String @id() @map("user_id") @db.Uuid
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
password String @db.VarChar(60) password String @db.VarChar(60)
role String @map("role") @db.VarChar(50) role String @map("role") @db.VarChar(50)
@ -27,12 +27,13 @@ model User {
pixels Pixel[] @relation("user") pixels Pixel[] @relation("user")
teams TeamUser[] teams TeamUser[]
reports Report[] reports Report[]
boards Board[] @relation("user")
@@map("user") @@map("user")
} }
model Session { model Session {
id String @id @unique @map("session_id") @db.Uuid id String @id() @map("session_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
browser String? @db.VarChar(20) browser String? @db.VarChar(20)
os String? @db.VarChar(20) os String? @db.VarChar(20)
@ -64,10 +65,9 @@ model Session {
} }
model Website { model Website {
id String @id @unique @map("website_id") @db.Uuid id String @id() @map("website_id") @db.Uuid
name String @db.VarChar(100) name String @db.VarChar(100)
domain String? @db.VarChar(500) domain String? @db.VarChar(500)
shareId String? @unique @map("share_id") @db.VarChar(50)
resetAt DateTime? @map("reset_at") @db.Timestamptz(6) resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
userId String? @map("user_id") @db.Uuid userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid teamId String? @map("team_id") @db.Uuid
@ -88,7 +88,6 @@ model Website {
@@index([userId]) @@index([userId])
@@index([teamId]) @@index([teamId])
@@index([createdAt]) @@index([createdAt])
@@index([shareId])
@@index([createdBy]) @@index([createdBy])
@@map("website") @@map("website")
} }
@ -187,7 +186,7 @@ model SessionData {
} }
model Team { model Team {
id String @id() @unique() @map("team_id") @db.Uuid id String @id() @map("team_id") @db.Uuid
name String @db.VarChar(50) name String @db.VarChar(50)
accessCode String? @unique @map("access_code") @db.VarChar(50) accessCode String? @unique @map("access_code") @db.VarChar(50)
logoUrl String? @map("logo_url") @db.VarChar(2183) logoUrl String? @map("logo_url") @db.VarChar(2183)
@ -199,13 +198,14 @@ model Team {
members TeamUser[] members TeamUser[]
links Link[] links Link[]
pixels Pixel[] pixels Pixel[]
boards Board[]
@@index([accessCode]) @@index([accessCode])
@@map("team") @@map("team")
} }
model TeamUser { model TeamUser {
id String @id() @unique() @map("team_user_id") @db.Uuid id String @id() @map("team_user_id") @db.Uuid
teamId String @map("team_id") @db.Uuid teamId String @map("team_id") @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
role String @db.VarChar(50) role String @db.VarChar(50)
@ -221,7 +221,7 @@ model TeamUser {
} }
model Report { model Report {
id String @id() @unique() @map("report_id") @db.Uuid id String @id() @map("report_id") @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
type String @db.VarChar(50) type String @db.VarChar(50)
@ -242,7 +242,7 @@ model Report {
} }
model Segment { model Segment {
id String @id() @unique() @map("segment_id") @db.Uuid id String @id() @map("segment_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
type String @db.VarChar(50) type String @db.VarChar(50)
name String @db.VarChar(200) name String @db.VarChar(200)
@ -257,7 +257,7 @@ model Segment {
} }
model Revenue { model Revenue {
id String @id() @unique() @map("revenue_id") @db.Uuid id String @id() @map("revenue_id") @db.Uuid
websiteId String @map("website_id") @db.Uuid websiteId String @map("website_id") @db.Uuid
sessionId String @map("session_id") @db.Uuid sessionId String @map("session_id") @db.Uuid
eventId String @map("event_id") @db.Uuid eventId String @map("event_id") @db.Uuid
@ -277,7 +277,7 @@ model Revenue {
} }
model Link { model Link {
id String @id() @unique() @map("link_id") @db.Uuid id String @id() @map("link_id") @db.Uuid
name String @db.VarChar(100) name String @db.VarChar(100)
url String @db.VarChar(500) url String @db.VarChar(500)
slug String @unique() @db.VarChar(100) slug String @unique() @db.VarChar(100)
@ -298,7 +298,7 @@ model Link {
} }
model Pixel { model Pixel {
id String @id() @unique() @map("pixel_id") @db.Uuid id String @id() @map("pixel_id") @db.Uuid
name String @db.VarChar(100) name String @db.VarChar(100)
slug String @unique() @db.VarChar(100) slug String @unique() @db.VarChar(100)
userId String? @map("user_id") @db.Uuid userId String? @map("user_id") @db.Uuid
@ -316,3 +316,39 @@ model Pixel {
@@index([createdAt]) @@index([createdAt])
@@map("pixel") @@map("pixel")
} }
model Board {
id String @id() @map("board_id") @db.Uuid
type String @db.VarChar(50)
name String @db.VarChar(200)
description String @db.VarChar(500)
parameters Json
slug String @unique() @db.VarChar(100)
userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
user User? @relation("user", fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
@@index([slug])
@@index([userId])
@@index([teamId])
@@index([createdAt])
@@map("board")
}
model Share {
id String @id() @map("share_id") @db.Uuid
entityId String @map("entity_id") @db.Uuid
name String @db.VarChar(200)
shareType Int @map("share_type") @db.Integer
slug String @unique() @db.VarChar(100)
parameters Json
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
@@index([entityId])
@@map("share")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -5,6 +5,18 @@
"value": "访问代码" "value": "访问代码"
} }
], ],
"label.account": [
{
"type": 0,
"value": "账户"
}
],
"label.action": [
{
"type": 0,
"value": "行为"
}
],
"label.actions": [ "label.actions": [
{ {
"type": 0, "type": 0,
@ -35,12 +47,24 @@
"value": "添加描述" "value": "添加描述"
} }
], ],
"label.add-link": [
{
"type": 0,
"value": "添加链接"
}
],
"label.add-member": [ "label.add-member": [
{ {
"type": 0, "type": 0,
"value": "添加成员" "value": "添加成员"
} }
], ],
"label.add-pixel": [
{
"type": 0,
"value": "添加像素"
}
],
"label.add-step": [ "label.add-step": [
{ {
"type": 0, "type": 0,
@ -83,12 +107,24 @@
"value": "所有时间段" "value": "所有时间段"
} }
], ],
"label.analysis": [
{
"type": 0,
"value": "分析"
}
],
"label.analytics": [ "label.analytics": [
{ {
"type": 0, "type": 0,
"value": "分析" "value": "分析"
} }
], ],
"label.application": [
{
"type": 0,
"value": "应用"
}
],
"label.apply": [ "label.apply": [
{ {
"type": 0, "type": 0,
@ -107,6 +143,12 @@
"value": "查看用户如何与您的营销互动,以及是什么促成了转化。" "value": "查看用户如何与您的营销互动,以及是什么促成了转化。"
} }
], ],
"label.audience": [
{
"type": 0,
"value": "受众"
}
],
"label.average": [ "label.average": [
{ {
"type": 0, "type": 0,
@ -125,6 +167,12 @@
"value": "之前" "value": "之前"
} }
], ],
"label.behavior": [
{
"type": 0,
"value": "行为"
}
],
"label.boards": [ "label.boards": [
{ {
"type": 0, "type": 0,
@ -173,12 +221,24 @@
"value": "修改密码" "value": "修改密码"
} }
], ],
"label.channel": [
{
"type": 0,
"value": "渠道"
}
],
"label.channels": [ "label.channels": [
{ {
"type": 0, "type": 0,
"value": "渠道" "value": "渠道"
} }
], ],
"label.chart": [
{
"type": 0,
"value": "图表"
}
],
"label.cities": [ "label.cities": [
{ {
"type": 0, "type": 0,
@ -203,6 +263,12 @@
"value": "队列" "value": "队列"
} }
], ],
"label.cohorts": [
{
"type": 0,
"value": "队列"
}
],
"label.compare": [ "label.compare": [
{ {
"type": 0, "type": 0,
@ -317,6 +383,12 @@
"value": "创建者" "value": "创建者"
} }
], ],
"label.criteria": [
{
"type": 0,
"value": "条件"
}
],
"label.currency": [ "label.currency": [
{ {
"type": 0, "type": 0,
@ -419,6 +491,12 @@
"value": "台式机" "value": "台式机"
} }
], ],
"label.destination-url": [
{
"type": 0,
"value": "目标URL"
}
],
"label.details": [ "label.details": [
{ {
"type": 0, "type": 0,
@ -455,6 +533,12 @@
"value": "唯一ID" "value": "唯一ID"
} }
], ],
"label.documentation": [
{
"type": 0,
"value": "文档"
}
],
"label.does-not-contain": [ "label.does-not-contain": [
{ {
"type": 0, "type": 0,
@ -479,6 +563,12 @@
"value": "域名" "value": "域名"
} }
], ],
"label.download": [
{
"type": 0,
"value": "下载"
}
],
"label.dropoff": [ "label.dropoff": [
{ {
"type": 0, "type": 0,
@ -506,7 +596,7 @@
"label.email": [ "label.email": [
{ {
"type": 0, "type": 0,
"value": "Email" "value": "邮箱"
} }
], ],
"label.enable-share-url": [ "label.enable-share-url": [
@ -527,6 +617,12 @@
"value": "入口 URL" "value": "入口 URL"
} }
], ],
"label.environment": [
{
"type": 0,
"value": "环境"
}
],
"label.event": [ "label.event": [
{ {
"type": 0, "type": 0,
@ -671,6 +767,12 @@
"value": "分组" "value": "分组"
} }
], ],
"label.growth": [
{
"type": 0,
"value": "增长"
}
],
"label.hostname": [ "label.hostname": [
{ {
"type": 0, "type": 0,
@ -701,6 +803,12 @@
"value": "通过使用筛选器和划分时间段来更深入地研究数据。" "value": "通过使用筛选器和划分时间段来更深入地研究数据。"
} }
], ],
"label.invalid-url": [
{
"type": 0,
"value": "无效URL"
}
],
"label.is": [ "label.is": [
{ {
"type": 0, "type": 0,
@ -863,12 +971,24 @@
"value": "少于等于" "value": "少于等于"
} }
], ],
"label.link": [
{
"type": 0,
"value": "链接"
}
],
"label.links": [ "label.links": [
{ {
"type": 0, "type": 0,
"value": "链接" "value": "链接"
} }
], ],
"label.location": [
{
"type": 0,
"value": "位置"
}
],
"label.login": [ "label.login": [
{ {
"type": 0, "type": 0,
@ -1020,7 +1140,7 @@
"label.online": [ "label.online": [
{ {
"type": 0, "type": 0,
"value": "Online" "value": "在线"
} }
], ],
"label.organic-search": [ "label.organic-search": [
@ -1165,6 +1285,12 @@
"value": "路径" "value": "路径"
} }
], ],
"label.pixel": [
{
"type": 0,
"value": "像素"
}
],
"label.pixels": [ "label.pixels": [
{ {
"type": 0, "type": 0,
@ -1185,6 +1311,12 @@
"value": " 提供支持" "value": " 提供支持"
} }
], ],
"label.preferences": [
{
"type": 0,
"value": "偏好"
}
],
"label.previous": [ "label.previous": [
{ {
"type": 0, "type": 0,
@ -1209,6 +1341,12 @@
"value": "个人资料" "value": "个人资料"
} }
], ],
"label.profiles": [
{
"type": 0,
"value": "个人资料"
}
],
"label.properties": [ "label.properties": [
{ {
"type": 0, "type": 0,
@ -1248,7 +1386,7 @@
"label.referral": [ "label.referral": [
{ {
"type": 0, "type": 0,
"value": "Referral" "value": "来源"
} }
], ],
"label.referrer": [ "label.referrer": [
@ -1371,6 +1509,24 @@
"value": "保存" "value": "保存"
} }
], ],
"label.save-cohort": [
{
"type": 0,
"value": "保存为群组"
}
],
"label.save-segment": [
{
"type": 0,
"value": "保存为细分"
}
],
"label.screen": [
{
"type": 0,
"value": "屏幕"
}
],
"label.screens": [ "label.screens": [
{ {
"type": 0, "type": 0,
@ -1383,6 +1539,18 @@
"value": "搜索" "value": "搜索"
} }
], ],
"label.segment": [
{
"type": 0,
"value": "细分"
}
],
"label.segments": [
{
"type": 0,
"value": "细分"
}
],
"label.select": [ "label.select": [
{ {
"type": 0, "type": 0,
@ -1485,6 +1653,24 @@
"value": "总和" "value": "总和"
} }
], ],
"label.support": [
{
"type": 0,
"value": "支持"
}
],
"label.switch-account": [
{
"type": 0,
"value": "切换账户"
}
],
"label.table": [
{
"type": 0,
"value": "表格"
}
],
"label.tablet": [ "label.tablet": [
{ {
"type": 0, "type": 0,
@ -1635,6 +1821,12 @@
"value": "跟踪代码" "value": "跟踪代码"
} }
], ],
"label.traffic": [
{
"type": 0,
"value": "流量"
}
],
"label.transactions": [ "label.transactions": [
{ {
"type": 0, "type": 0,
@ -1846,7 +2038,7 @@
"message.bad-request": [ "message.bad-request": [
{ {
"type": 0, "type": 0,
"value": "Bad request" "value": "请求错误"
} }
], ],
"message.collected-data": [ "message.collected-data": [
@ -1946,7 +2138,7 @@
"message.forbidden": [ "message.forbidden": [
{ {
"type": 0, "type": 0,
"value": "Forbidden" "value": "禁止访问"
} }
], ],
"message.go-to-settings": [ "message.go-to-settings": [
@ -2046,13 +2238,13 @@
"message.not-found": [ "message.not-found": [
{ {
"type": 0, "type": 0,
"value": "Not found" "value": "未找到"
} }
], ],
"message.nothing-selected": [ "message.nothing-selected": [
{ {
"type": 0, "type": 0,
"value": "Nothing selected." "value": "未选择"
} }
], ],
"message.page-not-found": [ "message.page-not-found": [
@ -2090,7 +2282,7 @@
"message.sever-error": [ "message.sever-error": [
{ {
"type": 0, "type": 0,
"value": "Server error" "value": "服务器错误"
} }
], ],
"message.share-url": [ "message.share-url": [
@ -2158,7 +2350,7 @@
"message.unauthorized": [ "message.unauthorized": [
{ {
"type": 0, "type": 0,
"value": "Unauthorized" "value": "未授权"
} }
], ],
"message.user-deleted": [ "message.user-deleted": [

View file

@ -6,13 +6,13 @@
"AD-06": "Sant Julia de Loria", "AD-06": "Sant Julia de Loria",
"AD-07": "Andorra la Vella", "AD-07": "Andorra la Vella",
"AD-08": "Escaldes-Engordany", "AD-08": "Escaldes-Engordany",
"AE-AJ": "'Ajman", "AE-AJ": "Ajman",
"AE-AZ": "Abu Zaby", "AE-AZ": "Abu Dhabi",
"AE-DU": "Dubayy", "AE-DU": "Dubai",
"AE-FU": "Al Fujayrah", "AE-FU": "Al Fujairah",
"AE-RK": "Ra's al Khaymah", "AE-RK": "Ras al Khaimah",
"AE-SH": "Ash Shariqah", "AE-SH": "Sharjah",
"AE-UQ": "Umm al Qaywayn", "AE-UQ": "Umm al Quwain",
"AF-BAL": "Balkh", "AF-BAL": "Balkh",
"AF-BAM": "Bamyan", "AF-BAM": "Bamyan",
"AF-BDG": "Badghis", "AF-BDG": "Badghis",

View file

@ -3,7 +3,7 @@ import 'dotenv/config';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import https from 'https'; import https from 'https';
import tar from 'tar'; import { list } from 'tar';
import zlib from 'zlib'; import zlib from 'zlib';
if (process.env.VERCEL && !process.env.BUILD_GEO) { if (process.env.VERCEL && !process.env.BUILD_GEO) {
@ -40,7 +40,7 @@ const isDirectMmdb = url.endsWith('.mmdb');
const downloadCompressed = url => const downloadCompressed = url =>
new Promise(resolve => { new Promise(resolve => {
https.get(url, res => { https.get(url, res => {
resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t())); resolve(res.pipe(zlib.createGunzip({})).pipe(list()));
}); });
}); });

View file

@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { TeamsAddButton } from '../../teams/TeamsAddButton';
import { AdminTeamsDataTable } from './AdminTeamsDataTable'; import { AdminTeamsDataTable } from './AdminTeamsDataTable';
export function AdminTeamsPage() { export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const handleSave = () => {};
return ( return (
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.teams)} /> <PageHeader title={formatMessage(labels.teams)}>
<TeamsAddButton onSave={handleSave} isAdmin={true} />
</PageHeader>
<Panel> <Panel>
<AdminTeamsDataTable /> <AdminTeamsDataTable />
</Panel> </Panel>

View file

@ -10,6 +10,7 @@ import {
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery } from '@/components/hooks';
import { messages } from '@/components/messages';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
export function UserAddForm({ onSave, onClose }) { export function UserAddForm({ onSave, onClose }) {
@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) {
<FormField <FormField
label={formatMessage(labels.password)} label={formatMessage(labels.password)}
name="password" name="password"
rules={{ required: formatMessage(labels.required) }} rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
}}
> >
<PasswordField autoComplete="new-password" data-test="input-password" /> <PasswordField autoComplete="new-password" data-test="input-password" />
</FormField> </FormField>

View file

@ -1,5 +1,5 @@
import { ConfirmationForm } from '@/components/common/ConfirmationForm'; import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages } from '@/components/hooks'; import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons'; import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton'; import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages'; import { messages } from '@/components/messages';
@ -15,7 +15,8 @@ export function LinkDeleteButton({
onSave?: () => void; onSave?: () => void;
}) { }) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages(); const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`); const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
const { touch } = useModified();
const handleConfirm = async (close: () => void) => { const handleConfirm = async (close: () => void) => {
await mutateAsync(null, { await mutateAsync(null, {

View file

@ -4,13 +4,14 @@ import {
Form, Form,
FormField, FormField,
FormSubmitButton, FormSubmitButton,
Grid,
Icon, Icon,
Label, Label,
Loading, Loading,
Row, Row,
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useConfig, useLinkQuery, useMessages } from '@/components/hooks'; import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery'; import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { RefreshCw } from '@/components/icons'; import { RefreshCw } from '@/components/icons';
@ -42,27 +43,20 @@ export function LinkEditForm({
const { linksUrl } = useConfig(); const { linksUrl } = useConfig();
const hostUrl = linksUrl || LINKS_URL; const hostUrl = linksUrl || LINKS_URL;
const { data, isLoading } = useLinkQuery(linkId); const { data, isLoading } = useLinkQuery(linkId);
const [slug, setSlug] = useState(generateId()); const [defaultSlug] = useState(generateId());
const handleSubmit = async (data: any) => { const handleSubmit = async (data: any) => {
await mutateAsync(data, { await mutateAsync(data, {
onSuccess: async () => { onSuccess: async () => {
toast(formatMessage(messages.saved)); toast(formatMessage(messages.saved));
touch('links'); touch('links');
touch(`link:${linkId}`);
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, },
}); });
}; };
const handleSlug = () => {
const slug = generateId();
setSlug(slug);
return slug;
};
const checkUrl = (url: string) => { const checkUrl = (url: string) => {
if (!isValidUrl(url)) { if (!isValidUrl(url)) {
return formatMessage(labels.invalidUrl); return formatMessage(labels.invalidUrl);
@ -70,19 +64,19 @@ export function LinkEditForm({
return true; return true;
}; };
useEffect(() => {
if (data) {
setSlug(data.slug);
}
}, [data]);
if (linkId && isLoading) { if (linkId && isLoading) {
return <Loading placement="absolute" />; return <Loading placement="absolute" />;
} }
return ( return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}> <Form
{({ setValue }) => { onSubmit={handleSubmit}
error={getErrorMessage(error)}
defaultValues={{ slug: defaultSlug, ...data }}
>
{({ setValue, watch }) => {
const slug = watch('slug');
return ( return (
<> <>
<FormField <FormField
@ -101,15 +95,25 @@ export function LinkEditForm({
<TextField placeholder="https://example.com" autoComplete="off" /> <TextField placeholder="https://example.com" autoComplete="off" />
</FormField> </FormField>
<FormField <Grid columns="1fr auto" alignItems="end" gap>
name="slug" <FormField
rules={{ name="slug"
required: formatMessage(labels.required), label={formatMessage({ id: 'label.slug', defaultMessage: 'Slug' })}
}} rules={{
style={{ display: 'none' }} required: formatMessage(labels.required),
> }}
<input type="hidden" /> >
</FormField> <TextField autoComplete="off" />
</FormField>
<Button
variant="quiet"
onPress={() => setValue('slug', generateId(), { shouldDirty: true })}
>
<Icon>
<RefreshCw />
</Icon>
</Button>
</Grid>
<Column> <Column>
<Label>{formatMessage(labels.link)}</Label> <Label>{formatMessage(labels.link)}</Label>
@ -121,14 +125,6 @@ export function LinkEditForm({
allowCopy allowCopy
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
<Button
variant="quiet"
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
>
<Icon>
<RefreshCw />
</Icon>
</Button>
</Row> </Row>
</Column> </Column>

View file

@ -1,8 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getLink } from '@/queries/prisma';
import { LinkPage } from './LinkPage'; import { LinkPage } from './LinkPage';
export default async function ({ params }: { params: Promise<{ linkId: string }> }) { export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
const { linkId } = await params; const { linkId } = await params;
const link = await getLink(linkId);
if (!link || link?.deletedAt) {
return null;
}
return <LinkPage linkId={linkId} />; return <LinkPage linkId={linkId} />;
} }

View file

@ -48,6 +48,7 @@ export function PixelEditForm({
onSuccess: async () => { onSuccess: async () => {
toast(formatMessage(messages.saved)); toast(formatMessage(messages.saved));
touch('pixels'); touch('pixels');
touch(`pixel:${pixelId}`);
onSave?.(); onSave?.();
onClose?.(); onClose?.();
}, },

View file

@ -1,8 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { getPixel } from '@/queries/prisma';
import { PixelPage } from './PixelPage'; import { PixelPage } from './PixelPage';
export default async function ({ params }: { params: { pixelId: string } }) { export default async function ({ params }: { params: { pixelId: string } }) {
const { pixelId } = await params; const { pixelId } = await params;
const pixel = await getPixel(pixelId);
if (!pixel || pixel?.deletedAt) {
return null;
}
return <PixelPage pixelId={pixelId} />; return <PixelPage pixelId={pixelId} />;
} }

View file

@ -4,6 +4,7 @@ import { DateRangeSetting } from './DateRangeSetting';
import { LanguageSetting } from './LanguageSetting'; import { LanguageSetting } from './LanguageSetting';
import { ThemeSetting } from './ThemeSetting'; import { ThemeSetting } from './ThemeSetting';
import { TimezoneSetting } from './TimezoneSetting'; import { TimezoneSetting } from './TimezoneSetting';
import { VersionSetting } from './VersionSetting';
export function PreferenceSettings() { export function PreferenceSettings() {
const { user } = useLoginQuery(); const { user } = useLoginQuery();
@ -31,6 +32,10 @@ export function PreferenceSettings() {
<Label>{formatMessage(labels.theme)}</Label> <Label>{formatMessage(labels.theme)}</Label>
<ThemeSetting /> <ThemeSetting />
</Column> </Column>
<Column>
<Label>{formatMessage(labels.version)}</Label>
<VersionSetting />
</Column>
</Column> </Column>
); );
} }

View file

@ -0,0 +1,8 @@
'use client';
import { Text } from '@umami/react-zen';
import { CURRENT_VERSION } from '@/lib/constants';
export function VersionSetting() {
return <Text>{CURRENT_VERSION}</Text>;
}

View file

@ -7,8 +7,17 @@ import {
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery } from '@/components/hooks';
import { UserSelect } from '@/components/input/UserSelect';
export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { export function TeamAddForm({
onSave,
onClose,
isAdmin,
}: {
onSave: () => void;
onClose: () => void;
isAdmin: boolean;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/teams'); const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
<FormField name="name" label={formatMessage(labels.name)}> <FormField name="name" label={formatMessage(labels.name)}>
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormField> </FormField>
{isAdmin && (
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
</FormField>
)}
<FormButtons> <FormButtons>
<Button isDisabled={isPending} onPress={onClose}> <Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}

View file

@ -0,0 +1,76 @@
import {
Button,
Form,
FormButtons,
FormField,
FormSubmitButton,
ListItem,
Select,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { UserSelect } from '@/components/input/UserSelect';
import { ROLES } from '@/lib/constants';
const roles = [ROLES.teamManager, ROLES.teamMember, ROLES.teamViewOnly];
export function TeamMemberAddForm({
teamId,
onSave,
onClose,
}: {
teamId: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users`);
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
const renderRole = role => {
switch (role) {
case ROLES.teamManager:
return formatMessage(labels.manager);
case ROLES.teamMember:
return formatMessage(labels.member);
case ROLES.teamViewOnly:
return formatMessage(labels.viewOnly);
}
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
name="userId"
label={formatMessage(labels.username)}
rules={{ required: 'Required' }}
>
<UserSelect teamId={teamId} />
</FormField>
<FormField name="role" label={formatMessage(labels.role)} rules={{ required: 'Required' }}>
<Select items={roles} renderValue={value => renderRole(value as any)}>
{roles.map(value => (
<ListItem key={value} id={value}>
{renderRole(value)}
</ListItem>
))}
</Select>
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -4,7 +4,13 @@ import { Plus } from '@/components/icons';
import { messages } from '@/components/messages'; import { messages } from '@/components/messages';
import { TeamAddForm } from './TeamAddForm'; import { TeamAddForm } from './TeamAddForm';
export function TeamsAddButton({ onSave }: { onSave?: () => void }) { export function TeamsAddButton({
onSave,
isAdmin = false,
}: {
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { toast } = useToast(); const { toast } = useToast();
const { touch } = useModified(); const { touch } = useModified();
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}> <Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />} {({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
</Dialog> </Dialog>
</Modal> </Modal>
</DialogTrigger> </DialogTrigger>

View file

@ -0,0 +1,40 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { messages } from '@/components/messages';
import { TeamMemberAddForm } from './TeamMemberAddForm';
export function TeamsMemberAddButton({
teamId,
onSave,
}: {
teamId: string;
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('teams:members');
onSave?.();
};
return (
<DialogTrigger>
<Button>
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addMember)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addMember)} style={{ width: 400 }}>
{({ close }) => <TeamMemberAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,10 +1,12 @@
import { Column } from '@umami/react-zen'; import { Column, Heading, Row } from '@umami/react-zen';
import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks';
import { Users } from '@/components/icons'; import { Users } from '@/components/icons';
import { labels } from '@/components/messages';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
import { TeamEditForm } from './TeamEditForm'; import { TeamEditForm } from './TeamEditForm';
import { TeamManage } from './TeamManage'; import { TeamManage } from './TeamManage';
import { TeamMembersDataTable } from './TeamMembersDataTable'; import { TeamMembersDataTable } from './TeamMembersDataTable';
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
const team: any = useTeam(); const team: any = useTeam();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { pathname } = useNavigation(); const { pathname } = useNavigation();
const { formatMessage } = useMessages();
const isAdmin = pathname.includes('/admin'); const isAdmin = pathname.includes('/admin');
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} /> <TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
</Panel> </Panel>
<Panel> <Panel>
<Row alignItems="center" justifyContent="space-between">
<Heading size="2">{formatMessage(labels.members)}</Heading>
{isAdmin && <TeamsMemberAddButton teamId={teamId} />}
</Row>
<TeamMembersDataTable teamId={teamId} allowEdit={canEdit} /> <TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
</Panel> </Panel>
{isTeamOwner && ( {isTeamOwner && (

View file

@ -21,9 +21,15 @@ export interface JourneyProps {
steps: number; steps: number;
startStep?: string; startStep?: string;
endStep?: string; endStep?: string;
view: string;
} }
export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) { const EVENT_TYPES = {
views: 1,
events: 2,
};
export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) {
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [activeNode, setActiveNode] = useState(null); const [activeNode, setActiveNode] = useState(null);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
steps, steps,
startStep, startStep,
endStep, endStep,
view,
eventType: EVENT_TYPES[view],
}); });
useEscapeKey(() => setSelectedNode(null)); useEscapeKey(() => setSelectedNode(null));

View file

@ -1,9 +1,10 @@
'use client'; 'use client';
import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen';
import { useState } from 'react'; import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages } from '@/components/hooks'; import { useDateRange, useMessages } from '@/components/hooks';
import { FilterButtons } from '@/components/input/FilterButtons';
import { Journey } from './Journey'; import { Journey } from './Journey';
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7]; const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const [view, setView] = useState('all');
const [steps, setSteps] = useState(DEFAULT_STEP); const [steps, setSteps] = useState(DEFAULT_STEP);
const [startStep, setStartStep] = useState(''); const [startStep, setStartStep] = useState('');
const [endStep, setEndStep] = useState(''); const [endStep, setEndStep] = useState('');
const buttons = [
{
id: 'all',
label: formatMessage(labels.all),
},
{
id: 'views',
label: formatMessage(labels.views),
},
{
id: 'events',
label: formatMessage(labels.events),
},
];
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
/> />
</Column> </Column>
</Grid> </Grid>
<Row justifyContent="flex-end">
<FilterButtons items={buttons} value={view} onChange={setView} />
</Row>
<Panel height="900px" allowFullscreen> <Panel height="900px" allowFullscreen>
<Journey <Journey
websiteId={websiteId} websiteId={websiteId}
@ -60,6 +80,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
steps={steps} steps={steps}
startStep={startStep} startStep={startStep}
endStep={endStep} endStep={endStep}
view={view}
/> />
</Panel> </Panel>
</Column> </Column>

View file

@ -12,9 +12,10 @@ import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar'; import { MetricsBar } from '@/components/metrics/MetricsBar';
import { renderDateLabels } from '@/lib/charts'; import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants'; import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date'; import { generateTimeSeries } from '@/lib/date';
import { formatLongCurrency, formatLongNumber } from '@/lib/format'; import { formatLongCurrency, formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage';
export interface RevenueProps { export interface RevenueProps {
websiteId: string; websiteId: string;
@ -24,7 +25,15 @@ export interface RevenueProps {
} }
export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
const [currency, setCurrency] = useState('USD'); const [currency, setCurrency] = useState(
getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY,
);
const handleCurrencyChange = (value: string) => {
setCurrency(value);
setItem(CURRENCY_CONFIG, value);
};
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { locale, dateLocale } = useLocale(); const { locale, dateLocale } = useLocale();
const { countryNames } = useCountryNames(locale); const { countryNames } = useCountryNames(locale);
@ -107,7 +116,7 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
return ( return (
<Column gap> <Column gap>
<Grid columns="280px" gap> <Grid columns="280px" gap>
<CurrencySelect value={currency} onChange={setCurrency} /> <CurrencySelect value={currency} onChange={handleCurrencyChange} />
</Grid> </Grid>
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (

View file

@ -21,30 +21,28 @@ export function WebsiteChart({
const { pageviews, sessions, compare } = (data || {}) as any; const { pageviews, sessions, compare } = (data || {}) as any;
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (data) { if (!data) {
const result = { return { pageviews: [], sessions: [] };
pageviews, }
sessions,
};
if (compare) { return {
result.compare = { pageviews,
pageviews: result.pageviews.map(({ x }, i) => ({ sessions,
...(compare && {
compare: {
pageviews: pageviews.map(({ x }, i) => ({
x, x,
y: compare.pageviews[i]?.y, y: compare.pageviews[i]?.y,
d: compare.pageviews[i]?.x, d: compare.pageviews[i]?.x,
})), })),
sessions: result.sessions.map(({ x }, i) => ({ sessions: sessions.map(({ x }, i) => ({
x, x,
y: compare.sessions[i]?.y, y: compare.sessions[i]?.y,
d: compare.sessions[i]?.x, d: compare.sessions[i]?.x,
})), })),
}; },
} }),
};
return result;
}
return { pageviews: [], sessions: [] };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit]);
return ( return (

View file

@ -169,6 +169,12 @@ export function WebsiteExpandedMenu({
path: updateParams({ view: 'hostname' }), path: updateParams({ view: 'hostname' }),
icon: <Network />, icon: <Network />,
}, },
{
id: 'distinctId',
label: formatMessage(labels.distinctId),
path: updateParams({ view: 'distinctId' }),
icon: <Tag />,
},
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tag), label: formatMessage(labels.tag),

View file

@ -1,11 +1,9 @@
import { Icon, Row, Text } from '@umami/react-zen'; import { Icon, Row, Text } from '@umami/react-zen';
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
import { Favicon } from '@/components/common/Favicon'; import { Favicon } from '@/components/common/Favicon';
import { LinkButton } from '@/components/common/LinkButton'; import { LinkButton } from '@/components/common/LinkButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { useMessages, useNavigation, useWebsite } from '@/components/hooks'; import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
import { Edit, Share } from '@/components/icons'; import { Edit } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { ActiveUsers } from '@/components/metrics/ActiveUsers'; import { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader({ showActions }: { showActions?: boolean }) { export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
@ -29,29 +27,14 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
<ActiveUsers websiteId={website.id} /> <ActiveUsers websiteId={website.id} />
{showActions && ( {showActions && (
<Row alignItems="center" gap> <LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
<ShareButton websiteId={website.id} shareId={website.shareId} /> <Icon>
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}> <Edit />
<Icon> </Icon>
<Edit /> <Text>{formatMessage(labels.edit)}</Text>
</Icon> </LinkButton>
<Text>{formatMessage(labels.edit)}</Text>
</LinkButton>
</Row>
)} )}
</Row> </Row>
</PageHeader> </PageHeader>
); );
} }
const ShareButton = ({ websiteId, shareId }) => {
const { formatMessage, labels } = useMessages();
return (
<DialogButton icon={<Share />} label={formatMessage(labels.share)} width="800px">
{({ close }) => {
return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />;
}}
</DialogButton>
);
};

View file

@ -10,7 +10,7 @@ import {
} from '@umami/react-zen'; } from '@umami/react-zen';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { Edit, More, Share } from '@/components/icons'; import { Edit, MoreHorizontal, Share } from '@/components/icons';
export function WebsiteMenu({ websiteId }: { websiteId: string }) { export function WebsiteMenu({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
<MenuTrigger> <MenuTrigger>
<Button variant="quiet"> <Button variant="quiet">
<Icon> <Icon>
<More /> <MoreHorizontal />
</Icon> </Icon>
</Button> </Button>
<Popover placement="bottom"> <Popover placement="bottom">

View file

@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
export function WebsiteMetricsBar({ export function WebsiteMetricsBar({
websiteId, websiteId,
compareMode,
}: { }: {
websiteId: string; websiteId: string;
showChange?: boolean; showChange?: boolean;
compareMode?: boolean; compareMode?: boolean;
}) { }) {
const { isAllTime } = useDateRange(); const { isAllTime, dateCompare } = useDateRange();
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId); const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({
websiteId,
compare: compareMode ? dateCompare?.compare : undefined,
});
const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {}; const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};

View file

@ -29,6 +29,7 @@ export function WebsiteNav({
event: undefined, event: undefined,
compare: undefined, compare: undefined,
view: undefined, view: undefined,
unit: undefined,
}); });
const items = [ const items = [

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { Column } from '@umami/react-zen'; import { Column, Row } from '@umami/react-zen';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { UnitFilter } from '@/components/input/UnitFilter';
import { WebsiteChart } from './WebsiteChart'; import { WebsiteChart } from './WebsiteChart';
import { WebsiteControls } from './WebsiteControls'; import { WebsiteControls } from './WebsiteControls';
import { WebsiteMetricsBar } from './WebsiteMetricsBar'; import { WebsiteMetricsBar } from './WebsiteMetricsBar';
@ -13,6 +14,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<Row justifyContent="end">
<UnitFilter />
</Row>
<WebsiteChart websiteId={websiteId} /> <WebsiteChart websiteId={websiteId} />
</Panel> </Panel>
<WebsitePanels websiteId={websiteId} /> <WebsitePanels websiteId={websiteId} />

View file

@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} allowCompare={true} /> <WebsiteControls websiteId={websiteId} allowCompare={true} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} compareMode={true} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<WebsiteChart websiteId={websiteId} compareMode={true} /> <WebsiteChart websiteId={websiteId} compareMode={true} />
</Panel> </Panel>

View file

@ -93,6 +93,11 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
label: formatMessage(labels.hostname), label: formatMessage(labels.hostname),
path: renderPath('hostname'), path: renderPath('hostname'),
}, },
{
id: 'distinctId',
label: formatMessage(labels.distinctId),
path: renderPath('distinctId'),
},
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tags), label: formatMessage(labels.tags),

View file

@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
const { updateParams } = useNavigation(); const { updateParams } = useNavigation();
const { formatValue } = useFormat(); const { formatValue } = useFormat();
const renderLink = (label: string, hostname: string) => {
return (
<a
href={`//${hostname}${label}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
);
};
return ( return (
<DataTable {...props}> <DataTable {...props}>
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr"> <DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
title={row.eventName || row.urlPath} title={row.eventName || row.urlPath}
truncate truncate
> >
{row.eventName || row.urlPath} {row.eventName || renderLink(row.urlPath, row.hostname)}
</Text> </Text>
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />} {row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
</Row> </Row>

View file

@ -1,5 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout'; import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout';
import { getWebsite } from '@/queries/prisma';
export default async function ({ export default async function ({
children, children,
@ -9,6 +10,11 @@ export default async function ({
params: Promise<{ websiteId: string }>; params: Promise<{ websiteId: string }>;
}) { }) {
const { websiteId } = await params; const { websiteId } = await params;
const website = await getWebsite(websiteId);
if (!website || website?.deletedAt) {
return null;
}
return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>; return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>;
} }

View file

@ -74,8 +74,9 @@ export function RealtimeLog({ data }: { data: any }) {
os: string; os: string;
country: string; country: string;
device: string; device: string;
hostname: string;
}) => { }) => {
const { __type, eventName, urlPath, browser, os, country, device } = log; const { __type, eventName, urlPath, browser, os, country, device, hostname } = log;
if (__type === TYPE_EVENT) { if (__type === TYPE_EVENT) {
return ( return (
@ -86,7 +87,8 @@ export function RealtimeLog({ data }: { data: any }) {
url: ( url: (
<a <a
key="a" key="a"
href={`//${website?.domain}${urlPath}`} href={`//${hostname}${urlPath}`}
style={{ fontWeight: 'bold' }}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -100,7 +102,12 @@ export function RealtimeLog({ data }: { data: any }) {
if (__type === TYPE_PAGEVIEW) { if (__type === TYPE_PAGEVIEW) {
return ( return (
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener"> <a
href={`//${hostname}${urlPath}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{urlPath} {urlPath}
</a> </a>
); );

View file

@ -39,10 +39,23 @@ export function SessionActivity({
const { isMobile } = useMobile(); const { isMobile } = useMobile();
let lastDay = null; let lastDay = null;
const renderLink = (label: string, hostname: string) => {
return (
<a
href={`//${hostname}${label}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
);
};
return ( return (
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
<Column gap> <Column gap>
{data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hostname, hasData }) => {
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
lastDay = createdAt; lastDay = createdAt;
@ -61,7 +74,7 @@ export function SessionActivity({
: formatMessage(labels.viewedPage)} : formatMessage(labels.viewedPage)}
</Text> </Text>
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate> <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
{eventName || urlPath} {eventName || renderLink(urlPath, hostname)}
</Text> </Text>
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />} {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
</Row> </Row>

View file

@ -0,0 +1,81 @@
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Row,
Text,
} from '@umami/react-zen';
import { useState } from 'react';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { SHARE_NAV_ITEMS } from './constants';
export interface ShareCreateFormProps {
websiteId: string;
onSave?: () => void;
onClose?: () => void;
}
export function ShareCreateForm({ websiteId, onSave, onClose }: ShareCreateFormProps) {
const { formatMessage, labels } = useMessages();
const { post } = useApi();
const { touch } = useModified();
const [isPending, setIsPending] = useState(false);
// Build default values - only overview and events enabled by default
const defaultValues: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
defaultValues[item.id] = item.id === 'overview' || item.id === 'events';
});
});
const handleSubmit = async (data: any) => {
setIsPending(true);
try {
const parameters: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
await post(`/websites/${websiteId}/shares`, { parameters });
touch('shares');
onSave?.();
onClose?.();
} finally {
setIsPending(false);
}
};
return (
<Form onSubmit={handleSubmit} defaultValues={defaultValues}>
<Column gap="3">
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="1">
<Text size="2" weight="bold">
{formatMessage((labels as any)[section.section])}
</Text>
<Column gap="1">
{section.items.map(item => (
<FormField key={item.id} name={item.id}>
<Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
</FormField>
))}
</Column>
</Column>
))}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={isPending}>{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</Column>
</Form>
);
}

View file

@ -0,0 +1,57 @@
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { messages } from '@/components/messages';
export function ShareDeleteButton({
shareId,
slug,
onSave,
}: {
shareId: string;
slug: string;
onSave?: () => void;
}) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/share/id/${shareId}`);
const { touch } = useModified();
const handleConfirm = async (close: () => void) => {
await mutateAsync(null, {
onSuccess: () => {
touch('shares');
onSave?.();
close();
},
});
};
return (
<DialogButton
icon={<Trash />}
title={formatMessage(labels.confirm)}
variant="quiet"
width="400px"
>
{({ close }) => (
<ConfirmationForm
message={
<FormattedMessage
{...messages.confirmRemove}
values={{
target: <b>{slug}</b>,
}}
/>
}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
buttonVariant="danger"
/>
)}
</DialogButton>
);
}

View file

@ -0,0 +1,16 @@
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { ShareEditForm } from './ShareEditForm';
export function ShareEditButton({ shareId }: { shareId: string }) {
const { formatMessage, labels } = useMessages();
return (
<DialogButton icon={<Edit />} title={formatMessage(labels.share)} variant="quiet" width="600px">
{({ close }) => {
return <ShareEditForm shareId={shareId} onClose={close} />;
}}
</DialogButton>
);
}

View file

@ -0,0 +1,122 @@
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Label,
Loading,
Row,
Text,
TextField,
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { SHARE_NAV_ITEMS } from './constants';
export function ShareEditForm({
shareId,
onSave,
onClose,
}: {
shareId: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/share/id/${shareId}`);
const { cloudMode } = useConfig();
const { get } = useApi();
const { modified } = useModified('shares');
const [share, setShare] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
useEffect(() => {
const loadShare = async () => {
setIsLoading(true);
try {
const data = await get(`/share/id/${shareId}`);
setShare(data);
} finally {
setIsLoading(false);
}
};
loadShare();
}, [shareId, modified]);
const handleSubmit = async (data: any) => {
const parameters: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
await mutateAsync(
{ slug: share.slug, parameters },
{
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('shares');
onSave?.();
onClose?.();
},
},
);
};
if (isLoading) {
return <Loading placement="absolute" />;
}
const url = getUrl(share?.slug || '');
// Build default values from share parameters
const defaultValues: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
const defaultSelected = item.id === 'overview' || item.id === 'events';
defaultValues[item.id] = share?.parameters?.[item.id] ?? defaultSelected;
});
});
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={defaultValues}>
<Column gap="3">
<Column>
<Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy />
</Column>
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="1">
<Text weight="bold">{formatMessage((labels as any)[section.section])}</Text>
<Column gap="1">
{section.items.map(item => (
<FormField key={item.id} name={item.id}>
<Checkbox>{formatMessage((labels as any)[item.label])}</Checkbox>
</FormField>
))}
</Column>
</Column>
))}
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton variant="primary">{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</Column>
</Form>
);
}

View file

@ -0,0 +1,46 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { useConfig, useMessages } from '@/components/hooks';
import { ShareDeleteButton } from './ShareDeleteButton';
import { ShareEditButton } from './ShareEditButton';
export function SharesTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages();
const { cloudMode } = useConfig();
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<DataTable {...props}>
<DataColumn id="slug" label={formatMessage(labels.shareUrl)}>
{({ slug }: any) => {
const url = getUrl(slug);
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => {
return (
<Row>
<ShareEditButton shareId={id} />
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
);
}}
</DataColumn>
</DataTable>
);
}

View file

@ -1,14 +1,11 @@
import { Column } from '@umami/react-zen'; import { Column } from '@umami/react-zen';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useWebsite } from '@/components/hooks';
import { WebsiteData } from './WebsiteData'; import { WebsiteData } from './WebsiteData';
import { WebsiteEditForm } from './WebsiteEditForm'; import { WebsiteEditForm } from './WebsiteEditForm';
import { WebsiteShareForm } from './WebsiteShareForm'; import { WebsiteShareForm } from './WebsiteShareForm';
import { WebsiteTrackingCode } from './WebsiteTrackingCode'; import { WebsiteTrackingCode } from './WebsiteTrackingCode';
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) { export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
const website = useWebsite();
return ( return (
<Column gap="6"> <Column gap="6">
<Panel> <Panel>
@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
<WebsiteTrackingCode websiteId={websiteId} /> <WebsiteTrackingCode websiteId={websiteId} />
</Panel> </Panel>
<Panel> <Panel>
<WebsiteShareForm websiteId={websiteId} shareId={website.shareId} /> <WebsiteShareForm websiteId={websiteId} />
</Panel> </Panel>
<Panel> <Panel>
<WebsiteData websiteId={websiteId} /> <WebsiteData websiteId={websiteId} />

View file

@ -1,93 +1,43 @@
import { import { Column, Heading, Row, Text } from '@umami/react-zen';
Button, import { Plus } from 'lucide-react';
Column, import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
Form, import { DialogButton } from '@/components/input/DialogButton';
FormButtons, import { ShareCreateForm } from './ShareCreateForm';
FormSubmitButton, import { SharesTable } from './SharesTable';
IconLabel,
Label,
Row,
Switch,
TextField,
} from '@umami/react-zen';
import { RefreshCcw } from 'lucide-react';
import { useState } from 'react';
import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
import { getRandomChars } from '@/lib/generate';
const generateId = () => getRandomChars(16);
export interface WebsiteShareFormProps { export interface WebsiteShareFormProps {
websiteId: string; websiteId: string;
shareId?: string;
onSave?: () => void;
onClose?: () => void;
} }
export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) { export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const [currentId, setCurrentId] = useState(shareId); const { data, isLoading } = useWebsiteSharesQuery({ websiteId });
const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
const { cloudMode } = useConfig();
const getUrl = (shareId: string) => { const shares = data?.data || [];
if (cloudMode) { const hasShares = shares.length > 0;
return `${process.env.cloudUrl}/share/${shareId}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
};
const url = getUrl(currentId);
const handleGenerate = () => {
setCurrentId(generateId());
};
const handleSwitch = () => {
setCurrentId(currentId ? null : generateId());
};
const handleSave = async () => {
const data = {
shareId: currentId,
};
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch(`website:${websiteId}`);
onSave?.();
onClose?.();
},
});
};
return ( return (
<Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}> <Column gap="4">
<Column gap> <Row justifyContent="space-between" alignItems="center">
<Switch isSelected={!!currentId} onChange={handleSwitch}> <Heading>{formatMessage(labels.share)}</Heading>
{formatMessage(labels.enableShareUrl)} <DialogButton
</Switch> icon={<Plus size={16} />}
{currentId && ( label={formatMessage(labels.add)}
<Row alignItems="flex-end" gap> title={formatMessage(labels.share)}
<Column flexGrow={1}> variant="primary"
<Label>{formatMessage(labels.shareUrl)}</Label> width="400px"
<TextField value={url} isReadOnly allowCopy /> >
</Column> {({ close }) => <ShareCreateForm websiteId={websiteId} onClose={close} />}
<Column> </DialogButton>
<Button onPress={handleGenerate}> </Row>
<IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} /> {hasShares ? (
</Button> <>
</Column> <Text>{formatMessage(messages.shareUrl)}</Text>
</Row> <SharesTable data={shares} />
)} </>
<FormButtons justifyContent="flex-end"> ) : (
<Row alignItems="center" gap> <Text color="muted">{formatMessage(messages.noDataAvailable)}</Text>
{onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>} )}
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton> </Column>
</Row>
</FormButtons>
</Column>
</Form>
); );
} }

View file

@ -0,0 +1,30 @@
export const SHARE_NAV_ITEMS = [
{
section: 'traffic',
items: [
{ id: 'overview', label: 'overview' },
{ id: 'events', label: 'events' },
{ id: 'sessions', label: 'sessions' },
{ id: 'realtime', label: 'realtime' },
{ id: 'compare', label: 'compare' },
{ id: 'breakdown', label: 'breakdown' },
],
},
{
section: 'behavior',
items: [
{ id: 'goals', label: 'goals' },
{ id: 'funnels', label: 'funnels' },
{ id: 'journeys', label: 'journeys' },
{ id: 'retention', label: 'retention' },
],
},
{
section: 'growth',
items: [
{ id: 'utm', label: 'utm' },
{ id: 'revenue', label: 'revenue' },
{ id: 'attribution', label: 'attribution' },
],
},
];

View file

@ -1,7 +1,14 @@
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request';
import { ok } from '@/lib/response'; import { ok } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
const { error } = await parseRequest(request);
if (error) {
return error();
}
if (redis.enabled) { if (redis.enabled) {
const token = request.headers.get('authorization')?.split(' ')?.[1]; const token = request.headers.get('authorization')?.split(' ')?.[1];

View file

@ -1,7 +1,7 @@
import { saveAuth } from '@/lib/auth'; import { saveAuth } from '@/lib/auth';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response'; import { json, serverError } from '@/lib/response';
export async function POST(request: Request) { export async function POST(request: Request) {
const { auth, error } = await parseRequest(request); const { auth, error } = await parseRequest(request);
@ -10,9 +10,13 @@ export async function POST(request: Request) {
return error(); return error();
} }
if (redis.enabled) { if (!redis.enabled) {
const token = await saveAuth({ userId: auth.user.id }, 86400); return serverError({
message: 'Redis is disabled',
return json({ user: auth.user, token }); });
} }
const token = await saveAuth({ userId: auth.user.id }, 86400);
return json({ user: auth.user, token });
} }

View file

@ -17,5 +17,6 @@ export async function GET(request: Request) {
telemetryDisabled: !!process.env.DISABLE_TELEMETRY, telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
trackerScriptName: process.env.TRACKER_SCRIPT_NAME, trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
updatesDisabled: !!process.env.DISABLE_UPDATES, updatesDisabled: !!process.env.DISABLE_UPDATES,
currentVersion: !!process.env.currentVersion,
}); });
} }

View file

@ -12,11 +12,16 @@ export async function POST(request: Request) {
} }
const { websiteId, parameters, filters } = body; const { websiteId, parameters, filters } = body;
const { eventType } = parameters;
if (!(await canViewWebsite(auth, websiteId))) { if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
} }
if (eventType) {
filters.eventType = eventType;
}
const queryFilters = await getQueryFilters(filters, websiteId); const queryFilters = await getQueryFilters(filters, websiteId);
const data = await getJourney(websiteId, parameters, queryFilters); const data = await getJourney(websiteId, parameters, queryFilters);

View file

@ -1,18 +1,22 @@
import { secret } from '@/lib/crypto'; import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt'; import { createToken } from '@/lib/jwt';
import { json, notFound } from '@/lib/response'; import { json, notFound } from '@/lib/response';
import { getSharedWebsite } from '@/queries/prisma'; import { getShareByCode } from '@/queries/prisma';
export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { shareId } = await params; const { slug } = await params;
const website = await getSharedWebsite(shareId); const share = await getShareByCode(slug);
if (!website) { if (!share) {
return notFound(); return notFound();
} }
const data = { websiteId: website.id }; const data = {
shareId: share.id,
websiteId: share.entityId,
parameters: share.parameters,
};
const token = createToken(data, secret()); const token = createToken(data, secret());
return json({ ...data, token }); return json({ ...data, token });

View file

@ -0,0 +1,80 @@
import z from 'zod';
import { parseRequest } from '@/lib/request';
import { json, notFound, ok, unauthorized } from '@/lib/response';
import { anyObjectParam } from '@/lib/schema';
import { canDeleteEntity, canUpdateEntity, canViewEntity } from '@/permissions';
import { deleteShare, getShare, updateShare } from '@/queries/prisma';
export async function GET(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { shareId } = await params;
const share = await getShare(shareId);
if (!(await canViewEntity(auth, share.entityId))) {
return unauthorized();
}
return json(share);
}
export async function POST(request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const schema = z.object({
slug: z.string().max(100),
parameters: anyObjectParam,
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { shareId } = await params;
const { slug, parameters } = body;
const share = await getShare(shareId);
if (!share) {
return notFound();
}
if (!(await canUpdateEntity(auth, share.entityId))) {
return unauthorized();
}
const result = await updateShare(shareId, {
slug,
parameters,
} as any);
return json(result);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ shareId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
const { shareId } = await params;
const share = await getShare(shareId);
if (!(await canDeleteEntity(auth, share.entityId))) {
return unauthorized();
}
await deleteShare(shareId);
return ok();
}

View file

@ -0,0 +1,39 @@
import z from 'zod';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { anyObjectParam } from '@/lib/schema';
import { canUpdateEntity } from '@/permissions';
import { createShare } from '@/queries/prisma';
export async function POST(request: Request) {
const schema = z.object({
entityId: z.uuid(),
shareType: z.coerce.number().int(),
slug: z.string().max(100).optional(),
parameters: anyObjectParam,
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { entityId, shareType, slug, parameters } = body;
if (!(await canUpdateEntity(auth, entityId))) {
return unauthorized();
}
const share = await createShare({
id: uuid(),
entityId,
shareType,
slug: slug || getRandomChars(16),
parameters,
});
return json(share);
}

View file

@ -28,6 +28,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
name: z.string().max(50), name: z.string().max(50),
ownerId: z.uuid().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -40,7 +41,7 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const { name } = body; const { name, ownerId } = body;
const team = await createTeam( const team = await createTeam(
{ {
@ -48,7 +49,7 @@ export async function POST(request: Request) {
name, name,
accessCode: `team_${getRandomChars(16)}`, accessCode: `team_${getRandomChars(16)}`,
}, },
auth.user.id, ownerId ?? auth.user.id,
); );
return json(team); return json(team);

View file

@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { hashPassword } from '@/lib/password'; import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, unauthorized } from '@/lib/response'; import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema'; import { userRoleParam } from '@/lib/schema';
import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions'; import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma'; import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({ const schema = z.object({
username: z.string().max(255).optional(), username: z.string().max(255).optional(),
password: z.string().max(255).optional(), password: z.string().min(8).max(255).optional(),
role: userRoleParam.optional(), role: userRoleParam.optional(),
}); });
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
const user = await getUser(userId); const user = await getUser(userId);
if (!user) {
return notFound();
}
const data: any = {}; const data: any = {};
if (password) { if (password) {

View file

@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
import { hashPassword } from '@/lib/password'; import { hashPassword } from '@/lib/password';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response'; import { badRequest, json, unauthorized } from '@/lib/response';
import { userRoleParam } from '@/lib/schema';
import { canCreateUser } from '@/permissions'; import { canCreateUser } from '@/permissions';
import { createUser, getUserByUsername } from '@/queries/prisma'; import { createUser, getUserByUsername } from '@/queries/prisma';
@ -11,8 +12,8 @@ export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
id: z.uuid().optional(), id: z.uuid().optional(),
username: z.string().max(255), username: z.string().max(255),
password: z.string(), password: z.string().min(8).max(255),
role: z.string().regex(/admin|user|view-only/i), role: userRoleParam,
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -1,7 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { SHARE_ID_REGEX } from '@/lib/constants';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; import { json, ok, unauthorized } from '@/lib/response';
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma'; import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
@ -33,7 +32,6 @@ export async function POST(
const schema = z.object({ const schema = z.object({
name: z.string().optional(), name: z.string().optional(),
domain: z.string().optional(), domain: z.string().optional(),
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -43,23 +41,15 @@ export async function POST(
} }
const { websiteId } = await params; const { websiteId } = await params;
const { name, domain, shareId } = body; const { name, domain } = body;
if (!(await canUpdateWebsite(auth, websiteId))) { if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
} }
try { const website = await updateWebsite(websiteId, { name, domain });
const website = await updateWebsite(websiteId, { name, domain, shareId });
return Response.json(website); return Response.json(website);
} catch (e: any) {
if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
return badRequest({ message: 'That share ID is already taken.' });
}
return serverError(e);
}
} }
export async function DELETE( export async function DELETE(

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema'; import { searchParams, segmentParamSchema, segmentTypeParam } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions'; import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createSegment, getWebsiteSegments } from '@/queries/prisma'; import { createSegment, getWebsiteSegments } from '@/queries/prisma';
@ -42,7 +42,7 @@ export async function POST(
const schema = z.object({ const schema = z.object({
type: segmentTypeParam, type: segmentTypeParam,
name: z.string().max(200), name: z.string().max(200),
parameters: anyObjectParam, parameters: segmentParamSchema,
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);

View file

@ -0,0 +1,74 @@
import { z } from 'zod';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema';
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
import { createShare, getSharesByEntityId } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...filterParams,
...pagingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { page, pageSize, search } = query;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getSharesByEntityId(websiteId, {
page,
pageSize,
search,
});
return json(data);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
parameters: anyObjectParam.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { parameters = {} } = body;
if (!(await canUpdateWebsite(auth, websiteId))) {
return unauthorized();
}
const slug = getRandomChars(16);
const share = await createShare({
id: uuid(),
entityId: websiteId,
shareType: ENTITY_TYPE.website,
slug,
parameters,
});
return json(share);
}

View file

@ -31,7 +31,11 @@ export async function GET(
const data = await getWebsiteStats(websiteId, filters); const data = await getWebsiteStats(websiteId, filters);
const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate); const { startDate, endDate } = getCompareDate(
filters.compare ?? 'prev',
filters.startDate,
filters.endDate,
);
const comparison = await getWebsiteStats(websiteId, { const comparison = await getWebsiteStats(websiteId, {
...filters, ...filters,

View file

@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { uuid } from '@/lib/crypto'; import { uuid } from '@/lib/crypto';
import redis from '@/lib/redis'; import { fetchAccount } from '@/lib/load';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema'; import { pagingParams, searchParams } from '@/lib/schema';
@ -52,7 +52,7 @@ export async function POST(request: Request) {
const { id, name, domain, shareId, teamId } = body; const { id, name, domain, shareId, teamId } = body;
if (process.env.CLOUD_MODE && !teamId) { if (process.env.CLOUD_MODE && !teamId) {
const account = await redis.client.get(`account:${auth.user.id}`); const account = await fetchAccount(auth.user.id);
if (!account?.hasSubscription) { if (!account?.hasSubscription) {
const count = await getWebsiteCount(auth.user.id); const count = await getWebsiteCount(auth.user.id);

View file

@ -0,0 +1,143 @@
'use client';
import { Column } from '@umami/react-zen';
import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation } from '@/components/hooks';
import { AlignEndHorizontal, Clock, Eye, Sheet, Tag, User } from '@/components/icons';
import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
export function ShareNav({
shareId,
parameters,
onItemClick,
}: {
shareId: string;
parameters: Record<string, boolean>;
onItemClick?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const renderPath = (path: string) => `/share/${shareId}${path}`;
const allItems = [
{
section: 'traffic',
label: formatMessage(labels.traffic),
items: [
{
id: 'overview',
label: formatMessage(labels.overview),
icon: <Eye />,
path: renderPath(''),
},
{
id: 'events',
label: formatMessage(labels.events),
icon: <Lightning />,
path: renderPath('/events'),
},
{
id: 'sessions',
label: formatMessage(labels.sessions),
icon: <User />,
path: renderPath('/sessions'),
},
{
id: 'realtime',
label: formatMessage(labels.realtime),
icon: <Clock />,
path: renderPath('/realtime'),
},
{
id: 'compare',
label: formatMessage(labels.compare),
icon: <AlignEndHorizontal />,
path: renderPath('/compare'),
},
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
path: renderPath('/breakdown'),
},
],
},
{
section: 'behavior',
label: formatMessage(labels.behavior),
items: [
{
id: 'goals',
label: formatMessage(labels.goals),
icon: <Target />,
path: renderPath('/goals'),
},
{
id: 'funnels',
label: formatMessage(labels.funnels),
icon: <Funnel />,
path: renderPath('/funnels'),
},
{
id: 'journeys',
label: formatMessage(labels.journeys),
icon: <Path />,
path: renderPath('/journeys'),
},
{
id: 'retention',
label: formatMessage(labels.retention),
icon: <Magnet />,
path: renderPath('/retention'),
},
],
},
{
section: 'growth',
label: formatMessage(labels.growth),
items: [
{
id: 'utm',
label: formatMessage(labels.utm),
icon: <Tag />,
path: renderPath('/utm'),
},
{
id: 'revenue',
label: formatMessage(labels.revenue),
icon: <Money />,
path: renderPath('/revenue'),
},
{
id: 'attribution',
label: formatMessage(labels.attribution),
icon: <Network />,
path: renderPath('/attribution'),
},
],
},
];
// Filter items based on parameters
const items = allItems
.map(section => ({
label: section.label,
items: section.items.filter(item => parameters[item.id] !== false),
}))
.filter(section => section.items.length > 0);
const selectedKey = items
.flatMap(e => e.items)
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
return (
<Column padding="3" position="sticky" top="0" gap>
<SideMenu
items={items}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
</Column>
);
}

View file

@ -1,6 +1,18 @@
'use client'; 'use client';
import { Column, useTheme } from '@umami/react-zen'; import { Column, Grid, useTheme } from '@umami/react-zen';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage';
import { GoalsPage } from '@/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage';
import { JourneysPage } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage';
import { RetentionPage } from '@/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage';
import { RevenuePage } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage';
import { UTMPage } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage';
import { ComparePage } from '@/app/(main)/websites/[websiteId]/compare/ComparePage';
import { EventsPage } from '@/app/(main)/websites/[websiteId]/events/EventsPage';
import { RealtimePage } from '@/app/(main)/websites/[websiteId]/realtime/RealtimePage';
import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/SessionsPage';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader'; import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage'; import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
@ -8,8 +20,26 @@ import { PageBody } from '@/components/common/PageBody';
import { useShareTokenQuery } from '@/components/hooks'; import { useShareTokenQuery } from '@/components/hooks';
import { Footer } from './Footer'; import { Footer } from './Footer';
import { Header } from './Header'; import { Header } from './Header';
import { ShareNav } from './ShareNav';
export function SharePage({ shareId }) { const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
'': WebsitePage,
overview: WebsitePage,
events: EventsPage,
sessions: SessionsPage,
realtime: RealtimePage,
compare: ComparePage,
breakdown: BreakdownPage,
goals: GoalsPage,
funnels: FunnelsPage,
journeys: JourneysPage,
retention: RetentionPage,
utm: UTMPage,
revenue: RevenuePage,
attribution: AttributionPage,
};
export function SharePage({ shareId, path = '' }: { shareId: string; path?: string }) {
const { shareToken, isLoading } = useShareTokenQuery(shareId); const { shareToken, isLoading } = useShareTokenQuery(shareId);
const { setTheme } = useTheme(); const { setTheme } = useTheme();
@ -26,16 +56,42 @@ export function SharePage({ shareId }) {
return null; return null;
} }
const { websiteId, parameters = {} } = shareToken;
// Check if the requested path is allowed
const pageKey = path || '';
const isAllowed = pageKey === '' || pageKey === 'overview' || parameters[pageKey] !== false;
if (!isAllowed) {
return null;
}
const PageComponent = PAGE_COMPONENTS[pageKey] || WebsitePage;
return ( return (
<Column backgroundColor="2"> <Column backgroundColor="2">
<PageBody gap> <Header />
<Header /> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<WebsiteProvider websiteId={shareToken.websiteId}> <Column
<WebsiteHeader showActions={false} /> display={{ xs: 'none', lg: 'flex' }}
<WebsitePage websiteId={shareToken.websiteId} /> width="240px"
</WebsiteProvider> height="100%"
<Footer /> border="right"
</PageBody> backgroundColor
marginRight="2"
>
<ShareNav shareId={shareId} parameters={parameters} />
</Column>
<PageBody gap>
<WebsiteProvider websiteId={websiteId}>
<WebsiteHeader showActions={false} />
<Column>
<PageComponent websiteId={websiteId} />
</Column>
</WebsiteProvider>
</PageBody>
</Grid>
<Footer />
</Column> </Column>
); );
} }

View file

@ -2,6 +2,7 @@ import { SharePage } from './SharePage';
export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) { export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) {
const { shareId } = await params; const { shareId } = await params;
const [slug, ...path] = shareId;
return <SharePage shareId={shareId[0]} />; return <SharePage shareId={slug} path={path.join('/')} />;
} }

View file

@ -51,6 +51,7 @@ export * from './queries/useWebsiteSegmentsQuery';
export * from './queries/useWebsiteSessionQuery'; export * from './queries/useWebsiteSessionQuery';
export * from './queries/useWebsiteSessionStatsQuery'; export * from './queries/useWebsiteSessionStatsQuery';
export * from './queries/useWebsiteSessionsQuery'; export * from './queries/useWebsiteSessionsQuery';
export * from './queries/useWebsiteSharesQuery';
export * from './queries/useWebsiteStatsQuery'; export * from './queries/useWebsiteStatsQuery';
export * from './queries/useWebsitesQuery'; export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteValuesQuery'; export * from './queries/useWebsiteValuesQuery';

View file

@ -16,7 +16,7 @@ export function useEventDataValuesQuery(
return useQuery<any>({ return useQuery<any>({
queryKey: [ queryKey: [
'websites:event-data:values', 'websites:event-data:values',
{ websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters }, { websiteId, startAt, endAt, unit, timezone, ...filters, event, propertyName },
], ],
queryFn: () => queryFn: () =>
get(`/websites/${websiteId}/event-data/values`, { get(`/websites/${websiteId}/event-data/values`, {

View file

@ -10,7 +10,7 @@ export function useResultQuery<T = any>(
) { ) {
const { websiteId, ...parameters } = params; const { websiteId, ...parameters } = params;
const { post, useQuery } = useApi(); const { post, useQuery } = useApi();
const { startDate, endDate, timezone } = useDateParameters(); const { startDate, endDate, timezone, unit } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<T>({ return useQuery<T>({
@ -22,6 +22,7 @@ export function useResultQuery<T = any>(
startDate, startDate,
endDate, endDate,
timezone, timezone,
unit,
...params, ...params,
...filters, ...filters,
}, },
@ -35,6 +36,7 @@ export function useResultQuery<T = any>(
startDate, startDate,
endDate, endDate,
timezone, timezone,
unit,
...parameters, ...parameters,
}, },
}), }),

View file

@ -3,7 +3,7 @@ import { useApi } from '../useApi';
const selector = (state: { shareToken: string }) => state.shareToken; const selector = (state: { shareToken: string }) => state.shareToken;
export function useShareTokenQuery(shareId: string): { export function useShareTokenQuery(slug: string): {
shareToken: any; shareToken: any;
isLoading?: boolean; isLoading?: boolean;
error?: Error; error?: Error;
@ -11,9 +11,9 @@ export function useShareTokenQuery(shareId: string): {
const shareToken = useApp(selector); const shareToken = useApp(selector);
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { isLoading, error } = useQuery({ const { isLoading, error } = useQuery({
queryKey: ['share', shareId], queryKey: ['share', slug],
queryFn: async () => { queryFn: async () => {
const data = await get(`/share/${shareId}`); const data = await get(`/share/${slug}`);
setShareToken(data); setShareToken(data);

View file

@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
options?: ReactQueryOptions<WebsiteExpandedMetricsData>, options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteExpandedMetricsData>({ return useQuery<WebsiteExpandedMetricsData>({
@ -29,8 +29,6 @@ export function useWebsiteExpandedMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -39,8 +37,6 @@ export function useWebsiteExpandedMetricsQuery(
get(`/websites/${websiteId}/metrics/expanded`, { get(`/websites/${websiteId}/metrics/expanded`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
options?: ReactQueryOptions<WebsiteMetricsData>, options?: ReactQueryOptions<WebsiteMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteMetricsData>({ return useQuery<WebsiteMetricsData>({
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
get(`/websites/${websiteId}/metrics`, { get(`/websites/${websiteId}/metrics`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -0,0 +1,20 @@
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useWebsiteSharesQuery(
{ websiteId }: { websiteId: string },
options?: ReactQueryOptions,
) {
const { modified } = useModified('shares');
const { get } = useApi();
return usePagedQuery({
queryKey: ['websiteShares', { websiteId, modified }],
queryFn: pageParams => {
return get(`/websites/${websiteId}/shares`, pageParams);
},
...options,
});
}

View file

@ -19,17 +19,16 @@ export interface WebsiteStatsData {
} }
export function useWebsiteStatsQuery( export function useWebsiteStatsQuery(
websiteId: string, { websiteId, compare }: { websiteId: string; compare?: string },
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>, options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteStatsData>({ return useQuery<WebsiteStatsData>({
queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }], queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
queryFn: () => queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
enabled: !!websiteId, enabled: !!websiteId,
...options, ...options,
}); });

View file

@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string,
return useQuery({ return useQuery({
queryKey: [ queryKey: [
'sessions', 'sessions',
{ websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, { websiteId, modified, startAt, endAt, timezone, ...params, ...filters },
], ],
queryFn: () => { queryFn: () => {
return get(`/websites/${websiteId}/sessions/weekly`, { return get(`/websites/${websiteId}/sessions/weekly`, {
startAt, startAt,
endAt, endAt,
unit,
timezone, timezone,
...params, ...params,
...filters, ...filters,

View file

@ -7,13 +7,13 @@ import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) { export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const { const {
query: { date = '', offset = 0, compare = 'prev' }, query: { date = '', unit = '', offset = 0, compare = 'prev' },
} = useNavigation(); } = useNavigation();
const { locale } = useLocale(); const { locale } = useLocale();
const dateRange = useMemo(() => { const dateRange = useMemo(() => {
const dateRangeObject = parseDateRange( const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
unit,
locale, locale,
options.timezone, options.timezone,
); );
@ -21,12 +21,13 @@ export function useDateRange(options: { ignoreOffset?: boolean; timezone?: strin
return !options.ignoreOffset && offset return !options.ignoreOffset && offset
? getOffsetDateRange(dateRangeObject, +offset) ? getOffsetDateRange(dateRangeObject, +offset)
: dateRangeObject; : dateRangeObject;
}, [date, offset, options]); }, [date, unit, offset, options]);
const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
return { return {
date, date,
unit,
offset, offset,
compare, compare,
isAllTime: date.endsWith(`:all`), isAllTime: date.endsWith(`:all`),

View file

@ -15,6 +15,7 @@ export function useFields() {
{ name: 'region', type: 'string', label: formatMessage(labels.region) }, { name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) }, { name: 'city', type: 'string', label: formatMessage(labels.city) },
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) }, { name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
{ name: 'distinctId', type: 'string', label: formatMessage(labels.distinctId) },
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) }, { name: 'tag', type: 'string', label: formatMessage(labels.tag) },
{ name: 'event', type: 'string', label: formatMessage(labels.event) }, { name: 'event', type: 'string', label: formatMessage(labels.event) },
]; ];

View file

@ -18,6 +18,7 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
page, page,
pageSize, pageSize,
search, search,
@ -42,6 +43,7 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
search, search,
segment, segment,
cohort, cohort,
@ -61,6 +63,7 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
page, page,
pageSize, pageSize,
search, search,

View file

@ -61,7 +61,9 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
websiteId={websiteId} websiteId={websiteId}
value={currentFilters} value={currentFilters}
onChange={setCurrentFilters} onChange={setCurrentFilters}
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []} exclude={
excludeFilters ? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event'] : []
}
/> />
</TabPanel> </TabPanel>
<TabPanel id="segments"> <TabPanel id="segments">

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
import { getItem } from '@/lib/storage';
export function UnitFilter() {
const { formatMessage, labels } = useMessages();
const { router, query, updateParams } = useNavigation();
const DATE_RANGE_UNIT_CONFIG = {
'0week': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'7day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'0month': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'30day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'90day': {
defaultUnit: 'day',
availableUnits: ['day', 'month'],
},
'6month': {
defaultUnit: 'month',
availableUnits: ['month', 'day'],
},
};
const unitConfig =
DATE_RANGE_UNIT_CONFIG[query.date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE];
if (!unitConfig) {
return null;
}
const handleChange = (value: string) => {
router.push(updateParams({ unit: value }));
};
const options = unitConfig.availableUnits.map(unit => ({
id: unit,
label: formatMessage(labels[unit]),
}));
const selectedUnit = query.unit ?? unitConfig.defaultUnit;
return (
<Row>
<Select
value={selectedUnit}
onChange={handleChange}
popoverProps={{ placement: 'bottom right' }}
style={{ width: 100 }}
>
{options.map(({ id, label }) => (
<ListItem key={id} id={id}>
{label}
</ListItem>
))}
</Select>
</Row>
);
}

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useMessages, useTeamMembersQuery, useUsersQuery } from '@/components/hooks';
export function UserSelect({
teamId,
onChange,
...props
}: {
teamId?: string;
} & SelectProps) {
const { formatMessage, messages } = useMessages();
const { data: users, isLoading: usersLoading } = useUsersQuery();
const { data: teamMembers, isLoading: teamMembersLoading } = useTeamMembersQuery(teamId);
const [username, setUsername] = useState<string>();
const [search, setSearch] = useState('');
const listItems = useMemo(() => {
if (!users) {
return [];
}
if (!teamId || !teamMembers) {
return users.data;
}
const teamMemberIds = teamMembers.data.map(({ userId }) => userId);
return users.data.filter(({ id }) => !teamMemberIds.includes(id));
}, [users, teamMembers, teamId]);
const handleSearch = (value: string) => {
setSearch(value);
};
const handleOpenChange = () => {
setSearch('');
};
const handleChange = (id: string) => {
setUsername(listItems.find(item => item.id === id)?.username);
onChange(id);
};
const renderValue = () => {
return (
<Row maxWidth="160px">
<Text truncate>{username}</Text>
</Row>
);
};
return (
<Select
{...props}
items={listItems}
value={username}
isLoading={usersLoading || (teamId && teamMembersLoading)}
allowSearch={true}
searchValue={search}
onSearch={handleSearch}
onChange={handleChange}
onOpenChange={handleOpenChange}
renderValue={renderValue}
listProps={{
renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
style: { maxHeight: 'calc(42vh - 65px)' },
}}
>
{({ id, username }: any) => <ListItem key={id}>{username}</ListItem>}
</Select>
);
}

View file

@ -41,7 +41,7 @@ export function WebsiteDateFilter({
}), }),
); );
} else { } else {
router.push(updateParams({ date, offset: undefined })); router.push(updateParams({ date, offset: undefined, unit: undefined }));
} }
}; };

View file

@ -65,7 +65,7 @@ export function WebsiteSelect({
renderValue={renderValue} renderValue={renderValue}
listProps={{ listProps={{
renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />, renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
style: { maxHeight: '400px' }, style: { maxHeight: 'calc(42vh - 65px)' },
}} }}
> >
{({ id, name }: any) => <ListItem key={id}>{name}</ListItem>} {({ id, name }: any) => <ListItem key={id}>{name}</ListItem>}

View file

@ -245,7 +245,10 @@ export const labels = defineMessages({
tag: { id: 'label.tag', defaultMessage: 'Tag' }, tag: { id: 'label.tag', defaultMessage: 'Tag' },
segment: { id: 'label.segment', defaultMessage: 'Segment' }, segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
minute: { id: 'label.minute', defaultMessage: 'Minute' },
hour: { id: 'label.hour', defaultMessage: 'Hour' },
day: { id: 'label.day', defaultMessage: 'Day' }, day: { id: 'label.day', defaultMessage: 'Day' },
month: { id: 'label.month', defaultMessage: 'Month' },
date: { id: 'label.date', defaultMessage: 'Date' }, date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
create: { id: 'label.create', defaultMessage: 'Create' }, create: { id: 'label.create', defaultMessage: 'Create' },
@ -351,6 +354,7 @@ export const labels = defineMessages({
growth: { id: 'label.growth', defaultMessage: 'Growth' }, growth: { id: 'label.growth', defaultMessage: 'Growth' },
account: { id: 'label.account', defaultMessage: 'Account' }, account: { id: 'label.account', defaultMessage: 'Account' },
application: { id: 'label.application', defaultMessage: 'Application' }, application: { id: 'label.application', defaultMessage: 'Application' },
version: { id: 'label.version', defaultMessage: 'Version' },
saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' }, saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' },
saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' }, saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' },
analysis: { id: 'label.analysis', defaultMessage: 'Analysis' }, analysis: { id: 'label.analysis', defaultMessage: 'Analysis' },

View file

@ -25,7 +25,7 @@ export const MetricCard = ({
showChange = false, showChange = false,
}: MetricCardProps) => { }: MetricCardProps) => {
const diff = value - change; const diff = value - change;
const pct = ((value - diff) / diff) * 100; const pct = diff !== 0 ? ((value - diff) / diff) * 100 : value !== 0 ? 100 : 0;
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } }); const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } }); const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });

View file

@ -19,11 +19,13 @@ export * from '@/app/(main)/teams/TeamAddForm';
export * from '@/app/(main)/teams/TeamJoinForm'; export * from '@/app/(main)/teams/TeamJoinForm';
export * from '@/app/(main)/teams/TeamLeaveButton'; export * from '@/app/(main)/teams/TeamLeaveButton';
export * from '@/app/(main)/teams/TeamLeaveForm'; export * from '@/app/(main)/teams/TeamLeaveForm';
export * from '@/app/(main)/teams/TeamMemberAddForm';
export * from '@/app/(main)/teams/TeamProvider'; export * from '@/app/(main)/teams/TeamProvider';
export * from '@/app/(main)/teams/TeamsAddButton'; export * from '@/app/(main)/teams/TeamsAddButton';
export * from '@/app/(main)/teams/TeamsDataTable'; export * from '@/app/(main)/teams/TeamsDataTable';
export * from '@/app/(main)/teams/TeamsHeader'; export * from '@/app/(main)/teams/TeamsHeader';
export * from '@/app/(main)/teams/TeamsJoinButton'; export * from '@/app/(main)/teams/TeamsJoinButton';
export * from '@/app/(main)/teams/TeamsMemberAddButton';
export * from '@/app/(main)/teams/TeamsTable'; export * from '@/app/(main)/teams/TeamsTable';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData'; export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm'; export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';

View file

@ -93,7 +93,7 @@
"label.event-name": "Име на събитие", "label.event-name": "Име на събитие",
"label.events": "Събития", "label.events": "Събития",
"label.exists": "Съществува", "label.exists": "Съществува",
"label.exit": "Exit URL", "label.exit": "URL за изход",
"label.false": "Грешно", "label.false": "Грешно",
"label.field": "Поле", "label.field": "Поле",
"label.fields": "Полета", "label.fields": "Полета",
@ -135,7 +135,7 @@
"label.last-days": "Последните {x} дни", "label.last-days": "Последните {x} дни",
"label.last-hours": "Последните {x} часа", "label.last-hours": "Последните {x} часа",
"label.last-months": "Последните {x} месеца", "label.last-months": "Последните {x} месеца",
"label.last-seen": "Last seen", "label.last-seen": "Последно видяно",
"label.leave": "Напусни", "label.leave": "Напусни",
"label.leave-team": "Напусни екип", "label.leave-team": "Напусни екип",
"label.less-than": "По-малко от", "label.less-than": "По-малко от",
@ -161,7 +161,7 @@
"label.none": "Няма", "label.none": "Няма",
"label.number-of-records": "{x} {x, plural, one {един} other {други}}", "label.number-of-records": "{x} {x, plural, one {един} other {други}}",
"label.ok": "Добре", "label.ok": "Добре",
"label.online": "Online", "label.online": "Онлайн",
"label.organic-search": "Органично търсене", "label.organic-search": "Органично търсене",
"label.organic-shopping": "Органично пазаруване", "label.organic-shopping": "Органично пазаруване",
"label.organic-social": "Органични социални мрежи", "label.organic-social": "Органични социални мрежи",
@ -185,9 +185,9 @@
"label.paths": "Пътища", "label.paths": "Пътища",
"label.pixels": "Пиксели", "label.pixels": "Пиксели",
"label.powered-by": "Поддържано от {name}", "label.powered-by": "Поддържано от {name}",
"label.previous": "Previous", "label.previous": "Предишен",
"label.previous-period": "Previous period", "label.previous-period": "Предишен период",
"label.previous-year": "Previous year", "label.previous-year": "Предишна година",
"label.profile": "Профил", "label.profile": "Профил",
"label.properties": "Свойства", "label.properties": "Свойства",
"label.property": "Свойство", "label.property": "Свойство",
@ -211,8 +211,8 @@
"label.reset-website": "Нулирай уебсайт", "label.reset-website": "Нулирай уебсайт",
"label.retention": "Привързване", "label.retention": "Привързване",
"label.retention-description": "Измерете привързаността към вашия уебсайт, като проследявате колко често потребителите се връщат.", "label.retention-description": "Измерете привързаността към вашия уебсайт, като проследявате колко често потребителите се връщат.",
"label.revenue": "Revenue", "label.revenue": "Приходи",
"label.revenue-description": "Look into your revenue across time.", "label.revenue-description": "Прегледайте приходите си във времето.",
"label.role": "Роля", "label.role": "Роля",
"label.run-query": "Изпълни запитване", "label.run-query": "Изпълни запитване",
"label.save": "Запази", "label.save": "Запази",
@ -260,14 +260,14 @@
"label.total": "Общо", "label.total": "Общо",
"label.total-records": "Общо записи", "label.total-records": "Общо записи",
"label.tracking-code": "Код за проследяване", "label.tracking-code": "Код за проследяване",
"label.transactions": "Transactions", "label.transactions": "Транзакции",
"label.transfer": "Прехвърли", "label.transfer": "Прехвърли",
"label.transfer-website": "Прехвърляне на уебсайт", "label.transfer-website": "Прехвърляне на уебсайт",
"label.true": "Вярно", "label.true": "Вярно",
"label.type": "Вид", "label.type": "Вид",
"label.unique": "Уникален", "label.unique": "Уникален",
"label.unique-visitors": "Уникални посетители", "label.unique-visitors": "Уникални посетители",
"label.uniqueCustomers": "Unique Customers", "label.uniqueCustomers": "Уникални клиенти",
"label.unknown": "Неизвестен", "label.unknown": "Неизвестен",
"label.untitled": "Без заглавие", "label.untitled": "Без заглавие",
"label.update": "Актуализирай", "label.update": "Актуализирай",
@ -282,7 +282,7 @@
"label.view-only": "Само за преглед", "label.view-only": "Само за преглед",
"label.views": "Прегледи", "label.views": "Прегледи",
"label.views-per-visit": "Прегледи на посещение", "label.views-per-visit": "Прегледи на посещение",
"label.visit-duration": "Visit duration", "label.visit-duration": "Продължителност на посещение",
"label.visitors": "Посетители", "label.visitors": "Посетители",
"label.visits": "Посещения", "label.visits": "Посещения",
"label.website": "Уебсайт", "label.website": "Уебсайт",
@ -292,8 +292,8 @@
"label.yesterday": "Вчера", "label.yesterday": "Вчера",
"message.action-confirmation": "Въведете {confirmation} в полето по-долу, за да потвърдите.", "message.action-confirmation": "Въведете {confirmation} в полето по-долу, за да потвърдите.",
"message.active-users": "{x} {x, plural, one {активен един} other {активни други}}", "message.active-users": "{x} {x, plural, one {активен един} other {активни други}}",
"message.bad-request": "Bad request", "message.bad-request": "Невалидна заявка",
"message.collected-data": "Collected data", "message.collected-data": "Събрани данни",
"message.confirm-delete": "Сигурни ли сте, че искате да изтриете {target}?", "message.confirm-delete": "Сигурни ли сте, че искате да изтриете {target}?",
"message.confirm-leave": "Сигурни ли сте, че искате да напуснете {target}?", "message.confirm-leave": "Сигурни ли сте, че искате да напуснете {target}?",
"message.confirm-remove": "Сигурни ли сте, че искате да премахнете {target}?", "message.confirm-remove": "Сигурни ли сте, че искате да премахнете {target}?",
@ -302,7 +302,7 @@
"message.delete-website-warning": "Всички данни за уебсайта ще бъдат изтрити.", "message.delete-website-warning": "Всички данни за уебсайта ще бъдат изтрити.",
"message.error": "Възникна грешка.", "message.error": "Възникна грешка.",
"message.event-log": "{event} на {url}", "message.event-log": "{event} на {url}",
"message.forbidden": "Forbidden", "message.forbidden": "Забранено",
"message.go-to-settings": "Отидете в настройките", "message.go-to-settings": "Отидете в настройките",
"message.incorrect-username-password": "Неправилно потребителско име и/или парола.", "message.incorrect-username-password": "Неправилно потребителско име и/или парола.",
"message.invalid-domain": "Невалиден домейн. Не включвайте http/https.", "message.invalid-domain": "Невалиден домейн. Не включвайте http/https.",
@ -316,13 +316,13 @@
"message.no-teams": "Няма създадени екипи.", "message.no-teams": "Няма създадени екипи.",
"message.no-users": "Няма потребители.", "message.no-users": "Няма потребители.",
"message.no-websites-configured": "Нямате конфигурирани уебсайтове.", "message.no-websites-configured": "Нямате конфигурирани уебсайтове.",
"message.not-found": "Not found", "message.not-found": "Не е намерено",
"message.nothing-selected": "Nothing selected.", "message.nothing-selected": "Няма избрано.",
"message.page-not-found": "Страницата не е намерена", "message.page-not-found": "Страницата не е намерена",
"message.reset-website": "За да нулирате този уебсайт, въведете {confirmation} в полето по-долу, за да потвърдите.", "message.reset-website": "За да нулирате този уебсайт, въведете {confirmation} в полето по-долу, за да потвърдите.",
"message.reset-website-warning": "Всички статистически данни за този уебсайт ще бъдат изтрити, но вашите настройки ще останат непроменени.", "message.reset-website-warning": "Всички статистически данни за този уебсайт ще бъдат изтрити, но вашите настройки ще останат непроменени.",
"message.saved": "Запазено.", "message.saved": "Запазено.",
"message.sever-error": "Server error", "message.sever-error": "Сървърна грешка",
"message.share-url": "Статистиката за вашия уебсайт е публично достъпна на следния URL адрес:", "message.share-url": "Статистиката за вашия уебсайт е публично достъпна на следния URL адрес:",
"message.team-already-member": "Вече сте член на екипа.", "message.team-already-member": "Вече сте член на екипа.",
"message.team-not-found": "Екипът не е намерен.", "message.team-not-found": "Екипът не е намерен.",
@ -332,7 +332,7 @@
"message.transfer-user-website-to-team": "Изберете екипът на който да бъде прехвърлен уебсайта.", "message.transfer-user-website-to-team": "Изберете екипът на който да бъде прехвърлен уебсайта.",
"message.transfer-website": "Прехвърли собствеността на уебсайта към вашия акаунт или към друг екип.", "message.transfer-website": "Прехвърли собствеността на уебсайта към вашия акаунт или към друг екип.",
"message.triggered-event": "Активирано събитие", "message.triggered-event": "Активирано събитие",
"message.unauthorized": "Unauthorized", "message.unauthorized": "Неоторизиран достъп",
"message.user-deleted": "Потребителят е изтрит.", "message.user-deleted": "Потребителят е изтрит.",
"message.viewed-page": "Страницата е видяна", "message.viewed-page": "Страницата е видяна",
"message.visitor-log": "Посетител от {country}, използващ {browser} на {os} {device}" "message.visitor-log": "Посетител от {country}, използващ {browser} на {os} {device}"

View file

@ -37,7 +37,7 @@
"label.compare-dates": "Vertaa päivämääriä", "label.compare-dates": "Vertaa päivämääriä",
"label.confirm": "Vahvista", "label.confirm": "Vahvista",
"label.confirm-password": "Vahvista salasana", "label.confirm-password": "Vahvista salasana",
"label.contains": "Contains", "label.contains": "Sisältää",
"label.content": "Sisältö", "label.content": "Sisältö",
"label.continue": "Jatka", "label.continue": "Jatka",
"label.conversion": "Konversio", "label.conversion": "Konversio",
@ -96,7 +96,7 @@
"label.false": "Epätosi", "label.false": "Epätosi",
"label.field": "Kenttä", "label.field": "Kenttä",
"label.fields": "Kentät", "label.fields": "Kentät",
"label.filter": "Filter", "label.filter": "Suodatin",
"label.filter-combined": "Yhdistetty", "label.filter-combined": "Yhdistetty",
"label.filter-raw": "Käsittelemätön", "label.filter-raw": "Käsittelemätön",
"label.filters": "Suodattimet", "label.filters": "Suodattimet",
@ -151,7 +151,7 @@
"label.members": "Jäsenet", "label.members": "Jäsenet",
"label.min": "Minimi", "label.min": "Minimi",
"label.mobile": "Puhelin", "label.mobile": "Puhelin",
"label.model": "Model", "label.model": "Malli",
"label.more": "Lisää", "label.more": "Lisää",
"label.my-account": "Oma tili", "label.my-account": "Oma tili",
"label.my-websites": "Omat verkkosivut", "label.my-websites": "Omat verkkosivut",
@ -184,9 +184,9 @@
"label.paths": "Polut", "label.paths": "Polut",
"label.pixels": "Pikselit", "label.pixels": "Pikselit",
"label.powered-by": "Voimanlähteenä {name}", "label.powered-by": "Voimanlähteenä {name}",
"label.previous": "Previous", "label.previous": "Edellinen",
"label.previous-period": "Previous period", "label.previous-period": "Edellinen ajanjakso",
"label.previous-year": "Previous year", "label.previous-year": "Edellinen vuosi",
"label.profile": "Profiili", "label.profile": "Profiili",
"label.properties": "Ominaisuudet", "label.properties": "Ominaisuudet",
"label.property": "Ominaisuus", "label.property": "Ominaisuus",
@ -195,16 +195,16 @@
"label.query-parameters": "Kyselyn parametrit", "label.query-parameters": "Kyselyn parametrit",
"label.realtime": "Juuri nyt", "label.realtime": "Juuri nyt",
"label.referral": "Viittaus", "label.referral": "Viittaus",
"label.referrer": "Referrer", "label.referrer": "Viittaaja",
"label.referrers": "Viittaajat", "label.referrers": "Viittaajat",
"label.refresh": "Päivitä", "label.refresh": "Päivitä",
"label.regenerate": "Regenerate", "label.regenerate": "Luo uudelleen",
"label.region": "Region", "label.region": "Alue",
"label.regions": "Regions", "label.regions": "Alueet",
"label.remaining": "Jäljellä", "label.remaining": "Jäljellä",
"label.remove": "Remove", "label.remove": "Poista",
"label.remove-member": "Remove member", "label.remove-member": "Poista jäsen",
"label.reports": "Reports", "label.reports": "Raportit",
"label.required": "Vaaditaan", "label.required": "Vaaditaan",
"label.reset": "Nollaa", "label.reset": "Nollaa",
"label.reset-website": "Nollaa tilastot", "label.reset-website": "Nollaa tilastot",
@ -212,19 +212,19 @@
"label.retention-description": "Measure your website stickiness by tracking how often users return.", "label.retention-description": "Measure your website stickiness by tracking how often users return.",
"label.revenue": "Tulot", "label.revenue": "Tulot",
"label.revenue-description": "Katso tulosi ajan mittaan.", "label.revenue-description": "Katso tulosi ajan mittaan.",
"label.role": "Role", "label.role": "Rooli",
"label.run-query": "Run query", "label.run-query": "Suorita kysely",
"label.save": "Tallenna", "label.save": "Tallenna",
"label.screens": "Näytöt", "label.screens": "Näytöt",
"label.search": "Search", "label.search": "Haku",
"label.select": "Select", "label.select": "Valitse",
"label.select-date": "Select date", "label.select-date": "Valitse päivämäärä",
"label.select-filter": "Valitse suodatin", "label.select-filter": "Valitse suodatin",
"label.select-role": "Select role", "label.select-role": "Valitse rooli",
"label.select-website": "Select website", "label.select-website": "Valitse verkkosivu",
"label.session": "Istunto", "label.session": "Istunto",
"label.session-data": "Istuntotiedot", "label.session-data": "Istuntotiedot",
"label.sessions": "Sessions", "label.sessions": "Istunnot",
"label.settings": "Asetukset", "label.settings": "Asetukset",
"label.share": "Jaa", "label.share": "Jaa",
"label.share-url": "Jaa URL", "label.share-url": "Jaa URL",
@ -233,107 +233,107 @@
"label.sources": "Lähteet", "label.sources": "Lähteet",
"label.start-step": "Aloitusvaihe", "label.start-step": "Aloitusvaihe",
"label.steps": "Vaiheet", "label.steps": "Vaiheet",
"label.sum": "Sum", "label.sum": "Summa",
"label.tablet": "Tabletti", "label.tablet": "Tabletti",
"label.tag": "Tunniste", "label.tag": "Tunniste",
"label.tags": "Tunnisteet", "label.tags": "Tunnisteet",
"label.team": "Team", "label.team": "Tiimi",
"label.team-id": "Team ID", "label.team-id": "Tiimin ID",
"label.team-manager": "Team manager", "label.team-manager": "Tiimin johtaja",
"label.team-member": "Team member", "label.team-member": "Tiimin jäsen",
"label.team-name": "Team name", "label.team-name": "Tiimin nimi",
"label.team-owner": "Team owner", "label.team-owner": "Tiimin omistaja",
"label.team-settings": "Tiimin asetukset", "label.team-settings": "Tiimin asetukset",
"label.team-view-only": "Team view only", "label.team-view-only": "Team view only",
"label.team-websites": "Team websites", "label.team-websites": "Tiimin verkkosivut",
"label.teams": "Teams", "label.teams": "Tiimit",
"label.terms": "Ehdot", "label.terms": "Ehdot",
"label.theme": "Teema", "label.theme": "Teema",
"label.this-month": "Tämä kuukausi", "label.this-month": "Tämä kuukausi",
"label.this-week": "Tämä viikko", "label.this-week": "Tämä viikko",
"label.this-year": "Tämä vuosi", "label.this-year": "Tämä vuosi",
"label.timezone": "Aikavyöhyke", "label.timezone": "Aikavyöhyke",
"label.title": "Title", "label.title": "Otsikko",
"label.today": "Tänään", "label.today": "Tänään",
"label.toggle-charts": "Kytke kaaviot päälle/pois", "label.toggle-charts": "Kytke kaaviot päälle/pois",
"label.total": "Total", "label.total": "Yhteensä",
"label.total-records": "Total records", "label.total-records": "Tietueita yhteensä",
"label.tracking-code": "Seurantakoodi", "label.tracking-code": "Seurantakoodi",
"label.transactions": "Transactions", "label.transactions": "Transaktiot",
"label.transfer": "Transfer", "label.transfer": "Siirrä",
"label.transfer-website": "Transfer website", "label.transfer-website": "Siirrä verkkosivu",
"label.true": "True", "label.true": "Tosi",
"label.type": "Type", "label.type": "Tyyppi",
"label.unique": "Unique", "label.unique": "Uniikki",
"label.unique-visitors": "Yksittäiset kävijät", "label.unique-visitors": "Yksittäiset kävijät",
"label.uniqueCustomers": "Unique Customers", "label.uniqueCustomers": "Uniikit asiakkaat",
"label.unknown": "Tuntematon", "label.unknown": "Tuntematon",
"label.untitled": "Untitled", "label.untitled": "Nimetön",
"label.update": "Update", "label.update": "Päivitä",
"label.user": "User", "label.user": "Käyttäjä",
"label.username": "Käyttäjänimi", "label.username": "Käyttäjänimi",
"label.users": "Users", "label.users": "Käyttäjät",
"label.utm": "UTM", "label.utm": "UTM",
"label.utm-description": "Track your campaigns through UTM parameters.", "label.utm-description": "Seuraa kampanjoitasi UTM-parametrien avulla.",
"label.value": "Value", "label.value": "Arvo",
"label.view": "View", "label.view": "Näytä",
"label.view-details": "Katso tiedot", "label.view-details": "Katso tiedot",
"label.view-only": "View only", "label.view-only": "Vain katselu",
"label.views": "Näyttökerrat", "label.views": "Näyttökerrat",
"label.views-per-visit": "Views per visit", "label.views-per-visit": "Katselukerrat vierailua kohti",
"label.visit-duration": "Keskimääräinen vierailuaika", "label.visit-duration": "Keskimääräinen vierailuaika",
"label.visitors": "Vierailijat", "label.visitors": "Vierailijat",
"label.visits": "Visits", "label.visits": "Vierailut",
"label.website": "Website", "label.website": "Verkkosivu",
"label.website-id": "Website ID", "label.website-id": "Verkkosivun ID",
"label.websites": "Verkkosivut", "label.websites": "Verkkosivut",
"label.window": "Window", "label.window": "Ikkuna",
"label.yesterday": "Yesterday", "label.yesterday": "Eilen",
"label.behavior": "Behavior", "label.behavior": "Käyttäytyminen",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.", "message.action-confirmation": "Kirjoita {confirmation} alla olevaan kenttään vahvistaaksesi.",
"message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}", "message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
"message.bad-request": "Bad request", "message.bad-request": "Virheellinen pyyntö",
"message.collected-data": "Collected data", "message.collected-data": "Kerätty data",
"message.confirm-delete": "Haluatko varmasti poistaa sivuston {target}?", "message.confirm-delete": "Haluatko varmasti poistaa sivuston {target}?",
"message.confirm-leave": "Are you sure you want to leave {target}?", "message.confirm-leave": "Haluatko varmasti poistua {target}?",
"message.confirm-remove": "Are you sure you want to remove {target}?", "message.confirm-remove": "Haluatko varmasti poistaa {target}?",
"message.confirm-reset": "Haluatko varmasti poistaa sivuston {target} tilastot?", "message.confirm-reset": "Haluatko varmasti poistaa sivuston {target} tilastot?",
"message.delete-team-warning": "Deleting a team will also delete all team websites.", "message.delete-team-warning": "Tiimin poistaminen poistaa myös kaikki tiimin sivustot.",
"message.delete-website-warning": "Kaikki siihen liittyvät tiedot poistetaan.", "message.delete-website-warning": "Kaikki siihen liittyvät tiedot poistetaan.",
"message.error": "Jotain meni pieleen.", "message.error": "Jotain meni pieleen.",
"message.event-log": "{event} on {url}", "message.event-log": "{event} on {url}",
"message.forbidden": "Forbidden", "message.forbidden": "Kielletty",
"message.go-to-settings": "Mene asetuksiin", "message.go-to-settings": "Mene asetuksiin",
"message.incorrect-username-password": "Väärä käyttäjänimi/salasana.", "message.incorrect-username-password": "Väärä käyttäjänimi/salasana.",
"message.invalid-domain": "Virheellinen verkkotunnus", "message.invalid-domain": "Virheellinen verkkotunnus",
"message.min-password-length": "Minimum length of {n} characters", "message.min-password-length": "Vähimmäispituus {n} merkkiä",
"message.new-version-available": "A new version of Umami {version} is available!", "message.new-version-available": "Umamista on saatavilla uusi versio {version}!",
"message.no-data-available": "Tietoja ei ole käytettävissä.", "message.no-data-available": "Tietoja ei ole käytettävissä.",
"message.no-event-data": "No event data is available.", "message.no-event-data": "Tapahtumatietoja ei ole saatavilla.",
"message.no-match-password": "Salasanat eivät täsmää", "message.no-match-password": "Salasanat eivät täsmää",
"message.no-results-found": "No results were found.", "message.no-results-found": "Tuloksia ei löytynyt.",
"message.no-team-websites": "This team does not have any websites.", "message.no-team-websites": "Tällä tiimillä ei ole verkkosivustoja.",
"message.no-teams": "You have not created any teams.", "message.no-teams": "Et ole luonut yhtään tiimiä.",
"message.no-users": "There are no users.", "message.no-users": "Käyttäjiä ei ole.",
"message.no-websites-configured": "Sinulla ei ole määritettyjä verkkosivustoja.", "message.no-websites-configured": "Sinulla ei ole määritettyjä verkkosivustoja.",
"message.not-found": "Not found", "message.not-found": "Ei löytynyt",
"message.nothing-selected": "Nothing selected.", "message.nothing-selected": "Mitään ei ole valittu.",
"message.page-not-found": "Sivua ei löydetty.", "message.page-not-found": "Sivua ei löydetty.",
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", "message.reset-website": "Nollaa verkkosivusto kirjoittamalla {confirmation} alla olevaan kenttään vahvistaaksesi.",
"message.reset-website-warning": "Kaikki sivuston tilastot poistetaan, mutta seurantakoodi pysyy muuttumattomana.", "message.reset-website-warning": "Kaikki sivuston tilastot poistetaan, mutta seurantakoodi pysyy muuttumattomana.",
"message.saved": "Tallennettu onnistuneesti.", "message.saved": "Tallennettu onnistuneesti.",
"message.sever-error": "Server error", "message.sever-error": "Palvelinvirhe",
"message.share-url": "Tämä on julkisesti jaettu URL sivustolle {target}.", "message.share-url": "Tämä on julkisesti jaettu URL sivustolle {target}.",
"message.team-already-member": "You are already a member of the team.", "message.team-already-member": "Olet jo tiimissä.",
"message.team-not-found": "Team not found.", "message.team-not-found": "Tiimiä ei löydetty.",
"message.team-websites-info": "Websites can be viewed by anyone on the team.", "message.team-websites-info": "Verkkosivuja voi tarkastella kuka tahansa tiimin jäsen.",
"message.tracking-code": "Seurantakoodi", "message.tracking-code": "Seurantakoodi",
"message.transfer-team-website-to-user": "Transfer this website to your account?", "message.transfer-team-website-to-user": "Siirretäänkö tämä verkkosivu tilillesi?",
"message.transfer-user-website-to-team": "Select the team to transfer this website to.", "message.transfer-user-website-to-team": "Valitse tiimi, johon verkkosivusto siirretään.",
"message.transfer-website": "Transfer website ownership to your account or another team.", "message.transfer-website": "Siirrä verkkosivusto tiliisi tai toiselle tiimille.",
"message.triggered-event": "Triggered event", "message.triggered-event": "Laukaistu tapahtuma",
"message.unauthorized": "Unauthorized", "message.unauthorized": "Ei oikeuksia",
"message.user-deleted": "User deleted.", "message.user-deleted": "Käyttäjä poistettu.",
"message.viewed-page": "Viewed page", "message.viewed-page": "Katsottu sivu",
"message.visitor-log": "Vierailija maasta {country} selaimella {browser} laitteella {os} {device}" "message.visitor-log": "Vierailija maasta {country} selaimella {browser} laitteella {os} {device}"
} }

View file

@ -23,7 +23,7 @@
"label.behavior": "行動", "label.behavior": "行動",
"label.boards": "ボード", "label.boards": "ボード",
"label.bounce-rate": "直帰率", "label.bounce-rate": "直帰率",
"label.breakdown": "故障", "label.breakdown": "内訳",
"label.browser": "ブラウザ", "label.browser": "ブラウザ",
"label.browsers": "ブラウザ", "label.browsers": "ブラウザ",
"label.campaigns": "キャンペーン", "label.campaigns": "キャンペーン",

View file

@ -1,11 +1,15 @@
{ {
"label.access-code": "访问代码", "label.access-code": "访问代码",
"label.account": "账户",
"label.action": "行为",
"label.actions": "用户行为", "label.actions": "用户行为",
"label.activity": "活动日志", "label.activity": "活动日志",
"label.add": "添加", "label.add": "添加",
"label.add-board": "添加看板", "label.add-board": "添加看板",
"label.add-description": "添加描述", "label.add-description": "添加描述",
"label.add-link": "添加链接",
"label.add-member": "添加成员", "label.add-member": "添加成员",
"label.add-pixel": "添加像素",
"label.add-step": "添加步骤", "label.add-step": "添加步骤",
"label.add-website": "添加网站", "label.add-website": "添加网站",
"label.admin": "管理员", "label.admin": "管理员",
@ -13,10 +17,13 @@
"label.after": "之后", "label.after": "之后",
"label.all": "所有", "label.all": "所有",
"label.all-time": "所有时间段", "label.all-time": "所有时间段",
"label.analysis": "分析",
"label.analytics": "分析", "label.analytics": "分析",
"label.application": "应用",
"label.apply": "应用", "label.apply": "应用",
"label.attribution": "归因", "label.attribution": "归因",
"label.attribution-description": "查看用户如何与您的营销互动,以及是什么促成了转化。", "label.attribution-description": "查看用户如何与您的营销互动,以及是什么促成了转化。",
"label.audience": "受众",
"label.average": "平均", "label.average": "平均",
"label.back": "返回", "label.back": "返回",
"label.before": "之前", "label.before": "之前",
@ -29,11 +36,14 @@
"label.campaigns": "活动", "label.campaigns": "活动",
"label.cancel": "取消", "label.cancel": "取消",
"label.change-password": "修改密码", "label.change-password": "修改密码",
"label.channel": "渠道",
"label.channels": "渠道", "label.channels": "渠道",
"label.chart": "图表",
"label.cities": "市/县", "label.cities": "市/县",
"label.city": "市/县", "label.city": "市/县",
"label.clear-all": "清除全部", "label.clear-all": "清除全部",
"label.cohort": "队列", "label.cohort": "队列",
"label.cohorts": "队列",
"label.compare": "比较", "label.compare": "比较",
"label.compare-dates": "比较日期", "label.compare-dates": "比较日期",
"label.confirm": "确认", "label.confirm": "确认",
@ -53,6 +63,7 @@
"label.create-user": "创建用户", "label.create-user": "创建用户",
"label.created": "已创建", "label.created": "已创建",
"label.created-by": "创建者", "label.created-by": "创建者",
"label.criteria": "条件",
"label.currency": "货币", "label.currency": "货币",
"label.current": "当前", "label.current": "当前",
"label.current-password": "当前密码", "label.current-password": "当前密码",
@ -70,24 +81,28 @@
"label.delete-website": "删除网站", "label.delete-website": "删除网站",
"label.description": "描述", "label.description": "描述",
"label.desktop": "台式机", "label.desktop": "台式机",
"label.destination-url": "目标URL",
"label.details": "详细信息", "label.details": "详细信息",
"label.device": "设备", "label.device": "设备",
"label.devices": "设备", "label.devices": "设备",
"label.direct": "直接", "label.direct": "直接",
"label.dismiss": "关闭", "label.dismiss": "关闭",
"label.distinct-id": "唯一ID", "label.distinct-id": "唯一ID",
"label.documentation": "文档",
"label.does-not-contain": "不包含", "label.does-not-contain": "不包含",
"label.does-not-include": "不包括", "label.does-not-include": "不包括",
"label.doest-not-exist": "不存在", "label.doest-not-exist": "不存在",
"label.domain": "域名", "label.domain": "域名",
"label.download": "下载",
"label.dropoff": "丢弃", "label.dropoff": "丢弃",
"label.edit": "编辑", "label.edit": "编辑",
"label.edit-dashboard": "编辑仪表盘", "label.edit-dashboard": "编辑仪表盘",
"label.edit-member": "编辑成员", "label.edit-member": "编辑成员",
"label.email": "Email", "label.email": "邮箱",
"label.enable-share-url": "启用共享链接", "label.enable-share-url": "启用共享链接",
"label.end-step": "结束步骤", "label.end-step": "结束步骤",
"label.entry": "入口 URL", "label.entry": "入口 URL",
"label.environment": "环境",
"label.event": "事件", "label.event": "事件",
"label.event-data": "事件数据", "label.event-data": "事件数据",
"label.event-name": "事件名称", "label.event-name": "事件名称",
@ -112,11 +127,13 @@
"label.greater-than": "大于", "label.greater-than": "大于",
"label.greater-than-equals": "大于或等于", "label.greater-than-equals": "大于或等于",
"label.grouped": "分组", "label.grouped": "分组",
"label.growth": "增长",
"label.hostname": "主机名", "label.hostname": "主机名",
"label.includes": "包括", "label.includes": "包括",
"label.insight": "洞察", "label.insight": "洞察",
"label.insights": "见解", "label.insights": "见解",
"label.insights-description": "通过使用筛选器和划分时间段来更深入地研究数据。", "label.insights-description": "通过使用筛选器和划分时间段来更深入地研究数据。",
"label.invalid-url": "无效URL",
"label.is": "等于", "label.is": "等于",
"label.is-false": "否", "label.is-false": "否",
"label.is-not": "不等于", "label.is-not": "不等于",
@ -140,7 +157,9 @@
"label.leave-team": "离开团队", "label.leave-team": "离开团队",
"label.less-than": "少于", "label.less-than": "少于",
"label.less-than-equals": "少于等于", "label.less-than-equals": "少于等于",
"label.link": "链接",
"label.links": "链接", "label.links": "链接",
"label.location": "位置",
"label.login": "登录", "label.login": "登录",
"label.logout": "退出", "label.logout": "退出",
"label.manage": "管理", "label.manage": "管理",
@ -161,7 +180,7 @@
"label.none": "无", "label.none": "无",
"label.number-of-records": "{x} {x, plural, one {record} other {records}}", "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
"label.ok": "好的", "label.ok": "好的",
"label.online": "Online", "label.online": "在线",
"label.organic-search": "自然搜索", "label.organic-search": "自然搜索",
"label.organic-shopping": "自然购物", "label.organic-shopping": "自然购物",
"label.organic-social": "自然社交", "label.organic-social": "自然社交",
@ -183,19 +202,22 @@
"label.password": "密码", "label.password": "密码",
"label.path": "路径", "label.path": "路径",
"label.paths": "路径", "label.paths": "路径",
"label.pixel": "像素",
"label.pixels": "像素", "label.pixels": "像素",
"label.powered-by": "由 {name} 提供支持", "label.powered-by": "由 {name} 提供支持",
"label.preferences": "偏好",
"label.previous": "先前", "label.previous": "先前",
"label.previous-period": "上一时期", "label.previous-period": "上一时期",
"label.previous-year": "上一年", "label.previous-year": "上一年",
"label.profile": "个人资料", "label.profile": "个人资料",
"label.profiles": "个人资料",
"label.properties": "属性", "label.properties": "属性",
"label.property": "属性", "label.property": "属性",
"label.queries": "查询", "label.queries": "查询",
"label.query": "查询", "label.query": "查询",
"label.query-parameters": "查询参数", "label.query-parameters": "查询参数",
"label.realtime": "实时", "label.realtime": "实时",
"label.referral": "Referral", "label.referral": "来源",
"label.referrer": "来源", "label.referrer": "来源",
"label.referrers": "来源域名", "label.referrers": "来源域名",
"label.refresh": "刷新", "label.refresh": "刷新",
@ -216,8 +238,13 @@
"label.role": "角色", "label.role": "角色",
"label.run-query": "查询", "label.run-query": "查询",
"label.save": "保存", "label.save": "保存",
"label.save-cohort": "保存为群组",
"label.save-segment": "保存为细分",
"label.screen": "屏幕",
"label.screens": "屏幕尺寸", "label.screens": "屏幕尺寸",
"label.search": "搜索", "label.search": "搜索",
"label.segment": "细分",
"label.segments": "细分",
"label.select": "选择", "label.select": "选择",
"label.select-date": "选择日期", "label.select-date": "选择日期",
"label.select-filter": "选择筛选器", "label.select-filter": "选择筛选器",
@ -235,6 +262,9 @@
"label.start-step": "开始步骤", "label.start-step": "开始步骤",
"label.steps": "步骤", "label.steps": "步骤",
"label.sum": "总和", "label.sum": "总和",
"label.support": "支持",
"label.switch-account": "切换账户",
"label.table": "表格",
"label.tablet": "平板", "label.tablet": "平板",
"label.tag": "标签", "label.tag": "标签",
"label.tags": "标签", "label.tags": "标签",
@ -260,6 +290,7 @@
"label.total": "总数", "label.total": "总数",
"label.total-records": "总记录数", "label.total-records": "总记录数",
"label.tracking-code": "跟踪代码", "label.tracking-code": "跟踪代码",
"label.traffic": "流量",
"label.transactions": "交易", "label.transactions": "交易",
"label.transfer": "转移", "label.transfer": "转移",
"label.transfer-website": "转移网站", "label.transfer-website": "转移网站",
@ -292,7 +323,7 @@
"label.yesterday": "昨天", "label.yesterday": "昨天",
"message.action-confirmation": "请在下方输入框中输入 {confirmation} 以确认操作。", "message.action-confirmation": "请在下方输入框中输入 {confirmation} 以确认操作。",
"message.active-users": "当前在线 {x} 位访客", "message.active-users": "当前在线 {x} 位访客",
"message.bad-request": "Bad request", "message.bad-request": "请求错误",
"message.collected-data": "已收集的数据", "message.collected-data": "已收集的数据",
"message.confirm-delete": "你确定要删除 {target} 吗?", "message.confirm-delete": "你确定要删除 {target} 吗?",
"message.confirm-leave": "你确定要离开 {target} 吗?", "message.confirm-leave": "你确定要离开 {target} 吗?",
@ -302,7 +333,7 @@
"message.delete-website-warning": "所有相关数据将会被删除。", "message.delete-website-warning": "所有相关数据将会被删除。",
"message.error": "发生错误。", "message.error": "发生错误。",
"message.event-log": "{url} 上的 {event}", "message.event-log": "{url} 上的 {event}",
"message.forbidden": "Forbidden", "message.forbidden": "禁止访问",
"message.go-to-settings": "去设置", "message.go-to-settings": "去设置",
"message.incorrect-username-password": "用户名或密码不正确。", "message.incorrect-username-password": "用户名或密码不正确。",
"message.invalid-domain": "无效域名", "message.invalid-domain": "无效域名",
@ -316,13 +347,13 @@
"message.no-teams": "您尚未创建任何团队。", "message.no-teams": "您尚未创建任何团队。",
"message.no-users": "暂无用户。", "message.no-users": "暂无用户。",
"message.no-websites-configured": "你还没有设置任何网站。", "message.no-websites-configured": "你还没有设置任何网站。",
"message.not-found": "Not found", "message.not-found": "未找到",
"message.nothing-selected": "Nothing selected.", "message.nothing-selected": "未选择",
"message.page-not-found": "页面未找到。", "message.page-not-found": "页面未找到。",
"message.reset-website": "如确定要重置该网站,请在下面输入 {confirmation} 以确认。", "message.reset-website": "如确定要重置该网站,请在下面输入 {confirmation} 以确认。",
"message.reset-website-warning": "此网站的所有统计数据将被删除,但您的跟踪代码将保持不变。", "message.reset-website-warning": "此网站的所有统计数据将被删除,但您的跟踪代码将保持不变。",
"message.saved": "保存成功。", "message.saved": "保存成功。",
"message.sever-error": "Server error", "message.sever-error": "服务器错误",
"message.share-url": "这是 {target} 的共享链接。", "message.share-url": "这是 {target} 的共享链接。",
"message.team-already-member": "你已是该团队的成员。", "message.team-already-member": "你已是该团队的成员。",
"message.team-not-found": "未找到团队。", "message.team-not-found": "未找到团队。",
@ -332,7 +363,7 @@
"message.transfer-user-website-to-team": "选择要转移此网站的团队。", "message.transfer-user-website-to-team": "选择要转移此网站的团队。",
"message.transfer-website": "将网站所有权转移到您的账户或其他团队。", "message.transfer-website": "将网站所有权转移到您的账户或其他团队。",
"message.triggered-event": "触发事件", "message.triggered-event": "触发事件",
"message.unauthorized": "Unauthorized", "message.unauthorized": "未授权",
"message.user-deleted": "用户已删除。", "message.user-deleted": "用户已删除。",
"message.viewed-page": "已浏览页面", "message.viewed-page": "已浏览页面",
"message.visitor-log": "来自 {country} 的访客在搭载 {os} 的 {device} 上使用 {browser} 浏览器进行访问。" "message.visitor-log": "来自 {country} 的访客在搭载 {os} 的 {device} 上使用 {browser} 浏览器进行访问。"

View file

@ -1,7 +1,6 @@
import debug from 'debug'; import debug from 'debug';
import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants'; import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
import { secret } from '@/lib/crypto'; import { createAuthKey, secret } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { ensureArray } from '@/lib/utils'; import { ensureArray } from '@/lib/utils';
@ -53,7 +52,7 @@ export async function checkAuth(request: Request) {
} }
export async function saveAuth(data: any, expire = 0) { export async function saveAuth(data: any, expire = 0) {
const authKey = `auth:${getRandomChars(32)}`; const authKey = `auth:${createAuthKey()}`;
if (redis.enabled) { if (redis.enabled) {
await redis.client.set(authKey, data); await redis.client.set(authKey, data);

View file

@ -61,7 +61,7 @@ function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
function getDateSQL(field: string, unit: string, timezone?: string) { function getDateSQL(field: string, unit: string, timezone?: string) {
if (timezone) { if (timezone) {
return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`; return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'), '${timezone}')`;
} }
return `toDateTime(date_trunc('${unit}', ${field}))`; return `toDateTime(date_trunc('${unit}', ${field}))`;
} }

View file

@ -4,6 +4,7 @@ export const LOCALE_CONFIG = 'umami.locale';
export const TIMEZONE_CONFIG = 'umami.timezone'; export const TIMEZONE_CONFIG = 'umami.timezone';
export const DATE_RANGE_CONFIG = 'umami.date-range'; export const DATE_RANGE_CONFIG = 'umami.date-range';
export const THEME_CONFIG = 'umami.theme'; export const THEME_CONFIG = 'umami.theme';
export const CURRENCY_CONFIG = 'umami.currency';
export const DASHBOARD_CONFIG = 'umami.dashboard'; export const DASHBOARD_CONFIG = 'umami.dashboard';
export const LAST_TEAM_CONFIG = 'umami.last-team'; export const LAST_TEAM_CONFIG = 'umami.last-team';
export const VERSION_CHECK = 'umami.version-check'; export const VERSION_CHECK = 'umami.version-check';
@ -25,6 +26,7 @@ export const DEFAULT_WEBSITE_LIMIT = 10;
export const DEFAULT_RESET_DATE = '2000-01-01'; export const DEFAULT_RESET_DATE = '2000-01-01';
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_DATE_COMPARE = 'prev'; export const DEFAULT_DATE_COMPARE = 'prev';
export const DEFAULT_CURRENCY = 'USD';
export const REALTIME_RANGE = 30; export const REALTIME_RANGE = 30;
export const REALTIME_INTERVAL = 10000; export const REALTIME_INTERVAL = 10000;
@ -53,6 +55,7 @@ export const SESSION_COLUMNS = [
'country', 'country',
'city', 'city',
'region', 'region',
'distinctId',
]; ];
export const SEGMENT_TYPES = { export const SEGMENT_TYPES = {
@ -67,6 +70,7 @@ export const FILTER_COLUMNS = {
referrer: 'referrer_domain', referrer: 'referrer_domain',
domain: 'referrer_domain', domain: 'referrer_domain',
hostname: 'hostname', hostname: 'hostname',
distinctId: 'distinct_id',
title: 'page_title', title: 'page_title',
query: 'url_query', query: 'url_query',
os: 'os', os: 'os',
@ -93,6 +97,13 @@ export const EVENT_TYPE = {
pixelEvent: 4, pixelEvent: 4,
} as const; } as const;
export const ENTITY_TYPE = {
website: 1,
link: 2,
pixel: 3,
board: 4,
} as const;
export const DATA_TYPE = { export const DATA_TYPE = {
string: 1, string: 1,
number: 2, number: 2,

View file

@ -63,3 +63,7 @@ export function uuid(...args: any) {
return process.env.USE_UUIDV7 ? v7() : v4(); return process.env.USE_UUIDV7 ? v7() : v4();
} }
export function createAuthKey() {
return crypto.randomBytes(16).toString('hex');
}

View file

@ -9,6 +9,7 @@ import {
differenceInCalendarMonths, differenceInCalendarMonths,
differenceInCalendarWeeks, differenceInCalendarWeeks,
differenceInCalendarYears, differenceInCalendarYears,
differenceInDays,
differenceInHours, differenceInHours,
differenceInMinutes, differenceInMinutes,
endOfDay, endOfDay,
@ -136,7 +137,12 @@ export function parseDateValue(value: string) {
return { num: +num, unit }; return { num: +num, unit };
} }
export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange { export function parseDateRange(
value: string,
unitValue?: string,
locale = 'en-US',
timezone?: string,
): DateRange {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return null; return null;
} }
@ -146,7 +152,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
const startDate = new Date(+startTime); const startDate = new Date(+startTime);
const endDate = new Date(+endTime); const endDate = new Date(+endTime);
const unit = getMinimumUnit(startDate, endDate); const unit = getMinimumUnit(startDate, endDate, true);
return { return {
startDate, startDate,
@ -169,14 +175,14 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
endDate: endOfHour(now), endDate: endOfHour(now),
offset: 0, offset: 0,
num: num || 1, num: num || 1,
unit, unit: unitValue || unit,
value, value,
}; };
case 'day': case 'day':
return { return {
startDate: num ? subDays(startOfDay(now), num) : startOfDay(now), startDate: num ? subDays(startOfDay(now), num) : startOfDay(now),
endDate: endOfDay(now), endDate: endOfDay(now),
unit: num ? 'day' : 'hour', unit: unitValue ? unitValue : num ? 'day' : 'hour',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -187,7 +193,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
? subWeeks(startOfWeek(now, { locale: dateLocale }), num) ? subWeeks(startOfWeek(now, { locale: dateLocale }), num)
: startOfWeek(now, { locale: dateLocale }), : startOfWeek(now, { locale: dateLocale }),
endDate: endOfWeek(now, { locale: dateLocale }), endDate: endOfWeek(now, { locale: dateLocale }),
unit: 'day', unit: unitValue || 'day',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -196,7 +202,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
return { return {
startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now), startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now),
endDate: endOfMonth(now), endDate: endOfMonth(now),
unit: num ? 'month' : 'day', unit: unitValue ? unitValue : num ? 'month' : 'day',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -205,7 +211,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
return { return {
startDate: num ? subYears(startOfYear(now), num) : startOfYear(now), startDate: num ? subYears(startOfYear(now), num) : startOfYear(now),
endDate: endOfYear(now), endDate: endOfYear(now),
unit: 'month', unit: unitValue || 'month',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -273,12 +279,20 @@ export function getAllowedUnits(startDate: Date, endDate: Date) {
return index >= 0 ? units.splice(index) : []; return index >= 0 ? units.splice(index) : [];
} }
export function getMinimumUnit(startDate: number | Date, endDate: number | Date) { export function getMinimumUnit(
startDate: number | Date,
endDate: number | Date,
isDateRange: boolean = false,
) {
if (differenceInMinutes(endDate, startDate) <= 60) { if (differenceInMinutes(endDate, startDate) <= 60) {
return 'minute'; return 'minute';
} else if (differenceInHours(endDate, startDate) <= 48) { } else if (
isDateRange
? differenceInHours(endDate, startDate) <= 48
: differenceInDays(endDate, startDate) <= 30
) {
return 'hour'; return 'hour';
} else if (differenceInCalendarMonths(endDate, startDate) <= 6) { } else if (differenceInCalendarMonths(endDate, startDate) <= 7) {
return 'day'; return 'day';
} else if (differenceInCalendarMonths(endDate, startDate) <= 24) { } else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
return 'month'; return 'month';

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