mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Compare commits
138 commits
fd2e2047cd
...
1229663814
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1229663814 | ||
|
|
e57239de1e | ||
|
|
abfb78bb98 | ||
|
|
9b310dacef | ||
|
|
489c2712d1 | ||
|
|
b43e7fd3a7 | ||
|
|
5880eae4e4 | ||
|
|
6169a58e86 | ||
|
|
452a385c4e | ||
|
|
482d6c1e47 | ||
|
|
4e8be724ac | ||
|
|
c9e14f3bce | ||
|
|
d028bfa1f5 | ||
|
|
78d467b478 | ||
|
|
9d3f5ad0fd | ||
|
|
018e76b067 | ||
|
|
2df24a78ca | ||
|
|
9339383497 | ||
|
|
b84942b6da | ||
|
|
3d75246802 | ||
|
|
376c570d26 | ||
|
|
558cb3b041 | ||
|
|
9e7285cf2b | ||
|
|
c5aa8be15c | ||
|
|
daccd22ab2 | ||
|
|
b958403224 | ||
|
|
752f395d83 | ||
|
|
c7a0d65590 | ||
|
|
c3dad5b7ef | ||
|
|
9426de90f7 | ||
|
|
c527819fd4 | ||
|
|
f7bdd5c54e | ||
|
|
f32ab11785 | ||
|
|
dde1c3a57a | ||
|
|
dd3649bbf7 | ||
|
|
67cdfdfb7e | ||
|
|
a0886c0594 | ||
|
|
a37de757a0 | ||
|
|
f9f9125532 | ||
|
|
2f998ff9d8 | ||
|
|
57eef5866b | ||
|
|
a1a092dc19 | ||
|
|
1498da2d02 | ||
|
|
4867492ca3 | ||
|
|
29a373467a | ||
|
|
4680c89e28 | ||
|
|
5f316a79e5 | ||
|
|
7bb30443a8 | ||
|
|
7f43a0d41a | ||
|
|
fdafe13c35 | ||
|
|
e782c2e627 | ||
|
|
801a3ec6bb | ||
|
|
128217c0f4 | ||
|
|
4a09f2bff6 | ||
|
|
c9f6653b62 | ||
|
|
355fa1e9a9 | ||
|
|
a2f0066c42 | ||
|
|
e73432dd26 | ||
|
|
3f173889ea | ||
|
|
af7f7adf5b | ||
|
|
d70152be38 | ||
|
|
7a89bbf19f | ||
|
|
4c9b2f10da | ||
|
|
c88425332b | ||
|
|
09e1267cb0 | ||
|
|
fce1c13017 | ||
|
|
adea3e9b1c | ||
|
|
3fc41bdd08 | ||
|
|
f4eaa9fd0d | ||
|
|
d4ff7c8e3f | ||
|
|
6fd428683d | ||
|
|
a1efb2d86a | ||
|
|
8b2196b97a | ||
|
|
63d2bfe118 | ||
|
|
8f55ed9da9 | ||
|
|
e5f794c329 | ||
|
|
14012ed68e | ||
|
|
dd8888cfcf | ||
|
|
d47ee6e8e8 | ||
|
|
52f812d9e5 | ||
|
|
f2c49845d0 | ||
|
|
ef3aec09be | ||
|
|
0eb598c817 | ||
|
|
3fe7afbb12 | ||
|
|
3a498333a6 | ||
|
|
63c9cf2e30 | ||
|
|
520c91c621 | ||
|
|
29f2c7b7d4 | ||
|
|
a270b0afea | ||
|
|
889a404650 | ||
|
|
b6013c3ee8 | ||
|
|
ebfbc282ee | ||
|
|
1a664d8719 | ||
|
|
6d480d9c36 | ||
|
|
a049fbb5b0 | ||
|
|
6420f2c813 | ||
|
|
fbf03d6563 | ||
|
|
fbe031bfe9 | ||
|
|
5213e04f44 | ||
|
|
17d24bf8e2 | ||
|
|
767fda21cd | ||
|
|
c122fb718b | ||
|
|
f105a52fc2 | ||
|
|
5506314e54 | ||
|
|
e90b2201ca | ||
|
|
d8dcf05a20 | ||
|
|
2e62a06aa4 | ||
|
|
75ae7528fb | ||
|
|
58880b6a5f | ||
|
|
491716f4dd | ||
|
|
42d0594118 | ||
|
|
34c31ca63c | ||
|
|
dacf13475a | ||
|
|
8286af1453 | ||
|
|
34677bca8f | ||
|
|
b0aa6fd6ef | ||
|
|
97c26bc075 | ||
|
|
4eddac21c7 | ||
|
|
5e3e6b3edd | ||
|
|
612b00179b | ||
|
|
783098fadc | ||
|
|
6859f00bf6 | ||
|
|
b0ed4bddb6 | ||
|
|
ad264f941d | ||
|
|
4c0c9e6aa0 | ||
|
|
687318bd09 | ||
|
|
437c168e6f | ||
|
|
886544f297 | ||
|
|
53dfc5e76a | ||
|
|
5fbef149d0 | ||
|
|
860e6390f1 | ||
|
|
9b0d1b092e | ||
|
|
2a71cc721b | ||
|
|
7bea47d9e8 | ||
|
|
e9cdabab5a | ||
|
|
5b97fb908a | ||
|
|
b088a2ee6e | ||
|
|
8cc571f548 |
159 changed files with 4285 additions and 2334 deletions
87
.github/workflows/cd.yml
vendored
87
.github/workflows/cd.yml
vendored
|
|
@ -3,13 +3,18 @@ name: Create docker images
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- "v*.*.*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
description: 'Optional image version (e.g. 3.0.0, v3.0.0, or 3.0.0-beta.1)'
|
description: "Optional image version (e.g. 3.0.0, v3.0.0, or 3.0.0-beta.1)"
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ""
|
||||||
|
include_latest:
|
||||||
|
description: "Include latest tag"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -22,6 +27,9 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
|
@ -46,6 +54,7 @@ jobs:
|
||||||
INPUT="${{ github.event.inputs.version }}"
|
INPUT="${{ github.event.inputs.version }}"
|
||||||
REF_TYPE="${{ github.ref_type }}"
|
REF_TYPE="${{ github.ref_type }}"
|
||||||
REF_NAME="${{ github.ref_name }}"
|
REF_NAME="${{ github.ref_name }}"
|
||||||
|
INCLUDE_LATEST="${{ github.event.inputs.include_latest }}"
|
||||||
|
|
||||||
# Determine version source
|
# Determine version source
|
||||||
if [[ -n "$INPUT" ]]; then
|
if [[ -n "$INPUT" ]]; then
|
||||||
|
|
@ -56,7 +65,8 @@ jobs:
|
||||||
VERSION=""
|
VERSION=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TAGS=""
|
GHCR_TAGS=""
|
||||||
|
DOCKER_TAGS=""
|
||||||
|
|
||||||
if [[ -n "$VERSION" ]]; then
|
if [[ -n "$VERSION" ]]; then
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
|
|
@ -64,37 +74,54 @@ jobs:
|
||||||
|
|
||||||
if [[ "$VERSION" == *-* ]]; then
|
if [[ "$VERSION" == *-* ]]; then
|
||||||
# prerelease: only version tag
|
# prerelease: only version tag
|
||||||
TAGS="$VERSION"
|
GHCR_TAGS="ghcr.io/${{ github.repository }}:$VERSION"
|
||||||
|
DOCKER_TAGS="umamisoftware/umami:$VERSION"
|
||||||
else
|
else
|
||||||
# stable release: version + hierarchy + latest
|
# stable release: version + hierarchy
|
||||||
TAGS="$VERSION,${MAJOR}.${MINOR},${MAJOR},postgresql-latest,latest"
|
GHCR_TAGS="ghcr.io/${{ github.repository }}:$VERSION"
|
||||||
|
GHCR_TAGS="$GHCR_TAGS,ghcr.io/${{ github.repository }}:${MAJOR}.${MINOR}"
|
||||||
|
GHCR_TAGS="$GHCR_TAGS,ghcr.io/${{ github.repository }}:${MAJOR}"
|
||||||
|
GHCR_TAGS="$GHCR_TAGS,ghcr.io/${{ github.repository }}:postgresql-latest"
|
||||||
|
|
||||||
|
DOCKER_TAGS="umamisoftware/umami:$VERSION"
|
||||||
|
DOCKER_TAGS="$DOCKER_TAGS,umamisoftware/umami:${MAJOR}.${MINOR}"
|
||||||
|
DOCKER_TAGS="$DOCKER_TAGS,umamisoftware/umami:${MAJOR}"
|
||||||
|
DOCKER_TAGS="$DOCKER_TAGS,umamisoftware/umami:postgresql-latest"
|
||||||
|
|
||||||
|
# Add latest tag based on trigger and input
|
||||||
|
if [[ "$REF_TYPE" == "tag" ]] || [[ "$INCLUDE_LATEST" == "true" ]]; then
|
||||||
|
GHCR_TAGS="$GHCR_TAGS,ghcr.io/${{ github.repository }}:latest"
|
||||||
|
DOCKER_TAGS="$DOCKER_TAGS,umamisoftware/umami:latest"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Non-tag build (e.g. from main branch)
|
# Non-tag build (e.g. from main branch)
|
||||||
TAGS="${REF_NAME}"
|
GHCR_TAGS="ghcr.io/${{ github.repository }}:${REF_NAME}"
|
||||||
|
DOCKER_TAGS="umamisoftware/umami:${REF_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
echo "ghcr_tags=$GHCR_TAGS" >> $GITHUB_OUTPUT
|
||||||
echo "Computed tags: $TAGS"
|
echo "docker_tags=$DOCKER_TAGS" >> $GITHUB_OUTPUT
|
||||||
|
echo "Computed GHCR tags: $GHCR_TAGS"
|
||||||
|
echo "Computed Docker Hub tags: $DOCKER_TAGS"
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push to GHCR
|
||||||
run: |
|
uses: docker/build-push-action@v5
|
||||||
TAGS="${{ steps.compute.outputs.tags }}"
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.compute.outputs.ghcr_tags }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
# Set image targets conditionally
|
- name: Build and push to Docker Hub
|
||||||
if [[ "${{ github.repository }}" == "umami-software/umami" ]]; then
|
if: github.repository == 'umami-software/umami'
|
||||||
IMAGES=("umamisoftware/umami" "ghcr.io/${{ github.repository }}")
|
uses: docker/build-push-action@v5
|
||||||
else
|
with:
|
||||||
IMAGES=("ghcr.io/${{ github.repository }}")
|
context: .
|
||||||
fi
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
for IMAGE in "${IMAGES[@]}"; do
|
tags: ${{ steps.compute.outputs.docker_tags }}
|
||||||
echo "Building and pushing $IMAGE with tags: $TAGS"
|
cache-from: type=gha
|
||||||
docker buildx build \
|
cache-to: type=gha,mode=max
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
--push \
|
|
||||||
$(echo "$TAGS" | tr ',' '\n' | sed "s|^|--tag ${IMAGE}:|") \
|
|
||||||
--cache-from type=gha \
|
|
||||||
--cache-to type=gha,mode=max \
|
|
||||||
.
|
|
||||||
done
|
|
||||||
|
|
|
||||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -3,7 +3,7 @@ name: Node.js CI
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DATABASE_TYPE: postgresql
|
DATABASE_URL: "postgresql://user:pass@localhost:5432/dummy"
|
||||||
SKIP_DB_CHECK: 1
|
SKIP_DB_CHECK: 1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -11,26 +11,17 @@ jobs:
|
||||||
if: github.repository == 'umami-software/umami'
|
if: github.repository == 'umami-software/umami'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- node-version: 18.18
|
|
||||||
pnpm-version: 10
|
|
||||||
db-type: postgresql
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4 # required so that setup-node will work
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: ${{ matrix.pnpm-version }}
|
version: 10
|
||||||
run_install: false
|
run_install: false
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js 18.18
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: 18.18
|
||||||
cache: 'pnpm'
|
cache: "pnpm"
|
||||||
env:
|
|
||||||
DATABASE_TYPE: ${{ matrix.db-type }}
|
|
||||||
- run: npm install --global pnpm
|
- run: npm install --global pnpm
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -21,6 +21,7 @@ package-lock.json
|
||||||
/dist
|
/dist
|
||||||
/generated
|
/generated
|
||||||
/src/generated
|
/src/generated
|
||||||
|
pm2.yml
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
@ -31,6 +32,7 @@ package-lock.json
|
||||||
.vscode
|
.vscode
|
||||||
.tool-versions
|
.tool-versions
|
||||||
.claude
|
.claude
|
||||||
|
nul
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const cloudMode = process.env.CLOUD_MODE || '';
|
||||||
const cloudUrl = process.env.CLOUD_URL || '';
|
const cloudUrl = process.env.CLOUD_URL || '';
|
||||||
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || '';
|
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT || '';
|
||||||
const corsMaxAge = process.env.CORS_MAX_AGE || '';
|
const corsMaxAge = process.env.CORS_MAX_AGE || '';
|
||||||
|
const defaultCurrency = process.env.DEFAULT_CURRENCY || '';
|
||||||
const defaultLocale = process.env.DEFAULT_LOCALE || '';
|
const defaultLocale = process.env.DEFAULT_LOCALE || '';
|
||||||
const forceSSL = process.env.FORCE_SSL || '';
|
const forceSSL = process.env.FORCE_SSL || '';
|
||||||
const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
|
const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
|
||||||
|
|
@ -170,6 +171,7 @@ export default {
|
||||||
cloudMode,
|
cloudMode,
|
||||||
cloudUrl,
|
cloudUrl,
|
||||||
currentVersion: pkg.version,
|
currentVersion: pkg.version,
|
||||||
|
defaultCurrency,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
},
|
},
|
||||||
basePath,
|
basePath,
|
||||||
|
|
|
||||||
18
package.json
18
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "umami",
|
"name": "umami",
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"description": "A modern, privacy-focused alternative to Google Analytics.",
|
"description": "A modern, privacy-focused alternative to Google Analytics.",
|
||||||
"author": "Umami Software, Inc. <hello@umami.is>",
|
"author": "Umami Software, Inc. <hello@umami.is>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3003 --turbo",
|
"dev": "next dev -p 3002 --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.2",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"kafkajs": "^2.1.0",
|
"kafkajs": "^2.1.0",
|
||||||
"lucide-react": "^0.543.0",
|
"lucide-react": "^0.543.0",
|
||||||
"maxmind": "^5.0.0",
|
"maxmind": "^5.0.0",
|
||||||
"next": "^15.5.7",
|
"next": "^15.5.10",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"prisma": "^6.18.0",
|
"prisma": "^6.18.0",
|
||||||
"pure-rand": "^7.0.1",
|
"pure-rand": "^7.0.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.3",
|
||||||
"react-error-boundary": "^4.0.4",
|
"react-error-boundary": "^4.0.4",
|
||||||
"react-intl": "^7.1.14",
|
"react-intl": "^7.1.14",
|
||||||
"react-simple-maps": "^2.3.0",
|
"react-simple-maps": "^2.3.0",
|
||||||
|
|
@ -143,8 +143,8 @@
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"cypress": "^13.6.6",
|
"cypress": "^15.8.0",
|
||||||
"extract-react-intl-messages": "^4.1.1",
|
"extract-react-intl-messages": "^5.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"lint-staged": "^16.2.6",
|
"lint-staged": "^16.2.6",
|
||||||
|
|
@ -164,7 +164,7 @@
|
||||||
"stylelint-config-css-modules": "^4.5.1",
|
"stylelint-config-css-modules": "^4.5.1",
|
||||||
"stylelint-config-prettier": "^9.0.3",
|
"stylelint-config-prettier": "^9.0.3",
|
||||||
"stylelint-config-recommended": "^14.0.0",
|
"stylelint-config-recommended": "^14.0.0",
|
||||||
"tar": "^6.1.2",
|
"tar": "^7.5.7",
|
||||||
"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
2732
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
40
prisma/migrations/15_add_share/migration.sql
Normal file
40
prisma/migrations/15_add_share/migration.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "share" (
|
||||||
|
"share_id" UUID NOT NULL,
|
||||||
|
"entity_id" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"share_type" INTEGER NOT NULL,
|
||||||
|
"slug" VARCHAR(100) NOT NULL,
|
||||||
|
"parameters" JSONB NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6),
|
||||||
|
|
||||||
|
CONSTRAINT "share_pkey" PRIMARY KEY ("share_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "share_entity_id_idx" ON "share"("entity_id");
|
||||||
|
|
||||||
|
-- MigrateData
|
||||||
|
INSERT INTO "share" (share_id, entity_id, name, share_type, slug, parameters, created_at)
|
||||||
|
SELECT gen_random_uuid(),
|
||||||
|
website_id,
|
||||||
|
name,
|
||||||
|
1,
|
||||||
|
share_id,
|
||||||
|
'{"overview":true}'::jsonb,
|
||||||
|
now()
|
||||||
|
FROM "website"
|
||||||
|
WHERE share_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "website_share_id_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "website_share_id_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "website" DROP COLUMN "share_id";
|
||||||
30
prisma/migrations/16_boards/migration.sql
Normal file
30
prisma/migrations/16_boards/migration.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "board" (
|
||||||
|
"board_id" UUID NOT NULL,
|
||||||
|
"type" VARCHAR(50) NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"description" VARCHAR(500) NOT NULL,
|
||||||
|
"parameters" JSONB NOT NULL,
|
||||||
|
"slug" VARCHAR(100) NOT NULL,
|
||||||
|
"user_id" UUID,
|
||||||
|
"team_id" UUID,
|
||||||
|
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(6),
|
||||||
|
|
||||||
|
CONSTRAINT "board_pkey" PRIMARY KEY ("board_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "board_slug_key" ON "board"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "board_slug_idx" ON "board"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "board_user_id_idx" ON "board"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "board_team_id_idx" ON "board"("team_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "board_created_at_idx" ON "board"("created_at");
|
||||||
29
prisma/migrations/17_remove_duplicate_key/migration.sql
Normal file
29
prisma/migrations/17_remove_duplicate_key/migration.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "link_link_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "pixel_pixel_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "report_report_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "revenue_revenue_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "segment_segment_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "session_session_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "team_team_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "team_user_team_user_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "user_user_id_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "website_website_id_key";
|
||||||
|
|
@ -11,7 +11,7 @@ datasource db {
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @unique @map("user_id") @db.Uuid
|
id String @id() @map("user_id") @db.Uuid
|
||||||
username String @unique @db.VarChar(255)
|
username String @unique @db.VarChar(255)
|
||||||
password String @db.VarChar(60)
|
password String @db.VarChar(60)
|
||||||
role String @map("role") @db.VarChar(50)
|
role String @map("role") @db.VarChar(50)
|
||||||
|
|
@ -27,12 +27,13 @@ model User {
|
||||||
pixels Pixel[] @relation("user")
|
pixels Pixel[] @relation("user")
|
||||||
teams TeamUser[]
|
teams TeamUser[]
|
||||||
reports Report[]
|
reports Report[]
|
||||||
|
boards Board[] @relation("user")
|
||||||
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @unique @map("session_id") @db.Uuid
|
id String @id() @map("session_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
browser String? @db.VarChar(20)
|
browser String? @db.VarChar(20)
|
||||||
os String? @db.VarChar(20)
|
os String? @db.VarChar(20)
|
||||||
|
|
@ -64,10 +65,9 @@ model Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Website {
|
model Website {
|
||||||
id String @id @unique @map("website_id") @db.Uuid
|
id String @id() @map("website_id") @db.Uuid
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
domain String? @db.VarChar(500)
|
domain String? @db.VarChar(500)
|
||||||
shareId String? @unique @map("share_id") @db.VarChar(50)
|
|
||||||
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
resetAt DateTime? @map("reset_at") @db.Timestamptz(6)
|
||||||
userId String? @map("user_id") @db.Uuid
|
userId String? @map("user_id") @db.Uuid
|
||||||
teamId String? @map("team_id") @db.Uuid
|
teamId String? @map("team_id") @db.Uuid
|
||||||
|
|
@ -88,7 +88,6 @@ model Website {
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([shareId])
|
|
||||||
@@index([createdBy])
|
@@index([createdBy])
|
||||||
@@map("website")
|
@@map("website")
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +186,7 @@ model SessionData {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
id String @id() @unique() @map("team_id") @db.Uuid
|
id String @id() @map("team_id") @db.Uuid
|
||||||
name String @db.VarChar(50)
|
name String @db.VarChar(50)
|
||||||
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
accessCode String? @unique @map("access_code") @db.VarChar(50)
|
||||||
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
logoUrl String? @map("logo_url") @db.VarChar(2183)
|
||||||
|
|
@ -199,13 +198,14 @@ model Team {
|
||||||
members TeamUser[]
|
members TeamUser[]
|
||||||
links Link[]
|
links Link[]
|
||||||
pixels Pixel[]
|
pixels Pixel[]
|
||||||
|
boards Board[]
|
||||||
|
|
||||||
@@index([accessCode])
|
@@index([accessCode])
|
||||||
@@map("team")
|
@@map("team")
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamUser {
|
model TeamUser {
|
||||||
id String @id() @unique() @map("team_user_id") @db.Uuid
|
id String @id() @map("team_user_id") @db.Uuid
|
||||||
teamId String @map("team_id") @db.Uuid
|
teamId String @map("team_id") @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
role String @db.VarChar(50)
|
role String @db.VarChar(50)
|
||||||
|
|
@ -221,7 +221,7 @@ model TeamUser {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Report {
|
model Report {
|
||||||
id String @id() @unique() @map("report_id") @db.Uuid
|
id String @id() @map("report_id") @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
type String @db.VarChar(50)
|
type String @db.VarChar(50)
|
||||||
|
|
@ -242,7 +242,7 @@ model Report {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Segment {
|
model Segment {
|
||||||
id String @id() @unique() @map("segment_id") @db.Uuid
|
id String @id() @map("segment_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
type String @db.VarChar(50)
|
type String @db.VarChar(50)
|
||||||
name String @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
|
|
@ -257,7 +257,7 @@ model Segment {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Revenue {
|
model Revenue {
|
||||||
id String @id() @unique() @map("revenue_id") @db.Uuid
|
id String @id() @map("revenue_id") @db.Uuid
|
||||||
websiteId String @map("website_id") @db.Uuid
|
websiteId String @map("website_id") @db.Uuid
|
||||||
sessionId String @map("session_id") @db.Uuid
|
sessionId String @map("session_id") @db.Uuid
|
||||||
eventId String @map("event_id") @db.Uuid
|
eventId String @map("event_id") @db.Uuid
|
||||||
|
|
@ -277,7 +277,7 @@ model Revenue {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Link {
|
model Link {
|
||||||
id String @id() @unique() @map("link_id") @db.Uuid
|
id String @id() @map("link_id") @db.Uuid
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
url String @db.VarChar(500)
|
url String @db.VarChar(500)
|
||||||
slug String @unique() @db.VarChar(100)
|
slug String @unique() @db.VarChar(100)
|
||||||
|
|
@ -298,7 +298,7 @@ model Link {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Pixel {
|
model Pixel {
|
||||||
id String @id() @unique() @map("pixel_id") @db.Uuid
|
id String @id() @map("pixel_id") @db.Uuid
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
slug String @unique() @db.VarChar(100)
|
slug String @unique() @db.VarChar(100)
|
||||||
userId String? @map("user_id") @db.Uuid
|
userId String? @map("user_id") @db.Uuid
|
||||||
|
|
@ -316,3 +316,39 @@ model Pixel {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("pixel")
|
@@map("pixel")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Board {
|
||||||
|
id String @id() @map("board_id") @db.Uuid
|
||||||
|
type String @db.VarChar(50)
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
description String @db.VarChar(500)
|
||||||
|
parameters Json
|
||||||
|
slug String @unique() @db.VarChar(100)
|
||||||
|
userId String? @map("user_id") @db.Uuid
|
||||||
|
teamId String? @map("team_id") @db.Uuid
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
user User? @relation("user", fields: [userId], references: [id])
|
||||||
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
|
|
||||||
|
@@index([slug])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([teamId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("board")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Share {
|
||||||
|
id String @id() @map("share_id") @db.Uuid
|
||||||
|
entityId String @map("entity_id") @db.Uuid
|
||||||
|
name String @db.VarChar(200)
|
||||||
|
shareType Int @map("share_type") @db.Integer
|
||||||
|
slug String @unique() @db.VarChar(100)
|
||||||
|
parameters Json
|
||||||
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@index([entityId])
|
||||||
|
@@map("share")
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
public/images/country/t1.png
Normal file
BIN
public/images/country/t1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -5,6 +5,18 @@
|
||||||
"value": "访问代码"
|
"value": "访问代码"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.account": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "账户"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.action": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "行为"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.actions": [
|
"label.actions": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -35,12 +47,24 @@
|
||||||
"value": "添加描述"
|
"value": "添加描述"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.add-link": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "添加链接"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.add-member": [
|
"label.add-member": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "添加成员"
|
"value": "添加成员"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.add-pixel": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "添加像素"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.add-step": [
|
"label.add-step": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -83,12 +107,24 @@
|
||||||
"value": "所有时间段"
|
"value": "所有时间段"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.analysis": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "分析"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.analytics": [
|
"label.analytics": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "分析"
|
"value": "分析"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.application": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "应用"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.apply": [
|
"label.apply": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -107,6 +143,12 @@
|
||||||
"value": "查看用户如何与您的营销互动,以及是什么促成了转化。"
|
"value": "查看用户如何与您的营销互动,以及是什么促成了转化。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.audience": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "受众"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.average": [
|
"label.average": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -125,6 +167,12 @@
|
||||||
"value": "之前"
|
"value": "之前"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.behavior": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "行为"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.boards": [
|
"label.boards": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -173,12 +221,24 @@
|
||||||
"value": "修改密码"
|
"value": "修改密码"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.channel": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "渠道"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.channels": [
|
"label.channels": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "渠道"
|
"value": "渠道"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.chart": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "图表"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.cities": [
|
"label.cities": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -203,6 +263,12 @@
|
||||||
"value": "队列"
|
"value": "队列"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.cohorts": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "队列"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.compare": [
|
"label.compare": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -317,6 +383,12 @@
|
||||||
"value": "创建者"
|
"value": "创建者"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.criteria": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "条件"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.currency": [
|
"label.currency": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -419,6 +491,12 @@
|
||||||
"value": "台式机"
|
"value": "台式机"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.destination-url": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "目标URL"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.details": [
|
"label.details": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -455,6 +533,12 @@
|
||||||
"value": "唯一ID"
|
"value": "唯一ID"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.documentation": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "文档"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.does-not-contain": [
|
"label.does-not-contain": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -479,6 +563,12 @@
|
||||||
"value": "域名"
|
"value": "域名"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.download": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "下载"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.dropoff": [
|
"label.dropoff": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -506,7 +596,7 @@
|
||||||
"label.email": [
|
"label.email": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Email"
|
"value": "邮箱"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.enable-share-url": [
|
"label.enable-share-url": [
|
||||||
|
|
@ -527,6 +617,12 @@
|
||||||
"value": "入口 URL"
|
"value": "入口 URL"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.environment": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "环境"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.event": [
|
"label.event": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -671,6 +767,12 @@
|
||||||
"value": "分组"
|
"value": "分组"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.growth": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "增长"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.hostname": [
|
"label.hostname": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -701,6 +803,12 @@
|
||||||
"value": "通过使用筛选器和划分时间段来更深入地研究数据。"
|
"value": "通过使用筛选器和划分时间段来更深入地研究数据。"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.invalid-url": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "无效URL"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.is": [
|
"label.is": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -863,12 +971,24 @@
|
||||||
"value": "少于等于"
|
"value": "少于等于"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.link": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "链接"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.links": [
|
"label.links": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "链接"
|
"value": "链接"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.location": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "位置"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.login": [
|
"label.login": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1020,7 +1140,7 @@
|
||||||
"label.online": [
|
"label.online": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Online"
|
"value": "在线"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.organic-search": [
|
"label.organic-search": [
|
||||||
|
|
@ -1165,6 +1285,12 @@
|
||||||
"value": "路径"
|
"value": "路径"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.pixel": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "像素"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.pixels": [
|
"label.pixels": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1185,6 +1311,12 @@
|
||||||
"value": " 提供支持"
|
"value": " 提供支持"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.preferences": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "偏好"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.previous": [
|
"label.previous": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1209,6 +1341,12 @@
|
||||||
"value": "个人资料"
|
"value": "个人资料"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.profiles": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "个人资料"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.properties": [
|
"label.properties": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1248,7 +1386,7 @@
|
||||||
"label.referral": [
|
"label.referral": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Referral"
|
"value": "来源"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"label.referrer": [
|
"label.referrer": [
|
||||||
|
|
@ -1371,6 +1509,24 @@
|
||||||
"value": "保存"
|
"value": "保存"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.save-cohort": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "保存为群组"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.save-segment": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "保存为细分"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.screen": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "屏幕"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.screens": [
|
"label.screens": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1383,6 +1539,18 @@
|
||||||
"value": "搜索"
|
"value": "搜索"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.segment": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "细分"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.segments": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "细分"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.select": [
|
"label.select": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1485,6 +1653,24 @@
|
||||||
"value": "总和"
|
"value": "总和"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.support": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "支持"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.switch-account": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "切换账户"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"label.table": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "表格"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.tablet": [
|
"label.tablet": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1635,6 +1821,12 @@
|
||||||
"value": "跟踪代码"
|
"value": "跟踪代码"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"label.traffic": [
|
||||||
|
{
|
||||||
|
"type": 0,
|
||||||
|
"value": "流量"
|
||||||
|
}
|
||||||
|
],
|
||||||
"label.transactions": [
|
"label.transactions": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
|
|
@ -1846,7 +2038,7 @@
|
||||||
"message.bad-request": [
|
"message.bad-request": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Bad request"
|
"value": "请求错误"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.collected-data": [
|
"message.collected-data": [
|
||||||
|
|
@ -1946,7 +2138,7 @@
|
||||||
"message.forbidden": [
|
"message.forbidden": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Forbidden"
|
"value": "禁止访问"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.go-to-settings": [
|
"message.go-to-settings": [
|
||||||
|
|
@ -2046,13 +2238,13 @@
|
||||||
"message.not-found": [
|
"message.not-found": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Not found"
|
"value": "未找到"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.nothing-selected": [
|
"message.nothing-selected": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Nothing selected."
|
"value": "未选择"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.page-not-found": [
|
"message.page-not-found": [
|
||||||
|
|
@ -2090,7 +2282,7 @@
|
||||||
"message.sever-error": [
|
"message.sever-error": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Server error"
|
"value": "服务器错误"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.share-url": [
|
"message.share-url": [
|
||||||
|
|
@ -2158,7 +2350,7 @@
|
||||||
"message.unauthorized": [
|
"message.unauthorized": [
|
||||||
{
|
{
|
||||||
"type": 0,
|
"type": 0,
|
||||||
"value": "Unauthorized"
|
"value": "未授权"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"message.user-deleted": [
|
"message.user-deleted": [
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@
|
||||||
"AD-06": "Sant Julia de Loria",
|
"AD-06": "Sant Julia de Loria",
|
||||||
"AD-07": "Andorra la Vella",
|
"AD-07": "Andorra la Vella",
|
||||||
"AD-08": "Escaldes-Engordany",
|
"AD-08": "Escaldes-Engordany",
|
||||||
"AE-AJ": "'Ajman",
|
"AE-AJ": "Ajman",
|
||||||
"AE-AZ": "Abu Zaby",
|
"AE-AZ": "Abu Dhabi",
|
||||||
"AE-DU": "Dubayy",
|
"AE-DU": "Dubai",
|
||||||
"AE-FU": "Al Fujayrah",
|
"AE-FU": "Al Fujairah",
|
||||||
"AE-RK": "Ra's al Khaymah",
|
"AE-RK": "Ras al Khaimah",
|
||||||
"AE-SH": "Ash Shariqah",
|
"AE-SH": "Sharjah",
|
||||||
"AE-UQ": "Umm al Qaywayn",
|
"AE-UQ": "Umm al Quwain",
|
||||||
"AF-BAL": "Balkh",
|
"AF-BAL": "Balkh",
|
||||||
"AF-BAM": "Bamyan",
|
"AF-BAM": "Bamyan",
|
||||||
"AF-BDG": "Badghis",
|
"AF-BDG": "Badghis",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import 'dotenv/config';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import tar from 'tar';
|
import { list } from 'tar';
|
||||||
import zlib from 'zlib';
|
import zlib from 'zlib';
|
||||||
|
|
||||||
if (process.env.VERCEL && !process.env.BUILD_GEO) {
|
if (process.env.VERCEL && !process.env.BUILD_GEO) {
|
||||||
|
|
@ -40,7 +40,7 @@ const isDirectMmdb = url.endsWith('.mmdb');
|
||||||
const downloadCompressed = url =>
|
const downloadCompressed = url =>
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
https.get(url, res => {
|
https.get(url, res => {
|
||||||
resolve(res.pipe(zlib.createGunzip({})).pipe(tar.t()));
|
resolve(res.pipe(zlib.createGunzip({})).pipe(list()));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export function MobileNav() {
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavMenu padding="3" onItemClick={close} border="bottom">
|
<NavMenu padding="3" onItemClick={close} border="bottom" width="100%">
|
||||||
<NavButton />
|
<NavButton />
|
||||||
{links.map(link => {
|
{links.map(link => {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { TeamsAddButton } from '../../teams/TeamsAddButton';
|
||||||
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
|
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
|
||||||
|
|
||||||
export function AdminTeamsPage() {
|
export function AdminTeamsPage() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
const handleSave = () => {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap="6" margin="2">
|
<Column gap="6" margin="2">
|
||||||
<PageHeader title={formatMessage(labels.teams)} />
|
<PageHeader title={formatMessage(labels.teams)}>
|
||||||
|
<TeamsAddButton onSave={handleSave} isAdmin={true} />
|
||||||
|
</PageHeader>
|
||||||
<Panel>
|
<Panel>
|
||||||
<AdminTeamsDataTable />
|
<AdminTeamsDataTable />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||||
|
import { messages } from '@/components/messages';
|
||||||
import { ROLES } from '@/lib/constants';
|
import { ROLES } from '@/lib/constants';
|
||||||
|
|
||||||
export function UserAddForm({ onSave, onClose }) {
|
export function UserAddForm({ onSave, onClose }) {
|
||||||
|
|
@ -37,7 +38,10 @@ export function UserAddForm({ onSave, onClose }) {
|
||||||
<FormField
|
<FormField
|
||||||
label={formatMessage(labels.password)}
|
label={formatMessage(labels.password)}
|
||||||
name="password"
|
name="password"
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
rules={{
|
||||||
|
required: formatMessage(labels.required),
|
||||||
|
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PasswordField autoComplete="new-password" data-test="input-password" />
|
<PasswordField autoComplete="new-password" data-test="input-password" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,11 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}>
|
<Form
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||||
import { useDeleteQuery, useMessages } from '@/components/hooks';
|
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
|
||||||
import { Trash } from '@/components/icons';
|
import { Trash } from '@/components/icons';
|
||||||
import { DialogButton } from '@/components/input/DialogButton';
|
import { DialogButton } from '@/components/input/DialogButton';
|
||||||
import { messages } from '@/components/messages';
|
import { messages } from '@/components/messages';
|
||||||
|
|
@ -15,7 +15,8 @@ export function LinkDeleteButton({
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
||||||
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
|
const { mutateAsync, isPending, error } = useDeleteQuery(`/links/${linkId}`);
|
||||||
|
const { touch } = useModified();
|
||||||
|
|
||||||
const handleConfirm = async (close: () => void) => {
|
const handleConfirm = async (close: () => void) => {
|
||||||
await mutateAsync(null, {
|
await mutateAsync(null, {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import {
|
||||||
Form,
|
Form,
|
||||||
FormField,
|
FormField,
|
||||||
FormSubmitButton,
|
FormSubmitButton,
|
||||||
|
Grid,
|
||||||
Icon,
|
Icon,
|
||||||
Label,
|
Label,
|
||||||
Loading,
|
Loading,
|
||||||
Row,
|
Row,
|
||||||
TextField,
|
TextField,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
|
import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
|
||||||
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
|
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
|
||||||
import { RefreshCw } from '@/components/icons';
|
import { RefreshCw } from '@/components/icons';
|
||||||
|
|
@ -42,27 +43,20 @@ export function LinkEditForm({
|
||||||
const { linksUrl } = useConfig();
|
const { linksUrl } = useConfig();
|
||||||
const hostUrl = linksUrl || LINKS_URL;
|
const hostUrl = linksUrl || LINKS_URL;
|
||||||
const { data, isLoading } = useLinkQuery(linkId);
|
const { data, isLoading } = useLinkQuery(linkId);
|
||||||
const [slug, setSlug] = useState(generateId());
|
const [defaultSlug] = useState(generateId());
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: any) => {
|
||||||
await mutateAsync(data, {
|
await mutateAsync(data, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast(formatMessage(messages.saved));
|
toast(formatMessage(messages.saved));
|
||||||
touch('links');
|
touch('links');
|
||||||
|
touch(`link:${linkId}`);
|
||||||
onSave?.();
|
onSave?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSlug = () => {
|
|
||||||
const slug = generateId();
|
|
||||||
|
|
||||||
setSlug(slug);
|
|
||||||
|
|
||||||
return slug;
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkUrl = (url: string) => {
|
const checkUrl = (url: string) => {
|
||||||
if (!isValidUrl(url)) {
|
if (!isValidUrl(url)) {
|
||||||
return formatMessage(labels.invalidUrl);
|
return formatMessage(labels.invalidUrl);
|
||||||
|
|
@ -70,19 +64,19 @@ export function LinkEditForm({
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
setSlug(data.slug);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (linkId && isLoading) {
|
if (linkId && isLoading) {
|
||||||
return <Loading placement="absolute" />;
|
return <Loading placement="absolute" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
|
<Form
|
||||||
{({ setValue }) => {
|
onSubmit={handleSubmit}
|
||||||
|
error={getErrorMessage(error)}
|
||||||
|
defaultValues={{ slug: defaultSlug, ...data }}
|
||||||
|
>
|
||||||
|
{({ setValue, watch }) => {
|
||||||
|
const slug = watch('slug');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
@ -101,15 +95,25 @@ export function LinkEditForm({
|
||||||
<TextField placeholder="https://example.com" autoComplete="off" />
|
<TextField placeholder="https://example.com" autoComplete="off" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<Grid columns="1fr auto" alignItems="end" gap>
|
||||||
<FormField
|
<FormField
|
||||||
name="slug"
|
name="slug"
|
||||||
|
label={formatMessage({ id: 'label.slug', defaultMessage: 'Slug' })}
|
||||||
rules={{
|
rules={{
|
||||||
required: formatMessage(labels.required),
|
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>
|
||||||
|
|
@ -121,14 +125,6 @@ export function LinkEditForm({
|
||||||
allowCopy
|
allowCopy
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="quiet"
|
|
||||||
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
|
|
||||||
>
|
|
||||||
<Icon>
|
|
||||||
<RefreshCw />
|
|
||||||
</Icon>
|
|
||||||
</Button>
|
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
export function LinksDataTable({ showActions = false }: { showActions?: boolean }) {
|
||||||
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} />}
|
{({ data }) => <LinksTable data={data} showActions={showActions} />}
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,30 @@ 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 { useMessages, useNavigation } from '@/components/hooks';
|
import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } 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)}>
|
||||||
<LinkAddButton teamId={teamId} />
|
{showActions && <LinkAddButton teamId={teamId} />}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Panel>
|
<Panel>
|
||||||
<LinksDataTable />
|
<LinksDataTable showActions={showActions} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</Column>
|
</Column>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||||
import { LinkDeleteButton } from './LinkDeleteButton';
|
import { LinkDeleteButton } from './LinkDeleteButton';
|
||||||
import { LinkEditButton } from './LinkEditButton';
|
import { LinkEditButton } from './LinkEditButton';
|
||||||
|
|
||||||
export function LinksTable(props: DataTableProps) {
|
export interface LinksTableProps extends 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');
|
||||||
|
|
@ -36,6 +40,7 @@ export function LinksTable(props: DataTableProps) {
|
||||||
<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 (
|
||||||
|
|
@ -46,6 +51,7 @@ export function LinksTable(props: DataTableProps) {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
|
)}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export function PixelEditForm({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast(formatMessage(messages.saved));
|
toast(formatMessage(messages.saved));
|
||||||
touch('pixels');
|
touch('pixels');
|
||||||
|
touch(`pixel:${pixelId}`);
|
||||||
onSave?.();
|
onSave?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
export function PixelsDataTable({ showActions = false }: { showActions?: boolean }) {
|
||||||
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} />}
|
{({ data }) => <PixelsTable data={data} showActions={showActions} />}
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,31 @@ 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 { useMessages, useNavigation } from '@/components/hooks';
|
import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } 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)}>
|
||||||
<PixelAddButton teamId={teamId} />
|
{showActions && <PixelAddButton teamId={teamId} />}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Panel>
|
<Panel>
|
||||||
<PixelsDataTable />
|
<PixelsDataTable showActions={showActions} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</Column>
|
</Column>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||||
import { PixelDeleteButton } from './PixelDeleteButton';
|
import { PixelDeleteButton } from './PixelDeleteButton';
|
||||||
import { PixelEditButton } from './PixelEditButton';
|
import { PixelEditButton } from './PixelEditButton';
|
||||||
|
|
||||||
export function PixelsTable(props: DataTableProps) {
|
export interface PixelsTableProps extends 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');
|
||||||
|
|
@ -31,6 +35,7 @@ export function PixelsTable(props: DataTableProps) {
|
||||||
<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;
|
||||||
|
|
@ -43,6 +48,7 @@ export function PixelsTable(props: DataTableProps) {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
|
)}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { DateRangeSetting } from './DateRangeSetting';
|
||||||
import { LanguageSetting } from './LanguageSetting';
|
import { LanguageSetting } from './LanguageSetting';
|
||||||
import { ThemeSetting } from './ThemeSetting';
|
import { ThemeSetting } from './ThemeSetting';
|
||||||
import { TimezoneSetting } from './TimezoneSetting';
|
import { TimezoneSetting } from './TimezoneSetting';
|
||||||
|
import { VersionSetting } from './VersionSetting';
|
||||||
|
|
||||||
export function PreferenceSettings() {
|
export function PreferenceSettings() {
|
||||||
const { user } = useLoginQuery();
|
const { user } = useLoginQuery();
|
||||||
|
|
@ -31,6 +32,10 @@ export function PreferenceSettings() {
|
||||||
<Label>{formatMessage(labels.theme)}</Label>
|
<Label>{formatMessage(labels.theme)}</Label>
|
||||||
<ThemeSetting />
|
<ThemeSetting />
|
||||||
</Column>
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<Label>{formatMessage(labels.version)}</Label>
|
||||||
|
<VersionSetting />
|
||||||
|
</Column>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
src/app/(main)/settings/preferences/VersionSetting.tsx
Normal file
8
src/app/(main)/settings/preferences/VersionSetting.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Text } from '@umami/react-zen';
|
||||||
|
import { CURRENT_VERSION } from '@/lib/constants';
|
||||||
|
|
||||||
|
export function VersionSetting() {
|
||||||
|
return <Text>{CURRENT_VERSION}</Text>;
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,17 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||||
|
import { UserSelect } from '@/components/input/UserSelect';
|
||||||
|
|
||||||
export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) {
|
export function TeamAddForm({
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
isAdmin,
|
||||||
|
}: {
|
||||||
|
onSave: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}) {
|
||||||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||||
const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
|
const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
|
||||||
|
|
||||||
|
|
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
|
||||||
<FormField name="name" label={formatMessage(labels.name)}>
|
<FormField name="name" label={formatMessage(labels.name)}>
|
||||||
<TextField autoComplete="off" />
|
<TextField autoComplete="off" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{isAdmin && (
|
||||||
|
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
|
||||||
|
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button isDisabled={isPending} onPress={onClose}>
|
<Button isDisabled={isPending} onPress={onClose}>
|
||||||
{formatMessage(labels.cancel)}
|
{formatMessage(labels.cancel)}
|
||||||
|
|
|
||||||
76
src/app/(main)/teams/TeamMemberAddForm.tsx
Normal file
76
src/app/(main)/teams/TeamMemberAddForm.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormButtons,
|
||||||
|
FormField,
|
||||||
|
FormSubmitButton,
|
||||||
|
ListItem,
|
||||||
|
Select,
|
||||||
|
} from '@umami/react-zen';
|
||||||
|
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
||||||
|
import { UserSelect } from '@/components/input/UserSelect';
|
||||||
|
import { ROLES } from '@/lib/constants';
|
||||||
|
|
||||||
|
const roles = [ROLES.teamManager, ROLES.teamMember, ROLES.teamViewOnly];
|
||||||
|
|
||||||
|
export function TeamMemberAddForm({
|
||||||
|
teamId,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
teamId: string;
|
||||||
|
onSave?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||||
|
const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users`);
|
||||||
|
|
||||||
|
const handleSubmit = async (data: any) => {
|
||||||
|
await mutateAsync(data, {
|
||||||
|
onSuccess: async () => {
|
||||||
|
onSave?.();
|
||||||
|
onClose?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRole = role => {
|
||||||
|
switch (role) {
|
||||||
|
case ROLES.teamManager:
|
||||||
|
return formatMessage(labels.manager);
|
||||||
|
case ROLES.teamMember:
|
||||||
|
return formatMessage(labels.member);
|
||||||
|
case ROLES.teamViewOnly:
|
||||||
|
return formatMessage(labels.viewOnly);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
|
||||||
|
<FormField
|
||||||
|
name="userId"
|
||||||
|
label={formatMessage(labels.username)}
|
||||||
|
rules={{ required: 'Required' }}
|
||||||
|
>
|
||||||
|
<UserSelect teamId={teamId} />
|
||||||
|
</FormField>
|
||||||
|
<FormField name="role" label={formatMessage(labels.role)} rules={{ required: 'Required' }}>
|
||||||
|
<Select items={roles} renderValue={value => renderRole(value as any)}>
|
||||||
|
{roles.map(value => (
|
||||||
|
<ListItem key={value} id={value}>
|
||||||
|
{renderRole(value)}
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormButtons>
|
||||||
|
<Button isDisabled={isPending} onPress={onClose}>
|
||||||
|
{formatMessage(labels.cancel)}
|
||||||
|
</Button>
|
||||||
|
<FormSubmitButton variant="primary" isDisabled={isPending}>
|
||||||
|
{formatMessage(labels.save)}
|
||||||
|
</FormSubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,13 @@ import { Plus } from '@/components/icons';
|
||||||
import { messages } from '@/components/messages';
|
import { messages } from '@/components/messages';
|
||||||
import { TeamAddForm } from './TeamAddForm';
|
import { TeamAddForm } from './TeamAddForm';
|
||||||
|
|
||||||
export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
|
export function TeamsAddButton({
|
||||||
|
onSave,
|
||||||
|
isAdmin = false,
|
||||||
|
}: {
|
||||||
|
onSave?: () => void;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { touch } = useModified();
|
const { touch } = useModified();
|
||||||
|
|
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
|
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
|
||||||
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />}
|
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
|
||||||
40
src/app/(main)/teams/TeamsMemberAddButton.tsx
Normal file
40
src/app/(main)/teams/TeamsMemberAddButton.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
|
||||||
|
import { useMessages, useModified } from '@/components/hooks';
|
||||||
|
import { Plus } from '@/components/icons';
|
||||||
|
import { messages } from '@/components/messages';
|
||||||
|
import { TeamMemberAddForm } from './TeamMemberAddForm';
|
||||||
|
|
||||||
|
export function TeamsMemberAddButton({
|
||||||
|
teamId,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
teamId: string;
|
||||||
|
onSave?: () => void;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { touch } = useModified();
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
toast(formatMessage(messages.saved));
|
||||||
|
touch('teams:members');
|
||||||
|
onSave?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button>
|
||||||
|
<Icon>
|
||||||
|
<Plus />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.addMember)}</Text>
|
||||||
|
</Button>
|
||||||
|
<Modal>
|
||||||
|
<Dialog title={formatMessage(labels.addMember)} style={{ width: 400 }}>
|
||||||
|
{({ close }) => <TeamMemberAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</DialogTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column, Heading, Row } from '@umami/react-zen';
|
||||||
import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
|
import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks';
|
import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks';
|
||||||
import { Users } from '@/components/icons';
|
import { Users } from '@/components/icons';
|
||||||
|
import { labels } from '@/components/messages';
|
||||||
import { ROLES } from '@/lib/constants';
|
import { ROLES } from '@/lib/constants';
|
||||||
|
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
|
||||||
import { TeamEditForm } from './TeamEditForm';
|
import { TeamEditForm } from './TeamEditForm';
|
||||||
import { TeamManage } from './TeamManage';
|
import { TeamManage } from './TeamManage';
|
||||||
import { TeamMembersDataTable } from './TeamMembersDataTable';
|
import { TeamMembersDataTable } from './TeamMembersDataTable';
|
||||||
|
|
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
|
||||||
const team: any = useTeam();
|
const team: any = useTeam();
|
||||||
const { user } = useLoginQuery();
|
const { user } = useLoginQuery();
|
||||||
const { pathname } = useNavigation();
|
const { pathname } = useNavigation();
|
||||||
|
const { formatMessage } = useMessages();
|
||||||
|
|
||||||
const isAdmin = pathname.includes('/admin');
|
const isAdmin = pathname.includes('/admin');
|
||||||
|
|
||||||
|
|
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
|
||||||
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
|
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel>
|
<Panel>
|
||||||
|
<Row alignItems="center" justifyContent="space-between">
|
||||||
|
<Heading size="2">{formatMessage(labels.members)}</Heading>
|
||||||
|
{isAdmin && <TeamsMemberAddButton teamId={teamId} />}
|
||||||
|
</Row>
|
||||||
<TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
|
<TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
|
||||||
</Panel>
|
</Panel>
|
||||||
{isTeamOwner && (
|
{isTeamOwner && (
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,31 @@ 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 { useMessages, useNavigation } from '@/components/hooks';
|
import { useLoginQuery, useMessages, useNavigation, useTeamMembersQuery } 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)}>
|
||||||
<WebsiteAddButton teamId={teamId} />
|
{showActions && <WebsiteAddButton teamId={teamId} />}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Panel>
|
<Panel>
|
||||||
<WebsitesDataTable teamId={teamId} />
|
<WebsitesDataTable teamId={teamId} showActions={showActions} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</Column>
|
</Column>
|
||||||
</PageBody>
|
</PageBody>
|
||||||
|
|
|
||||||
|
|
@ -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, useResultQuery } from '@/components/hooks';
|
import { useMessages, useNavigation, 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,6 +20,8 @@ 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,
|
||||||
|
|
@ -36,13 +38,13 @@ 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} />
|
||||||
|
|
@ -51,6 +53,7 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
|
||||||
}}
|
}}
|
||||||
</ReportEditButton>
|
</ReportEditButton>
|
||||||
</Column>
|
</Column>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
{data?.map(
|
{data?.map(
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -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, useReportsQuery } from '@/components/hooks';
|
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
|
||||||
import { Funnel } from './Funnel';
|
import { Funnel } from './Funnel';
|
||||||
import { FunnelAddButton } from './FunnelAddButton';
|
import { FunnelAddButton } from './FunnelAddButton';
|
||||||
|
|
||||||
|
|
@ -13,13 +13,17 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -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, useResultQuery } from '@/components/hooks';
|
import { useMessages, useNavigation, 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,6 +25,8 @@ 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,
|
||||||
|
|
@ -45,6 +47,7 @@ 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 }) => {
|
||||||
|
|
@ -60,6 +63,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
||||||
}}
|
}}
|
||||||
</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">
|
||||||
|
|
|
||||||
|
|
@ -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, useReportsQuery } from '@/components/hooks';
|
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
|
||||||
import { Goal } from './Goal';
|
import { Goal } from './Goal';
|
||||||
import { GoalAddButton } from './GoalAddButton';
|
import { GoalAddButton } from './GoalAddButton';
|
||||||
|
|
||||||
|
|
@ -13,13 +13,17 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,15 @@ export interface JourneyProps {
|
||||||
steps: number;
|
steps: number;
|
||||||
startStep?: string;
|
startStep?: string;
|
||||||
endStep?: string;
|
endStep?: string;
|
||||||
|
view: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) {
|
const EVENT_TYPES = {
|
||||||
|
views: 1,
|
||||||
|
events: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) {
|
||||||
const [selectedNode, setSelectedNode] = useState(null);
|
const [selectedNode, setSelectedNode] = useState(null);
|
||||||
const [activeNode, setActiveNode] = useState(null);
|
const [activeNode, setActiveNode] = useState(null);
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
|
||||||
steps,
|
steps,
|
||||||
startStep,
|
startStep,
|
||||||
endStep,
|
endStep,
|
||||||
|
view,
|
||||||
|
eventType: EVENT_TYPES[view],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEscapeKey(() => setSelectedNode(null));
|
useEscapeKey(() => setSelectedNode(null));
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
|
import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { useDateRange, useMessages } from '@/components/hooks';
|
import { useDateRange, useMessages } from '@/components/hooks';
|
||||||
|
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||||
import { Journey } from './Journey';
|
import { Journey } from './Journey';
|
||||||
|
|
||||||
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
|
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
|
||||||
|
|
@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
||||||
const {
|
const {
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
} = useDateRange();
|
} = useDateRange();
|
||||||
|
const [view, setView] = useState('all');
|
||||||
const [steps, setSteps] = useState(DEFAULT_STEP);
|
const [steps, setSteps] = useState(DEFAULT_STEP);
|
||||||
const [startStep, setStartStep] = useState('');
|
const [startStep, setStartStep] = useState('');
|
||||||
const [endStep, setEndStep] = useState('');
|
const [endStep, setEndStep] = useState('');
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
label: formatMessage(labels.all),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'views',
|
||||||
|
label: formatMessage(labels.views),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events',
|
||||||
|
label: formatMessage(labels.events),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
|
|
@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
||||||
/>
|
/>
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Row justifyContent="flex-end">
|
||||||
|
<FilterButtons items={buttons} value={view} onChange={setView} />
|
||||||
|
</Row>
|
||||||
<Panel height="900px" allowFullscreen>
|
<Panel height="900px" allowFullscreen>
|
||||||
<Journey
|
<Journey
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
|
|
@ -60,6 +80,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
||||||
steps={steps}
|
steps={steps}
|
||||||
startStep={startStep}
|
startStep={startStep}
|
||||||
endStep={endStep}
|
endStep={endStep}
|
||||||
|
view={view}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ import { ListTable } from '@/components/metrics/ListTable';
|
||||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||||
import { renderDateLabels } from '@/lib/charts';
|
import { renderDateLabels } from '@/lib/charts';
|
||||||
import { CHART_COLORS } from '@/lib/constants';
|
import { CHART_COLORS, CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
|
||||||
import { generateTimeSeries } from '@/lib/date';
|
import { generateTimeSeries } from '@/lib/date';
|
||||||
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
||||||
|
import { getItem, setItem } from '@/lib/storage';
|
||||||
|
|
||||||
export interface RevenueProps {
|
export interface RevenueProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
|
|
@ -24,7 +25,15 @@ export interface RevenueProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
||||||
const [currency, setCurrency] = useState('USD');
|
const [currency, setCurrency] = useState(
|
||||||
|
getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCurrencyChange = (value: string) => {
|
||||||
|
setCurrency(value);
|
||||||
|
setItem(CURRENCY_CONFIG, value);
|
||||||
|
};
|
||||||
|
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { locale, dateLocale } = useLocale();
|
const { locale, dateLocale } = useLocale();
|
||||||
const { countryNames } = useCountryNames(locale);
|
const { countryNames } = useCountryNames(locale);
|
||||||
|
|
@ -107,7 +116,7 @@ export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<Grid columns="280px" gap>
|
<Grid columns="280px" gap>
|
||||||
<CurrencySelect value={currency} onChange={setCurrency} />
|
<CurrencySelect value={currency} onChange={handleCurrencyChange} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||||
{data && (
|
{data && (
|
||||||
|
|
|
||||||
|
|
@ -21,30 +21,28 @@ export function WebsiteChart({
|
||||||
const { pageviews, sessions, compare } = (data || {}) as any;
|
const { pageviews, sessions, compare } = (data || {}) as any;
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
if (data) {
|
if (!data) {
|
||||||
const result = {
|
return { pageviews: [], sessions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
pageviews,
|
pageviews,
|
||||||
sessions,
|
sessions,
|
||||||
};
|
...(compare && {
|
||||||
|
compare: {
|
||||||
if (compare) {
|
pageviews: pageviews.map(({ x }, i) => ({
|
||||||
result.compare = {
|
|
||||||
pageviews: result.pageviews.map(({ x }, i) => ({
|
|
||||||
x,
|
x,
|
||||||
y: compare.pageviews[i]?.y,
|
y: compare.pageviews[i]?.y,
|
||||||
d: compare.pageviews[i]?.x,
|
d: compare.pageviews[i]?.x,
|
||||||
})),
|
})),
|
||||||
sessions: result.sessions.map(({ x }, i) => ({
|
sessions: sessions.map(({ x }, i) => ({
|
||||||
x,
|
x,
|
||||||
y: compare.sessions[i]?.y,
|
y: compare.sessions[i]?.y,
|
||||||
d: compare.sessions[i]?.x,
|
d: compare.sessions[i]?.x,
|
||||||
})),
|
})),
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return { pageviews: [], sessions: [] };
|
|
||||||
}, [data, startDate, endDate, unit]);
|
}, [data, startDate, endDate, unit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,12 @@ export function WebsiteExpandedMenu({
|
||||||
path: updateParams({ view: 'hostname' }),
|
path: updateParams({ view: 'hostname' }),
|
||||||
icon: <Network />,
|
icon: <Network />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'distinctId',
|
||||||
|
label: formatMessage(labels.distinctId),
|
||||||
|
path: updateParams({ view: 'distinctId' }),
|
||||||
|
icon: <Tag />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'tag',
|
id: 'tag',
|
||||||
label: formatMessage(labels.tag),
|
label: formatMessage(labels.tag),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { Icon, Row, Text } from '@umami/react-zen';
|
import { Icon, Row, Text } from '@umami/react-zen';
|
||||||
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
|
|
||||||
import { Favicon } from '@/components/common/Favicon';
|
import { Favicon } from '@/components/common/Favicon';
|
||||||
import { LinkButton } from '@/components/common/LinkButton';
|
import { LinkButton } from '@/components/common/LinkButton';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
|
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
|
||||||
import { Edit, Share } from '@/components/icons';
|
import { Edit } from '@/components/icons';
|
||||||
import { DialogButton } from '@/components/input/DialogButton';
|
|
||||||
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
|
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
|
||||||
|
|
||||||
export function WebsiteHeader({
|
export function WebsiteHeader({
|
||||||
|
|
@ -35,29 +33,14 @@ export function WebsiteHeader({
|
||||||
<ActiveUsers websiteId={website.id} />
|
<ActiveUsers websiteId={website.id} />
|
||||||
|
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<Row alignItems="center" gap>
|
|
||||||
<ShareButton websiteId={website.id} shareId={website.shareId} />
|
|
||||||
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
<LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
|
||||||
<Icon>
|
<Icon>
|
||||||
<Edit />
|
<Edit />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.edit)}</Text>
|
<Text>{formatMessage(labels.edit)}</Text>
|
||||||
</LinkButton>
|
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,10 @@ 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%" height="100%">
|
<Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="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"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { useMessages, useNavigation } from '@/components/hooks';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { Edit, More, Share } from '@/components/icons';
|
import { Edit, MoreHorizontal, Share } from '@/components/icons';
|
||||||
|
|
||||||
export function WebsiteMenu({ websiteId }: { websiteId: string }) {
|
export function WebsiteMenu({ websiteId }: { websiteId: string }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
|
||||||
<MenuTrigger>
|
<MenuTrigger>
|
||||||
<Button variant="quiet">
|
<Button variant="quiet">
|
||||||
<Icon>
|
<Icon>
|
||||||
<More />
|
<MoreHorizontal />
|
||||||
</Icon>
|
</Icon>
|
||||||
</Button>
|
</Button>
|
||||||
<Popover placement="bottom">
|
<Popover placement="bottom">
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
|
||||||
|
|
||||||
export function WebsiteMetricsBar({
|
export function WebsiteMetricsBar({
|
||||||
websiteId,
|
websiteId,
|
||||||
|
compareMode,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
showChange?: boolean;
|
showChange?: boolean;
|
||||||
compareMode?: boolean;
|
compareMode?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { isAllTime } = useDateRange();
|
const { isAllTime, dateCompare } = useDateRange();
|
||||||
const { formatMessage, labels, getErrorMessage } = useMessages();
|
const { formatMessage, labels, getErrorMessage } = useMessages();
|
||||||
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId);
|
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({
|
||||||
|
websiteId,
|
||||||
|
compare: compareMode ? dateCompare?.compare : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};
|
const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export function WebsiteNav({
|
||||||
event: undefined,
|
event: undefined,
|
||||||
compare: undefined,
|
compare: undefined,
|
||||||
view: undefined,
|
view: undefined,
|
||||||
|
unit: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column, Row } from '@umami/react-zen';
|
||||||
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { UnitFilter } from '@/components/input/UnitFilter';
|
||||||
import { WebsiteChart } from './WebsiteChart';
|
import { WebsiteChart } from './WebsiteChart';
|
||||||
import { WebsiteControls } from './WebsiteControls';
|
import { WebsiteControls } from './WebsiteControls';
|
||||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
||||||
|
|
@ -13,6 +14,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||||
<Panel minHeight="520px">
|
<Panel minHeight="520px">
|
||||||
|
<Row justifyContent="end">
|
||||||
|
<UnitFilter />
|
||||||
|
</Row>
|
||||||
<WebsiteChart websiteId={websiteId} />
|
<WebsiteChart websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<WebsitePanels websiteId={websiteId} />
|
<WebsitePanels websiteId={websiteId} />
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
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, useNavigation } from '@/components/hooks';
|
import { useMessages } 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,
|
||||||
|
|
@ -18,7 +16,6 @@ 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">
|
||||||
|
|
@ -116,25 +113,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} allowCompare={true} />
|
<WebsiteControls websiteId={websiteId} allowCompare={true} />
|
||||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
<WebsiteMetricsBar websiteId={websiteId} compareMode={true} showChange={true} />
|
||||||
<Panel minHeight="520px">
|
<Panel minHeight="520px">
|
||||||
<WebsiteChart websiteId={websiteId} compareMode={true} />
|
<WebsiteChart websiteId={websiteId} compareMode={true} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,11 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
|
||||||
label: formatMessage(labels.hostname),
|
label: formatMessage(labels.hostname),
|
||||||
path: renderPath('hostname'),
|
path: renderPath('hostname'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'distinctId',
|
||||||
|
label: formatMessage(labels.distinctId),
|
||||||
|
path: renderPath('distinctId'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'tag',
|
id: 'tag',
|
||||||
label: formatMessage(labels.tags),
|
label: formatMessage(labels.tags),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
'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 { type Key, useState } from 'react';
|
import locale from 'date-fns/locale/af';
|
||||||
|
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';
|
||||||
|
|
@ -15,16 +21,61 @@ 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 } = useMessages();
|
const { formatMessage, labels, getErrorMessage } = 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>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
|
||||||
const { updateParams } = useNavigation();
|
const { updateParams } = useNavigation();
|
||||||
const { formatValue } = useFormat();
|
const { formatValue } = useFormat();
|
||||||
|
|
||||||
|
const renderLink = (label: string, hostname: string) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`//${hostname}${label}`}
|
||||||
|
style={{ fontWeight: 'bold' }}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable {...props}>
|
<DataTable {...props}>
|
||||||
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
|
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
|
||||||
|
|
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
|
||||||
title={row.eventName || row.urlPath}
|
title={row.eventName || row.urlPath}
|
||||||
truncate
|
truncate
|
||||||
>
|
>
|
||||||
{row.eventName || row.urlPath}
|
{row.eventName || renderLink(row.urlPath, row.hostname)}
|
||||||
</Text>
|
</Text>
|
||||||
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
|
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,9 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
os: string;
|
os: string;
|
||||||
country: string;
|
country: string;
|
||||||
device: string;
|
device: string;
|
||||||
|
hostname: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { __type, eventName, urlPath, browser, os, country, device } = log;
|
const { __type, eventName, urlPath, browser, os, country, device, hostname } = log;
|
||||||
|
|
||||||
if (__type === TYPE_EVENT) {
|
if (__type === TYPE_EVENT) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -86,7 +87,8 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
url: (
|
url: (
|
||||||
<a
|
<a
|
||||||
key="a"
|
key="a"
|
||||||
href={`//${website?.domain}${urlPath}`}
|
href={`//${hostname}${urlPath}`}
|
||||||
|
style={{ fontWeight: 'bold' }}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
|
|
@ -100,7 +102,12 @@ export function RealtimeLog({ data }: { data: any }) {
|
||||||
|
|
||||||
if (__type === TYPE_PAGEVIEW) {
|
if (__type === TYPE_PAGEVIEW) {
|
||||||
return (
|
return (
|
||||||
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener">
|
<a
|
||||||
|
href={`//${hostname}${urlPath}`}
|
||||||
|
style={{ fontWeight: 'bold' }}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
{urlPath}
|
{urlPath}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,23 @@ export function SessionActivity({
|
||||||
const { isMobile } = useMobile();
|
const { isMobile } = useMobile();
|
||||||
let lastDay = null;
|
let lastDay = null;
|
||||||
|
|
||||||
|
const renderLink = (label: string, hostname: string) => {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`//${hostname}${label}`}
|
||||||
|
style={{ fontWeight: 'bold' }}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||||
<Column gap>
|
<Column gap>
|
||||||
{data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => {
|
{data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hostname, hasData }) => {
|
||||||
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
|
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
|
||||||
lastDay = createdAt;
|
lastDay = createdAt;
|
||||||
|
|
||||||
|
|
@ -61,7 +74,7 @@ export function SessionActivity({
|
||||||
: formatMessage(labels.viewedPage)}
|
: formatMessage(labels.viewedPage)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
|
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
|
||||||
{eventName || urlPath}
|
{eventName || renderLink(urlPath, hostname)}
|
||||||
</Text>
|
</Text>
|
||||||
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
|
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
|
||||||
|
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
|
||||||
|
import { Trash } from '@/components/icons';
|
||||||
|
import { DialogButton } from '@/components/input/DialogButton';
|
||||||
|
import { messages } from '@/components/messages';
|
||||||
|
|
||||||
|
export function ShareDeleteButton({
|
||||||
|
shareId,
|
||||||
|
slug,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
shareId: string;
|
||||||
|
slug: string;
|
||||||
|
onSave?: () => void;
|
||||||
|
}) {
|
||||||
|
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
|
||||||
|
const { mutateAsync, isPending, error } = useDeleteQuery(`/share/id/${shareId}`);
|
||||||
|
const { touch } = useModified();
|
||||||
|
|
||||||
|
const handleConfirm = async (close: () => void) => {
|
||||||
|
await mutateAsync(null, {
|
||||||
|
onSuccess: () => {
|
||||||
|
touch('shares');
|
||||||
|
onSave?.();
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogButton
|
||||||
|
icon={<Trash />}
|
||||||
|
title={formatMessage(labels.confirm)}
|
||||||
|
variant="quiet"
|
||||||
|
width="400px"
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<ConfirmationForm
|
||||||
|
message={
|
||||||
|
<FormattedMessage
|
||||||
|
{...messages.confirmRemove}
|
||||||
|
values={{
|
||||||
|
target: <b>{slug}</b>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isLoading={isPending}
|
||||||
|
error={getErrorMessage(error)}
|
||||||
|
onConfirm={handleConfirm.bind(null, close)}
|
||||||
|
onClose={close}
|
||||||
|
buttonLabel={formatMessage(labels.delete)}
|
||||||
|
buttonVariant="danger"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { Edit } from '@/components/icons';
|
||||||
|
import { DialogButton } from '@/components/input/DialogButton';
|
||||||
|
import { ShareEditForm } from './ShareEditForm';
|
||||||
|
|
||||||
|
export function ShareEditButton({ shareId }: { shareId: string }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogButton icon={<Edit />} title={formatMessage(labels.share)} variant="quiet" width="600px">
|
||||||
|
{({ close }) => {
|
||||||
|
return <ShareEditForm shareId={shareId} onClose={close} />;
|
||||||
|
}}
|
||||||
|
</DialogButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
Normal file
164
src/app/(main)/websites/[websiteId]/settings/ShareEditForm.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
Normal file
49
src/app/(main)/websites/[websiteId]/settings/SharesTable.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column } from '@umami/react-zen';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { useWebsite } from '@/components/hooks';
|
|
||||||
import { WebsiteData } from './WebsiteData';
|
import { WebsiteData } from './WebsiteData';
|
||||||
import { WebsiteEditForm } from './WebsiteEditForm';
|
import { WebsiteEditForm } from './WebsiteEditForm';
|
||||||
import { WebsiteShareForm } from './WebsiteShareForm';
|
import { WebsiteShareForm } from './WebsiteShareForm';
|
||||||
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
||||||
|
|
||||||
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
|
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
|
||||||
const website = useWebsite();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap="6">
|
<Column gap="6">
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
@ -18,7 +15,7 @@ export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal
|
||||||
<WebsiteTrackingCode websiteId={websiteId} />
|
<WebsiteTrackingCode websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel>
|
<Panel>
|
||||||
<WebsiteShareForm websiteId={websiteId} shareId={website.shareId} />
|
<WebsiteShareForm websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel>
|
<Panel>
|
||||||
<WebsiteData websiteId={websiteId} />
|
<WebsiteData websiteId={websiteId} />
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,43 @@
|
||||||
import {
|
import { Column, Heading, Row, Text } from '@umami/react-zen';
|
||||||
Button,
|
import { Plus } from 'lucide-react';
|
||||||
Column,
|
import { useMessages, useWebsiteSharesQuery } from '@/components/hooks';
|
||||||
Form,
|
import { DialogButton } from '@/components/input/DialogButton';
|
||||||
FormButtons,
|
import { ShareEditForm } from './ShareEditForm';
|
||||||
FormSubmitButton,
|
import { SharesTable } from './SharesTable';
|
||||||
IconLabel,
|
|
||||||
Label,
|
|
||||||
Row,
|
|
||||||
Switch,
|
|
||||||
TextField,
|
|
||||||
} from '@umami/react-zen';
|
|
||||||
import { RefreshCcw } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
|
|
||||||
import { getRandomChars } from '@/lib/generate';
|
|
||||||
|
|
||||||
const generateId = () => getRandomChars(16);
|
|
||||||
|
|
||||||
export interface WebsiteShareFormProps {
|
export interface WebsiteShareFormProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
shareId?: string;
|
|
||||||
onSave?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) {
|
export function WebsiteShareForm({ websiteId }: WebsiteShareFormProps) {
|
||||||
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const [currentId, setCurrentId] = useState(shareId);
|
const { data } = useWebsiteSharesQuery({ websiteId });
|
||||||
const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
|
|
||||||
const { cloudMode } = useConfig();
|
|
||||||
|
|
||||||
const getUrl = (shareId: string) => {
|
const shares = data?.data || [];
|
||||||
if (cloudMode) {
|
const hasShares = shares.length > 0;
|
||||||
return `${process.env.cloudUrl}/share/${shareId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = getUrl(currentId);
|
|
||||||
|
|
||||||
const handleGenerate = () => {
|
|
||||||
setCurrentId(generateId());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSwitch = () => {
|
|
||||||
setCurrentId(currentId ? null : generateId());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
const data = {
|
|
||||||
shareId: currentId,
|
|
||||||
};
|
|
||||||
await mutateAsync(data, {
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast(formatMessage(messages.saved));
|
|
||||||
touch(`website:${websiteId}`);
|
|
||||||
onSave?.();
|
|
||||||
onClose?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}>
|
<Column gap="4">
|
||||||
<Column gap>
|
<Row justifyContent="space-between" alignItems="center">
|
||||||
<Switch isSelected={!!currentId} onChange={handleSwitch}>
|
<Heading>{formatMessage(labels.share)}</Heading>
|
||||||
{formatMessage(labels.enableShareUrl)}
|
<DialogButton
|
||||||
</Switch>
|
icon={<Plus size={16} />}
|
||||||
{currentId && (
|
label={formatMessage(labels.add)}
|
||||||
<Row alignItems="flex-end" gap>
|
title={formatMessage(labels.share)}
|
||||||
<Column flexGrow={1}>
|
variant="primary"
|
||||||
<Label>{formatMessage(labels.shareUrl)}</Label>
|
width="600px"
|
||||||
<TextField value={url} isReadOnly allowCopy />
|
>
|
||||||
</Column>
|
{({ close }) => <ShareEditForm websiteId={websiteId} onClose={close} />}
|
||||||
<Column>
|
</DialogButton>
|
||||||
<Button onPress={handleGenerate}>
|
|
||||||
<IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} />
|
|
||||||
</Button>
|
|
||||||
</Column>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
{hasShares ? (
|
||||||
|
<>
|
||||||
|
<Text>{formatMessage(messages.shareUrl)}</Text>
|
||||||
|
<SharesTable data={shares} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text color="muted">{formatMessage(messages.noDataAvailable)}</Text>
|
||||||
)}
|
)}
|
||||||
<FormButtons justifyContent="flex-end">
|
|
||||||
<Row alignItems="center" gap>
|
|
||||||
{onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>}
|
|
||||||
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
|
|
||||||
</Row>
|
|
||||||
</FormButtons>
|
|
||||||
</Column>
|
</Column>
|
||||||
</Form>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
src/app/(main)/websites/[websiteId]/settings/constants.ts
Normal file
30
src/app/(main)/websites/[websiteId]/settings/constants.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export const SHARE_NAV_ITEMS = [
|
||||||
|
{
|
||||||
|
section: 'traffic',
|
||||||
|
items: [
|
||||||
|
{ id: 'overview', label: 'overview' },
|
||||||
|
{ id: 'events', label: 'events' },
|
||||||
|
{ id: 'sessions', label: 'sessions' },
|
||||||
|
{ id: 'realtime', label: 'realtime' },
|
||||||
|
{ id: 'compare', label: 'compare' },
|
||||||
|
{ id: 'breakdown', label: 'breakdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: 'behavior',
|
||||||
|
items: [
|
||||||
|
{ id: 'goals', label: 'goals' },
|
||||||
|
{ id: 'funnels', label: 'funnels' },
|
||||||
|
{ id: 'journeys', label: 'journeys' },
|
||||||
|
{ id: 'retention', label: 'retention' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: 'growth',
|
||||||
|
items: [
|
||||||
|
{ id: 'utm', label: 'utm' },
|
||||||
|
{ id: 'revenue', label: 'revenue' },
|
||||||
|
{ id: 'attribution', label: 'attribution' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import redis from '@/lib/redis';
|
import 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];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { saveAuth } from '@/lib/auth';
|
import { saveAuth } from '@/lib/auth';
|
||||||
import redis from '@/lib/redis';
|
import redis from '@/lib/redis';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { json } from '@/lib/response';
|
import { json, serverError } from '@/lib/response';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { auth, error } = await parseRequest(request);
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
@ -10,9 +10,13 @@ export async function POST(request: Request) {
|
||||||
return error();
|
return error();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redis.enabled) {
|
if (!redis.enabled) {
|
||||||
|
return serverError({
|
||||||
|
message: 'Redis is disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
const token = await saveAuth({ userId: auth.user.id }, 86400);
|
||||||
|
|
||||||
return json({ user: auth.user, token });
|
return json({ user: auth.user, token });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,6 @@ export async function GET(request: Request) {
|
||||||
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
|
||||||
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
|
||||||
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
updatesDisabled: !!process.env.DISABLE_UPDATES,
|
||||||
|
currentVersion: !!process.env.currentVersion,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,16 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { websiteId, parameters, filters } = body;
|
const { websiteId, parameters, filters } = body;
|
||||||
|
const { eventType } = parameters;
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventType) {
|
||||||
|
filters.eventType = eventType;
|
||||||
|
}
|
||||||
|
|
||||||
const queryFilters = await getQueryFilters(filters, websiteId);
|
const queryFilters = await getQueryFilters(filters, websiteId);
|
||||||
|
|
||||||
const data = await getJourney(websiteId, parameters, queryFilters);
|
const data = await getJourney(websiteId, parameters, queryFilters);
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,8 @@ 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 { getSharedWebsite } from '@/queries/prisma';
|
import type { WhiteLabel } from '@/lib/types';
|
||||||
|
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) {
|
||||||
|
|
@ -48,20 +43,25 @@ async function getWhiteLabel(accountId: string): Promise<WhiteLabel | null> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) {
|
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { shareId } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
const website = await getSharedWebsite(shareId);
|
const share = await getShareByCode(slug);
|
||||||
|
|
||||||
if (!website) {
|
if (!share) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: { websiteId: string; token: string; whiteLabel?: WhiteLabel } = {
|
const website = await getWebsite(share.entityId);
|
||||||
websiteId: website.id,
|
|
||||||
token: createToken({ websiteId: website.id }, secret()),
|
const data: Record<string, any> = {
|
||||||
|
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) {
|
||||||
82
src/app/api/share/id/[shareId]/route.ts
Normal file
82
src/app/api/share/id/[shareId]/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
39
src/app/api/share/route.ts
Normal file
39
src/app/api/share/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import z from 'zod';
|
||||||
|
import { uuid } from '@/lib/crypto';
|
||||||
|
import { getRandomChars } from '@/lib/generate';
|
||||||
|
import { parseRequest } from '@/lib/request';
|
||||||
|
import { json, unauthorized } from '@/lib/response';
|
||||||
|
import { anyObjectParam } from '@/lib/schema';
|
||||||
|
import { canUpdateEntity } from '@/permissions';
|
||||||
|
import { createShare } from '@/queries/prisma';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const schema = z.object({
|
||||||
|
entityId: z.uuid(),
|
||||||
|
shareType: z.coerce.number().int(),
|
||||||
|
slug: z.string().max(100).optional(),
|
||||||
|
parameters: anyObjectParam,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entityId, shareType, slug, parameters } = body;
|
||||||
|
|
||||||
|
if (!(await canUpdateEntity(auth, entityId))) {
|
||||||
|
return unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = await createShare({
|
||||||
|
id: uuid(),
|
||||||
|
entityId,
|
||||||
|
shareType,
|
||||||
|
slug: slug || getRandomChars(16),
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json(share);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ export async function GET(request: Request) {
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().max(50),
|
name: z.string().max(50),
|
||||||
|
ownerId: z.uuid().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
@ -40,7 +41,7 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name } = body;
|
const { name, ownerId } = body;
|
||||||
|
|
||||||
const team = await createTeam(
|
const team = await createTeam(
|
||||||
{
|
{
|
||||||
|
|
@ -48,7 +49,7 @@ export async function POST(request: Request) {
|
||||||
name,
|
name,
|
||||||
accessCode: `team_${getRandomChars(16)}`,
|
accessCode: `team_${getRandomChars(16)}`,
|
||||||
},
|
},
|
||||||
auth.user.id,
|
ownerId ?? auth.user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return json(team);
|
return json(team);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { hashPassword } from '@/lib/password';
|
import { hashPassword } from '@/lib/password';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { badRequest, json, ok, unauthorized } from '@/lib/response';
|
import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response';
|
||||||
import { userRoleParam } from '@/lib/schema';
|
import { userRoleParam } from '@/lib/schema';
|
||||||
import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
|
import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
|
||||||
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
|
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
|
||||||
|
|
@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.string().max(255).optional(),
|
username: z.string().max(255).optional(),
|
||||||
password: z.string().max(255).optional(),
|
password: z.string().min(8).max(255).optional(),
|
||||||
role: userRoleParam.optional(),
|
role: userRoleParam.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -47,6 +47,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ use
|
||||||
|
|
||||||
const user = await getUser(userId);
|
const user = await getUser(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { uuid } from '@/lib/crypto';
|
||||||
import { hashPassword } from '@/lib/password';
|
import { hashPassword } from '@/lib/password';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { badRequest, json, unauthorized } from '@/lib/response';
|
import { badRequest, json, unauthorized } from '@/lib/response';
|
||||||
|
import { userRoleParam } from '@/lib/schema';
|
||||||
import { canCreateUser } from '@/permissions';
|
import { canCreateUser } from '@/permissions';
|
||||||
import { createUser, getUserByUsername } from '@/queries/prisma';
|
import { createUser, getUserByUsername } from '@/queries/prisma';
|
||||||
|
|
||||||
|
|
@ -11,8 +12,8 @@ export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
id: z.uuid().optional(),
|
id: z.uuid().optional(),
|
||||||
username: z.string().max(255),
|
username: z.string().max(255),
|
||||||
password: z.string(),
|
password: z.string().min(8).max(255),
|
||||||
role: z.string().regex(/admin|user|view-only/i),
|
role: userRoleParam,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
|
||||||
34
src/app/api/websites/[websiteId]/events/stats/route.ts
Normal file
34
src/app/api/websites/[websiteId]/events/stats/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SHARE_ID_REGEX } from '@/lib/constants';
|
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
import { json, ok, unauthorized } from '@/lib/response';
|
||||||
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
|
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
|
||||||
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
|
import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
|
||||||
|
|
||||||
|
|
@ -33,7 +32,6 @@ export async function POST(
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
domain: z.string().optional(),
|
domain: z.string().optional(),
|
||||||
shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
@ -43,23 +41,15 @@ export async function POST(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
const { name, domain, shareId } = body;
|
const { name, domain } = body;
|
||||||
|
|
||||||
if (!(await canUpdateWebsite(auth, websiteId))) {
|
if (!(await canUpdateWebsite(auth, websiteId))) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const website = await updateWebsite(websiteId, { name, domain });
|
||||||
const website = await updateWebsite(websiteId, { name, domain, shareId });
|
|
||||||
|
|
||||||
return Response.json(website);
|
return Response.json(website);
|
||||||
} catch (e: any) {
|
|
||||||
if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
|
|
||||||
return badRequest({ message: 'That share ID is already taken.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return serverError(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||||
import { uuid } from '@/lib/crypto';
|
import { uuid } from '@/lib/crypto';
|
||||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||||
import { json, unauthorized } from '@/lib/response';
|
import { json, unauthorized } from '@/lib/response';
|
||||||
import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema';
|
import { searchParams, segmentParamSchema, segmentTypeParam } from '@/lib/schema';
|
||||||
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
|
import { canUpdateWebsite, canViewWebsite } from '@/permissions';
|
||||||
import { createSegment, getWebsiteSegments } from '@/queries/prisma';
|
import { createSegment, getWebsiteSegments } from '@/queries/prisma';
|
||||||
|
|
||||||
|
|
@ -42,7 +42,7 @@ export async function POST(
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: segmentTypeParam,
|
type: segmentTypeParam,
|
||||||
name: z.string().max(200),
|
name: z.string().max(200),
|
||||||
parameters: anyObjectParam,
|
parameters: segmentParamSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
|
||||||
77
src/app/api/websites/[websiteId]/shares/route.ts
Normal file
77
src/app/api/websites/[websiteId]/shares/route.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,11 @@ export async function GET(
|
||||||
|
|
||||||
const data = await getWebsiteStats(websiteId, filters);
|
const data = await getWebsiteStats(websiteId, filters);
|
||||||
|
|
||||||
const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate);
|
const { startDate, endDate } = getCompareDate(
|
||||||
|
filters.compare ?? 'prev',
|
||||||
|
filters.startDate,
|
||||||
|
filters.endDate,
|
||||||
|
);
|
||||||
|
|
||||||
const comparison = await getWebsiteStats(websiteId, {
|
const comparison = await getWebsiteStats(websiteId, {
|
||||||
...filters,
|
...filters,
|
||||||
|
|
|
||||||
77
src/app/share/ShareProvider.tsx
Normal file
77
src/app/share/ShareProvider.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
'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>;
|
||||||
|
}
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { SharePage } from './SharePage';
|
|
||||||
|
|
||||||
export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) {
|
|
||||||
const { shareId } = await params;
|
|
||||||
|
|
||||||
return <SharePage shareId={shareId[0]} />;
|
|
||||||
}
|
|
||||||
206
src/app/share/[slug]/[[...path]]/ShareNav.tsx
Normal file
206
src/app/share/[slug]/[[...path]]/ShareNav.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/app/share/[slug]/[[...path]]/SharePage.tsx
Normal file
103
src/app/share/[slug]/[[...path]]/SharePage.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/share/[slug]/[[...path]]/page.tsx
Normal file
5
src/app/share/[slug]/[[...path]]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { SharePage } from './SharePage';
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
return <SharePage />;
|
||||||
|
}
|
||||||
13
src/app/share/[slug]/layout.tsx
Normal file
13
src/app/share/[slug]/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@ 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' }}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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';
|
||||||
|
|
||||||
|
|
@ -42,9 +43,11 @@ export function SideMenu({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={id} href={path}>
|
<Link key={id} href={path}>
|
||||||
<NavMenuItem isSelected={isSelected}>
|
<Row padding hoverBackgroundColor="3">
|
||||||
<IconLabel icon={icon}>{label}</IconLabel>
|
<IconLabel icon={icon}>
|
||||||
</NavMenuItem>
|
<Text weight={isSelected ? 'bold' : undefined}>{label}</Text>
|
||||||
|
</IconLabel>
|
||||||
|
</Row>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
6
src/components/hooks/context/useShare.ts
Normal file
6
src/components/hooks/context/useShare.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { ShareContext } from '@/app/share/ShareProvider';
|
||||||
|
|
||||||
|
export function useShare() {
|
||||||
|
return useContext(ShareContext);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// 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';
|
||||||
|
|
@ -51,6 +52,7 @@ export * from './queries/useWebsiteSegmentsQuery';
|
||||||
export * from './queries/useWebsiteSessionQuery';
|
export * from './queries/useWebsiteSessionQuery';
|
||||||
export * from './queries/useWebsiteSessionStatsQuery';
|
export * from './queries/useWebsiteSessionStatsQuery';
|
||||||
export * from './queries/useWebsiteSessionsQuery';
|
export * from './queries/useWebsiteSessionsQuery';
|
||||||
|
export * from './queries/useWebsiteSharesQuery';
|
||||||
export * from './queries/useWebsiteStatsQuery';
|
export * from './queries/useWebsiteStatsQuery';
|
||||||
export * from './queries/useWebsitesQuery';
|
export * from './queries/useWebsitesQuery';
|
||||||
export * from './queries/useWebsiteValuesQuery';
|
export * from './queries/useWebsiteValuesQuery';
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export function useEventDataValuesQuery(
|
||||||
return useQuery<any>({
|
return useQuery<any>({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'websites:event-data:values',
|
'websites:event-data:values',
|
||||||
{ websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters },
|
{ websiteId, startAt, endAt, unit, timezone, ...filters, event, propertyName },
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
get(`/websites/${websiteId}/event-data/values`, {
|
get(`/websites/${websiteId}/event-data/values`, {
|
||||||
|
|
|
||||||
37
src/components/hooks/queries/useEventStatsQuery.ts
Normal file
37
src/components/hooks/queries/useEventStatsQuery.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
import { setShareToken, useApp } from '@/store/app';
|
import { setShare, useApp } from '@/store/app';
|
||||||
import { useApi } from '../useApi';
|
import { useApi } from '../useApi';
|
||||||
|
|
||||||
const selector = (state: { shareToken: string }) => state.shareToken;
|
const selector = state => state.share;
|
||||||
|
|
||||||
export function useShareTokenQuery(shareId: string): {
|
export function useShareTokenQuery(slug: string) {
|
||||||
shareToken: any;
|
const share = useApp(selector);
|
||||||
isLoading?: boolean;
|
|
||||||
error?: Error;
|
|
||||||
} {
|
|
||||||
const shareToken = useApp(selector);
|
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { isLoading, error } = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['share', shareId],
|
queryKey: ['share', slug],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await get(`/share/${shareId}`);
|
const data = await get(`/share/${slug}`);
|
||||||
|
|
||||||
setShareToken(data);
|
setShare(data);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { shareToken, isLoading, error };
|
return { share, ...query };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
|
||||||
options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
|
options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
|
||||||
) {
|
) {
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
const { startAt, endAt } = useDateParameters();
|
||||||
const filters = useFilterParameters();
|
const filters = useFilterParameters();
|
||||||
|
|
||||||
return useQuery<WebsiteExpandedMetricsData>({
|
return useQuery<WebsiteExpandedMetricsData>({
|
||||||
|
|
@ -29,8 +29,6 @@ export function useWebsiteExpandedMetricsQuery(
|
||||||
websiteId,
|
websiteId,
|
||||||
startAt,
|
startAt,
|
||||||
endAt,
|
endAt,
|
||||||
unit,
|
|
||||||
timezone,
|
|
||||||
...filters,
|
...filters,
|
||||||
...params,
|
...params,
|
||||||
},
|
},
|
||||||
|
|
@ -39,8 +37,6 @@ export function useWebsiteExpandedMetricsQuery(
|
||||||
get(`/websites/${websiteId}/metrics/expanded`, {
|
get(`/websites/${websiteId}/metrics/expanded`, {
|
||||||
startAt,
|
startAt,
|
||||||
endAt,
|
endAt,
|
||||||
unit,
|
|
||||||
timezone,
|
|
||||||
...filters,
|
...filters,
|
||||||
...params,
|
...params,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
|
||||||
options?: ReactQueryOptions<WebsiteMetricsData>,
|
options?: ReactQueryOptions<WebsiteMetricsData>,
|
||||||
) {
|
) {
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
const { startAt, endAt } = useDateParameters();
|
||||||
const filters = useFilterParameters();
|
const filters = useFilterParameters();
|
||||||
|
|
||||||
return useQuery<WebsiteMetricsData>({
|
return useQuery<WebsiteMetricsData>({
|
||||||
|
|
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
|
||||||
websiteId,
|
websiteId,
|
||||||
startAt,
|
startAt,
|
||||||
endAt,
|
endAt,
|
||||||
unit,
|
|
||||||
timezone,
|
|
||||||
...filters,
|
...filters,
|
||||||
...params,
|
...params,
|
||||||
},
|
},
|
||||||
|
|
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
|
||||||
get(`/websites/${websiteId}/metrics`, {
|
get(`/websites/${websiteId}/metrics`, {
|
||||||
startAt,
|
startAt,
|
||||||
endAt,
|
endAt,
|
||||||
unit,
|
|
||||||
timezone,
|
|
||||||
...filters,
|
...filters,
|
||||||
...params,
|
...params,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
20
src/components/hooks/queries/useWebsiteSharesQuery.ts
Normal file
20
src/components/hooks/queries/useWebsiteSharesQuery.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { ReactQueryOptions } from '@/lib/types';
|
||||||
|
import { useApi } from '../useApi';
|
||||||
|
import { useModified } from '../useModified';
|
||||||
|
import { usePagedQuery } from '../usePagedQuery';
|
||||||
|
|
||||||
|
export function useWebsiteSharesQuery(
|
||||||
|
{ websiteId }: { websiteId: string },
|
||||||
|
options?: ReactQueryOptions,
|
||||||
|
) {
|
||||||
|
const { modified } = useModified('shares');
|
||||||
|
const { get } = useApi();
|
||||||
|
|
||||||
|
return usePagedQuery({
|
||||||
|
queryKey: ['websiteShares', { websiteId, modified }],
|
||||||
|
queryFn: pageParams => {
|
||||||
|
return get(`/websites/${websiteId}/shares`, pageParams);
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -19,17 +19,16 @@ export interface WebsiteStatsData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWebsiteStatsQuery(
|
export function useWebsiteStatsQuery(
|
||||||
websiteId: string,
|
{ websiteId, compare }: { websiteId: string; compare?: string },
|
||||||
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
|
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
|
||||||
) {
|
) {
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { startAt, endAt, unit, timezone } = useDateParameters();
|
const { startAt, endAt } = useDateParameters();
|
||||||
const filters = useFilterParameters();
|
const filters = useFilterParameters();
|
||||||
|
|
||||||
return useQuery<WebsiteStatsData>({
|
return useQuery<WebsiteStatsData>({
|
||||||
queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
|
queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
|
||||||
queryFn: () =>
|
queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
|
||||||
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
|
|
||||||
enabled: !!websiteId,
|
enabled: !!websiteId,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string,
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'sessions',
|
'sessions',
|
||||||
{ websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters },
|
{ websiteId, modified, startAt, endAt, timezone, ...params, ...filters },
|
||||||
],
|
],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
return get(`/websites/${websiteId}/sessions/weekly`, {
|
return get(`/websites/${websiteId}/sessions/weekly`, {
|
||||||
startAt,
|
startAt,
|
||||||
endAt,
|
endAt,
|
||||||
unit,
|
|
||||||
timezone,
|
timezone,
|
||||||
...params,
|
...params,
|
||||||
...filters,
|
...filters,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue