Compare commits

..

No commits in common. "1229663814c12fb02b3c5d7ac487ab04266a2d58" and "fd2e2047cdf400da1deccec062a570ed1540b4d5" have entirely different histories.

159 changed files with 2334 additions and 4285 deletions

View file

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

View file

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

2
.gitignore vendored
View file

@ -21,7 +21,6 @@ package-lock.json
/dist /dist
/generated /generated
/src/generated /src/generated
pm2.yml
# misc # misc
.DS_Store .DS_Store
@ -32,7 +31,6 @@ pm2.yml
.vscode .vscode
.tool-versions .tool-versions
.claude .claude
nul
# debug # debug
npm-debug.log* npm-debug.log*

View file

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

View file

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

2732
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

View file

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

View file

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

View file

@ -42,7 +42,7 @@ export function MobileNav() {
{({ close }) => { {({ close }) => {
return ( return (
<> <>
<NavMenu padding="3" onItemClick={close} border="bottom" width="100%"> <NavMenu padding="3" onItemClick={close} border="bottom">
<NavButton /> <NavButton />
{links.map(link => { {links.map(link => {
return ( return (

View file

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

View file

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

View file

@ -30,11 +30,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
}; };
return ( return (
<Form <Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}>
onSubmit={handleSubmit}
error={getMessage(error?.code)}
values={{ ...user, password: '' }}
>
<FormField name="username" label={formatMessage(labels.username)}> <FormField name="username" label={formatMessage(labels.username)}>
<TextField data-test="input-username" /> <TextField data-test="input-username" />
</FormField> </FormField>

View file

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

View file

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

View file

@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
import { useLinksQuery, useNavigation } from '@/components/hooks'; import { useLinksQuery, useNavigation } from '@/components/hooks';
import { LinksTable } from './LinksTable'; import { LinksTable } from './LinksTable';
export function LinksDataTable({ showActions = false }: { showActions?: boolean }) { export function LinksDataTable() {
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const query = useLinksQuery({ teamId }); const query = useLinksQuery({ teamId });
return ( return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}> <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <LinksTable data={data} showActions={showActions} />} {({ data }) => <LinksTable data={data} />}
</DataGrid> </DataGrid>
); );
} }

View file

@ -4,30 +4,21 @@ import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { LinkAddButton } from './LinkAddButton'; import { LinkAddButton } from './LinkAddButton';
export function LinksPage() { export function LinksPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.links)}> <PageHeader title={formatMessage(labels.links)}>
{showActions && <LinkAddButton teamId={teamId} />} <LinkAddButton teamId={teamId} />
</PageHeader> </PageHeader>
<Panel> <Panel>
<LinksDataTable showActions={showActions} /> <LinksDataTable />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -6,11 +6,7 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { LinkDeleteButton } from './LinkDeleteButton'; import { LinkDeleteButton } from './LinkDeleteButton';
import { LinkEditButton } from './LinkEditButton'; import { LinkEditButton } from './LinkEditButton';
export interface LinksTableProps extends DataTableProps { export function LinksTable(props: DataTableProps) {
showActions?: boolean;
}
export function LinksTable({ showActions, ...props }: LinksTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { websiteId, renderUrl } = useNavigation(); const { websiteId, renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('link'); const { getSlugUrl } = useSlug('link');
@ -40,18 +36,16 @@ export function LinksTable({ showActions, ...props }: LinksTableProps) {
<DataColumn id="created" label={formatMessage(labels.created)} width="200px"> <DataColumn id="created" label={formatMessage(labels.created)} width="200px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />} {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn> </DataColumn>
{showActions && ( <DataColumn id="action" align="end" width="100px">
<DataColumn id="action" align="end" width="100px"> {({ id, name }: any) => {
{({ id, name }: any) => { return (
return ( <Row>
<Row> <LinkEditButton linkId={id} />
<LinkEditButton linkId={id} /> <LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} /> </Row>
</Row> );
); }}
}} </DataColumn>
</DataColumn>
)}
</DataTable> </DataTable>
); );
} }

View file

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

View file

@ -2,13 +2,13 @@ import { DataGrid } from '@/components/common/DataGrid';
import { useNavigation, usePixelsQuery } from '@/components/hooks'; import { useNavigation, usePixelsQuery } from '@/components/hooks';
import { PixelsTable } from './PixelsTable'; import { PixelsTable } from './PixelsTable';
export function PixelsDataTable({ showActions = false }: { showActions?: boolean }) { export function PixelsDataTable() {
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const query = usePixelsQuery({ teamId }); const query = usePixelsQuery({ teamId });
return ( return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}> <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <PixelsTable data={data} showActions={showActions} />} {({ data }) => <PixelsTable data={data} />}
</DataGrid> </DataGrid>
); );
} }

View file

@ -3,31 +3,22 @@ import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { PixelAddButton } from './PixelAddButton'; import { PixelAddButton } from './PixelAddButton';
import { PixelsDataTable } from './PixelsDataTable'; import { PixelsDataTable } from './PixelsDataTable';
export function PixelsPage() { export function PixelsPage() {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.pixels)}> <PageHeader title={formatMessage(labels.pixels)}>
{showActions && <PixelAddButton teamId={teamId} />} <PixelAddButton teamId={teamId} />
</PageHeader> </PageHeader>
<Panel> <Panel>
<PixelsDataTable showActions={showActions} /> <PixelsDataTable />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -6,11 +6,7 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { PixelDeleteButton } from './PixelDeleteButton'; import { PixelDeleteButton } from './PixelDeleteButton';
import { PixelEditButton } from './PixelEditButton'; import { PixelEditButton } from './PixelEditButton';
export interface PixelsTableProps extends DataTableProps { export function PixelsTable(props: DataTableProps) {
showActions?: boolean;
}
export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation(); const { renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('pixel'); const { getSlugUrl } = useSlug('pixel');
@ -35,20 +31,18 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
<DataColumn id="created" label={formatMessage(labels.created)}> <DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />} {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn> </DataColumn>
{showActions && ( <DataColumn id="action" align="end" width="100px">
<DataColumn id="action" align="end" width="100px"> {(row: any) => {
{(row: any) => { const { id, name } = row;
const { id, name } = row;
return ( return (
<Row> <Row>
<PixelEditButton pixelId={id} /> <PixelEditButton pixelId={id} />
<PixelDeleteButton pixelId={id} name={name} /> <PixelDeleteButton pixelId={id} name={name} />
</Row> </Row>
); );
}} }}
</DataColumn> </DataColumn>
)}
</DataTable> </DataTable>
); );
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,31 +3,22 @@ import { Column } from '@umami/react-zen';
import { PageBody } from '@/components/common/PageBody'; import { PageBody } from '@/components/common/PageBody';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { WebsiteAddButton } from './WebsiteAddButton'; import { WebsiteAddButton } from './WebsiteAddButton';
import { WebsitesDataTable } from './WebsitesDataTable'; import { WebsitesDataTable } from './WebsitesDataTable';
export function WebsitesPage() { export function WebsitesPage() {
const { user } = useLoginQuery();
const { teamId } = useNavigation(); const { teamId } = useNavigation();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { data } = useTeamMembersQuery(teamId);
const showActions =
(teamId &&
data?.data.filter(team => team.userId === user.id && team.role !== ROLES.teamViewOnly)
.length > 0) ||
(!teamId && user.role !== ROLES.viewOnly);
return ( return (
<PageBody> <PageBody>
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.websites)}> <PageHeader title={formatMessage(labels.websites)}>
{showActions && <WebsiteAddButton teamId={teamId} />} <WebsiteAddButton teamId={teamId} />
</PageHeader> </PageHeader>
<Panel> <Panel>
<WebsitesDataTable teamId={teamId} showActions={showActions} /> <WebsitesDataTable teamId={teamId} />
</Panel> </Panel>
</Column> </Column>
</PageBody> </PageBody>

View file

@ -1,6 +1,6 @@
import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useNavigation, useResultQuery } from '@/components/hooks'; import { useMessages, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons'; import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ReportEditButton } from '@/components/input/ReportEditButton';
import { ChangeLabel } from '@/components/metrics/ChangeLabel'; import { ChangeLabel } from '@/components/metrics/ChangeLabel';
@ -20,8 +20,6 @@ type FunnelResult = {
export function Funnel({ id, name, type, parameters, websiteId }) { export function Funnel({ id, name, type, parameters, websiteId }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading } = useResultQuery(type, { const { data, error, isLoading } = useResultQuery(type, {
websiteId, websiteId,
...parameters, ...parameters,
@ -38,22 +36,21 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
</Text> </Text>
</Row> </Row>
</Column> </Column>
{!isSharePage && ( <Column>
<Column> <ReportEditButton id={id} name={name} type={type}>
<ReportEditButton id={id} name={name} type={type}> {({ close }) => {
{({ close }) => { return (
return ( <Dialog
<Dialog title={formatMessage(labels.funnel)}
title={formatMessage(labels.funnel)} variant="modal"
style={{ minHeight: 300, minWidth: 400 }} style={{ minHeight: 300, minWidth: 400 }}
> >
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} /> <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog> </Dialog>
); );
}} }}
</ReportEditButton> </ReportEditButton>
</Column> </Column>
)}
</Grid> </Grid>
{data?.map( {data?.map(
( (

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks'; import { useDateRange, useReportsQuery } from '@/components/hooks';
import { Funnel } from './Funnel'; import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton'; import { FunnelAddButton } from './FunnelAddButton';
@ -13,17 +13,13 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
{!isSharePage && ( <SectionHeader>
<SectionHeader> <FunnelAddButton websiteId={websiteId} />
<FunnelAddButton websiteId={websiteId} /> </SectionHeader>
</SectionHeader>
)}
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (
<Grid gap> <Grid gap>

View file

@ -1,6 +1,6 @@
import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useNavigation, useResultQuery } from '@/components/hooks'; import { useMessages, useResultQuery } from '@/components/hooks';
import { File, User } from '@/components/icons'; import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton'; import { ReportEditButton } from '@/components/input/ReportEditButton';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
@ -25,8 +25,6 @@ export type GoalData = { num: number; total: number };
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) { export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, { const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
websiteId, websiteId,
startDate, startDate,
@ -47,23 +45,21 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
</Text> </Text>
</Row> </Row>
</Column> </Column>
{!isSharePage && ( <Column>
<Column> <ReportEditButton id={id} name={name} type={type}>
<ReportEditButton id={id} name={name} type={type}> {({ close }) => {
{({ close }) => { return (
return ( <Dialog
<Dialog title={formatMessage(labels.goal)}
title={formatMessage(labels.goal)} variant="modal"
variant="modal" style={{ minHeight: 300, minWidth: 400 }}
style={{ minHeight: 300, minWidth: 400 }} >
> <GoalEditForm id={id} websiteId={websiteId} onClose={close} />
<GoalEditForm id={id} websiteId={websiteId} onClose={close} /> </Dialog>
</Dialog> );
); }}
}} </ReportEditButton>
</ReportEditButton> </Column>
</Column>
)}
</Grid> </Grid>
<Row alignItems="center" justifyContent="space-between" gap> <Row alignItems="center" justifyContent="space-between" gap>
<Text color="muted"> <Text color="muted">

View file

@ -4,7 +4,7 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks'; import { useDateRange, useReportsQuery } from '@/components/hooks';
import { Goal } from './Goal'; import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton'; import { GoalAddButton } from './GoalAddButton';
@ -13,17 +13,13 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
{!isSharePage && ( <SectionHeader>
<SectionHeader> <GoalAddButton websiteId={websiteId} />
<GoalAddButton websiteId={websiteId} /> </SectionHeader>
</SectionHeader>
)}
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && ( {data && (
<Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,10 +9,11 @@ import { WebsiteNav } from './WebsiteNav';
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) { export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
return ( return (
<WebsiteProvider websiteId={websiteId}> <WebsiteProvider websiteId={websiteId}>
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%"> <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
<Column <Column
display={{ xs: 'none', lg: 'flex' }} display={{ xs: 'none', lg: 'flex' }}
width="240px" width="240px"
height="100%"
border="right" border="right"
backgroundColor backgroundColor
marginRight="2" marginRight="2"

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,15 @@
import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import { GridRow } from '@/components/common/GridRow'; import { GridRow } from '@/components/common/GridRow';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricsTable } from '@/components/metrics/MetricsTable'; import { MetricsTable } from '@/components/metrics/MetricsTable';
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic'; import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
import { WorldMap } from '@/components/metrics/WorldMap'; import { WorldMap } from '@/components/metrics/WorldMap';
export function WebsitePanels({ websiteId }: { websiteId: string }) { export function WebsitePanels({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const tableProps = { const tableProps = {
websiteId, websiteId,
limit: 10, limit: 10,
@ -16,6 +18,7 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
metric: formatMessage(labels.visitors), metric: formatMessage(labels.visitors),
}; };
const rowProps = { minHeight: '570px' }; const rowProps = { minHeight: '570px' };
const isSharePage = pathname.includes('/share/');
return ( return (
<Grid gap="3"> <Grid gap="3">
@ -113,6 +116,25 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
<WeeklyTraffic websiteId={websiteId} /> <WeeklyTraffic websiteId={websiteId} />
</Panel> </Panel>
</GridRow> </GridRow>
{isSharePage && (
<GridRow layout="two-one" {...rowProps}>
<Panel>
<Heading size="2">{formatMessage(labels.events)}</Heading>
<Row border="bottom" marginBottom="4" />
<MetricsTable
websiteId={websiteId}
type="event"
title={formatMessage(labels.event)}
metric={formatMessage(labels.count)}
limit={15}
filterLink={false}
/>
</Panel>
<Panel gridColumn={{ xs: 'span 1', md: 'span 2' }}>
<EventsChart websiteId={websiteId} />
</Panel>
</GridRow>
)}
</Grid> </Grid>
); );
} }

View file

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

View file

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

View file

@ -1,18 +1,12 @@
'use client'; 'use client';
import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
import locale from 'date-fns/locale/af'; import { type Key, useState } from 'react';
import { type Key, useMemo, useState } from 'react';
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
import { EventsChart } from '@/components/metrics/EventsChart'; import { EventsChart } from '@/components/metrics/EventsChart';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { MetricsTable } from '@/components/metrics/MetricsTable'; import { MetricsTable } from '@/components/metrics/MetricsTable';
import { formatLongNumber } from '@/lib/format';
import { getItem, setItem } from '@/lib/storage'; import { getItem, setItem } from '@/lib/storage';
import { EventProperties } from './EventProperties'; import { EventProperties } from './EventProperties';
import { EventsDataTable } from './EventsDataTable'; import { EventsDataTable } from './EventsDataTable';
@ -21,61 +15,16 @@ const KEY_NAME = 'umami.events.tab';
export function EventsPage({ websiteId }) { export function EventsPage({ websiteId }) {
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels } = useMessages();
const { data, isLoading, isFetching, error } = useEventStatsQuery({
websiteId,
});
const handleSelect = (value: Key) => { const handleSelect = (value: Key) => {
setItem(KEY_NAME, value); setItem(KEY_NAME, value);
setTab(value); setTab(value);
}; };
const metrics = useMemo(() => {
if (!data) return [];
const { events, visitors, visits, uniqueEvents } = data || {};
return [
{
value: visitors,
label: formatMessage(labels.visitors),
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
formatValue: formatLongNumber,
},
{
value: events,
label: formatMessage(labels.events),
formatValue: formatLongNumber,
},
{
value: uniqueEvents,
label: formatMessage(labels.uniqueEvents),
formatValue: formatLongNumber,
},
] as any;
}, [data, locale]);
return ( return (
<Column gap="3"> <Column gap="3">
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<LoadingPanel
data={metrics}
isLoading={isLoading}
isFetching={isFetching}
error={getErrorMessage(error)}
minHeight="136px"
>
<MetricsBar>
{metrics?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
</LoadingPanel>
<Panel> <Panel>
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}> <Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
<TabList> <TabList>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,164 +0,0 @@
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Grid,
Label,
Loading,
Row,
Text,
TextField,
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
import { SHARE_NAV_ITEMS } from './constants';
export function ShareEditForm({
shareId,
websiteId,
onSave,
onClose,
}: {
shareId?: string;
websiteId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { cloudMode } = useConfig();
const { get, post } = useApi();
const { touch } = useModified();
const { modified } = useModified('shares');
const [share, setShare] = useState<any>(null);
const [isLoading, setIsLoading] = useState(!!shareId);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<any>(null);
const isEditing = !!shareId;
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
useEffect(() => {
if (!shareId) return;
const loadShare = async () => {
setIsLoading(true);
try {
const data = await get(`/share/id/${shareId}`);
setShare(data);
} finally {
setIsLoading(false);
}
};
loadShare();
}, [shareId, modified]);
const handleSubmit = async (data: any) => {
const parameters: Record<string, boolean> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
setIsPending(true);
setError(null);
try {
if (isEditing) {
await post(`/share/id/${shareId}`, { name: data.name, slug: share.slug, parameters });
} else {
await post(`/websites/${websiteId}/shares`, { name: data.name, parameters });
}
touch('shares');
onSave?.();
onClose?.();
} catch (e) {
setError(e);
} finally {
setIsPending(false);
}
};
if (isLoading) {
return <Loading placement="absolute" />;
}
const url = isEditing ? getUrl(share?.slug || '') : null;
// Build default values from share parameters
const defaultValues: Record<string, any> = {
name: share?.name || '',
};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
const defaultSelected = item.id === 'overview' || item.id === 'events';
defaultValues[item.id] = share?.parameters?.[item.id] ?? defaultSelected;
});
});
// Get all item ids for validation
const allItemIds = SHARE_NAV_ITEMS.flatMap(section => section.items.map(item => item.id));
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={defaultValues}>
{({ watch }) => {
const values = watch();
const hasSelection = allItemIds.some(id => values[id]);
return (
<Column gap="6">
{url && (
<Column>
<Label>{formatMessage(labels.shareUrl)}</Label>
<TextField value={url} isReadOnly allowCopy />
</Column>
)}
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" autoFocus={!isEditing} />
</FormField>
<Grid columns="repeat(auto-fit, minmax(150px, 1fr))" gap="3">
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="3">
<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>
))}
</Grid>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton
variant="primary"
isDisabled={isPending || !hasSelection || !values.name}
>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Column>
);
}}
</Form>
);
}

View file

@ -1,49 +0,0 @@
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, useMobile } from '@/components/hooks';
import { ShareDeleteButton } from './ShareDeleteButton';
import { ShareEditButton } from './ShareEditButton';
export function SharesTable(props: DataTableProps) {
const { formatMessage, labels } = useMessages();
const { cloudMode } = useConfig();
const { isMobile } = useMobile();
const getUrl = (slug: string) => {
return `${cloudMode ? process.env.cloudUrl : window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<DataTable {...props}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={formatMessage(labels.shareUrl)} width="2fr">
{({ slug }: any) => {
const url = getUrl(slug);
return (
<ExternalLink href={url} prefetch={false}>
{url}
</ExternalLink>
);
}}
</DataColumn>
{!isMobile && (
<DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
)}
<DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => {
return (
<Row>
<ShareEditButton shareId={id} />
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
);
}}
</DataColumn>
</DataTable>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,8 +4,13 @@ import { createToken } from '@/lib/jwt';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import redis from '@/lib/redis'; import redis from '@/lib/redis';
import { json, notFound } from '@/lib/response'; import { json, notFound } from '@/lib/response';
import type { WhiteLabel } from '@/lib/types'; import { getSharedWebsite } from '@/queries/prisma';
import { getShareByCode, getWebsite } from '@/queries/prisma';
export interface WhiteLabel {
name: string;
url: string;
image: string;
}
async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> { async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> {
if (website.userId) { if (website.userId) {
@ -43,25 +48,20 @@ async function getWhiteLabel(accountId: string): Promise<WhiteLabel | null> {
return null; return null;
} }
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) {
const { slug } = await params; const { shareId } = await params;
const share = await getShareByCode(slug); const website = await getSharedWebsite(shareId);
if (!share) { if (!website) {
return notFound(); return notFound();
} }
const website = await getWebsite(share.entityId); const data: { websiteId: string; token: string; whiteLabel?: WhiteLabel } = {
websiteId: website.id,
const data: Record<string, any> = { token: createToken({ websiteId: website.id }, secret()),
shareId: share.id,
websiteId: share.entityId,
parameters: share.parameters,
}; };
data.token = createToken(data, secret());
const accountId = await getAccountId(website); const accountId = await getAccountId(website);
if (accountId) { if (accountId) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,34 +0,0 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { dateRangeParams, filterParams } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getWebsiteEventStats } from '@/queries/sql/events/getWebsiteEventStats';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
...dateRangeParams,
...filterParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const filters = await getQueryFilters(query, websiteId);
const data = await getWebsiteEventStats(websiteId, filters);
return json({ data });
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,77 +0,0 @@
'use client';
import { Loading } from '@umami/react-zen';
import { usePathname, useRouter } from 'next/navigation';
import { createContext, type ReactNode, useEffect } from 'react';
import { useShareTokenQuery } from '@/components/hooks';
import type { WhiteLabel } from '@/lib/types';
export interface ShareData {
shareId: string;
slug: string;
websiteId: string;
parameters: any;
token: string;
whiteLabel?: WhiteLabel;
}
export const ShareContext = createContext<ShareData>(null);
const ALL_SECTION_IDS = [
'overview',
'events',
'sessions',
'realtime',
'compare',
'breakdown',
'goals',
'funnels',
'journeys',
'retention',
'utm',
'revenue',
'attribution',
];
function getSharePath(pathname: string) {
const segments = pathname.split('/');
const firstSegment = segments[3];
// If first segment looks like a domain name, skip it
if (firstSegment?.includes('.')) {
return segments[4];
}
return firstSegment;
}
export function ShareProvider({ slug, children }: { slug: string; children: ReactNode }) {
const { share, isLoading, isFetching } = useShareTokenQuery(slug);
const router = useRouter();
const pathname = usePathname();
const path = getSharePath(pathname);
const allowedSections = share?.parameters
? ALL_SECTION_IDS.filter(id => share.parameters[id] !== false)
: [];
const shouldRedirect =
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === undefined || path === '' || path === 'overview');
useEffect(() => {
if (shouldRedirect) {
router.replace(`/share/${slug}/${allowedSections[0]}`);
}
}, [shouldRedirect, slug, allowedSections, router]);
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
if (!share || shouldRedirect) {
return null;
}
return <ShareContext.Provider value={{ ...share, slug }}>{children}</ShareContext.Provider>;
}

View file

@ -0,0 +1,23 @@
import { Row, Text } from '@umami/react-zen';
import type { WhiteLabel } from '@/app/api/share/[shareId]/route';
import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
export function Footer({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
if (whiteLabel) {
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={whiteLabel.url} target="_blank">
<Text weight="bold">{whiteLabel.name}</Text>
</a>
</Row>
);
}
return (
<Row as="footer" paddingY="6" justifyContent="flex-end">
<a href={HOMEPAGE_URL} target="_blank">
<Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`}
</a>
</Row>
);
}

View file

@ -0,0 +1,33 @@
import { Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import type { WhiteLabel } from '@/app/api/share/[shareId]/route';
import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Logo } from '@/components/svg';
export function Header({ whiteLabel }: { whiteLabel?: WhiteLabel }) {
const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
return (
<Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
<a href={logoUrl} target="_blank" rel="noopener">
<Row alignItems="center" gap>
{logoImage ? (
<img src={logoImage} alt={logoName} style={{ height: 24 }} />
) : (
<Icon>
<Logo />
</Icon>
)}
<Text weight="bold">{logoName}</Text>
</Row>
</a>
<Row alignItems="center" gap>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
</Row>
);
}

View file

@ -0,0 +1,43 @@
'use client';
import { Column, useTheme } from '@umami/react-zen';
import { useEffect } from 'react';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { useShareTokenQuery } from '@/components/hooks';
import { Footer } from './Footer';
import { Header } from './Header';
export function SharePage({ shareId }) {
const { shareToken, isLoading } = useShareTokenQuery(shareId);
const { setTheme } = useTheme();
useEffect(() => {
const url = new URL(window?.location?.href);
const theme = url.searchParams.get('theme');
if (theme === 'light' || theme === 'dark') {
setTheme(theme);
}
}, []);
if (isLoading || !shareToken) {
return null;
}
const { whiteLabel } = shareToken;
return (
<Column backgroundColor="2">
<PageBody gap>
<Header whiteLabel={whiteLabel} />
<WebsiteProvider websiteId={shareToken.websiteId}>
<WebsiteHeader showActions={false} allowLink={false} />
<WebsitePage websiteId={shareToken.websiteId} />
</WebsiteProvider>
<Footer whiteLabel={whiteLabel} />
</PageBody>
</Column>
);
}

View file

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

View file

@ -1,206 +0,0 @@
import { Button, Column, Icon, Row, Text, ThemeButton } from '@umami/react-zen';
import { SideMenu } from '@/components/common/SideMenu';
import { useMessages, useNavigation, useShare } from '@/components/hooks';
import { AlignEndHorizontal, Clock, Eye, PanelLeft, Sheet, Tag, User } from '@/components/icons';
import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { Funnel, Lightning, Logo, Magnet, Money, Network, Path, Target } from '@/components/svg';
export function ShareNav({
collapsed,
onCollapse,
onItemClick,
}: {
collapsed?: boolean;
onCollapse?: (collapsed: boolean) => void;
onItemClick?: () => void;
}) {
const share = useShare();
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
const { slug, parameters, whiteLabel } = share;
const logoUrl = whiteLabel?.url || 'https://umami.is';
const logoName = whiteLabel?.name || 'umami';
const logoImage = whiteLabel?.image;
const renderPath = (path: string) => `/share/${slug}${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] === true),
}))
.filter(section => section.items.length > 0);
const selectedKey = items
.flatMap(e => e.items)
.find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
const isMobile = !!onItemClick;
return (
<Column
position={isMobile ? undefined : 'fixed'}
padding="3"
width={isMobile ? '100%' : collapsed ? '60px' : '240px'}
maxHeight="100dvh"
height="100dvh"
border={isMobile ? undefined : 'right'}
borderColor={isMobile ? undefined : '4'}
>
<Row as="header" gap alignItems="center" justifyContent="space-between">
{!collapsed && (
<a href={logoUrl} target="_blank" rel="noopener" style={{ marginLeft: 12 }}>
<Row alignItems="center" gap>
{logoImage ? (
<img src={logoImage} alt={logoName} style={{ height: 24 }} />
) : (
<Icon>
<Logo />
</Icon>
)}
<Text weight="bold">{logoName}</Text>
</Row>
</a>
)}
{!onItemClick && (
<Button variant="quiet" onPress={() => onCollapse?.(!collapsed)}>
<Icon color="muted">
<PanelLeft />
</Icon>
</Button>
)}
</Row>
{!collapsed && (
<Column flexGrow={1} overflowY="auto">
<SideMenu
items={items}
selectedKey={selectedKey}
allowMinimize={false}
onItemClick={onItemClick}
/>
</Column>
)}
<Column
flexGrow={collapsed ? 1 : undefined}
justifyContent="flex-end"
alignItems={collapsed ? 'center' : undefined}
>
{collapsed ? (
<Column gap="2" alignItems="center">
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Column>
) : (
<Row>
<ThemeButton />
<LanguageButton />
<PreferencesButton />
</Row>
)}
</Column>
</Column>
);
}

View file

@ -1,103 +0,0 @@
'use client';
import { Column, Grid, Row, useTheme } from '@umami/react-zen';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { AttributionPage } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage';
import { BreakdownPage } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage';
import { FunnelsPage } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage';
import { GoalsPage } from '@/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage';
import { JourneysPage } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage';
import { RetentionPage } from '@/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage';
import { RevenuePage } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage';
import { UTMPage } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage';
import { ComparePage } from '@/app/(main)/websites/[websiteId]/compare/ComparePage';
import { EventsPage } from '@/app/(main)/websites/[websiteId]/events/EventsPage';
import { RealtimePage } from '@/app/(main)/websites/[websiteId]/realtime/RealtimePage';
import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/SessionsPage';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { PageBody } from '@/components/common/PageBody';
import { useShare } from '@/components/hooks';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { ShareNav } from './ShareNav';
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,
};
function getSharePath(pathname: string) {
const segments = pathname.split('/');
const firstSegment = segments[3];
// If first segment looks like a domain name, skip it
if (firstSegment?.includes('.')) {
return segments[4];
}
return firstSegment;
}
export function SharePage() {
const [navCollapsed, setNavCollapsed] = useState(false);
const share = useShare();
const { setTheme } = useTheme();
const pathname = usePathname();
const path = getSharePath(pathname);
const { websiteId, parameters = {} } = share;
useEffect(() => {
const url = new URL(window?.location?.href);
const theme = url.searchParams.get('theme');
if (theme === 'light' || theme === 'dark') {
setTheme(theme);
}
}, []);
// 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 (
<Grid columns={{ xs: '1fr', lg: `${navCollapsed ? '60px' : '240px'} 1fr` }} width="100%">
<Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
<MobileMenuButton>
{({ close }) => {
return <ShareNav onItemClick={close} />;
}}
</MobileMenuButton>
</Row>
<Column display={{ xs: 'none', lg: 'flex' }} marginRight="2">
<ShareNav collapsed={navCollapsed} onCollapse={setNavCollapsed} />
</Column>
<PageBody gap>
<WebsiteProvider websiteId={websiteId}>
<Column>
<WebsiteHeader showActions={false} />
<PageComponent websiteId={websiteId} />
</Column>
</WebsiteProvider>
</PageBody>
</Grid>
);
}

View file

@ -1,5 +0,0 @@
import { SharePage } from './SharePage';
export default function () {
return <SharePage />;
}

View file

@ -1,13 +0,0 @@
import { ShareProvider } from '@/app/share/ShareProvider';
export default async function ({
params,
children,
}: {
params: Promise<{ slug: string }>;
children: React.ReactNode;
}) {
const { slug } = await params;
return <ShareProvider slug={slug}>{children}</ShareProvider>;
}

View file

@ -31,7 +31,6 @@ export function PageBody({
<Column <Column
{...props} {...props}
width="100%" width="100%"
minHeight="100vh"
paddingBottom="6" paddingBottom="6"
maxWidth={maxWidth} maxWidth={maxWidth}
paddingX={{ xs: '3', md: '6' }} paddingX={{ xs: '3', md: '6' }}

View file

@ -7,7 +7,6 @@ import {
NavMenuItem, NavMenuItem,
type NavMenuProps, type NavMenuProps,
Row, Row,
Text,
} from '@umami/react-zen'; } from '@umami/react-zen';
import Link from 'next/link'; import Link from 'next/link';
@ -43,11 +42,9 @@ export function SideMenu({
return ( return (
<Link key={id} href={path}> <Link key={id} href={path}>
<Row padding hoverBackgroundColor="3"> <NavMenuItem isSelected={isSelected}>
<IconLabel icon={icon}> <IconLabel icon={icon}>{label}</IconLabel>
<Text weight={isSelected ? 'bold' : undefined}>{label}</Text> </NavMenuItem>
</IconLabel>
</Row>
</Link> </Link>
); );
}); });

View file

@ -1,6 +0,0 @@
import { useContext } from 'react';
import { ShareContext } from '@/app/share/ShareProvider';
export function useShare() {
return useContext(ShareContext);
}

View file

@ -3,7 +3,6 @@
// Context hooks // Context hooks
export * from './context/useLink'; export * from './context/useLink';
export * from './context/usePixel'; export * from './context/usePixel';
export * from './context/useShare';
export * from './context/useTeam'; export * from './context/useTeam';
export * from './context/useUser'; export * from './context/useUser';
export * from './context/useWebsite'; export * from './context/useWebsite';
@ -52,7 +51,6 @@ export * from './queries/useWebsiteSegmentsQuery';
export * from './queries/useWebsiteSessionQuery'; export * from './queries/useWebsiteSessionQuery';
export * from './queries/useWebsiteSessionStatsQuery'; export * from './queries/useWebsiteSessionStatsQuery';
export * from './queries/useWebsiteSessionsQuery'; export * from './queries/useWebsiteSessionsQuery';
export * from './queries/useWebsiteSharesQuery';
export * from './queries/useWebsiteStatsQuery'; export * from './queries/useWebsiteStatsQuery';
export * from './queries/useWebsitesQuery'; export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteValuesQuery'; export * from './queries/useWebsiteValuesQuery';

View file

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

View file

@ -1,37 +0,0 @@
import type { UseQueryOptions } from '@tanstack/react-query';
import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters';
export interface EventStatsData {
events: number;
visitors: number;
visits: number;
uniqueEvents: number;
}
type EventStatsApiResponse = {
data: EventStatsData;
};
export function useEventStatsQuery(
{ websiteId }: { websiteId: string },
options?: UseQueryOptions<EventStatsApiResponse, Error, EventStatsData>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters();
return useQuery<EventStatsApiResponse, Error, EventStatsData>({
queryKey: ['websites:events:stats', { websiteId, startAt, endAt, ...filters }],
queryFn: () =>
get(`/websites/${websiteId}/events/stats`, {
startAt,
endAt,
...filters,
}),
select: response => response.data,
enabled: !!websiteId,
...options,
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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