mirror of
https://github.com/umami-software/umami.git
synced 2026-02-03 20:27:13 +01:00
Compare commits
97 commits
220c2af344
...
1498da2d02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1498da2d02 | ||
|
|
4867492ca3 | ||
|
|
29a373467a | ||
|
|
5f316a79e5 | ||
|
|
7bb30443a8 | ||
|
|
7f43a0d41a | ||
|
|
128217c0f4 | ||
|
|
355fa1e9a9 | ||
|
|
a2f0066c42 | ||
|
|
e73432dd26 | ||
|
|
3f173889ea | ||
|
|
d70152be38 | ||
|
|
7a89bbf19f | ||
|
|
c88425332b | ||
|
|
09e1267cb0 | ||
|
|
fce1c13017 | ||
|
|
3fc41bdd08 | ||
|
|
f4eaa9fd0d | ||
|
|
d4ff7c8e3f | ||
|
|
6fd428683d | ||
|
|
a1efb2d86a | ||
|
|
8b2196b97a | ||
|
|
63d2bfe118 | ||
|
|
8f55ed9da9 | ||
|
|
e5f794c329 | ||
|
|
14012ed68e | ||
|
|
dd8888cfcf | ||
|
|
d47ee6e8e8 | ||
|
|
52f812d9e5 | ||
|
|
f2c49845d0 | ||
|
|
ef3aec09be | ||
|
|
0eb598c817 | ||
|
|
3fe7afbb12 | ||
|
|
3a498333a6 | ||
|
|
cd6a28ea4a | ||
|
|
63c9cf2e30 | ||
|
|
520c91c621 | ||
|
|
29f2c7b7d4 | ||
|
|
a270b0afea | ||
|
|
889a404650 | ||
|
|
2bd0734162 | ||
|
|
b6013c3ee8 | ||
|
|
ebfbc282ee | ||
|
|
1a664d8719 | ||
|
|
6d480d9c36 | ||
|
|
a049fbb5b0 | ||
|
|
6420f2c813 | ||
|
|
fbf03d6563 | ||
|
|
fbe031bfe9 | ||
|
|
5213e04f44 | ||
|
|
17d24bf8e2 | ||
|
|
767fda21cd | ||
|
|
c122fb718b | ||
|
|
f105a52fc2 | ||
|
|
5506314e54 | ||
|
|
e90b2201ca | ||
|
|
d8dcf05a20 | ||
|
|
2e62a06aa4 | ||
|
|
75ae7528fb | ||
|
|
58880b6a5f | ||
|
|
491716f4dd | ||
|
|
42d0594118 | ||
|
|
34c31ca63c | ||
|
|
dacf13475a | ||
|
|
8286af1453 | ||
|
|
34677bca8f | ||
|
|
b0aa6fd6ef | ||
|
|
97c26bc075 | ||
|
|
4eddac21c7 | ||
|
|
5e3e6b3edd | ||
|
|
612b00179b | ||
|
|
783098fadc | ||
|
|
6859f00bf6 | ||
|
|
b0ed4bddb6 | ||
|
|
ad264f941d | ||
|
|
4c0c9e6aa0 | ||
|
|
687318bd09 | ||
|
|
912d2d544d | ||
|
|
3072f02f1b | ||
|
|
86d2672c47 | ||
|
|
741c6039e6 | ||
|
|
37b6194c5f | ||
|
|
b75c15dc43 | ||
|
|
d04fff65fe | ||
|
|
437c168e6f | ||
|
|
886544f297 | ||
|
|
53dfc5e76a | ||
|
|
5fbef149d0 | ||
|
|
860e6390f1 | ||
|
|
9b0d1b092e | ||
|
|
2a71cc721b | ||
|
|
7bea47d9e8 | ||
|
|
e9cdabab5a | ||
|
|
5b97fb908a | ||
|
|
b088a2ee6e | ||
|
|
8cc571f548 | ||
|
|
59a16e719b |
134 changed files with 3670 additions and 1780 deletions
10
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
10
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
|
|
@ -24,13 +24,13 @@ body:
|
|||
render: shell
|
||||
- type: input
|
||||
attributes:
|
||||
label: Which Umami version are you using? (if relevant)
|
||||
label: Which Umami version are you using?
|
||||
description: 'For example: 2.18.0, 2.15.1, 1.39.0, etc'
|
||||
- type: input
|
||||
attributes:
|
||||
label: Which browser are you using? (if relevant)
|
||||
description: 'For example: Chrome, Edge, Firefox, etc'
|
||||
label: How are you deploying your application?
|
||||
description: 'For example: Vercel, Railway, Docker, etc'
|
||||
- type: input
|
||||
attributes:
|
||||
label: How are you deploying your application? (if relevant)
|
||||
description: 'For example: Vercel, Railway, Docker, etc'
|
||||
label: Which browser are you using?
|
||||
description: 'For example: Chrome, Edge, Firefox, etc'
|
||||
|
|
|
|||
87
.github/workflows/cd.yml
vendored
87
.github/workflows/cd.yml
vendored
|
|
@ -3,13 +3,18 @@ name: Create docker images
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
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
|
||||
default: ''
|
||||
default: ""
|
||||
include_latest:
|
||||
description: "Include latest tag"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -22,6 +27,9 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
|
@ -46,6 +54,7 @@ jobs:
|
|||
INPUT="${{ github.event.inputs.version }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
INCLUDE_LATEST="${{ github.event.inputs.include_latest }}"
|
||||
|
||||
# Determine version source
|
||||
if [[ -n "$INPUT" ]]; then
|
||||
|
|
@ -56,7 +65,8 @@ jobs:
|
|||
VERSION=""
|
||||
fi
|
||||
|
||||
TAGS=""
|
||||
GHCR_TAGS=""
|
||||
DOCKER_TAGS=""
|
||||
|
||||
if [[ -n "$VERSION" ]]; then
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
|
|
@ -64,37 +74,54 @@ jobs:
|
|||
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
# prerelease: only version tag
|
||||
TAGS="$VERSION"
|
||||
GHCR_TAGS="ghcr.io/${{ github.repository }}:$VERSION"
|
||||
DOCKER_TAGS="umamisoftware/umami:$VERSION"
|
||||
else
|
||||
# stable release: version + hierarchy + latest
|
||||
TAGS="$VERSION,${MAJOR}.${MINOR},${MAJOR},postgresql-latest,latest"
|
||||
# stable release: version + hierarchy
|
||||
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
|
||||
else
|
||||
# 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
|
||||
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "Computed tags: $TAGS"
|
||||
echo "ghcr_tags=$GHCR_TAGS" >> $GITHUB_OUTPUT
|
||||
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
|
||||
run: |
|
||||
TAGS="${{ steps.compute.outputs.tags }}"
|
||||
- name: Build and push to GHCR
|
||||
uses: docker/build-push-action@v5
|
||||
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
|
||||
if [[ "${{ github.repository }}" == "umami-software/umami" ]]; then
|
||||
IMAGES=("umamisoftware/umami" "ghcr.io/${{ github.repository }}")
|
||||
else
|
||||
IMAGES=("ghcr.io/${{ github.repository }}")
|
||||
fi
|
||||
|
||||
for IMAGE in "${IMAGES[@]}"; do
|
||||
echo "Building and pushing $IMAGE with tags: $TAGS"
|
||||
docker buildx build \
|
||||
--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
|
||||
- name: Build and push to Docker Hub
|
||||
if: github.repository == 'umami-software/umami'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.compute.outputs.docker_tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
|
|
|||
39
.github/workflows/ci.yml
vendored
39
.github/workflows/ci.yml
vendored
|
|
@ -3,7 +3,7 @@ name: Node.js CI
|
|||
on: [push]
|
||||
|
||||
env:
|
||||
DATABASE_TYPE: postgresql
|
||||
DATABASE_URL: "postgresql://user:pass@localhost:5432/dummy"
|
||||
SKIP_DB_CHECK: 1
|
||||
|
||||
jobs:
|
||||
|
|
@ -11,27 +11,18 @@ jobs:
|
|||
if: github.repository == 'umami-software/umami'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- node-version: 18.18
|
||||
pnpm-version: 10
|
||||
db-type: postgresql
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4 # required so that setup-node will work
|
||||
with:
|
||||
version: ${{ matrix.pnpm-version }}
|
||||
run_install: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
env:
|
||||
DATABASE_TYPE: ${{ matrix.db-type }}
|
||||
- run: npm install --global pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- name: Use Node.js 18.18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.18
|
||||
cache: "pnpm"
|
||||
- run: npm install --global pnpm
|
||||
- run: pnpm install
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
|
|
|
|||
91
.gitignore
vendored
91
.gitignore
vendored
|
|
@ -1,45 +1,46 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
.pnpm-store
|
||||
package-lock.json
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next
|
||||
/out
|
||||
|
||||
# production
|
||||
/build
|
||||
/public/script.js
|
||||
/geo
|
||||
/dist
|
||||
/generated
|
||||
/src/generated
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.idea
|
||||
.yarn
|
||||
*.iml
|
||||
*.log
|
||||
.vscode
|
||||
.tool-versions
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.*
|
||||
*.env.*
|
||||
|
||||
*.dev.yml
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
.pnpm-store
|
||||
package-lock.json
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next
|
||||
/out
|
||||
|
||||
# production
|
||||
/build
|
||||
/public/script.js
|
||||
/geo
|
||||
/dist
|
||||
/generated
|
||||
/src/generated
|
||||
pm2.yml
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.idea
|
||||
.yarn
|
||||
*.iml
|
||||
*.log
|
||||
.vscode
|
||||
.tool-versions
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.*
|
||||
*.env.*
|
||||
|
||||
*.dev.yml
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const cloudMode = process.env.CLOUD_MODE || '';
|
|||
const cloudUrl = process.env.CLOUD_URL || '';
|
||||
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || '';
|
||||
const corsMaxAge = process.env.CORS_MAX_AGE || '';
|
||||
const defaultCurrency = process.env.DEFAULT_CURRENCY || '';
|
||||
const defaultLocale = process.env.DEFAULT_LOCALE || '';
|
||||
const forceSSL = process.env.FORCE_SSL || '';
|
||||
const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
|
||||
|
|
@ -170,6 +171,7 @@ export default {
|
|||
cloudMode,
|
||||
cloudUrl,
|
||||
currentVersion: pkg.version,
|
||||
defaultCurrency,
|
||||
defaultLocale,
|
||||
},
|
||||
basePath,
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "umami",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.3",
|
||||
"description": "A modern, privacy-focused alternative to Google Analytics.",
|
||||
"author": "Umami Software, Inc. <hello@umami.is>",
|
||||
"license": "MIT",
|
||||
|
|
@ -97,20 +97,20 @@
|
|||
"is-docker": "^3.0.0",
|
||||
"is-localhost-ip": "^2.0.0",
|
||||
"isbot": "^5.1.31",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jszip": "^3.10.1",
|
||||
"kafkajs": "^2.1.0",
|
||||
"lucide-react": "^0.543.0",
|
||||
"maxmind": "^5.0.0",
|
||||
"next": "^15.5.7",
|
||||
"next": "^15.5.9",
|
||||
"node-fetch": "^3.2.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.3",
|
||||
"prisma": "^6.18.0",
|
||||
"pure-rand": "^7.0.1",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-error-boundary": "^4.0.4",
|
||||
"react-intl": "^7.1.14",
|
||||
"react-simple-maps": "^2.3.0",
|
||||
|
|
@ -143,8 +143,8 @@
|
|||
"@types/react-window": "^1.8.8",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"cypress": "^13.6.6",
|
||||
"extract-react-intl-messages": "^4.1.1",
|
||||
"cypress": "^15.8.0",
|
||||
"extract-react-intl-messages": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"lint-staged": "^16.2.6",
|
||||
|
|
@ -164,7 +164,7 @@
|
|||
"stylelint-config-css-modules": "^4.5.1",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-config-recommended": "^14.0.0",
|
||||
"tar": "^6.1.2",
|
||||
"tar": "^7.5.4",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.5.0",
|
||||
|
|
|
|||
2223
pnpm-lock.yaml
generated
2223
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
40
prisma/migrations/15_add_share/migration.sql
Normal file
40
prisma/migrations/15_add_share/migration.sql
Normal 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";
|
||||
30
prisma/migrations/16_boards/migration.sql
Normal file
30
prisma/migrations/16_boards/migration.sql
Normal 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");
|
||||
29
prisma/migrations/17_remove_duplicate_key/migration.sql
Normal file
29
prisma/migrations/17_remove_duplicate_key/migration.sql
Normal 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";
|
||||
|
|
@ -11,7 +11,7 @@ datasource db {
|
|||
}
|
||||
|
||||
model User {
|
||||
id String @id @unique @map("user_id") @db.Uuid
|
||||
id String @id() @map("user_id") @db.Uuid
|
||||
username String @unique @db.VarChar(255)
|
||||
password String @db.VarChar(60)
|
||||
role String @map("role") @db.VarChar(50)
|
||||
|
|
@ -27,12 +27,13 @@ model User {
|
|||
pixels Pixel[] @relation("user")
|
||||
teams TeamUser[]
|
||||
reports Report[]
|
||||
boards Board[] @relation("user")
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @unique @map("session_id") @db.Uuid
|
||||
id String @id() @map("session_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
browser String? @db.VarChar(20)
|
||||
os String? @db.VarChar(20)
|
||||
|
|
@ -64,10 +65,9 @@ model Session {
|
|||
}
|
||||
|
||||
model Website {
|
||||
id String @id @unique @map("website_id") @db.Uuid
|
||||
id String @id() @map("website_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
domain String? @db.VarChar(500)
|
||||
shareId String? @unique @map("share_id") @db.VarChar(50)
|
||||
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
teamId String? @map("team_id") @db.Uuid
|
||||
|
|
@ -88,7 +88,6 @@ model Website {
|
|||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([createdAt])
|
||||
@@index([shareId])
|
||||
@@index([createdBy])
|
||||
@@map("website")
|
||||
}
|
||||
|
|
@ -187,7 +186,7 @@ model SessionData {
|
|||
}
|
||||
|
||||
model Team {
|
||||
id String @id() @unique() @map("team_id") @db.Uuid
|
||||
id String @id() @map("team_id") @db.Uuid
|
||||
name String @db.VarChar(50)
|
||||
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
||||
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
||||
|
|
@ -199,13 +198,14 @@ model Team {
|
|||
members TeamUser[]
|
||||
links Link[]
|
||||
pixels Pixel[]
|
||||
boards Board[]
|
||||
|
||||
@@index([accessCode])
|
||||
@@map("team")
|
||||
}
|
||||
|
||||
model TeamUser {
|
||||
id String @id() @unique() @map("team_user_id") @db.Uuid
|
||||
id String @id() @map("team_user_id") @db.Uuid
|
||||
teamId String @map("team_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
role String @db.VarChar(50)
|
||||
|
|
@ -221,7 +221,7 @@ model TeamUser {
|
|||
}
|
||||
|
||||
model Report {
|
||||
id String @id() @unique() @map("report_id") @db.Uuid
|
||||
id String @id() @map("report_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
type String @db.VarChar(50)
|
||||
|
|
@ -242,7 +242,7 @@ model Report {
|
|||
}
|
||||
|
||||
model Segment {
|
||||
id String @id() @unique() @map("segment_id") @db.Uuid
|
||||
id String @id() @map("segment_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
type String @db.VarChar(50)
|
||||
name String @db.VarChar(200)
|
||||
|
|
@ -257,7 +257,7 @@ model Segment {
|
|||
}
|
||||
|
||||
model Revenue {
|
||||
id String @id() @unique() @map("revenue_id") @db.Uuid
|
||||
id String @id() @map("revenue_id") @db.Uuid
|
||||
websiteId String @map("website_id") @db.Uuid
|
||||
sessionId String @map("session_id") @db.Uuid
|
||||
eventId String @map("event_id") @db.Uuid
|
||||
|
|
@ -277,7 +277,7 @@ model Revenue {
|
|||
}
|
||||
|
||||
model Link {
|
||||
id String @id() @unique() @map("link_id") @db.Uuid
|
||||
id String @id() @map("link_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
url String @db.VarChar(500)
|
||||
slug String @unique() @db.VarChar(100)
|
||||
|
|
@ -298,7 +298,7 @@ model Link {
|
|||
}
|
||||
|
||||
model Pixel {
|
||||
id String @id() @unique() @map("pixel_id") @db.Uuid
|
||||
id String @id() @map("pixel_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
slug String @unique() @db.VarChar(100)
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
|
|
@ -316,3 +316,39 @@ model Pixel {
|
|||
@@index([createdAt])
|
||||
@@map("pixel")
|
||||
}
|
||||
|
||||
model Board {
|
||||
id String @id() @map("board_id") @db.Uuid
|
||||
type String @db.VarChar(50)
|
||||
name String @db.VarChar(200)
|
||||
description String @db.VarChar(500)
|
||||
parameters Json
|
||||
slug String @unique() @db.VarChar(100)
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
teamId String? @map("team_id") @db.Uuid
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
user User? @relation("user", fields: [userId], references: [id])
|
||||
team Team? @relation(fields: [teamId], references: [id])
|
||||
|
||||
@@index([slug])
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([createdAt])
|
||||
@@map("board")
|
||||
}
|
||||
|
||||
model Share {
|
||||
id String @id() @map("share_id") @db.Uuid
|
||||
entityId String @map("entity_id") @db.Uuid
|
||||
name String @db.VarChar(200)
|
||||
shareType Int @map("share_type") @db.Integer
|
||||
slug String @unique() @db.VarChar(100)
|
||||
parameters Json
|
||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@index([entityId])
|
||||
@@map("share")
|
||||
}
|
||||
|
|
|
|||
BIN
public/images/country/t1.png
Normal file
BIN
public/images/country/t1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -5,6 +5,18 @@
|
|||
"value": "访问代码"
|
||||
}
|
||||
],
|
||||
"label.account": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "账户"
|
||||
}
|
||||
],
|
||||
"label.action": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "行为"
|
||||
}
|
||||
],
|
||||
"label.actions": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -35,12 +47,24 @@
|
|||
"value": "添加描述"
|
||||
}
|
||||
],
|
||||
"label.add-link": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "添加链接"
|
||||
}
|
||||
],
|
||||
"label.add-member": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "添加成员"
|
||||
}
|
||||
],
|
||||
"label.add-pixel": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "添加像素"
|
||||
}
|
||||
],
|
||||
"label.add-step": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -83,12 +107,24 @@
|
|||
"value": "所有时间段"
|
||||
}
|
||||
],
|
||||
"label.analysis": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "分析"
|
||||
}
|
||||
],
|
||||
"label.analytics": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "分析"
|
||||
}
|
||||
],
|
||||
"label.application": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "应用"
|
||||
}
|
||||
],
|
||||
"label.apply": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -107,6 +143,12 @@
|
|||
"value": "查看用户如何与您的营销互动,以及是什么促成了转化。"
|
||||
}
|
||||
],
|
||||
"label.audience": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "受众"
|
||||
}
|
||||
],
|
||||
"label.average": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -125,6 +167,12 @@
|
|||
"value": "之前"
|
||||
}
|
||||
],
|
||||
"label.behavior": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "行为"
|
||||
}
|
||||
],
|
||||
"label.boards": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -173,12 +221,24 @@
|
|||
"value": "修改密码"
|
||||
}
|
||||
],
|
||||
"label.channel": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "渠道"
|
||||
}
|
||||
],
|
||||
"label.channels": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "渠道"
|
||||
}
|
||||
],
|
||||
"label.chart": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "图表"
|
||||
}
|
||||
],
|
||||
"label.cities": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -203,6 +263,12 @@
|
|||
"value": "队列"
|
||||
}
|
||||
],
|
||||
"label.cohorts": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "队列"
|
||||
}
|
||||
],
|
||||
"label.compare": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -317,6 +383,12 @@
|
|||
"value": "创建者"
|
||||
}
|
||||
],
|
||||
"label.criteria": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "条件"
|
||||
}
|
||||
],
|
||||
"label.currency": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -419,6 +491,12 @@
|
|||
"value": "台式机"
|
||||
}
|
||||
],
|
||||
"label.destination-url": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "目标URL"
|
||||
}
|
||||
],
|
||||
"label.details": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -455,6 +533,12 @@
|
|||
"value": "唯一ID"
|
||||
}
|
||||
],
|
||||
"label.documentation": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "文档"
|
||||
}
|
||||
],
|
||||
"label.does-not-contain": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -479,6 +563,12 @@
|
|||
"value": "域名"
|
||||
}
|
||||
],
|
||||
"label.download": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "下载"
|
||||
}
|
||||
],
|
||||
"label.dropoff": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -506,7 +596,7 @@
|
|||
"label.email": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Email"
|
||||
"value": "邮箱"
|
||||
}
|
||||
],
|
||||
"label.enable-share-url": [
|
||||
|
|
@ -527,6 +617,12 @@
|
|||
"value": "入口 URL"
|
||||
}
|
||||
],
|
||||
"label.environment": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "环境"
|
||||
}
|
||||
],
|
||||
"label.event": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -671,6 +767,12 @@
|
|||
"value": "分组"
|
||||
}
|
||||
],
|
||||
"label.growth": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "增长"
|
||||
}
|
||||
],
|
||||
"label.hostname": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -701,6 +803,12 @@
|
|||
"value": "通过使用筛选器和划分时间段来更深入地研究数据。"
|
||||
}
|
||||
],
|
||||
"label.invalid-url": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "无效URL"
|
||||
}
|
||||
],
|
||||
"label.is": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -863,12 +971,24 @@
|
|||
"value": "少于等于"
|
||||
}
|
||||
],
|
||||
"label.link": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "链接"
|
||||
}
|
||||
],
|
||||
"label.links": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "链接"
|
||||
}
|
||||
],
|
||||
"label.location": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "位置"
|
||||
}
|
||||
],
|
||||
"label.login": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -1020,7 +1140,7 @@
|
|||
"label.online": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Online"
|
||||
"value": "在线"
|
||||
}
|
||||
],
|
||||
"label.organic-search": [
|
||||
|
|
@ -1165,6 +1285,12 @@
|
|||
"value": "路径"
|
||||
}
|
||||
],
|
||||
"label.pixel": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "像素"
|
||||
}
|
||||
],
|
||||
"label.pixels": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -1185,6 +1311,12 @@
|
|||
"value": " 提供支持"
|
||||
}
|
||||
],
|
||||
"label.preferences": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "偏好"
|
||||
}
|
||||
],
|
||||
"label.previous": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -1209,6 +1341,12 @@
|
|||
"value": "个人资料"
|
||||
}
|
||||
],
|
||||
"label.profiles": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "个人资料"
|
||||
}
|
||||
],
|
||||
"label.properties": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -1248,7 +1386,7 @@
|
|||
"label.referral": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Referral"
|
||||
"value": "来源"
|
||||
}
|
||||
],
|
||||
"label.referrer": [
|
||||
|
|
@ -1371,6 +1509,24 @@
|
|||
"value": "保存"
|
||||
}
|
||||
],
|
||||
"label.save-cohort": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "保存为群组"
|
||||
}
|
||||
],
|
||||
"label.save-segment": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "保存为细分"
|
||||
}
|
||||
],
|
||||
"label.screen": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "屏幕"
|
||||
}
|
||||
],
|
||||
"label.screens": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -1383,6 +1539,18 @@
|
|||
"value": "搜索"
|
||||
}
|
||||
],
|
||||
"label.segment": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "细分"
|
||||
}
|
||||
],
|
||||
"label.segments": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "细分"
|
||||
}
|
||||
],
|
||||
"label.select": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -1485,6 +1653,24 @@
|
|||
"value": "总和"
|
||||
}
|
||||
],
|
||||
"label.support": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "支持"
|
||||
}
|
||||
],
|
||||
"label.switch-account": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "切换账户"
|
||||
}
|
||||
],
|
||||
"label.table": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "表格"
|
||||
}
|
||||
],
|
||||
"label.tablet": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -1635,6 +1821,12 @@
|
|||
"value": "跟踪代码"
|
||||
}
|
||||
],
|
||||
"label.traffic": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "流量"
|
||||
}
|
||||
],
|
||||
"label.transactions": [
|
||||
{
|
||||
"type": 0,
|
||||
|
|
@ -1846,7 +2038,7 @@
|
|||
"message.bad-request": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Bad request"
|
||||
"value": "请求错误"
|
||||
}
|
||||
],
|
||||
"message.collected-data": [
|
||||
|
|
@ -1946,7 +2138,7 @@
|
|||
"message.forbidden": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Forbidden"
|
||||
"value": "禁止访问"
|
||||
}
|
||||
],
|
||||
"message.go-to-settings": [
|
||||
|
|
@ -2046,13 +2238,13 @@
|
|||
"message.not-found": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Not found"
|
||||
"value": "未找到"
|
||||
}
|
||||
],
|
||||
"message.nothing-selected": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Nothing selected."
|
||||
"value": "未选择"
|
||||
}
|
||||
],
|
||||
"message.page-not-found": [
|
||||
|
|
@ -2090,7 +2282,7 @@
|
|||
"message.sever-error": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Server error"
|
||||
"value": "服务器错误"
|
||||
}
|
||||
],
|
||||
"message.share-url": [
|
||||
|
|
@ -2158,7 +2350,7 @@
|
|||
"message.unauthorized": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Unauthorized"
|
||||
"value": "未授权"
|
||||
}
|
||||
],
|
||||
"message.user-deleted": [
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@
|
|||
"AD-06": "Sant Julia de Loria",
|
||||
"AD-07": "Andorra la Vella",
|
||||
"AD-08": "Escaldes-Engordany",
|
||||
"AE-AJ": "'Ajman",
|
||||
"AE-AZ": "Abu Zaby",
|
||||
"AE-DU": "Dubayy",
|
||||
"AE-FU": "Al Fujayrah",
|
||||
"AE-RK": "Ra's al Khaymah",
|
||||
"AE-SH": "Ash Shariqah",
|
||||
"AE-UQ": "Umm al Qaywayn",
|
||||
"AE-AJ": "Ajman",
|
||||
"AE-AZ": "Abu Dhabi",
|
||||
"AE-DU": "Dubai",
|
||||
"AE-FU": "Al Fujairah",
|
||||
"AE-RK": "Ras al Khaimah",
|
||||
"AE-SH": "Sharjah",
|
||||
"AE-UQ": "Umm al Quwain",
|
||||
"AF-BAL": "Balkh",
|
||||
"AF-BAM": "Bamyan",
|
||||
"AF-BDG": "Badghis",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'dotenv/config';
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import https from 'https';
|
||||
import tar from 'tar';
|
||||
import { list } from 'tar';
|
||||
import zlib from 'zlib';
|
||||
|
||||
if (process.env.VERCEL && !process.env.BUILD_GEO) {
|
||||
|
|
@ -40,7 +40,7 @@ const isDirectMmdb = url.endsWith('.mmdb');
|
|||
const downloadCompressed = url =>
|
||||
new Promise(resolve => {
|
||||
https.get(url, res => {
|
||||
resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t()));
|
||||
resolve(res.pipe(zlib.createGunzip({})).pipe(list()));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen';
|
|||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { TeamsAddButton } from '../../teams/TeamsAddButton';
|
||||
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
|
||||
|
||||
export function AdminTeamsPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleSave = () => {};
|
||||
|
||||
return (
|
||||
<Column gap="6" margin="2">
|
||||
<PageHeader title={formatMessage(labels.teams)} />
|
||||
<PageHeader title={formatMessage(labels.teams)}>
|
||||
<TeamsAddButton onSave={handleSave} isAdmin={true} />
|
||||
</PageHeader>
|
||||
<Panel>
|
||||
<AdminTeamsDataTable />
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { messages } from '@/components/messages';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
export function UserAddForm({ onSave, onClose }) {
|
||||
|
|
@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) {
|
|||
<FormField
|
||||
label={formatMessage(labels.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" />
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||
import { useDeleteQuery, useMessages } from '@/components/hooks';
|
||||
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
|
||||
import { Trash } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { messages } from '@/components/messages';
|
||||
|
|
@ -15,7 +15,8 @@ export function LinkDeleteButton({
|
|||
onSave?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
||||
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
|
||||
const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
|
||||
const { touch } = useModified();
|
||||
|
||||
const handleConfirm = async (close: () => void) => {
|
||||
await mutateAsync(null, {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import {
|
|||
Form,
|
||||
FormField,
|
||||
FormSubmitButton,
|
||||
Grid,
|
||||
Icon,
|
||||
Label,
|
||||
Loading,
|
||||
Row,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
|
||||
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
|
||||
import { RefreshCw } from '@/components/icons';
|
||||
|
|
@ -42,27 +43,20 @@ export function LinkEditForm({
|
|||
const { linksUrl } = useConfig();
|
||||
const hostUrl = linksUrl || LINKS_URL;
|
||||
const { data, isLoading } = useLinkQuery(linkId);
|
||||
const [slug, setSlug] = useState(generateId());
|
||||
const [defaultSlug] = useState(generateId());
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('links');
|
||||
touch(`link:${linkId}`);
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSlug = () => {
|
||||
const slug = generateId();
|
||||
|
||||
setSlug(slug);
|
||||
|
||||
return slug;
|
||||
};
|
||||
|
||||
const checkUrl = (url: string) => {
|
||||
if (!isValidUrl(url)) {
|
||||
return formatMessage(labels.invalidUrl);
|
||||
|
|
@ -70,19 +64,19 @@ export function LinkEditForm({
|
|||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setSlug(data.slug);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (linkId && isLoading) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
|
||||
{({ setValue }) => {
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
error={getErrorMessage(error)}
|
||||
defaultValues={{ slug: defaultSlug, ...data }}
|
||||
>
|
||||
{({ setValue, watch }) => {
|
||||
const slug = watch('slug');
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
|
|
@ -101,15 +95,25 @@ export function LinkEditForm({
|
|||
<TextField placeholder="https://example.com" autoComplete="off" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
name="slug"
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<input type="hidden" />
|
||||
</FormField>
|
||||
<Grid columns="1fr auto" alignItems="end" gap>
|
||||
<FormField
|
||||
name="slug"
|
||||
label={formatMessage({ id: 'label.slug', defaultMessage: 'Slug' })}
|
||||
rules={{
|
||||
required: formatMessage(labels.required),
|
||||
}}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
</FormField>
|
||||
<Button
|
||||
variant="quiet"
|
||||
onPress={() => setValue('slug', generateId(), { shouldDirty: true })}
|
||||
>
|
||||
<Icon>
|
||||
<RefreshCw />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Column>
|
||||
<Label>{formatMessage(labels.link)}</Label>
|
||||
|
|
@ -121,14 +125,6 @@ export function LinkEditForm({
|
|||
allowCopy
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
variant="quiet"
|
||||
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
|
||||
>
|
||||
<Icon>
|
||||
<RefreshCw />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { getLink } from '@/queries/prisma';
|
||||
import { LinkPage } from './LinkPage';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
|
||||
const { linkId } = await params;
|
||||
const link = await getLink(linkId);
|
||||
|
||||
if (!link || link?.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <LinkPage linkId={linkId} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export function PixelEditForm({
|
|||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('pixels');
|
||||
touch(`pixel:${pixelId}`);
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { getPixel } from '@/queries/prisma';
|
||||
import { PixelPage } from './PixelPage';
|
||||
|
||||
export default async function ({ params }: { params: { pixelId: string } }) {
|
||||
const { pixelId } = await params;
|
||||
const pixel = await getPixel(pixelId);
|
||||
|
||||
if (!pixel || pixel?.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <PixelPage pixelId={pixelId} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { DateRangeSetting } from './DateRangeSetting';
|
|||
import { LanguageSetting } from './LanguageSetting';
|
||||
import { ThemeSetting } from './ThemeSetting';
|
||||
import { TimezoneSetting } from './TimezoneSetting';
|
||||
import { VersionSetting } from './VersionSetting';
|
||||
|
||||
export function PreferenceSettings() {
|
||||
const { user } = useLoginQuery();
|
||||
|
|
@ -31,6 +32,10 @@ export function PreferenceSettings() {
|
|||
<Label>{formatMessage(labels.theme)}</Label>
|
||||
<ThemeSetting />
|
||||
</Column>
|
||||
<Column>
|
||||
<Label>{formatMessage(labels.version)}</Label>
|
||||
<VersionSetting />
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
8
src/app/(main)/settings/preferences/VersionSetting.tsx
Normal file
8
src/app/(main)/settings/preferences/VersionSetting.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -7,8 +7,17 @@ import {
|
|||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { UserSelect } from '@/components/input/UserSelect';
|
||||
|
||||
export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) {
|
||||
export function TeamAddForm({
|
||||
onSave,
|
||||
onClose,
|
||||
isAdmin,
|
||||
}: {
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||
const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
|
||||
|
||||
|
|
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
|
|||
<FormField name="name" label={formatMessage(labels.name)}>
|
||||
<TextField autoComplete="off" />
|
||||
</FormField>
|
||||
{isAdmin && (
|
||||
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
|
||||
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
|
||||
</FormField>
|
||||
)}
|
||||
<FormButtons>
|
||||
<Button isDisabled={isPending} onPress={onClose}>
|
||||
{formatMessage(labels.cancel)}
|
||||
|
|
|
|||
76
src/app/(main)/teams/TeamMemberAddForm.tsx
Normal file
76
src/app/(main)/teams/TeamMemberAddForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,13 @@ import { Plus } from '@/components/icons';
|
|||
import { messages } from '@/components/messages';
|
||||
import { TeamAddForm } from './TeamAddForm';
|
||||
|
||||
export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
|
||||
export function TeamsAddButton({
|
||||
onSave,
|
||||
isAdmin = false,
|
||||
}: {
|
||||
onSave?: () => void;
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { toast } = useToast();
|
||||
const { touch } = useModified();
|
||||
|
|
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
|
|||
</Button>
|
||||
<Modal>
|
||||
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
|
||||
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />}
|
||||
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
|
|
|
|||
40
src/app/(main)/teams/TeamsMemberAddButton.tsx
Normal file
40
src/app/(main)/teams/TeamsMemberAddButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { Column } from '@umami/react-zen';
|
||||
import { Column, Heading, Row } from '@umami/react-zen';
|
||||
import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks';
|
||||
import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks';
|
||||
import { Users } from '@/components/icons';
|
||||
import { labels } from '@/components/messages';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
|
||||
import { TeamEditForm } from './TeamEditForm';
|
||||
import { TeamManage } from './TeamManage';
|
||||
import { TeamMembersDataTable } from './TeamMembersDataTable';
|
||||
|
|
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
|
|||
const team: any = useTeam();
|
||||
const { user } = useLoginQuery();
|
||||
const { pathname } = useNavigation();
|
||||
const { formatMessage } = useMessages();
|
||||
|
||||
const isAdmin = pathname.includes('/admin');
|
||||
|
||||
|
|
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
|
|||
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
|
||||
</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} />
|
||||
</Panel>
|
||||
{isTeamOwner && (
|
||||
|
|
|
|||
|
|
@ -21,9 +21,15 @@ export interface JourneyProps {
|
|||
steps: number;
|
||||
startStep?: string;
|
||||
endStep?: string;
|
||||
view: string;
|
||||
}
|
||||
|
||||
export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) {
|
||||
const EVENT_TYPES = {
|
||||
views: 1,
|
||||
events: 2,
|
||||
};
|
||||
|
||||
export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) {
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [activeNode, setActiveNode] = useState(null);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
|
|||
steps,
|
||||
startStep,
|
||||
endStep,
|
||||
view,
|
||||
eventType: EVENT_TYPES[view],
|
||||
});
|
||||
|
||||
useEscapeKey(() => setSelectedNode(null));
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
'use client';
|
||||
import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
|
||||
import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen';
|
||||
import { useState } from 'react';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import { Journey } from './Journey';
|
||||
|
||||
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
|
||||
|
|
@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
|||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const [view, setView] = useState('all');
|
||||
const [steps, setSteps] = useState(DEFAULT_STEP);
|
||||
const [startStep, setStartStep] = useState('');
|
||||
const [endStep, setEndStep] = useState('');
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
id: 'all',
|
||||
label: formatMessage(labels.all),
|
||||
},
|
||||
{
|
||||
id: 'views',
|
||||
label: formatMessage(labels.views),
|
||||
},
|
||||
{
|
||||
id: 'events',
|
||||
label: formatMessage(labels.events),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
|
|
@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
|||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
<Row justifyContent="flex-end">
|
||||
<FilterButtons items={buttons} value={view} onChange={setView} />
|
||||
</Row>
|
||||
<Panel height="900px" allowFullscreen>
|
||||
<Journey
|
||||
websiteId={websiteId}
|
||||
|
|
@ -60,6 +80,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
|||
steps={steps}
|
||||
startStep={startStep}
|
||||
endStep={endStep}
|
||||
view={view}
|
||||
/>
|
||||
</Panel>
|
||||
</Column>
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ import { ListTable } from '@/components/metrics/ListTable';
|
|||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
import { renderDateLabels } from '@/lib/charts';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
|
||||
import { generateTimeSeries } from '@/lib/date';
|
||||
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
|
||||
export interface RevenueProps {
|
||||
websiteId: string;
|
||||
|
|
@ -24,7 +25,15 @@ export interface RevenueProps {
|
|||
}
|
||||
|
||||
export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
||||
const [currency, setCurrency] = useState('USD');
|
||||
const [currency, setCurrency] = useState(
|
||||
getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY,
|
||||
);
|
||||
|
||||
const handleCurrencyChange = (value: string) => {
|
||||
setCurrency(value);
|
||||
setItem(CURRENCY_CONFIG, value);
|
||||
};
|
||||
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale, dateLocale } = useLocale();
|
||||
const { countryNames } = useCountryNames(locale);
|
||||
|
|
@ -107,7 +116,7 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
|||
return (
|
||||
<Column gap>
|
||||
<Grid columns="280px" gap>
|
||||
<CurrencySelect value={currency} onChange={setCurrency} />
|
||||
<CurrencySelect value={currency} onChange={handleCurrencyChange} />
|
||||
</Grid>
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
{data && (
|
||||
|
|
|
|||
|
|
@ -21,30 +21,28 @@ export function WebsiteChart({
|
|||
const { pageviews, sessions, compare } = (data || {}) as any;
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (data) {
|
||||
const result = {
|
||||
pageviews,
|
||||
sessions,
|
||||
};
|
||||
if (!data) {
|
||||
return { pageviews: [], sessions: [] };
|
||||
}
|
||||
|
||||
if (compare) {
|
||||
result.compare = {
|
||||
pageviews: result.pageviews.map(({ x }, i) => ({
|
||||
return {
|
||||
pageviews,
|
||||
sessions,
|
||||
...(compare && {
|
||||
compare: {
|
||||
pageviews: pageviews.map(({ x }, i) => ({
|
||||
x,
|
||||
y: compare.pageviews[i]?.y,
|
||||
d: compare.pageviews[i]?.x,
|
||||
})),
|
||||
sessions: result.sessions.map(({ x }, i) => ({
|
||||
sessions: sessions.map(({ x }, i) => ({
|
||||
x,
|
||||
y: compare.sessions[i]?.y,
|
||||
d: compare.sessions[i]?.x,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
return { pageviews: [], sessions: [] };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}, [data, startDate, endDate, unit]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -169,6 +169,12 @@ export function WebsiteExpandedMenu({
|
|||
path: updateParams({ view: 'hostname' }),
|
||||
icon: <Network />,
|
||||
},
|
||||
{
|
||||
id: 'distinctId',
|
||||
label: formatMessage(labels.distinctId),
|
||||
path: updateParams({ view: 'distinctId' }),
|
||||
icon: <Tag />,
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: formatMessage(labels.tag),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { Icon, Row, Text } from '@umami/react-zen';
|
||||
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
|
||||
import { Favicon } from '@/components/common/Favicon';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
|
||||
import { Edit, Share } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { Edit } from '@/components/icons';
|
||||
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
|
||||
|
||||
export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
|
||||
|
|
@ -29,29 +27,14 @@ export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
|
|||
<ActiveUsers websiteId={website.id} />
|
||||
|
||||
{showActions && (
|
||||
<Row alignItems="center" gap>
|
||||
<ShareButton websiteId={website.id} shareId={website.shareId} />
|
||||
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
||||
<Icon>
|
||||
<Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</LinkButton>
|
||||
</Row>
|
||||
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
||||
<Icon>
|
||||
<Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</LinkButton>
|
||||
)}
|
||||
</Row>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@umami/react-zen';
|
||||
import { Fragment } from 'react';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { Edit, More, Share } from '@/components/icons';
|
||||
import { Edit, MoreHorizontal, Share } from '@/components/icons';
|
||||
|
||||
export function WebsiteMenu({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
|
|||
<MenuTrigger>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
<More />
|
||||
<MoreHorizontal />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Popover placement="bottom">
|
||||
|
|
|
|||
|
|
@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
|
|||
|
||||
export function WebsiteMetricsBar({
|
||||
websiteId,
|
||||
compareMode,
|
||||
}: {
|
||||
websiteId: string;
|
||||
showChange?: boolean;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { isAllTime } = useDateRange();
|
||||
const { isAllTime, dateCompare } = useDateRange();
|
||||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId);
|
||||
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({
|
||||
websiteId,
|
||||
compare: compareMode ? dateCompare?.compare : undefined,
|
||||
});
|
||||
|
||||
const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function WebsiteNav({
|
|||
event: undefined,
|
||||
compare: undefined,
|
||||
view: undefined,
|
||||
unit: undefined,
|
||||
});
|
||||
|
||||
const items = [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { Column, Row } from '@umami/react-zen';
|
||||
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { UnitFilter } from '@/components/input/UnitFilter';
|
||||
import { WebsiteChart } from './WebsiteChart';
|
||||
import { WebsiteControls } from './WebsiteControls';
|
||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
||||
|
|
@ -13,6 +14,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
|
|||
<WebsiteControls websiteId={websiteId} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||
<Panel minHeight="520px">
|
||||
<Row justifyContent="end">
|
||||
<UnitFilter />
|
||||
</Row>
|
||||
<WebsiteChart websiteId={websiteId} />
|
||||
</Panel>
|
||||
<WebsitePanels websiteId={websiteId} />
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
|
|||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} allowCompare={true} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} compareMode={true} showChange={true} />
|
||||
<Panel minHeight="520px">
|
||||
<WebsiteChart websiteId={websiteId} compareMode={true} />
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,11 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
|
|||
label: formatMessage(labels.hostname),
|
||||
path: renderPath('hostname'),
|
||||
},
|
||||
{
|
||||
id: 'distinctId',
|
||||
label: formatMessage(labels.distinctId),
|
||||
path: renderPath('distinctId'),
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: formatMessage(labels.tags),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
|
|||
const { updateParams } = useNavigation();
|
||||
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 (
|
||||
<DataTable {...props}>
|
||||
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
|
||||
|
|
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
|
|||
title={row.eventName || row.urlPath}
|
||||
truncate
|
||||
>
|
||||
{row.eventName || row.urlPath}
|
||||
{row.eventName || renderLink(row.urlPath, row.hostname)}
|
||||
</Text>
|
||||
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout';
|
||||
import { getWebsite } from '@/queries/prisma';
|
||||
|
||||
export default async function ({
|
||||
children,
|
||||
|
|
@ -9,6 +10,11 @@ export default async function ({
|
|||
params: Promise<{ websiteId: string }>;
|
||||
}) {
|
||||
const { websiteId } = await params;
|
||||
const website = await getWebsite(websiteId);
|
||||
|
||||
if (!website || website?.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,8 +74,9 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
os: string;
|
||||
country: string;
|
||||
device: string;
|
||||
hostname: string;
|
||||
}) => {
|
||||
const { __type, eventName, urlPath, browser, os, country, device } = log;
|
||||
const { __type, eventName, urlPath, browser, os, country, device, hostname } = log;
|
||||
|
||||
if (__type === TYPE_EVENT) {
|
||||
return (
|
||||
|
|
@ -86,7 +87,8 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
url: (
|
||||
<a
|
||||
key="a"
|
||||
href={`//${website?.domain}${urlPath}`}
|
||||
href={`//${hostname}${urlPath}`}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
|
|
@ -100,7 +102,12 @@ export function RealtimeLog({ data }: { data: any }) {
|
|||
|
||||
if (__type === TYPE_PAGEVIEW) {
|
||||
return (
|
||||
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener">
|
||||
<a
|
||||
href={`//${hostname}${urlPath}`}
|
||||
style={{ fontWeight: 'bold' }}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{urlPath}
|
||||
</a>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -39,10 +39,23 @@ export function SessionActivity({
|
|||
const { isMobile } = useMobile();
|
||||
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 (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<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));
|
||||
lastDay = createdAt;
|
||||
|
||||
|
|
@ -61,7 +74,7 @@ export function SessionActivity({
|
|||
: formatMessage(labels.viewedPage)}
|
||||
</Text>
|
||||
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
|
||||
{eventName || urlPath}
|
||||
{eventName || renderLink(urlPath, hostname)}
|
||||
</Text>
|
||||
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
|
||||
</Row>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
122
src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
Normal file
122
src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
Normal file
46
src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
import { Column } from '@umami/react-zen';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useWebsite } from '@/components/hooks';
|
||||
import { WebsiteData } from './WebsiteData';
|
||||
import { WebsiteEditForm } from './WebsiteEditForm';
|
||||
import { WebsiteShareForm } from './WebsiteShareForm';
|
||||
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
||||
|
||||
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
|
||||
const website = useWebsite();
|
||||
|
||||
return (
|
||||
<Column gap="6">
|
||||
<Panel>
|
||||
|
|
@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
|
|||
<WebsiteTrackingCode websiteId={websiteId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<WebsiteShareForm websiteId={websiteId} shareId={website.shareId} />
|
||||
<WebsiteShareForm websiteId={websiteId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<WebsiteData websiteId={websiteId} />
|
||||
|
|
|
|||
|
|
@ -1,93 +1,43 @@
|
|||
import {
|
||||
Button,
|
||||
Column,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormSubmitButton,
|
||||
IconLabel,
|
||||
Label,
|
||||
Row,
|
||||
Switch,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
|
||||
const generateId = () => getRandomChars(16);
|
||||
import { Column, Heading, Row, Text } from '@umami/react-zen';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { ShareCreateForm } from './ShareCreateForm';
|
||||
import { SharesTable } from './SharesTable';
|
||||
|
||||
export interface WebsiteShareFormProps {
|
||||
websiteId: string;
|
||||
shareId?: string;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) {
|
||||
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
|
||||
const [currentId, setCurrentId] = useState(shareId);
|
||||
const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
|
||||
const { cloudMode } = useConfig();
|
||||
export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { data, isLoading } = useWebsiteSharesQuery({ websiteId });
|
||||
|
||||
const getUrl = (shareId: string) => {
|
||||
if (cloudMode) {
|
||||
return `${process.env.cloudUrl}/share/${shareId}`;
|
||||
}
|
||||
|
||||
return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
|
||||
};
|
||||
|
||||
const url = getUrl(currentId);
|
||||
|
||||
const handleGenerate = () => {
|
||||
setCurrentId(generateId());
|
||||
};
|
||||
|
||||
const handleSwitch = () => {
|
||||
setCurrentId(currentId ? null : generateId());
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const data = {
|
||||
shareId: currentId,
|
||||
};
|
||||
await mutateAsync(data, {
|
||||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch(`website:${websiteId}`);
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
});
|
||||
};
|
||||
const shares = data?.data || [];
|
||||
const hasShares = shares.length > 0;
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}>
|
||||
<Column gap>
|
||||
<Switch isSelected={!!currentId} onChange={handleSwitch}>
|
||||
{formatMessage(labels.enableShareUrl)}
|
||||
</Switch>
|
||||
{currentId && (
|
||||
<Row alignItems="flex-end" gap>
|
||||
<Column flexGrow={1}>
|
||||
<Label>{formatMessage(labels.shareUrl)}</Label>
|
||||
<TextField value={url} isReadOnly allowCopy />
|
||||
</Column>
|
||||
<Column>
|
||||
<Button onPress={handleGenerate}>
|
||||
<IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} />
|
||||
</Button>
|
||||
</Column>
|
||||
</Row>
|
||||
)}
|
||||
<FormButtons justifyContent="flex-end">
|
||||
<Row alignItems="center" gap>
|
||||
{onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>}
|
||||
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
|
||||
</Row>
|
||||
</FormButtons>
|
||||
</Column>
|
||||
</Form>
|
||||
<Column gap="4">
|
||||
<Row justifyContent="space-between" alignItems="center">
|
||||
<Heading>{formatMessage(labels.share)}</Heading>
|
||||
<DialogButton
|
||||
icon={<Plus size={16} />}
|
||||
label={formatMessage(labels.add)}
|
||||
title={formatMessage(labels.share)}
|
||||
variant="primary"
|
||||
width="400px"
|
||||
>
|
||||
{({ close }) => <ShareCreateForm websiteId={websiteId} onClose={close} />}
|
||||
</DialogButton>
|
||||
</Row>
|
||||
{hasShares ? (
|
||||
<>
|
||||
<Text>{formatMessage(messages.shareUrl)}</Text>
|
||||
<SharesTable data={shares} />
|
||||
</>
|
||||
) : (
|
||||
<Text color="muted">{formatMessage(messages.noDataAvailable)}</Text>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
30
src/app/(main)/websites/[websiteId]/settings/constants.ts
Normal file
30
src/app/(main)/websites/[websiteId]/settings/constants.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
import redis from '@/lib/redis';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { ok } from '@/lib/response';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { error } = await parseRequest(request);
|
||||
|
||||
if (error) {
|
||||
return error();
|
||||
}
|
||||
|
||||
if (redis.enabled) {
|
||||
const token = request.headers.get('authorization')?.split(' ')?.[1];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { saveAuth } from '@/lib/auth';
|
||||
import redis from '@/lib/redis';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json } from '@/lib/response';
|
||||
import { json, serverError } from '@/lib/response';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { auth, error } = await parseRequest(request);
|
||||
|
|
@ -10,9 +10,13 @@ export async function POST(request: Request) {
|
|||
return error();
|
||||
}
|
||||
|
||||
if (redis.enabled) {
|
||||
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
||||
|
||||
return json({ user: auth.user, token });
|
||||
if (!redis.enabled) {
|
||||
return serverError({
|
||||
message: 'Redis is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
||||
|
||||
return json({ user: auth.user, token });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@ export async function GET(request: Request) {
|
|||
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
||||
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
||||
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
||||
currentVersion: !!process.env.currentVersion,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,16 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
const { websiteId, parameters, filters } = body;
|
||||
const { eventType } = parameters;
|
||||
|
||||
if (!(await canViewWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
if (eventType) {
|
||||
filters.eventType = eventType;
|
||||
}
|
||||
|
||||
const queryFilters = await getQueryFilters(filters, websiteId);
|
||||
|
||||
const data = await getJourney(websiteId, parameters, queryFilters);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
import { secret } from '@/lib/crypto';
|
||||
import { createToken } from '@/lib/jwt';
|
||||
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 }> }) {
|
||||
const { shareId } = await params;
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
const website = await getSharedWebsite(shareId);
|
||||
const share = await getShareByCode(slug);
|
||||
|
||||
if (!website) {
|
||||
if (!share) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data = { websiteId: website.id };
|
||||
const data = {
|
||||
shareId: share.id,
|
||||
websiteId: share.entityId,
|
||||
parameters: share.parameters,
|
||||
};
|
||||
const token = createToken(data, secret());
|
||||
|
||||
return json({ ...data, token });
|
||||
80
src/app/api/share/id/[shareId]/route.ts
Normal file
80
src/app/api/share/id/[shareId]/route.ts
Normal 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();
|
||||
}
|
||||
39
src/app/api/share/route.ts
Normal file
39
src/app/api/share/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ export async function GET(request: Request) {
|
|||
export async function POST(request: Request) {
|
||||
const schema = z.object({
|
||||
name: z.string().max(50),
|
||||
ownerId: z.uuid().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
@ -40,7 +41,7 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const { name } = body;
|
||||
const { name, ownerId } = body;
|
||||
|
||||
const team = await createTeam(
|
||||
{
|
||||
|
|
@ -48,7 +49,7 @@ export async function POST(request: Request) {
|
|||
name,
|
||||
accessCode: `team_${getRandomChars(16)}`,
|
||||
},
|
||||
auth.user.id,
|
||||
ownerId ?? auth.user.id,
|
||||
);
|
||||
|
||||
return json(team);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import { hashPassword } from '@/lib/password';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, ok, unauthorized } from '@/lib/response';
|
||||
import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response';
|
||||
import { userRoleParam } from '@/lib/schema';
|
||||
import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
|
||||
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
|
||||
|
|
@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
|
|||
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||
const schema = z.object({
|
||||
username: z.string().max(255).optional(),
|
||||
password: z.string().max(255).optional(),
|
||||
password: z.string().min(8).max(255).optional(),
|
||||
role: userRoleParam.optional(),
|
||||
});
|
||||
|
||||
|
|
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
|
|||
|
||||
const user = await getUser(userId);
|
||||
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
|
||||
if (password) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
|
|||
import { hashPassword } from '@/lib/password';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, unauthorized } from '@/lib/response';
|
||||
import { userRoleParam } from '@/lib/schema';
|
||||
import { canCreateUser } from '@/permissions';
|
||||
import { createUser, getUserByUsername } from '@/queries/prisma';
|
||||
|
||||
|
|
@ -11,8 +12,8 @@ export async function POST(request: Request) {
|
|||
const schema = z.object({
|
||||
id: z.uuid().optional(),
|
||||
username: z.string().max(255),
|
||||
password: z.string(),
|
||||
role: z.string().regex(/admin|user|view-only/i),
|
||||
password: z.string().min(8).max(255),
|
||||
role: userRoleParam,
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { SHARE_ID_REGEX } from '@/lib/constants';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||
import { json, ok, unauthorized } from '@/lib/response';
|
||||
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
|
||||
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
|
||||
|
||||
|
|
@ -33,7 +32,6 @@ export async function POST(
|
|||
const schema = z.object({
|
||||
name: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
@ -43,23 +41,15 @@ export async function POST(
|
|||
}
|
||||
|
||||
const { websiteId } = await params;
|
||||
const { name, domain, shareId } = body;
|
||||
const { name, domain } = body;
|
||||
|
||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
const website = await updateWebsite(websiteId, { name, domain, shareId });
|
||||
const website = await updateWebsite(websiteId, { name, domain });
|
||||
|
||||
return Response.json(website);
|
||||
} catch (e: any) {
|
||||
if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
|
||||
return badRequest({ message: 'That share ID is already taken.' });
|
||||
}
|
||||
|
||||
return serverError(e);
|
||||
}
|
||||
return Response.json(website);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||
import { uuid } from '@/lib/crypto';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema';
|
||||
import { searchParams, segmentParamSchema, segmentTypeParam } from '@/lib/schema';
|
||||
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
|
||||
import { createSegment, getWebsiteSegments } from '@/queries/prisma';
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ export async function POST(
|
|||
const schema = z.object({
|
||||
type: segmentTypeParam,
|
||||
name: z.string().max(200),
|
||||
parameters: anyObjectParam,
|
||||
parameters: segmentParamSchema,
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
|
|||
74
src/app/api/websites/[websiteId]/shares/route.ts
Normal file
74
src/app/api/websites/[websiteId]/shares/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -31,7 +31,11 @@ export async function GET(
|
|||
|
||||
const data = await getWebsiteStats(websiteId, filters);
|
||||
|
||||
const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate);
|
||||
const { startDate, endDate } = getCompareDate(
|
||||
filters.compare ?? 'prev',
|
||||
filters.startDate,
|
||||
filters.endDate,
|
||||
);
|
||||
|
||||
const comparison = await getWebsiteStats(websiteId, {
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import redis from '@/lib/redis';
|
||||
import { fetchAccount } from '@/lib/load';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams } from '@/lib/schema';
|
||||
|
|
@ -52,7 +52,7 @@ export async function POST(request: Request) {
|
|||
const { id, name, domain, shareId, teamId } = body;
|
||||
|
||||
if (process.env.CLOUD_MODE && !teamId) {
|
||||
const account = await redis.client.get(`account:${auth.user.id}`);
|
||||
const account = await fetchAccount(auth.user.id);
|
||||
|
||||
if (!account?.hasSubscription) {
|
||||
const count = await getWebsiteCount(auth.user.id);
|
||||
|
|
|
|||
143
src/app/share/[...shareId]/ShareNav.tsx
Normal file
143
src/app/share/[...shareId]/ShareNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,18 @@
|
|||
'use client';
|
||||
import { Column, useTheme } from '@umami/react-zen';
|
||||
import { Column, Grid, useTheme } from '@umami/react-zen';
|
||||
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 { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||
|
|
@ -8,8 +20,26 @@ import { PageBody } from '@/components/common/PageBody';
|
|||
import { useShareTokenQuery } from '@/components/hooks';
|
||||
import { Footer } from './Footer';
|
||||
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 { setTheme } = useTheme();
|
||||
|
||||
|
|
@ -26,16 +56,42 @@ export function SharePage({ shareId }) {
|
|||
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 (
|
||||
<Column backgroundColor="2">
|
||||
<PageBody gap>
|
||||
<Header />
|
||||
<WebsiteProvider websiteId={shareToken.websiteId}>
|
||||
<WebsiteHeader showActions={false} />
|
||||
<WebsitePage websiteId={shareToken.websiteId} />
|
||||
</WebsiteProvider>
|
||||
<Footer />
|
||||
</PageBody>
|
||||
<Header />
|
||||
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
|
||||
<Column
|
||||
display={{ xs: 'none', lg: 'flex' }}
|
||||
width="240px"
|
||||
height="100%"
|
||||
border="right"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { SharePage } from './SharePage';
|
|||
|
||||
export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) {
|
||||
const { shareId } = await params;
|
||||
const [slug, ...path] = shareId;
|
||||
|
||||
return <SharePage shareId={shareId[0]} />;
|
||||
return <SharePage shareId={slug} path={path.join('/')} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export * from './queries/useWebsiteSegmentsQuery';
|
|||
export * from './queries/useWebsiteSessionQuery';
|
||||
export * from './queries/useWebsiteSessionStatsQuery';
|
||||
export * from './queries/useWebsiteSessionsQuery';
|
||||
export * from './queries/useWebsiteSharesQuery';
|
||||
export * from './queries/useWebsiteStatsQuery';
|
||||
export * from './queries/useWebsitesQuery';
|
||||
export * from './queries/useWebsiteValuesQuery';
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function useEventDataValuesQuery(
|
|||
return useQuery<any>({
|
||||
queryKey: [
|
||||
'websites:event-data:values',
|
||||
{ websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters },
|
||||
{ websiteId, startAt, endAt, unit, timezone, ...filters, event, propertyName },
|
||||
],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data/values`, {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function useResultQuery<T = any>(
|
|||
) {
|
||||
const { websiteId, ...parameters } = params;
|
||||
const { post, useQuery } = useApi();
|
||||
const { startDate, endDate, timezone } = useDateParameters();
|
||||
const { startDate, endDate, timezone, unit } = useDateParameters();
|
||||
const filters = useFilterParameters();
|
||||
|
||||
return useQuery<T>({
|
||||
|
|
@ -22,6 +22,7 @@ export function useResultQuery<T = any>(
|
|||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
unit,
|
||||
...params,
|
||||
...filters,
|
||||
},
|
||||
|
|
@ -35,6 +36,7 @@ export function useResultQuery<T = any>(
|
|||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
unit,
|
||||
...parameters,
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useApi } from '../useApi';
|
|||
|
||||
const selector = (state: { shareToken: string }) => state.shareToken;
|
||||
|
||||
export function useShareTokenQuery(shareId: string): {
|
||||
export function useShareTokenQuery(slug: string): {
|
||||
shareToken: any;
|
||||
isLoading?: boolean;
|
||||
error?: Error;
|
||||
|
|
@ -11,9 +11,9 @@ export function useShareTokenQuery(shareId: string): {
|
|||
const shareToken = useApp(selector);
|
||||
const { get, useQuery } = useApi();
|
||||
const { isLoading, error } = useQuery({
|
||||
queryKey: ['share', shareId],
|
||||
queryKey: ['share', slug],
|
||||
queryFn: async () => {
|
||||
const data = await get(`/share/${shareId}`);
|
||||
const data = await get(`/share/${slug}`);
|
||||
|
||||
setShareToken(data);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
|
|||
options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||
const { startAt, endAt } = useDateParameters();
|
||||
const filters = useFilterParameters();
|
||||
|
||||
return useQuery<WebsiteExpandedMetricsData>({
|
||||
|
|
@ -29,8 +29,6 @@ export function useWebsiteExpandedMetricsQuery(
|
|||
websiteId,
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...filters,
|
||||
...params,
|
||||
},
|
||||
|
|
@ -39,8 +37,6 @@ export function useWebsiteExpandedMetricsQuery(
|
|||
get(`/websites/${websiteId}/metrics/expanded`, {
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...filters,
|
||||
...params,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
|
|||
options?: ReactQueryOptions<WebsiteMetricsData>,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||
const { startAt, endAt } = useDateParameters();
|
||||
const filters = useFilterParameters();
|
||||
|
||||
return useQuery<WebsiteMetricsData>({
|
||||
|
|
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
|
|||
websiteId,
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...filters,
|
||||
...params,
|
||||
},
|
||||
|
|
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
|
|||
get(`/websites/${websiteId}/metrics`, {
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...filters,
|
||||
...params,
|
||||
}),
|
||||
|
|
|
|||
20
src/components/hooks/queries/useWebsiteSharesQuery.ts
Normal file
20
src/components/hooks/queries/useWebsiteSharesQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -19,17 +19,16 @@ export interface WebsiteStatsData {
|
|||
}
|
||||
|
||||
export function useWebsiteStatsQuery(
|
||||
websiteId: string,
|
||||
{ websiteId, compare }: { websiteId: string; compare?: string },
|
||||
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
||||
const { startAt, endAt } = useDateParameters();
|
||||
const filters = useFilterParameters();
|
||||
|
||||
return useQuery<WebsiteStatsData>({
|
||||
queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
|
||||
queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
|
||||
queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
|
||||
enabled: !!websiteId,
|
||||
...options,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string,
|
|||
return useQuery({
|
||||
queryKey: [
|
||||
'sessions',
|
||||
{ websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters },
|
||||
{ websiteId, modified, startAt, endAt, timezone, ...params, ...filters },
|
||||
],
|
||||
queryFn: () => {
|
||||
return get(`/websites/${websiteId}/sessions/weekly`, {
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone,
|
||||
...params,
|
||||
...filters,
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import { getItem } from '@/lib/storage';
|
|||
|
||||
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
|
||||
const {
|
||||
query: { date = '', offset = 0, compare = 'prev' },
|
||||
query: { date = '', unit = '', offset = 0, compare = 'prev' },
|
||||
} = useNavigation();
|
||||
const { locale } = useLocale();
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
const dateRangeObject = parseDateRange(
|
||||
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
|
||||
unit,
|
||||
locale,
|
||||
options.timezone,
|
||||
);
|
||||
|
|
@ -21,12 +21,13 @@ export function useDateRange(options: { ignoreOffset?: boolean; timezone?: strin
|
|||
return !options.ignoreOffset && offset
|
||||
? getOffsetDateRange(dateRangeObject, +offset)
|
||||
: dateRangeObject;
|
||||
}, [date, offset, options]);
|
||||
}, [date, unit, offset, options]);
|
||||
|
||||
const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
|
||||
|
||||
return {
|
||||
date,
|
||||
unit,
|
||||
offset,
|
||||
compare,
|
||||
isAllTime: date.endsWith(`:all`),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export function useFields() {
|
|||
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
|
||||
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
|
||||
{ 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: 'event', type: 'string', label: formatMessage(labels.event) },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export function useFilterParameters() {
|
|||
event,
|
||||
tag,
|
||||
hostname,
|
||||
distinctId,
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
|
|
@ -42,6 +43,7 @@ export function useFilterParameters() {
|
|||
event,
|
||||
tag,
|
||||
hostname,
|
||||
distinctId,
|
||||
search,
|
||||
segment,
|
||||
cohort,
|
||||
|
|
@ -61,6 +63,7 @@ export function useFilterParameters() {
|
|||
event,
|
||||
tag,
|
||||
hostname,
|
||||
distinctId,
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
|
|||
websiteId={websiteId}
|
||||
value={currentFilters}
|
||||
onChange={setCurrentFilters}
|
||||
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
|
||||
exclude={
|
||||
excludeFilters ? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event'] : []
|
||||
}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="segments">
|
||||
|
|
|
|||
71
src/components/input/UnitFilter.tsx
Normal file
71
src/components/input/UnitFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/input/UserSelect.tsx
Normal file
71
src/components/input/UserSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ export function WebsiteDateFilter({
|
|||
}),
|
||||
);
|
||||
} else {
|
||||
router.push(updateParams({ date, offset: undefined }));
|
||||
router.push(updateParams({ date, offset: undefined, unit: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function WebsiteSelect({
|
|||
renderValue={renderValue}
|
||||
listProps={{
|
||||
renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
|
||||
style: { maxHeight: '400px' },
|
||||
style: { maxHeight: 'calc(42vh - 65px)' },
|
||||
}}
|
||||
>
|
||||
{({ id, name }: any) => <ListItem key={id}>{name}</ListItem>}
|
||||
|
|
|
|||
|
|
@ -245,7 +245,10 @@ export const labels = defineMessages({
|
|||
tag: { id: 'label.tag', defaultMessage: 'Tag' },
|
||||
segment: { id: 'label.segment', defaultMessage: 'Segment' },
|
||||
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
|
||||
minute: { id: 'label.minute', defaultMessage: 'Minute' },
|
||||
hour: { id: 'label.hour', defaultMessage: 'Hour' },
|
||||
day: { id: 'label.day', defaultMessage: 'Day' },
|
||||
month: { id: 'label.month', defaultMessage: 'Month' },
|
||||
date: { id: 'label.date', defaultMessage: 'Date' },
|
||||
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
|
||||
create: { id: 'label.create', defaultMessage: 'Create' },
|
||||
|
|
@ -351,6 +354,7 @@ export const labels = defineMessages({
|
|||
growth: { id: 'label.growth', defaultMessage: 'Growth' },
|
||||
account: { id: 'label.account', defaultMessage: 'Account' },
|
||||
application: { id: 'label.application', defaultMessage: 'Application' },
|
||||
version: { id: 'label.version', defaultMessage: 'Version' },
|
||||
saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' },
|
||||
saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' },
|
||||
analysis: { id: 'label.analysis', defaultMessage: 'Analysis' },
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const MetricCard = ({
|
|||
showChange = false,
|
||||
}: MetricCardProps) => {
|
||||
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 changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ export * from '@/app/(main)/teams/TeamAddForm';
|
|||
export * from '@/app/(main)/teams/TeamJoinForm';
|
||||
export * from '@/app/(main)/teams/TeamLeaveButton';
|
||||
export * from '@/app/(main)/teams/TeamLeaveForm';
|
||||
export * from '@/app/(main)/teams/TeamMemberAddForm';
|
||||
export * from '@/app/(main)/teams/TeamProvider';
|
||||
export * from '@/app/(main)/teams/TeamsAddButton';
|
||||
export * from '@/app/(main)/teams/TeamsDataTable';
|
||||
export * from '@/app/(main)/teams/TeamsHeader';
|
||||
export * from '@/app/(main)/teams/TeamsJoinButton';
|
||||
export * from '@/app/(main)/teams/TeamsMemberAddButton';
|
||||
export * from '@/app/(main)/teams/TeamsTable';
|
||||
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData';
|
||||
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@
|
|||
"label.event-name": "Име на събитие",
|
||||
"label.events": "Събития",
|
||||
"label.exists": "Съществува",
|
||||
"label.exit": "Exit URL",
|
||||
"label.exit": "URL за изход",
|
||||
"label.false": "Грешно",
|
||||
"label.field": "Поле",
|
||||
"label.fields": "Полета",
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
"label.last-days": "Последните {x} дни",
|
||||
"label.last-hours": "Последните {x} часа",
|
||||
"label.last-months": "Последните {x} месеца",
|
||||
"label.last-seen": "Last seen",
|
||||
"label.last-seen": "Последно видяно",
|
||||
"label.leave": "Напусни",
|
||||
"label.leave-team": "Напусни екип",
|
||||
"label.less-than": "По-малко от",
|
||||
|
|
@ -161,7 +161,7 @@
|
|||
"label.none": "Няма",
|
||||
"label.number-of-records": "{x} {x, plural, one {един} other {други}}",
|
||||
"label.ok": "Добре",
|
||||
"label.online": "Online",
|
||||
"label.online": "Онлайн",
|
||||
"label.organic-search": "Органично търсене",
|
||||
"label.organic-shopping": "Органично пазаруване",
|
||||
"label.organic-social": "Органични социални мрежи",
|
||||
|
|
@ -185,9 +185,9 @@
|
|||
"label.paths": "Пътища",
|
||||
"label.pixels": "Пиксели",
|
||||
"label.powered-by": "Поддържано от {name}",
|
||||
"label.previous": "Previous",
|
||||
"label.previous-period": "Previous period",
|
||||
"label.previous-year": "Previous year",
|
||||
"label.previous": "Предишен",
|
||||
"label.previous-period": "Предишен период",
|
||||
"label.previous-year": "Предишна година",
|
||||
"label.profile": "Профил",
|
||||
"label.properties": "Свойства",
|
||||
"label.property": "Свойство",
|
||||
|
|
@ -211,8 +211,8 @@
|
|||
"label.reset-website": "Нулирай уебсайт",
|
||||
"label.retention": "Привързване",
|
||||
"label.retention-description": "Измерете привързаността към вашия уебсайт, като проследявате колко често потребителите се връщат.",
|
||||
"label.revenue": "Revenue",
|
||||
"label.revenue-description": "Look into your revenue across time.",
|
||||
"label.revenue": "Приходи",
|
||||
"label.revenue-description": "Прегледайте приходите си във времето.",
|
||||
"label.role": "Роля",
|
||||
"label.run-query": "Изпълни запитване",
|
||||
"label.save": "Запази",
|
||||
|
|
@ -260,14 +260,14 @@
|
|||
"label.total": "Общо",
|
||||
"label.total-records": "Общо записи",
|
||||
"label.tracking-code": "Код за проследяване",
|
||||
"label.transactions": "Transactions",
|
||||
"label.transactions": "Транзакции",
|
||||
"label.transfer": "Прехвърли",
|
||||
"label.transfer-website": "Прехвърляне на уебсайт",
|
||||
"label.true": "Вярно",
|
||||
"label.type": "Вид",
|
||||
"label.unique": "Уникален",
|
||||
"label.unique-visitors": "Уникални посетители",
|
||||
"label.uniqueCustomers": "Unique Customers",
|
||||
"label.uniqueCustomers": "Уникални клиенти",
|
||||
"label.unknown": "Неизвестен",
|
||||
"label.untitled": "Без заглавие",
|
||||
"label.update": "Актуализирай",
|
||||
|
|
@ -282,7 +282,7 @@
|
|||
"label.view-only": "Само за преглед",
|
||||
"label.views": "Прегледи",
|
||||
"label.views-per-visit": "Прегледи на посещение",
|
||||
"label.visit-duration": "Visit duration",
|
||||
"label.visit-duration": "Продължителност на посещение",
|
||||
"label.visitors": "Посетители",
|
||||
"label.visits": "Посещения",
|
||||
"label.website": "Уебсайт",
|
||||
|
|
@ -292,8 +292,8 @@
|
|||
"label.yesterday": "Вчера",
|
||||
"message.action-confirmation": "Въведете {confirmation} в полето по-долу, за да потвърдите.",
|
||||
"message.active-users": "{x} {x, plural, one {активен един} other {активни други}}",
|
||||
"message.bad-request": "Bad request",
|
||||
"message.collected-data": "Collected data",
|
||||
"message.bad-request": "Невалидна заявка",
|
||||
"message.collected-data": "Събрани данни",
|
||||
"message.confirm-delete": "Сигурни ли сте, че искате да изтриете {target}?",
|
||||
"message.confirm-leave": "Сигурни ли сте, че искате да напуснете {target}?",
|
||||
"message.confirm-remove": "Сигурни ли сте, че искате да премахнете {target}?",
|
||||
|
|
@ -302,7 +302,7 @@
|
|||
"message.delete-website-warning": "Всички данни за уебсайта ще бъдат изтрити.",
|
||||
"message.error": "Възникна грешка.",
|
||||
"message.event-log": "{event} на {url}",
|
||||
"message.forbidden": "Forbidden",
|
||||
"message.forbidden": "Забранено",
|
||||
"message.go-to-settings": "Отидете в настройките",
|
||||
"message.incorrect-username-password": "Неправилно потребителско име и/или парола.",
|
||||
"message.invalid-domain": "Невалиден домейн. Не включвайте http/https.",
|
||||
|
|
@ -316,13 +316,13 @@
|
|||
"message.no-teams": "Няма създадени екипи.",
|
||||
"message.no-users": "Няма потребители.",
|
||||
"message.no-websites-configured": "Нямате конфигурирани уебсайтове.",
|
||||
"message.not-found": "Not found",
|
||||
"message.nothing-selected": "Nothing selected.",
|
||||
"message.not-found": "Не е намерено",
|
||||
"message.nothing-selected": "Няма избрано.",
|
||||
"message.page-not-found": "Страницата не е намерена",
|
||||
"message.reset-website": "За да нулирате този уебсайт, въведете {confirmation} в полето по-долу, за да потвърдите.",
|
||||
"message.reset-website-warning": "Всички статистически данни за този уебсайт ще бъдат изтрити, но вашите настройки ще останат непроменени.",
|
||||
"message.saved": "Запазено.",
|
||||
"message.sever-error": "Server error",
|
||||
"message.sever-error": "Сървърна грешка",
|
||||
"message.share-url": "Статистиката за вашия уебсайт е публично достъпна на следния URL адрес:",
|
||||
"message.team-already-member": "Вече сте член на екипа.",
|
||||
"message.team-not-found": "Екипът не е намерен.",
|
||||
|
|
@ -332,7 +332,7 @@
|
|||
"message.transfer-user-website-to-team": "Изберете екипът на който да бъде прехвърлен уебсайта.",
|
||||
"message.transfer-website": "Прехвърли собствеността на уебсайта към вашия акаунт или към друг екип.",
|
||||
"message.triggered-event": "Активирано събитие",
|
||||
"message.unauthorized": "Unauthorized",
|
||||
"message.unauthorized": "Неоторизиран достъп",
|
||||
"message.user-deleted": "Потребителят е изтрит.",
|
||||
"message.viewed-page": "Страницата е видяна",
|
||||
"message.visitor-log": "Посетител от {country}, използващ {browser} на {os} {device}"
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"label.compare-dates": "Vertaa päivämääriä",
|
||||
"label.confirm": "Vahvista",
|
||||
"label.confirm-password": "Vahvista salasana",
|
||||
"label.contains": "Contains",
|
||||
"label.contains": "Sisältää",
|
||||
"label.content": "Sisältö",
|
||||
"label.continue": "Jatka",
|
||||
"label.conversion": "Konversio",
|
||||
|
|
@ -96,7 +96,7 @@
|
|||
"label.false": "Epätosi",
|
||||
"label.field": "Kenttä",
|
||||
"label.fields": "Kentät",
|
||||
"label.filter": "Filter",
|
||||
"label.filter": "Suodatin",
|
||||
"label.filter-combined": "Yhdistetty",
|
||||
"label.filter-raw": "Käsittelemätön",
|
||||
"label.filters": "Suodattimet",
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
"label.members": "Jäsenet",
|
||||
"label.min": "Minimi",
|
||||
"label.mobile": "Puhelin",
|
||||
"label.model": "Model",
|
||||
"label.model": "Malli",
|
||||
"label.more": "Lisää",
|
||||
"label.my-account": "Oma tili",
|
||||
"label.my-websites": "Omat verkkosivut",
|
||||
|
|
@ -184,9 +184,9 @@
|
|||
"label.paths": "Polut",
|
||||
"label.pixels": "Pikselit",
|
||||
"label.powered-by": "Voimanlähteenä {name}",
|
||||
"label.previous": "Previous",
|
||||
"label.previous-period": "Previous period",
|
||||
"label.previous-year": "Previous year",
|
||||
"label.previous": "Edellinen",
|
||||
"label.previous-period": "Edellinen ajanjakso",
|
||||
"label.previous-year": "Edellinen vuosi",
|
||||
"label.profile": "Profiili",
|
||||
"label.properties": "Ominaisuudet",
|
||||
"label.property": "Ominaisuus",
|
||||
|
|
@ -195,16 +195,16 @@
|
|||
"label.query-parameters": "Kyselyn parametrit",
|
||||
"label.realtime": "Juuri nyt",
|
||||
"label.referral": "Viittaus",
|
||||
"label.referrer": "Referrer",
|
||||
"label.referrer": "Viittaaja",
|
||||
"label.referrers": "Viittaajat",
|
||||
"label.refresh": "Päivitä",
|
||||
"label.regenerate": "Regenerate",
|
||||
"label.region": "Region",
|
||||
"label.regions": "Regions",
|
||||
"label.regenerate": "Luo uudelleen",
|
||||
"label.region": "Alue",
|
||||
"label.regions": "Alueet",
|
||||
"label.remaining": "Jäljellä",
|
||||
"label.remove": "Remove",
|
||||
"label.remove-member": "Remove member",
|
||||
"label.reports": "Reports",
|
||||
"label.remove": "Poista",
|
||||
"label.remove-member": "Poista jäsen",
|
||||
"label.reports": "Raportit",
|
||||
"label.required": "Vaaditaan",
|
||||
"label.reset": "Nollaa",
|
||||
"label.reset-website": "Nollaa tilastot",
|
||||
|
|
@ -212,19 +212,19 @@
|
|||
"label.retention-description": "Measure your website stickiness by tracking how often users return.",
|
||||
"label.revenue": "Tulot",
|
||||
"label.revenue-description": "Katso tulosi ajan mittaan.",
|
||||
"label.role": "Role",
|
||||
"label.run-query": "Run query",
|
||||
"label.role": "Rooli",
|
||||
"label.run-query": "Suorita kysely",
|
||||
"label.save": "Tallenna",
|
||||
"label.screens": "Näytöt",
|
||||
"label.search": "Search",
|
||||
"label.select": "Select",
|
||||
"label.select-date": "Select date",
|
||||
"label.search": "Haku",
|
||||
"label.select": "Valitse",
|
||||
"label.select-date": "Valitse päivämäärä",
|
||||
"label.select-filter": "Valitse suodatin",
|
||||
"label.select-role": "Select role",
|
||||
"label.select-website": "Select website",
|
||||
"label.select-role": "Valitse rooli",
|
||||
"label.select-website": "Valitse verkkosivu",
|
||||
"label.session": "Istunto",
|
||||
"label.session-data": "Istuntotiedot",
|
||||
"label.sessions": "Sessions",
|
||||
"label.sessions": "Istunnot",
|
||||
"label.settings": "Asetukset",
|
||||
"label.share": "Jaa",
|
||||
"label.share-url": "Jaa URL",
|
||||
|
|
@ -233,107 +233,107 @@
|
|||
"label.sources": "Lähteet",
|
||||
"label.start-step": "Aloitusvaihe",
|
||||
"label.steps": "Vaiheet",
|
||||
"label.sum": "Sum",
|
||||
"label.sum": "Summa",
|
||||
"label.tablet": "Tabletti",
|
||||
"label.tag": "Tunniste",
|
||||
"label.tags": "Tunnisteet",
|
||||
"label.team": "Team",
|
||||
"label.team-id": "Team ID",
|
||||
"label.team-manager": "Team manager",
|
||||
"label.team-member": "Team member",
|
||||
"label.team-name": "Team name",
|
||||
"label.team-owner": "Team owner",
|
||||
"label.team": "Tiimi",
|
||||
"label.team-id": "Tiimin ID",
|
||||
"label.team-manager": "Tiimin johtaja",
|
||||
"label.team-member": "Tiimin jäsen",
|
||||
"label.team-name": "Tiimin nimi",
|
||||
"label.team-owner": "Tiimin omistaja",
|
||||
"label.team-settings": "Tiimin asetukset",
|
||||
"label.team-view-only": "Team view only",
|
||||
"label.team-websites": "Team websites",
|
||||
"label.teams": "Teams",
|
||||
"label.team-websites": "Tiimin verkkosivut",
|
||||
"label.teams": "Tiimit",
|
||||
"label.terms": "Ehdot",
|
||||
"label.theme": "Teema",
|
||||
"label.this-month": "Tämä kuukausi",
|
||||
"label.this-week": "Tämä viikko",
|
||||
"label.this-year": "Tämä vuosi",
|
||||
"label.timezone": "Aikavyöhyke",
|
||||
"label.title": "Title",
|
||||
"label.title": "Otsikko",
|
||||
"label.today": "Tänään",
|
||||
"label.toggle-charts": "Kytke kaaviot päälle/pois",
|
||||
"label.total": "Total",
|
||||
"label.total-records": "Total records",
|
||||
"label.total": "Yhteensä",
|
||||
"label.total-records": "Tietueita yhteensä",
|
||||
"label.tracking-code": "Seurantakoodi",
|
||||
"label.transactions": "Transactions",
|
||||
"label.transfer": "Transfer",
|
||||
"label.transfer-website": "Transfer website",
|
||||
"label.true": "True",
|
||||
"label.type": "Type",
|
||||
"label.unique": "Unique",
|
||||
"label.transactions": "Transaktiot",
|
||||
"label.transfer": "Siirrä",
|
||||
"label.transfer-website": "Siirrä verkkosivu",
|
||||
"label.true": "Tosi",
|
||||
"label.type": "Tyyppi",
|
||||
"label.unique": "Uniikki",
|
||||
"label.unique-visitors": "Yksittäiset kävijät",
|
||||
"label.uniqueCustomers": "Unique Customers",
|
||||
"label.uniqueCustomers": "Uniikit asiakkaat",
|
||||
"label.unknown": "Tuntematon",
|
||||
"label.untitled": "Untitled",
|
||||
"label.update": "Update",
|
||||
"label.user": "User",
|
||||
"label.untitled": "Nimetön",
|
||||
"label.update": "Päivitä",
|
||||
"label.user": "Käyttäjä",
|
||||
"label.username": "Käyttäjänimi",
|
||||
"label.users": "Users",
|
||||
"label.users": "Käyttäjät",
|
||||
"label.utm": "UTM",
|
||||
"label.utm-description": "Track your campaigns through UTM parameters.",
|
||||
"label.value": "Value",
|
||||
"label.view": "View",
|
||||
"label.utm-description": "Seuraa kampanjoitasi UTM-parametrien avulla.",
|
||||
"label.value": "Arvo",
|
||||
"label.view": "Näytä",
|
||||
"label.view-details": "Katso tiedot",
|
||||
"label.view-only": "View only",
|
||||
"label.view-only": "Vain katselu",
|
||||
"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.visitors": "Vierailijat",
|
||||
"label.visits": "Visits",
|
||||
"label.website": "Website",
|
||||
"label.website-id": "Website ID",
|
||||
"label.visits": "Vierailut",
|
||||
"label.website": "Verkkosivu",
|
||||
"label.website-id": "Verkkosivun ID",
|
||||
"label.websites": "Verkkosivut",
|
||||
"label.window": "Window",
|
||||
"label.yesterday": "Yesterday",
|
||||
"label.behavior": "Behavior",
|
||||
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
|
||||
"label.window": "Ikkuna",
|
||||
"label.yesterday": "Eilen",
|
||||
"label.behavior": "Käyttäytyminen",
|
||||
"message.action-confirmation": "Kirjoita {confirmation} alla olevaan kenttään vahvistaaksesi.",
|
||||
"message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
|
||||
"message.bad-request": "Bad request",
|
||||
"message.collected-data": "Collected data",
|
||||
"message.bad-request": "Virheellinen pyyntö",
|
||||
"message.collected-data": "Kerätty data",
|
||||
"message.confirm-delete": "Haluatko varmasti poistaa sivuston {target}?",
|
||||
"message.confirm-leave": "Are you sure you want to leave {target}?",
|
||||
"message.confirm-remove": "Are you sure you want to remove {target}?",
|
||||
"message.confirm-leave": "Haluatko varmasti poistua {target}?",
|
||||
"message.confirm-remove": "Haluatko varmasti poistaa {target}?",
|
||||
"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.error": "Jotain meni pieleen.",
|
||||
"message.event-log": "{event} on {url}",
|
||||
"message.forbidden": "Forbidden",
|
||||
"message.forbidden": "Kielletty",
|
||||
"message.go-to-settings": "Mene asetuksiin",
|
||||
"message.incorrect-username-password": "Väärä käyttäjänimi/salasana.",
|
||||
"message.invalid-domain": "Virheellinen verkkotunnus",
|
||||
"message.min-password-length": "Minimum length of {n} characters",
|
||||
"message.new-version-available": "A new version of Umami {version} is available!",
|
||||
"message.min-password-length": "Vähimmäispituus {n} merkkiä",
|
||||
"message.new-version-available": "Umamista on saatavilla uusi versio {version}!",
|
||||
"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-results-found": "No results were found.",
|
||||
"message.no-team-websites": "This team does not have any websites.",
|
||||
"message.no-teams": "You have not created any teams.",
|
||||
"message.no-users": "There are no users.",
|
||||
"message.no-results-found": "Tuloksia ei löytynyt.",
|
||||
"message.no-team-websites": "Tällä tiimillä ei ole verkkosivustoja.",
|
||||
"message.no-teams": "Et ole luonut yhtään tiimiä.",
|
||||
"message.no-users": "Käyttäjiä ei ole.",
|
||||
"message.no-websites-configured": "Sinulla ei ole määritettyjä verkkosivustoja.",
|
||||
"message.not-found": "Not found",
|
||||
"message.nothing-selected": "Nothing selected.",
|
||||
"message.not-found": "Ei löytynyt",
|
||||
"message.nothing-selected": "Mitään ei ole valittu.",
|
||||
"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.saved": "Tallennettu onnistuneesti.",
|
||||
"message.sever-error": "Server error",
|
||||
"message.sever-error": "Palvelinvirhe",
|
||||
"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-not-found": "Team not found.",
|
||||
"message.team-websites-info": "Websites can be viewed by anyone on the team.",
|
||||
"message.team-already-member": "Olet jo tiimissä.",
|
||||
"message.team-not-found": "Tiimiä ei löydetty.",
|
||||
"message.team-websites-info": "Verkkosivuja voi tarkastella kuka tahansa tiimin jäsen.",
|
||||
"message.tracking-code": "Seurantakoodi",
|
||||
"message.transfer-team-website-to-user": "Transfer this website to your account?",
|
||||
"message.transfer-user-website-to-team": "Select the team to transfer this website to.",
|
||||
"message.transfer-website": "Transfer website ownership to your account or another team.",
|
||||
"message.triggered-event": "Triggered event",
|
||||
"message.unauthorized": "Unauthorized",
|
||||
"message.user-deleted": "User deleted.",
|
||||
"message.viewed-page": "Viewed page",
|
||||
"message.transfer-team-website-to-user": "Siirretäänkö tämä verkkosivu tilillesi?",
|
||||
"message.transfer-user-website-to-team": "Valitse tiimi, johon verkkosivusto siirretään.",
|
||||
"message.transfer-website": "Siirrä verkkosivusto tiliisi tai toiselle tiimille.",
|
||||
"message.triggered-event": "Laukaistu tapahtuma",
|
||||
"message.unauthorized": "Ei oikeuksia",
|
||||
"message.user-deleted": "Käyttäjä poistettu.",
|
||||
"message.viewed-page": "Katsottu sivu",
|
||||
"message.visitor-log": "Vierailija maasta {country} selaimella {browser} laitteella {os} {device}"
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"label.behavior": "行動",
|
||||
"label.boards": "ボード",
|
||||
"label.bounce-rate": "直帰率",
|
||||
"label.breakdown": "故障",
|
||||
"label.breakdown": "内訳",
|
||||
"label.browser": "ブラウザ",
|
||||
"label.browsers": "ブラウザ",
|
||||
"label.campaigns": "キャンペーン",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
{
|
||||
"label.access-code": "访问代码",
|
||||
"label.account": "账户",
|
||||
"label.action": "行为",
|
||||
"label.actions": "用户行为",
|
||||
"label.activity": "活动日志",
|
||||
"label.add": "添加",
|
||||
"label.add-board": "添加看板",
|
||||
"label.add-description": "添加描述",
|
||||
"label.add-link": "添加链接",
|
||||
"label.add-member": "添加成员",
|
||||
"label.add-pixel": "添加像素",
|
||||
"label.add-step": "添加步骤",
|
||||
"label.add-website": "添加网站",
|
||||
"label.admin": "管理员",
|
||||
|
|
@ -13,10 +17,13 @@
|
|||
"label.after": "之后",
|
||||
"label.all": "所有",
|
||||
"label.all-time": "所有时间段",
|
||||
"label.analysis": "分析",
|
||||
"label.analytics": "分析",
|
||||
"label.application": "应用",
|
||||
"label.apply": "应用",
|
||||
"label.attribution": "归因",
|
||||
"label.attribution-description": "查看用户如何与您的营销互动,以及是什么促成了转化。",
|
||||
"label.audience": "受众",
|
||||
"label.average": "平均",
|
||||
"label.back": "返回",
|
||||
"label.before": "之前",
|
||||
|
|
@ -29,11 +36,14 @@
|
|||
"label.campaigns": "活动",
|
||||
"label.cancel": "取消",
|
||||
"label.change-password": "修改密码",
|
||||
"label.channel": "渠道",
|
||||
"label.channels": "渠道",
|
||||
"label.chart": "图表",
|
||||
"label.cities": "市/县",
|
||||
"label.city": "市/县",
|
||||
"label.clear-all": "清除全部",
|
||||
"label.cohort": "队列",
|
||||
"label.cohorts": "队列",
|
||||
"label.compare": "比较",
|
||||
"label.compare-dates": "比较日期",
|
||||
"label.confirm": "确认",
|
||||
|
|
@ -53,6 +63,7 @@
|
|||
"label.create-user": "创建用户",
|
||||
"label.created": "已创建",
|
||||
"label.created-by": "创建者",
|
||||
"label.criteria": "条件",
|
||||
"label.currency": "货币",
|
||||
"label.current": "当前",
|
||||
"label.current-password": "当前密码",
|
||||
|
|
@ -70,24 +81,28 @@
|
|||
"label.delete-website": "删除网站",
|
||||
"label.description": "描述",
|
||||
"label.desktop": "台式机",
|
||||
"label.destination-url": "目标URL",
|
||||
"label.details": "详细信息",
|
||||
"label.device": "设备",
|
||||
"label.devices": "设备",
|
||||
"label.direct": "直接",
|
||||
"label.dismiss": "关闭",
|
||||
"label.distinct-id": "唯一ID",
|
||||
"label.documentation": "文档",
|
||||
"label.does-not-contain": "不包含",
|
||||
"label.does-not-include": "不包括",
|
||||
"label.doest-not-exist": "不存在",
|
||||
"label.domain": "域名",
|
||||
"label.download": "下载",
|
||||
"label.dropoff": "丢弃",
|
||||
"label.edit": "编辑",
|
||||
"label.edit-dashboard": "编辑仪表盘",
|
||||
"label.edit-member": "编辑成员",
|
||||
"label.email": "Email",
|
||||
"label.email": "邮箱",
|
||||
"label.enable-share-url": "启用共享链接",
|
||||
"label.end-step": "结束步骤",
|
||||
"label.entry": "入口 URL",
|
||||
"label.environment": "环境",
|
||||
"label.event": "事件",
|
||||
"label.event-data": "事件数据",
|
||||
"label.event-name": "事件名称",
|
||||
|
|
@ -112,11 +127,13 @@
|
|||
"label.greater-than": "大于",
|
||||
"label.greater-than-equals": "大于或等于",
|
||||
"label.grouped": "分组",
|
||||
"label.growth": "增长",
|
||||
"label.hostname": "主机名",
|
||||
"label.includes": "包括",
|
||||
"label.insight": "洞察",
|
||||
"label.insights": "见解",
|
||||
"label.insights-description": "通过使用筛选器和划分时间段来更深入地研究数据。",
|
||||
"label.invalid-url": "无效URL",
|
||||
"label.is": "等于",
|
||||
"label.is-false": "否",
|
||||
"label.is-not": "不等于",
|
||||
|
|
@ -140,7 +157,9 @@
|
|||
"label.leave-team": "离开团队",
|
||||
"label.less-than": "少于",
|
||||
"label.less-than-equals": "少于等于",
|
||||
"label.link": "链接",
|
||||
"label.links": "链接",
|
||||
"label.location": "位置",
|
||||
"label.login": "登录",
|
||||
"label.logout": "退出",
|
||||
"label.manage": "管理",
|
||||
|
|
@ -161,7 +180,7 @@
|
|||
"label.none": "无",
|
||||
"label.number-of-records": "{x} {x, plural, one {record} other {records}}",
|
||||
"label.ok": "好的",
|
||||
"label.online": "Online",
|
||||
"label.online": "在线",
|
||||
"label.organic-search": "自然搜索",
|
||||
"label.organic-shopping": "自然购物",
|
||||
"label.organic-social": "自然社交",
|
||||
|
|
@ -183,19 +202,22 @@
|
|||
"label.password": "密码",
|
||||
"label.path": "路径",
|
||||
"label.paths": "路径",
|
||||
"label.pixel": "像素",
|
||||
"label.pixels": "像素",
|
||||
"label.powered-by": "由 {name} 提供支持",
|
||||
"label.preferences": "偏好",
|
||||
"label.previous": "先前",
|
||||
"label.previous-period": "上一时期",
|
||||
"label.previous-year": "上一年",
|
||||
"label.profile": "个人资料",
|
||||
"label.profiles": "个人资料",
|
||||
"label.properties": "属性",
|
||||
"label.property": "属性",
|
||||
"label.queries": "查询",
|
||||
"label.query": "查询",
|
||||
"label.query-parameters": "查询参数",
|
||||
"label.realtime": "实时",
|
||||
"label.referral": "Referral",
|
||||
"label.referral": "来源",
|
||||
"label.referrer": "来源",
|
||||
"label.referrers": "来源域名",
|
||||
"label.refresh": "刷新",
|
||||
|
|
@ -216,8 +238,13 @@
|
|||
"label.role": "角色",
|
||||
"label.run-query": "查询",
|
||||
"label.save": "保存",
|
||||
"label.save-cohort": "保存为群组",
|
||||
"label.save-segment": "保存为细分",
|
||||
"label.screen": "屏幕",
|
||||
"label.screens": "屏幕尺寸",
|
||||
"label.search": "搜索",
|
||||
"label.segment": "细分",
|
||||
"label.segments": "细分",
|
||||
"label.select": "选择",
|
||||
"label.select-date": "选择日期",
|
||||
"label.select-filter": "选择筛选器",
|
||||
|
|
@ -235,6 +262,9 @@
|
|||
"label.start-step": "开始步骤",
|
||||
"label.steps": "步骤",
|
||||
"label.sum": "总和",
|
||||
"label.support": "支持",
|
||||
"label.switch-account": "切换账户",
|
||||
"label.table": "表格",
|
||||
"label.tablet": "平板",
|
||||
"label.tag": "标签",
|
||||
"label.tags": "标签",
|
||||
|
|
@ -260,6 +290,7 @@
|
|||
"label.total": "总数",
|
||||
"label.total-records": "总记录数",
|
||||
"label.tracking-code": "跟踪代码",
|
||||
"label.traffic": "流量",
|
||||
"label.transactions": "交易",
|
||||
"label.transfer": "转移",
|
||||
"label.transfer-website": "转移网站",
|
||||
|
|
@ -292,7 +323,7 @@
|
|||
"label.yesterday": "昨天",
|
||||
"message.action-confirmation": "请在下方输入框中输入 {confirmation} 以确认操作。",
|
||||
"message.active-users": "当前在线 {x} 位访客",
|
||||
"message.bad-request": "Bad request",
|
||||
"message.bad-request": "请求错误",
|
||||
"message.collected-data": "已收集的数据",
|
||||
"message.confirm-delete": "你确定要删除 {target} 吗?",
|
||||
"message.confirm-leave": "你确定要离开 {target} 吗?",
|
||||
|
|
@ -302,7 +333,7 @@
|
|||
"message.delete-website-warning": "所有相关数据将会被删除。",
|
||||
"message.error": "发生错误。",
|
||||
"message.event-log": "{url} 上的 {event}",
|
||||
"message.forbidden": "Forbidden",
|
||||
"message.forbidden": "禁止访问",
|
||||
"message.go-to-settings": "去设置",
|
||||
"message.incorrect-username-password": "用户名或密码不正确。",
|
||||
"message.invalid-domain": "无效域名",
|
||||
|
|
@ -316,13 +347,13 @@
|
|||
"message.no-teams": "您尚未创建任何团队。",
|
||||
"message.no-users": "暂无用户。",
|
||||
"message.no-websites-configured": "你还没有设置任何网站。",
|
||||
"message.not-found": "Not found",
|
||||
"message.nothing-selected": "Nothing selected.",
|
||||
"message.not-found": "未找到",
|
||||
"message.nothing-selected": "未选择",
|
||||
"message.page-not-found": "页面未找到。",
|
||||
"message.reset-website": "如确定要重置该网站,请在下面输入 {confirmation} 以确认。",
|
||||
"message.reset-website-warning": "此网站的所有统计数据将被删除,但您的跟踪代码将保持不变。",
|
||||
"message.saved": "保存成功。",
|
||||
"message.sever-error": "Server error",
|
||||
"message.sever-error": "服务器错误",
|
||||
"message.share-url": "这是 {target} 的共享链接。",
|
||||
"message.team-already-member": "你已是该团队的成员。",
|
||||
"message.team-not-found": "未找到团队。",
|
||||
|
|
@ -332,7 +363,7 @@
|
|||
"message.transfer-user-website-to-team": "选择要转移此网站的团队。",
|
||||
"message.transfer-website": "将网站所有权转移到您的账户或其他团队。",
|
||||
"message.triggered-event": "触发事件",
|
||||
"message.unauthorized": "Unauthorized",
|
||||
"message.unauthorized": "未授权",
|
||||
"message.user-deleted": "用户已删除。",
|
||||
"message.viewed-page": "已浏览页面",
|
||||
"message.visitor-log": "来自 {country} 的访客在搭载 {os} 的 {device} 上使用 {browser} 浏览器进行访问。"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import debug from 'debug';
|
||||
import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
|
||||
import { secret } from '@/lib/crypto';
|
||||
import { getRandomChars } from '@/lib/generate';
|
||||
import { createAuthKey, secret } from '@/lib/crypto';
|
||||
import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
|
||||
import redis from '@/lib/redis';
|
||||
import { ensureArray } from '@/lib/utils';
|
||||
|
|
@ -53,7 +52,7 @@ export async function checkAuth(request: Request) {
|
|||
}
|
||||
|
||||
export async function saveAuth(data: any, expire = 0) {
|
||||
const authKey = `auth:${getRandomChars(32)}`;
|
||||
const authKey = `auth:${createAuthKey()}`;
|
||||
|
||||
if (redis.enabled) {
|
||||
await redis.client.set(authKey, data);
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
|
|||
|
||||
function getDateSQL(field: string, unit: string, timezone?: string) {
|
||||
if (timezone) {
|
||||
return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`;
|
||||
return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'), '${timezone}')`;
|
||||
}
|
||||
return `toDateTime(date_trunc('${unit}', ${field}))`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export const LOCALE_CONFIG = 'umami.locale';
|
|||
export const TIMEZONE_CONFIG = 'umami.timezone';
|
||||
export const DATE_RANGE_CONFIG = 'umami.date-range';
|
||||
export const THEME_CONFIG = 'umami.theme';
|
||||
export const CURRENCY_CONFIG = 'umami.currency';
|
||||
export const DASHBOARD_CONFIG = 'umami.dashboard';
|
||||
export const LAST_TEAM_CONFIG = 'umami.last-team';
|
||||
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_PAGE_SIZE = 20;
|
||||
export const DEFAULT_DATE_COMPARE = 'prev';
|
||||
export const DEFAULT_CURRENCY = 'USD';
|
||||
|
||||
export const REALTIME_RANGE = 30;
|
||||
export const REALTIME_INTERVAL = 10000;
|
||||
|
|
@ -53,6 +55,7 @@ export const SESSION_COLUMNS = [
|
|||
'country',
|
||||
'city',
|
||||
'region',
|
||||
'distinctId',
|
||||
];
|
||||
|
||||
export const SEGMENT_TYPES = {
|
||||
|
|
@ -67,6 +70,7 @@ export const FILTER_COLUMNS = {
|
|||
referrer: 'referrer_domain',
|
||||
domain: 'referrer_domain',
|
||||
hostname: 'hostname',
|
||||
distinctId: 'distinct_id',
|
||||
title: 'page_title',
|
||||
query: 'url_query',
|
||||
os: 'os',
|
||||
|
|
@ -93,6 +97,13 @@ export const EVENT_TYPE = {
|
|||
pixelEvent: 4,
|
||||
} as const;
|
||||
|
||||
export const ENTITY_TYPE = {
|
||||
website: 1,
|
||||
link: 2,
|
||||
pixel: 3,
|
||||
board: 4,
|
||||
} as const;
|
||||
|
||||
export const DATA_TYPE = {
|
||||
string: 1,
|
||||
number: 2,
|
||||
|
|
|
|||
|
|
@ -63,3 +63,7 @@ export function uuid(...args: any) {
|
|||
|
||||
return process.env.USE_UUIDV7 ? v7() : v4();
|
||||
}
|
||||
|
||||
export function createAuthKey() {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
differenceInCalendarMonths,
|
||||
differenceInCalendarWeeks,
|
||||
differenceInCalendarYears,
|
||||
differenceInDays,
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
endOfDay,
|
||||
|
|
@ -136,7 +137,12 @@ export function parseDateValue(value: string) {
|
|||
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') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -146,7 +152,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
|
|||
|
||||
const startDate = new Date(+startTime);
|
||||
const endDate = new Date(+endTime);
|
||||
const unit = getMinimumUnit(startDate, endDate);
|
||||
const unit = getMinimumUnit(startDate, endDate, true);
|
||||
|
||||
return {
|
||||
startDate,
|
||||
|
|
@ -169,14 +175,14 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
|
|||
endDate: endOfHour(now),
|
||||
offset: 0,
|
||||
num: num || 1,
|
||||
unit,
|
||||
unit: unitValue || unit,
|
||||
value,
|
||||
};
|
||||
case 'day':
|
||||
return {
|
||||
startDate: num ? subDays(startOfDay(now), num) : startOfDay(now),
|
||||
endDate: endOfDay(now),
|
||||
unit: num ? 'day' : 'hour',
|
||||
unit: unitValue ? unitValue : num ? 'day' : 'hour',
|
||||
offset: 0,
|
||||
num: num || 1,
|
||||
value,
|
||||
|
|
@ -187,7 +193,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
|
|||
? subWeeks(startOfWeek(now, { locale: dateLocale }), num)
|
||||
: startOfWeek(now, { locale: dateLocale }),
|
||||
endDate: endOfWeek(now, { locale: dateLocale }),
|
||||
unit: 'day',
|
||||
unit: unitValue || 'day',
|
||||
offset: 0,
|
||||
num: num || 1,
|
||||
value,
|
||||
|
|
@ -196,7 +202,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
|
|||
return {
|
||||
startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now),
|
||||
endDate: endOfMonth(now),
|
||||
unit: num ? 'month' : 'day',
|
||||
unit: unitValue ? unitValue : num ? 'month' : 'day',
|
||||
offset: 0,
|
||||
num: num || 1,
|
||||
value,
|
||||
|
|
@ -205,7 +211,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
|
|||
return {
|
||||
startDate: num ? subYears(startOfYear(now), num) : startOfYear(now),
|
||||
endDate: endOfYear(now),
|
||||
unit: 'month',
|
||||
unit: unitValue || 'month',
|
||||
offset: 0,
|
||||
num: num || 1,
|
||||
value,
|
||||
|
|
@ -273,12 +279,20 @@ export function getAllowedUnits(startDate: Date, endDate: Date) {
|
|||
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) {
|
||||
return 'minute';
|
||||
} else if (differenceInHours(endDate, startDate) <= 48) {
|
||||
} else if (
|
||||
isDateRange
|
||||
? differenceInHours(endDate, startDate) <= 48
|
||||
: differenceInDays(endDate, startDate) <= 30
|
||||
) {
|
||||
return 'hour';
|
||||
} else if (differenceInCalendarMonths(endDate, startDate) <= 6) {
|
||||
} else if (differenceInCalendarMonths(endDate, startDate) <= 7) {
|
||||
return 'day';
|
||||
} else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
|
||||
return 'month';
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue