Merge branch 'master' into master

This commit is contained in:
Arnaud Gissinger 2024-04-07 15:49:22 +02:00 committed by GitHub
commit dbc51f072a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
202 changed files with 2012 additions and 1389 deletions

View file

@ -0,0 +1,90 @@
CREATE TABLE umami.website_event_join
(
session_id UUID,
visit_id UUID,
created_at DateTime('UTC')
)
engine = MergeTree
ORDER BY (session_id, created_at)
SETTINGS index_granularity = 8192;
INSERT INTO umami.website_event_join
SELECT DISTINCT
s.session_id,
generateUUIDv4() visit_id,
s.created_at
FROM (SELECT DISTINCT session_id,
date_trunc('hour', created_at) created_at
FROM website_event) s;
-- create new table
CREATE TABLE umami.website_event_new
(
website_id UUID,
session_id UUID,
visit_id UUID,
event_id UUID,
hostname LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
device LowCardinality(String),
screen LowCardinality(String),
language LowCardinality(String),
country LowCardinality(String),
subdivision1 LowCardinality(String),
subdivision2 LowCardinality(String),
city String,
url_path String,
url_query String,
referrer_path String,
referrer_query String,
referrer_domain String,
page_title String,
event_type UInt32,
event_name String,
created_at DateTime('UTC'),
job_id UUID
)
engine = MergeTree
ORDER BY (website_id, session_id, created_at)
SETTINGS index_granularity = 8192;
INSERT INTO umami.website_event_new
SELECT we.website_id,
we.session_id,
j.visit_id,
we.event_id,
we.hostname,
we.browser,
we.os,
we.device,
we.screen,
we.language,
we.country,
we.subdivision1,
we.subdivision2,
we.city,
we.url_path,
we.url_query,
we.referrer_path,
we.referrer_query,
we.referrer_domain,
we.page_title,
we.event_type,
we.event_name,
we.created_at,
we.job_id
FROM umami.website_event we
JOIN umami.website_event_join j
ON we.session_id = j.session_id
and date_trunc('hour', we.created_at) = j.created_at
RENAME TABLE umami.website_event TO umami.website_event_old;
RENAME TABLE umami.website_event_new TO umami.website_event;
/*
DROP TABLE umami.website_event_old
DROP TABLE umami.website_event_join
*/

View file

@ -3,6 +3,7 @@ CREATE TABLE umami.website_event
( (
website_id UUID, website_id UUID,
session_id UUID, session_id UUID,
visit_id UUID,
event_id UUID, event_id UUID,
--sessions --sessions
hostname LowCardinality(String), hostname LowCardinality(String),

View file

@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE `website_event` ADD COLUMN `visit_id` VARCHAR(36) NULL;
UPDATE `website_event` we
JOIN (SELECT DISTINCT
s.session_id,
s.visit_time,
BIN_TO_UUID(RANDOM_BYTES(16) & 0xffffffffffff0fff3fffffffffffffff | 0x00000000000040008000000000000000) uuid
FROM (SELECT DISTINCT session_id,
DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00') visit_time
FROM `website_event`) s) a
ON we.session_id = a.session_id and DATE_FORMAT(we.created_at, '%Y-%m-%d %H:00:00') = a.visit_time
SET we.visit_id = a.uuid
WHERE we.visit_id IS NULL;
ALTER TABLE `website_event` MODIFY `visit_id` VARCHAR(36) NOT NULL;
-- CreateIndex
CREATE INDEX `website_event_visit_id_idx` ON `website_event`(`visit_id`);
-- CreateIndex
CREATE INDEX `website_event_website_id_visit_id_created_at_idx` ON `website_event`(`website_id`, `visit_id`, `created_at`);

View file

@ -92,6 +92,7 @@ model WebsiteEvent {
id String @id() @map("event_id") @db.VarChar(36) id String @id() @map("event_id") @db.VarChar(36)
websiteId String @map("website_id") @db.VarChar(36) websiteId String @map("website_id") @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36) sessionId String @map("session_id") @db.VarChar(36)
visitId String @map("visit_id") @db.VarChar(36)
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
urlPath String @map("url_path") @db.VarChar(500) urlPath String @map("url_path") @db.VarChar(500)
urlQuery String? @map("url_query") @db.VarChar(500) urlQuery String? @map("url_query") @db.VarChar(500)
@ -107,6 +108,7 @@ model WebsiteEvent {
@@index([createdAt]) @@index([createdAt])
@@index([sessionId]) @@index([sessionId])
@@index([visitId])
@@index([websiteId]) @@index([websiteId])
@@index([websiteId, createdAt]) @@index([websiteId, createdAt])
@@index([websiteId, createdAt, urlPath]) @@index([websiteId, createdAt, urlPath])
@ -115,6 +117,7 @@ model WebsiteEvent {
@@index([websiteId, createdAt, pageTitle]) @@index([websiteId, createdAt, pageTitle])
@@index([websiteId, createdAt, eventName]) @@index([websiteId, createdAt, eventName])
@@index([websiteId, sessionId, createdAt]) @@index([websiteId, sessionId, createdAt])
@@index([websiteId, visitId, createdAt])
@@map("website_event") @@map("website_event")
} }

View file

@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "website_event" ADD COLUMN "visit_id" UUID NULL;
UPDATE "website_event" we
SET visit_id = a.uuid
FROM (SELECT DISTINCT
s.session_id,
s.visit_time,
gen_random_uuid() uuid
FROM (SELECT DISTINCT session_id,
date_trunc('hour', created_at) visit_time
FROM "website_event") s) a
WHERE we.session_id = a.session_id
and date_trunc('hour', we.created_at) = a.visit_time;
ALTER TABLE "website_event" ALTER COLUMN "visit_id" SET NOT NULL;
-- CreateIndex
CREATE INDEX "website_event_visit_id_idx" ON "website_event"("visit_id");
-- CreateIndex
CREATE INDEX "website_event_website_id_visit_id_created_at_idx" ON "website_event"("website_id", "visit_id", "created_at");

View file

@ -92,6 +92,7 @@ model WebsiteEvent {
id String @id() @map("event_id") @db.Uuid id String @id() @map("event_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
visitId String @map("visit_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
urlPath String @map("url_path") @db.VarChar(500) urlPath String @map("url_path") @db.VarChar(500)
urlQuery String? @map("url_query") @db.VarChar(500) urlQuery String? @map("url_query") @db.VarChar(500)
@ -107,6 +108,7 @@ model WebsiteEvent {
@@index([createdAt]) @@index([createdAt])
@@index([sessionId]) @@index([sessionId])
@@index([visitId])
@@index([websiteId]) @@index([websiteId])
@@index([websiteId, createdAt]) @@index([websiteId, createdAt])
@@index([websiteId, createdAt, urlPath]) @@index([websiteId, createdAt, urlPath])
@ -115,6 +117,7 @@ model WebsiteEvent {
@@index([websiteId, createdAt, pageTitle]) @@index([websiteId, createdAt, pageTitle])
@@index([websiteId, createdAt, eventName]) @@index([websiteId, createdAt, eventName])
@@index([websiteId, sessionId, createdAt]) @@index([websiteId, sessionId, createdAt])
@@index([websiteId, visitId, createdAt])
@@map("website_event") @@map("website_event")
} }

View file

@ -14,6 +14,7 @@ const frameAncestors = process.env.ALLOWED_FRAME_URLS || '';
const disableLogin = process.env.DISABLE_LOGIN || ''; const disableLogin = process.env.DISABLE_LOGIN || '';
const disableUI = process.env.DISABLE_UI || ''; const disableUI = process.env.DISABLE_UI || '';
const hostURL = process.env.HOST_URL || ''; const hostURL = process.env.HOST_URL || '';
const privateMode = process.env.PRIVATE_MODE || '';
const contentSecurityPolicy = [ const contentSecurityPolicy = [
`default-src 'self'`, `default-src 'self'`,
@ -120,6 +121,7 @@ const config = {
disableLogin, disableLogin,
disableUI, disableUI,
hostURL, hostURL,
privateMode,
}, },
basePath, basePath,
output: 'standalone', output: 'standalone',

View file

@ -66,7 +66,7 @@
"dependencies": { "dependencies": {
"@clickhouse/client": "^0.2.2", "@clickhouse/client": "^0.2.2",
"@fontsource/inter": "^4.5.15", "@fontsource/inter": "^4.5.15",
"@prisma/client": "5.10.2", "@prisma/client": "5.11.0",
"@prisma/extension-read-replicas": "^0.3.0", "@prisma/extension-read-replicas": "^0.3.0",
"@react-spring/web": "^9.7.3", "@react-spring/web": "^9.7.3",
"@tanstack/react-query": "^5.28.6", "@tanstack/react-query": "^5.28.6",
@ -98,11 +98,11 @@
"maxmind": "^4.3.6", "maxmind": "^4.3.6",
"md5": "^2.3.0", "md5": "^2.3.0",
"moment-timezone": "^0.5.35", "moment-timezone": "^0.5.35",
"next": "14.1.3", "next": "14.1.4",
"next-basics": "^0.39.0", "next-basics": "^0.39.0",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prisma": "5.10.2", "prisma": "5.11.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-basics": "^0.123.0", "react-basics": "^0.123.0",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
@ -115,7 +115,6 @@
"request-ip": "^3.3.0", "request-ip": "^3.3.0",
"semver": "^7.5.4", "semver": "^7.5.4",
"thenby": "^1.3.4", "thenby": "^1.3.4",
"timezone-support": "^2.0.2",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"yup": "^0.32.11", "yup": "^0.32.11",
"zustand": "^4.3.8" "zustand": "^4.3.8"

View file

@ -41,7 +41,7 @@
"value": "Add website" "value": "Add website"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "إضافة موقع" "value": "إضافة موقع"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "مدير" "value": "مدير"

View file

@ -41,7 +41,7 @@
"value": "Дадаць сайт" "value": "Дадаць сайт"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Адміністратар" "value": "Адміністратар"

View file

@ -41,7 +41,7 @@
"value": "ওয়েবসাইট যুক্ত করুন" "value": "ওয়েবসাইট যুক্ত করুন"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "অ্যাডমিন" "value": "অ্যাডমিন"

View file

@ -41,7 +41,7 @@
"value": "Afegeix lloc web" "value": "Afegeix lloc web"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrador" "value": "Administrador"

View file

@ -41,7 +41,7 @@
"value": "Přidat web" "value": "Přidat web"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrátor" "value": "Administrátor"

View file

@ -41,7 +41,7 @@
"value": "Tilføj hjemmeside" "value": "Tilføj hjemmeside"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Websiite hinzuefüege" "value": "Websiite hinzuefüege"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Website hinzufügen" "value": "Website hinzufügen"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Προσθήκη ιστότοπου" "value": "Προσθήκη ιστότοπου"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Διαχειριστής" "value": "Διαχειριστής"

View file

@ -41,7 +41,7 @@
"value": "Add website" "value": "Add website"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Add website" "value": "Add website"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Nuevo sitio web" "value": "Nuevo sitio web"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrador" "value": "Administrador"

View file

@ -41,7 +41,7 @@
"value": "افزودن وب‌سایت" "value": "افزودن وب‌سایت"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "مدیر" "value": "مدیر"

View file

@ -41,7 +41,7 @@
"value": "Lisää verkkosivu" "value": "Lisää verkkosivu"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Järjestelmänvalvoja" "value": "Järjestelmänvalvoja"

View file

@ -41,7 +41,7 @@
"value": "Legg heimasíðu afturat" "value": "Legg heimasíðu afturat"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Fyrisitari" "value": "Fyrisitari"

View file

@ -41,7 +41,7 @@
"value": "Ajouter un site" "value": "Ajouter un site"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrateur" "value": "Administrateur"

View file

@ -41,7 +41,7 @@
"value": "Engadir sitio web" "value": "Engadir sitio web"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administradora" "value": "Administradora"

View file

@ -41,7 +41,7 @@
"value": "הוספת אתר" "value": "הוספת אתר"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "מנהל" "value": "מנהל"

View file

@ -41,7 +41,7 @@
"value": "वेबसाइट" "value": "वेबसाइट"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "प्रशासक" "value": "प्रशासक"

View file

@ -41,7 +41,7 @@
"value": "Dodaj web stranicu" "value": "Dodaj web stranicu"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Weboldal hozzáadása" "value": "Weboldal hozzáadása"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Adminisztrátor" "value": "Adminisztrátor"

View file

@ -41,7 +41,7 @@
"value": "Tambah situs web" "value": "Tambah situs web"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Pengelola" "value": "Pengelola"

View file

@ -41,7 +41,7 @@
"value": "Aggiungi sito" "value": "Aggiungi sito"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Amministratore" "value": "Amministratore"

View file

@ -41,7 +41,7 @@
"value": "Webサイトの追加" "value": "Webサイトの追加"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "管理者" "value": "管理者"

View file

@ -41,7 +41,7 @@
"value": "បន្ថែមគេហទំព័រ" "value": "បន្ថែមគេហទំព័រ"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "អ្នកគ្រប់គ្រង" "value": "អ្នកគ្រប់គ្រង"

View file

@ -41,7 +41,7 @@
"value": "웹사이트 추가" "value": "웹사이트 추가"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "관리자" "value": "관리자"

View file

@ -41,7 +41,7 @@
"value": "Pridėti svetainę" "value": "Pridėti svetainę"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administratorius" "value": "Administratorius"

View file

@ -41,7 +41,7 @@
"value": "Веб нэмэх" "value": "Веб нэмэх"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Админ" "value": "Админ"

View file

@ -41,7 +41,7 @@
"value": "Tambah laman web" "value": "Tambah laman web"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Pentadbir" "value": "Pentadbir"

View file

@ -41,7 +41,7 @@
"value": "ဝက်ဘ်ဆိုဒ်ထည့်မည်" "value": "ဝက်ဘ်ဆိုဒ်ထည့်မည်"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "အက်ဒမင်" "value": "အက်ဒမင်"

View file

@ -41,7 +41,7 @@
"value": "Legg til nettsted" "value": "Legg til nettsted"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Website koppelen" "value": "Website koppelen"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Beheerder" "value": "Beheerder"

View file

@ -41,7 +41,7 @@
"value": "Dodaj witrynę" "value": "Dodaj witrynę"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Adicionar site" "value": "Adicionar site"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrador" "value": "Administrador"

View file

@ -41,7 +41,7 @@
"value": "Adicionar website" "value": "Adicionar website"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrador" "value": "Administrador"

View file

@ -41,7 +41,7 @@
"value": "Adăugare site web" "value": "Adăugare site web"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Добавить сайт" "value": "Добавить сайт"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Администратор" "value": "Администратор"

View file

@ -41,7 +41,7 @@
"value": "වෙබ් අඩවිය එක් කරන්න" "value": "වෙබ් අඩවිය එක් කරන්න"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Pridať web" "value": "Pridať web"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrátor" "value": "Administrátor"

View file

@ -41,7 +41,7 @@
"value": "Dodaj spletno mesto" "value": "Dodaj spletno mesto"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administrator" "value": "Administrator"

View file

@ -41,7 +41,7 @@
"value": "Lägg till webbplats" "value": "Lägg till webbplats"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Administratör" "value": "Administratör"

View file

@ -41,7 +41,7 @@
"value": "வலைத்தளத்தைச் சேர்க்க" "value": "வலைத்தளத்தைச் சேர்க்க"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "நிர்வாகியைச் சேர்க்க" "value": "நிர்வாகியைச் சேர்க்க"

View file

@ -41,7 +41,7 @@
"value": "เพิ่มเว็บไซต์" "value": "เพิ่มเว็บไซต์"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "ผู้ดูแลระบบ" "value": "ผู้ดูแลระบบ"

View file

@ -41,7 +41,7 @@
"value": "Web sitesi ekle" "value": "Web sitesi ekle"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Yönetici" "value": "Yönetici"

View file

@ -41,7 +41,7 @@
"value": "Додати сайт" "value": "Додати сайт"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Адміністратор" "value": "Адміністратор"

View file

@ -41,7 +41,7 @@
"value": "ویب سائٹ کا اضافہ کریں" "value": "ویب سائٹ کا اضافہ کریں"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "منتظم" "value": "منتظم"

View file

@ -41,7 +41,7 @@
"value": "Thêm website" "value": "Thêm website"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "Quản trị" "value": "Quản trị"

View file

@ -41,7 +41,7 @@
"value": "添加网站" "value": "添加网站"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "管理员" "value": "管理员"

View file

@ -41,7 +41,7 @@
"value": "新增網站" "value": "新增網站"
} }
], ],
"label.administrator": [ "label.admin": [
{ {
"type": 0, "type": 0,
"value": "管理員" "value": "管理員"

View file

@ -14,7 +14,7 @@ import styles from './NavBar.module.css';
export function NavBar() { export function NavBar() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname, router } = useNavigation(); const { pathname, router } = useNavigation();
const { renderTeamUrl } = useTeamUrl(); const { teamId, renderTeamUrl } = useTeamUrl();
const cloudMode = !!process.env.cloudMode; const cloudMode = !!process.env.cloudMode;
@ -34,25 +34,38 @@ export function NavBar() {
label: formatMessage(labels.settings), label: formatMessage(labels.settings),
url: renderTeamUrl('/settings'), url: renderTeamUrl('/settings'),
children: [ children: [
...(teamId
? [
{
label: formatMessage(labels.team),
url: renderTeamUrl('/settings/team'),
},
]
: []),
{ {
label: formatMessage(labels.websites), label: formatMessage(labels.websites),
url: '/settings/websites', url: renderTeamUrl('/settings/websites'),
},
{
label: formatMessage(labels.teams),
url: '/settings/teams',
},
{
label: formatMessage(labels.users),
url: '/settings/users',
},
{
label: formatMessage(labels.profile),
url: '/profile',
}, },
...(!teamId
? [
{
label: formatMessage(labels.teams),
url: renderTeamUrl('/settings/teams'),
},
{
label: formatMessage(labels.users),
url: '/settings/users',
},
]
: [
{
label: formatMessage(labels.members),
url: renderTeamUrl('/settings/members'),
},
]),
], ],
}, },
cloudMode && { {
label: formatMessage(labels.profile), label: formatMessage(labels.profile),
url: '/profile', url: '/profile',
}, },
@ -94,6 +107,7 @@ export function NavBar() {
<ProfileButton /> <ProfileButton />
</div> </div>
<div className={styles.mobile}> <div className={styles.mobile}>
<TeamsButton onChange={handleTeamChange} showText={false} />
<HamburgerButton menuItems={menuItems} /> <HamburgerButton menuItems={menuItems} />
</div> </div>
</div> </div>

View file

@ -1,14 +1,17 @@
.notice { .notice {
position: absolute; position: absolute;
display: flex;
justify-content: space-between;
width: 100%;
max-width: 800px; max-width: 800px;
gap: 20px; gap: 20px;
margin: 80px auto; margin: 60px auto;
align-self: center; align-self: center;
background: var(--base50); background: var(--base50);
padding: 20px; padding: 20px;
border: 1px solid var(--base300); border: 1px solid var(--base300);
border-radius: var(--border-radius); border-radius: var(--border-radius);
z-index: var(--z-index-popup); z-index: 9999;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1); box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1);
} }

View file

@ -4,9 +4,9 @@ import { Button } from 'react-basics';
import { setItem } from 'next-basics'; import { setItem } from 'next-basics';
import useStore, { checkVersion } from 'store/version'; import useStore, { checkVersion } from 'store/version';
import { REPO_URL, VERSION_CHECK } from 'lib/constants'; import { REPO_URL, VERSION_CHECK } from 'lib/constants';
import styles from './UpdateNotice.module.css';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import styles from './UpdateNotice.module.css';
export function UpdateNotice({ user, config }) { export function UpdateNotice({ user, config }) {
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();
@ -16,8 +16,9 @@ export function UpdateNotice({ user, config }) {
const allowUpdate = const allowUpdate =
user?.isAdmin && user?.isAdmin &&
!config?.updatesDisabled && !config?.updatesDisabled &&
!config?.cloudMode &&
!pathname.includes('/share/') && !pathname.includes('/share/') &&
!process.env.cloudMode &&
!process.env.privateMode &&
!dismissed; !dismissed;
const updateCheck = useCallback(() => { const updateCheck = useCallback(() => {

View file

@ -0,0 +1,3 @@
.field {
width: 200px;
}

View file

@ -3,6 +3,7 @@ import { Button, Flexbox } from 'react-basics';
import { useDateRange, useMessages } from 'components/hooks'; import { useDateRange, useMessages } from 'components/hooks';
import { DEFAULT_DATE_RANGE } from 'lib/constants'; import { DEFAULT_DATE_RANGE } from 'lib/constants';
import { DateRange } from 'lib/types'; import { DateRange } from 'lib/types';
import styles from './DateRangeSetting.module.css';
export function DateRangeSetting() { export function DateRangeSetting() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -13,8 +14,9 @@ export function DateRangeSetting() {
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE); const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
return ( return (
<Flexbox gap={10}> <Flexbox gap={10} width={300}>
<DateFilter <DateFilter
className={styles.field}
value={value} value={value}
startDate={dateRange.startDate} startDate={dateRange.startDate}
endDate={dateRange.endDate} endDate={dateRange.endDate}

View file

@ -23,7 +23,7 @@ export function ProfileSettings() {
return formatMessage(labels.user); return formatMessage(labels.user);
} }
if (value === ROLES.admin) { if (value === ROLES.admin) {
return formatMessage(labels.administrator); return formatMessage(labels.admin);
} }
if (value === ROLES.viewOnly) { if (value === ROLES.viewOnly) {
return formatMessage(labels.viewOnly); return formatMessage(labels.viewOnly);

View file

@ -1,3 +1,7 @@
.dropdown {
width: 200px;
}
div.menu { div.menu {
max-height: 300px; max-height: 300px;
width: 300px; width: 300px;

View file

@ -1,26 +1,29 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dropdown, Item, Button, Flexbox } from 'react-basics'; import { Dropdown, Item, Button, Flexbox } from 'react-basics';
import { listTimeZones } from 'timezone-support'; import moment from 'moment-timezone';
import { useTimezone, useMessages } from 'components/hooks'; import { useTimezone, useMessages } from 'components/hooks';
import { getTimezone } from 'lib/date'; import { getTimezone } from 'lib/date';
import styles from './TimezoneSetting.module.css'; import styles from './TimezoneSetting.module.css';
const timezones = moment.tz.names();
export function TimezoneSetting() { export function TimezoneSetting() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [timezone, saveTimezone] = useTimezone(); const { timezone, saveTimezone } = useTimezone();
const options = search const options = search
? listTimeZones().filter(n => n.toLowerCase().includes(search.toLowerCase())) ? timezones.filter(n => n.toLowerCase().includes(search.toLowerCase()))
: listTimeZones(); : timezones;
const handleReset = () => saveTimezone(getTimezone()); const handleReset = () => saveTimezone(getTimezone());
return ( return (
<Flexbox gap={10}> <Flexbox gap={10}>
<Dropdown <Dropdown
className={styles.dropdown}
items={options} items={options}
value={timezone} value={timezone}
onChange={saveTimezone} onChange={(value: any) => saveTimezone(value)}
menuProps={{ className: styles.menu }} menuProps={{ className: styles.menu }}
allowSearch={true} allowSearch={true}
onSearch={setSearch} onSearch={setSearch}

View file

@ -1,38 +0,0 @@
.menu {
width: 360px;
max-height: 300px;
overflow: auto;
}
.item {
display: flex;
flex-direction: row;
justify-content: space-between;
border-radius: var(--border-radius);
}
.item:hover {
background: var(--base75);
}
.type {
color: var(--font-color300);
}
.selected {
font-weight: bold;
}
.popup {
display: flex;
}
.filter {
display: flex;
flex-direction: column;
gap: 20px;
}
.dropdown {
min-width: 60px;
}

View file

@ -3,9 +3,6 @@ import { createPortal } from 'react-dom';
import { REPORT_PARAMETERS } from 'lib/constants'; import { REPORT_PARAMETERS } from 'lib/constants';
import PopupForm from './PopupForm'; import PopupForm from './PopupForm';
import FieldSelectForm from './FieldSelectForm'; import FieldSelectForm from './FieldSelectForm';
import FieldAggregateForm from './FieldAggregateForm';
import FieldFilterForm from './FieldFilterForm';
import styles from './FieldAddForm.module.css';
export function FieldAddForm({ export function FieldAddForm({
fields = [], fields = [],
@ -18,7 +15,11 @@ export function FieldAddForm({
onAdd: (group: string, value: string) => void; onAdd: (group: string, value: string) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [selected, setSelected] = useState<{ name: string; type: string; value: string }>(); const [selected, setSelected] = useState<{
name: string;
type: string;
value: string;
}>();
const handleSelect = (value: any) => { const handleSelect = (value: any) => {
const { type } = value; const { type } = value;
@ -38,14 +39,8 @@ export function FieldAddForm({
}; };
return createPortal( return createPortal(
<PopupForm className={styles.popup}> <PopupForm>
{!selected && <FieldSelectForm fields={fields} onSelect={handleSelect} />} {!selected && <FieldSelectForm fields={fields} onSelect={handleSelect} />}
{selected && group === REPORT_PARAMETERS.fields && (
<FieldAggregateForm {...selected} onSelect={handleSave} />
)}
{selected && group === REPORT_PARAMETERS.filters && (
<FieldFilterForm {...selected} onSelect={handleSave} />
)}
</PopupForm>, </PopupForm>,
document.body, document.body,
); );

View file

@ -0,0 +1,36 @@
.menu {
position: absolute;
max-width: 300px;
max-height: 210px;
}
.filter {
display: flex;
flex-direction: column;
gap: 20px;
}
.dropdown {
min-width: 200px;
}
.text {
min-width: 200px;
}
.selected {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
white-space: nowrap;
min-width: 200px;
font-weight: 900;
background: var(--base100);
border-radius: var(--border-radius);
cursor: pointer;
}
.search {
position: relative;
}

View file

@ -0,0 +1,224 @@
import { useState, useMemo } from 'react';
import {
Form,
FormRow,
Item,
Flexbox,
Dropdown,
Button,
SearchField,
TextField,
Text,
Icon,
Icons,
Menu,
Loading,
} from 'react-basics';
import { useMessages, useFilters, useFormat, useLocale, useWebsiteValues } from 'components/hooks';
import { OPERATORS } from 'lib/constants';
import { isEqualsOperator } from 'lib/params';
import styles from './FieldFilterEditForm.module.css';
export interface FieldFilterFormProps {
websiteId?: string;
name: string;
label?: string;
type: string;
startDate: Date;
endDate: Date;
operator?: string;
defaultValue?: string;
onChange?: (filter: { name: string; type: string; operator: string; value: string }) => void;
allowFilterSelect?: boolean;
isNew?: boolean;
}
export default function FieldFilterEditForm({
websiteId,
name,
label,
type,
startDate,
endDate,
operator: defaultOperator = 'eq',
defaultValue = '',
onChange,
allowFilterSelect = true,
isNew,
}: FieldFilterFormProps) {
const { formatMessage, labels } = useMessages();
const [operator, setOperator] = useState(defaultOperator);
const [value, setValue] = useState(defaultValue);
const [showMenu, setShowMenu] = useState(false);
const isEquals = isEqualsOperator(operator);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState(isEquals ? value : '');
const { filters } = useFilters();
const { formatValue } = useFormat();
const { locale } = useLocale();
const isDisabled = !operator || (isEquals && !selected) || (!isEquals && !value);
const {
data: values = [],
isLoading,
refetch,
} = useWebsiteValues({
websiteId,
type: name,
startDate,
endDate,
search,
});
const formattedValues = useMemo(() => {
if (!values) {
return {};
}
const formatted = {};
const format = (val: string) => {
formatted[val] = formatValue(val, name);
return formatted[val];
};
if (values?.length !== 1) {
const { compare } = new Intl.Collator(locale, { numeric: true });
values.sort((a, b) => compare(formatted[a] ?? format(a), formatted[b] ?? format(b)));
} else {
format(values[0]);
}
return formatted;
}, [formatValue, locale, name, values]);
const filteredValues = useMemo(() => {
return value
? values.filter((n: string | number) =>
formattedValues[n].toLowerCase().includes(value.toLowerCase()),
)
: values;
}, [value, formattedValues]);
const renderFilterValue = (value: any) => {
return filters.find((filter: { value: any }) => filter.value === value)?.label;
};
const handleAdd = () => {
onChange({ name, type, operator, value: isEquals ? selected : value });
};
const handleMenuSelect = (value: string) => {
setSelected(value);
setShowMenu(false);
};
const handleSearch = (value: string) => {
setSearch(value);
};
const handleReset = () => {
setSelected('');
setValue('');
setSearch('');
refetch();
};
const handleOperatorChange = (value: any) => {
setOperator(value);
if ([OPERATORS.equals, OPERATORS.notEquals].includes(value)) {
setValue('');
} else {
setSelected('');
}
};
const handleBlur = () => {
window.setTimeout(() => setShowMenu(false), 500);
};
return (
<Form>
<FormRow label={label} className={styles.filter}>
<Flexbox gap={10}>
{allowFilterSelect && (
<Dropdown
className={styles.dropdown}
items={filters.filter(f => f.type === type)}
value={operator}
renderValue={renderFilterValue}
onChange={handleOperatorChange}
>
{({ value, label }) => {
return <Item key={value}>{label}</Item>;
}}
</Dropdown>
)}
{selected && isEquals && (
<div className={styles.selected} onClick={handleReset}>
<Text>{formatValue(selected, name)}</Text>
<Icon>
<Icons.Close />
</Icon>
</div>
)}
{!selected && isEquals && (
<div className={styles.search}>
<SearchField
className={styles.text}
value={value}
placeholder={formatMessage(labels.enter)}
onChange={e => setValue(e.target.value)}
onSearch={handleSearch}
delay={500}
onFocus={() => setShowMenu(true)}
onBlur={handleBlur}
/>
{showMenu && (
<ResultsMenu
values={filteredValues}
type={name}
isLoading={isLoading}
onSelect={handleMenuSelect}
/>
)}
</div>
)}
{!selected && !isEquals && (
<TextField
className={styles.text}
value={value}
onChange={e => setValue(e.target.value)}
/>
)}
</Flexbox>
<Button variant="primary" onClick={handleAdd} disabled={isDisabled}>
{formatMessage(isNew ? labels.add : labels.update)}
</Button>
</FormRow>
</Form>
);
}
const ResultsMenu = ({ values, type, isLoading, onSelect }) => {
const { formatValue } = useFormat();
if (isLoading) {
return (
<Menu className={styles.menu} variant="popup">
<Item>
<Loading icon="dots" position="center" />
</Item>
</Menu>
);
}
if (!values?.length) {
return null;
}
return (
<Menu className={styles.menu} variant="popup" onSelect={onSelect}>
{values?.map((value: any) => {
return <Item key={value}>{formatValue(value, type)}</Item>;
})}
</Menu>
);
};

View file

@ -1,24 +0,0 @@
.popup {
display: flex;
max-width: 300px;
max-height: 210px;
overflow-x: hidden;
}
.popup > div {
overflow-y: auto;
}
.filter {
display: flex;
flex-direction: column;
gap: 20px;
}
.dropdown {
min-width: 180px;
}
.text {
min-width: 180px;
}

View file

@ -1,121 +0,0 @@
import { useState, useMemo } from 'react';
import {
Form,
FormRow,
Item,
Flexbox,
Dropdown,
Button,
TextField,
Menu,
Popup,
PopupTrigger,
} from 'react-basics';
import { useMessages, useFilters, useFormat, useLocale } from 'components/hooks';
import { safeDecodeURIComponent } from 'next-basics';
import { OPERATORS } from 'lib/constants';
import styles from './FieldFilterForm.module.css';
export interface FieldFilterFormProps {
name: string;
label?: string;
type: string;
values?: any[];
onSelect?: (key: any) => void;
allowFilterSelect?: boolean;
}
export default function FieldFilterForm({
name,
label,
type,
values,
onSelect,
allowFilterSelect = true,
}: FieldFilterFormProps) {
const { formatMessage, labels } = useMessages();
const [filter, setFilter] = useState('eq');
const [value, setValue] = useState('');
const { getFilters } = useFilters();
const { formatValue } = useFormat();
const { locale } = useLocale();
const filters = getFilters(type);
const formattedValues = useMemo(() => {
const formatted = {};
const format = (val: string) => {
formatted[val] = formatValue(val, name);
return formatted[val];
};
if (values.length !== 1) {
const { compare } = new Intl.Collator(locale, { numeric: true });
values.sort((a, b) => compare(formatted[a] ?? format(a), formatted[b] ?? format(b)));
} else {
format(values[0]);
}
return formatted;
}, [formatValue, locale, name, values]);
const filteredValues = useMemo(() => {
return value ? values.filter(n => n.includes(value)) : values;
}, [value, formattedValues]);
const renderFilterValue = value => {
return filters.find(f => f.value === value)?.label;
};
const handleAdd = () => {
onSelect({ name, type, filter, value });
};
const handleMenuSelect = value => {
setValue(value);
};
const showMenu =
[OPERATORS.equals, OPERATORS.notEquals].includes(filter as any) &&
!(filteredValues.length === 1 && filteredValues[0] === value);
return (
<Form>
<FormRow label={label} className={styles.filter}>
<Flexbox gap={10}>
{allowFilterSelect && (
<Dropdown
className={styles.dropdown}
items={filters}
value={filter}
renderValue={renderFilterValue}
onChange={(key: any) => setFilter(key)}
>
{({ value, label }) => {
return <Item key={value}>{label}</Item>;
}}
</Dropdown>
)}
<PopupTrigger>
<TextField
className={styles.text}
value={decodeURIComponent(value)}
onChange={e => setValue(e.target.value)}
/>
{showMenu && (
<Popup className={styles.popup} alignment="end">
{filteredValues.length > 0 && (
<Menu variant="popup" onSelect={handleMenuSelect}>
{filteredValues.map(value => {
return <Item key={value}>{safeDecodeURIComponent(value)}</Item>;
})}
</Menu>
)}
</Popup>
)}
</PopupTrigger>
</Flexbox>
<Button variant="primary" onClick={handleAdd} disabled={!filter || !value}>
{formatMessage(labels.add)}
</Button>
</FormRow>
</Form>
);
}

View file

@ -1,30 +1,18 @@
import { useMessages } from 'components/hooks'; import { useFields, useMessages } from 'components/hooks';
import Icons from 'components/icons'; import Icons from 'components/icons';
import { useContext } from 'react'; import { useContext } from 'react';
import { Button, FormRow, Icon, Popup, PopupTrigger } from 'react-basics'; import { Button, FormRow, Icon, Popup, PopupTrigger } from 'react-basics';
import FieldSelectForm from '../[reportId]/FieldSelectForm'; import FieldSelectForm from '../[reportId]/FieldSelectForm';
import ParameterList from '../[reportId]/ParameterList'; import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm'; import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from './Report';
export function InsightsFieldParameters() { export function FieldParameters() {
const { report, updateReport } = useContext(ReportContext); const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { parameters } = report || {}; const { parameters } = report || {};
const { fields } = parameters || {}; const { fields } = parameters || {};
const { fields: fieldOptions } = useFields();
const fieldOptions = [
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'query', type: 'string', label: formatMessage(labels.query) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
{ name: 'country', type: 'string', label: formatMessage(labels.country) },
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
];
const handleAdd = (value: { name: any }) => { const handleAdd = (value: { name: any }) => {
if (!fields.find(({ name }) => name === value.name)) { if (!fields.find(({ name }) => name === value.name)) {
@ -72,4 +60,4 @@ export function InsightsFieldParameters() {
); );
} }
export default InsightsFieldParameters; export default FieldParameters;

View file

@ -15,7 +15,7 @@
white-space: nowrap; white-space: nowrap;
} }
.filter { .op {
color: var(--blue900); color: var(--blue900);
background-color: var(--blue100); background-color: var(--blue100);
font-size: 12px; font-size: 12px;
@ -34,3 +34,7 @@
border-radius: 5px; border-radius: 5px;
white-space: nowrap; white-space: nowrap;
} }
.edit {
margin-top: 20px;
}

View file

@ -0,0 +1,136 @@
import { useContext } from 'react';
import { useMessages, useFormat, useFilters, useFields } from 'components/hooks';
import Icons from 'components/icons';
import { Button, FormRow, Icon, Popup, PopupTrigger } from 'react-basics';
import FilterSelectForm from '../[reportId]/FilterSelectForm';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from './Report';
import FieldFilterEditForm from '../[reportId]/FieldFilterEditForm';
import { isSearchOperator } from 'lib/params';
import styles from './FilterParameters.module.css';
export function FilterParameters() {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const { parameters } = report || {};
const { websiteId, filters, dateRange } = parameters || {};
const { fields } = useFields();
const handleAdd = (value: { name: any }) => {
if (!filters.find(({ name }) => name === value.name)) {
updateReport({ parameters: { filters: filters.concat(value) } });
}
};
const handleRemove = (name: string) => {
updateReport({ parameters: { filters: filters.filter(f => f.name !== name) } });
};
const handleChange = (close: () => void, filter: { name: any }) => {
updateReport({
parameters: {
filters: filters.map(f => {
if (filter.name === f.name) {
return filter;
}
return f;
}),
},
});
close();
};
const AddButton = () => {
return (
<PopupTrigger>
<Button size="sm">
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup position="bottom" alignment="start">
<PopupForm>
<FilterSelectForm
websiteId={websiteId}
fields={fields.filter(({ name }) => !filters.find(f => f.name === name))}
onChange={handleAdd}
/>
</PopupForm>
</Popup>
</PopupTrigger>
);
};
return (
<FormRow label={formatMessage(labels.filters)} action={<AddButton />}>
<ParameterList>
{filters.map(
({ name, operator, value }: { name: string; operator: string; value: string }) => {
const label = fields.find(f => f.name === name)?.label;
const isSearch = isSearchOperator(operator);
return (
<ParameterList.Item key={name} onRemove={() => handleRemove(name)}>
<FilterParameter
startDate={dateRange.startDate}
endDate={dateRange.endDate}
websiteId={websiteId}
name={name}
label={label}
operator={operator}
value={isSearch ? value : formatValue(value, name)}
onChange={handleChange}
/>
</ParameterList.Item>
);
},
)}
</ParameterList>
</FormRow>
);
}
const FilterParameter = ({
websiteId,
name,
label,
operator,
value,
type = 'string',
startDate,
endDate,
onChange,
}) => {
const { operatorLabels } = useFilters();
return (
<PopupTrigger>
<div className={styles.item}>
<div className={styles.label}>{label}</div>
<div className={styles.op}>{operatorLabels[operator]}</div>
<div className={styles.value}>{value}</div>
</div>
<Popup className={styles.edit} alignment="start">
{(close: any) => (
<PopupForm>
<FieldFilterEditForm
websiteId={websiteId}
name={name}
label={label}
type={type}
startDate={startDate}
endDate={endDate}
operator={operator}
defaultValue={value}
onChange={onChange.bind(null, close)}
/>
</PopupForm>
)}
</Popup>
</PopupTrigger>
);
};
export default FilterParameters;

View file

@ -1,59 +1,41 @@
import { useState } from 'react'; import { useState } from 'react';
import { Loading } from 'react-basics';
import { subDays } from 'date-fns';
import FieldSelectForm from './FieldSelectForm'; import FieldSelectForm from './FieldSelectForm';
import FieldFilterForm from './FieldFilterForm'; import FieldFilterEditForm from './FieldFilterEditForm';
import { useApi } from 'components/hooks'; import { useDateRange } from 'components/hooks';
function useValues(websiteId: string, type: string) {
const now = Date.now();
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery({
queryKey: ['websites:values', websiteId, type],
queryFn: () =>
get(`/websites/${websiteId}/values`, {
type,
startAt: +subDays(now, 90),
endAt: now,
}),
enabled: !!(websiteId && type),
});
return { data, error, isLoading };
}
export interface FilterSelectFormProps { export interface FilterSelectFormProps {
websiteId: string; websiteId?: string;
fields: any[]; fields: any[];
onSelect?: (key: any) => void; onChange?: (filter: { name: string; type: string; operator: string; value: string }) => void;
allowFilterSelect?: boolean; allowFilterSelect?: boolean;
} }
export default function FilterSelectForm({ export default function FilterSelectForm({
websiteId, websiteId,
fields, fields,
onSelect, onChange,
allowFilterSelect, allowFilterSelect,
}: FilterSelectFormProps) { }: FilterSelectFormProps) {
const [field, setField] = useState<{ name: string; label: string; type: string }>(); const [field, setField] = useState<{ name: string; label: string; type: string }>();
const { data, isLoading } = useValues(websiteId, field?.name); const [{ startDate, endDate }] = useDateRange(websiteId);
if (!field) { if (!field) {
return <FieldSelectForm fields={fields} onSelect={setField} showType={false} />; return <FieldSelectForm fields={fields} onSelect={setField} showType={false} />;
} }
if (isLoading) { const { name, label, type } = field;
return <Loading position="center" icon="dots" />;
}
return ( return (
<FieldFilterForm <FieldFilterEditForm
name={field?.name} websiteId={websiteId}
label={field?.label} name={name}
type={field?.type} label={label}
values={data} type={type}
onSelect={onSelect} startDate={startDate}
endDate={endDate}
onChange={onChange}
allowFilterSelect={allowFilterSelect} allowFilterSelect={allowFilterSelect}
isNew={true}
/> />
); );
} }

View file

@ -4,6 +4,7 @@ import Icons from 'components/icons';
import Empty from 'components/common/Empty'; import Empty from 'components/common/Empty';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import styles from './ParameterList.module.css'; import styles from './ParameterList.module.css';
import classNames from 'classnames';
export interface ParameterListProps { export interface ParameterListProps {
children?: ReactNode; children?: ReactNode;
@ -20,9 +21,19 @@ export function ParameterList({ children }: ParameterListProps) {
); );
} }
const Item = ({ children, onRemove }: { children?: ReactNode; onRemove?: () => void }) => { const Item = ({
children,
className,
onClick,
onRemove,
}: {
children?: ReactNode;
className?: string;
onClick?: () => void;
onRemove?: () => void;
}) => {
return ( return (
<div className={styles.item}> <div className={classNames(styles.item, className)} onClick={onClick}>
{children} {children}
<Icon onClick={onRemove}> <Icon onClick={onRemove}>
<Icons.Close /> <Icons.Close />

View file

@ -1,5 +1,4 @@
.form { .form {
position: absolute;
background: var(--base50); background: var(--base50);
min-width: 300px; min-width: 300px;
padding: 20px; padding: 20px;

View file

@ -13,7 +13,7 @@ export function Report({
className, className,
}: { }: {
reportId: string; reportId: string;
defaultParameters: { [key: string]: any }; defaultParameters: { type: string; parameters: { [key: string]: any } };
children: ReactNode; children: ReactNode;
className?: string; className?: string;
}) { }) {

View file

@ -60,10 +60,9 @@ export function EventDataParameters() {
} }
}; };
const handleRemove = (group: string, index: number) => { const handleRemove = (group: string) => {
const data = [...parameterData[group]]; const data = [...parameterData[group]];
data.splice(index, 1); updateReport({ parameters: { [group]: data.filter(({ name }) => name !== group) } });
updateReport({ parameters: { [group]: data } });
}; };
const AddButton = ({ group, onAdd }) => { const AddButton = ({ group, onAdd }) => {
@ -104,29 +103,28 @@ export function EventDataParameters() {
label={label} label={label}
action={<AddButton group={group} onAdd={handleAdd} />} action={<AddButton group={group} onAdd={handleAdd} />}
> >
<ParameterList <ParameterList>
items={parameterData[group]} {parameterData[group].map(({ name, value }) => {
onRemove={index => handleRemove(group, index)}
>
{({ name, value }) => {
return ( return (
<div className={styles.parameter}> <ParameterList.Item key={name} onRemove={() => handleRemove(group)}>
{group === REPORT_PARAMETERS.fields && ( <div className={styles.parameter}>
<> {group === REPORT_PARAMETERS.fields && (
<div>{name}</div> <>
<div className={styles.op}>{value}</div> <div>{name}</div>
</> <div className={styles.op}>{value}</div>
)} </>
{group === REPORT_PARAMETERS.filters && ( )}
<> {group === REPORT_PARAMETERS.filters && (
<div>{name}</div> <>
<div className={styles.op}>{value[0]}</div> <div>{name}</div>
<div>{value[1]}</div> <div className={styles.op}>{value[0]}</div>
</> <div>{value[1]}</div>
)} </>
</div> )}
</div>
</ParameterList.Item>
); );
}} })}
</ParameterList> </ParameterList>
</FormRow> </FormRow>
); );

View file

@ -37,12 +37,12 @@
.card { .card {
display: grid; display: grid;
gap: 20px; gap: 20px;
margin-top: 14px;
} }
.header { .header {
display: flex; display: flex;
align-items: center; flex-direction: column;
font-weight: 700;
gap: 20px; gap: 20px;
} }
@ -51,19 +51,16 @@
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background: var(--base900); background: var(--base900);
height: 50px; height: 30px;
border-radius: 5px; border-radius: 5px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.label { .label {
color: var(--base700); color: var(--base600);
} font-weight: 700;
text-transform: uppercase;
.value {
color: var(--base50);
margin-inline-end: 20px;
} }
.track { .track {
@ -72,13 +69,33 @@
} }
.info { .info {
display: flex;
justify-content: space-between;
text-transform: lowercase; text-transform: lowercase;
} }
.item { .item {
padding: 6px 10px; font-size: 20px;
border-radius: 4px; color: var(--base900);
border: 1px solid var(--base300); font-weight: 700;
}
.metric {
color: var(--base700);
display: flex;
justify-content: space-between;
gap: 10px;
margin: 10px 0;
text-transform: lowercase;
}
.visitors {
color: var(--base900);
font-size: 24px;
font-weight: 900;
margin-right: 10px;
}
.percent {
font-size: 20px;
font-weight: 700;
align-self: flex-end;
} }

View file

@ -2,8 +2,8 @@ import { useContext } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import styles from './FunnelChart.module.css';
import { formatLongNumber } from 'lib/format'; import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css';
export interface FunnelChartProps { export interface FunnelChartProps {
className?: string; className?: string;
@ -18,35 +18,33 @@ export function FunnelChart({ className }: FunnelChartProps) {
return ( return (
<div className={classNames(styles.chart, className)}> <div className={classNames(styles.chart, className)}>
{data?.map(({ url, visitors, dropped, dropoff, remaining }, index: number) => { {data?.map(({ type, value, visitors, dropped, dropoff, remaining }, index: number) => {
return ( return (
<div key={url} className={styles.step}> <div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div> <div className={styles.num}>{index + 1}</div>
<div className={styles.card}> <div className={styles.card}>
<div className={styles.header}> <div className={styles.header}>
<span className={styles.label}>{formatMessage(labels.viewedPage)}:</span> <span className={styles.label}>
<span className={styles.item}>{url}</span> {formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)}
</span>
<span className={styles.item}>{value}</span>
</div>
<div className={styles.metric}>
<div>
<span className={styles.visitors}>{formatLongNumber(visitors)}</span>
{formatMessage(labels.visitors)}
</div>
<div className={styles.percent}>{(remaining * 100).toFixed(2)}%</div>
</div> </div>
<div className={styles.track}> <div className={styles.track}>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}> <div className={styles.bar} style={{ width: `${remaining * 100}%` }}></div>
<span className={styles.value}>
{remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`}
</span>
</div>
</div> </div>
<div className={styles.info}> {dropoff > 0 && (
<div> <div className={styles.info}>
<b>{formatLongNumber(visitors)}</b> <b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
<span> {formatMessage(labels.visitors)}</span> {(dropoff * 100).toFixed(2)}%)
<span> ({(remaining * 100).toFixed(2)}%)</span>
</div> </div>
{dropoff > 0 && ( )}
<div>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%)
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View file

@ -0,0 +1,16 @@
.item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.type {
color: var(--base700);
}
.value {
display: flex;
align-self: center;
gap: 20px;
}

View file

@ -10,50 +10,65 @@ import {
Popup, Popup,
SubmitButton, SubmitButton,
TextField, TextField,
Button,
} from 'react-basics'; } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm'; import FunnelStepAddForm from './FunnelStepAddForm';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList'; import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm'; import PopupForm from '../[reportId]/PopupForm';
import styles from './FunnelParameters.module.css';
export function FunnelParameters() { export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {}; const { id, parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {}; const { websiteId, dateRange, steps } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2; const queryDisabled = !websiteId || !dateRange || steps?.length < 2;
const handleSubmit = (data: any, e: any) => { const handleSubmit = (data: any, e: any) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (!queryDisabled) { if (!queryDisabled) {
runReport(data); runReport(data);
} }
}; };
const handleAddUrl = (url: string) => { const handleAddStep = (step: { type: string; value: string }) => {
updateReport({ parameters: { urls: parameters.urls.concat(url) } }); updateReport({ parameters: { steps: parameters.steps.concat(step) } });
}; };
const handleRemoveUrl = (index: number, e: any) => { const handleUpdateStep = (
e.stopPropagation(); close: () => void,
const urls = [...parameters.urls]; index: number,
urls.splice(index, 1); step: { type: string; value: string },
updateReport({ parameters: { urls } }); ) => {
const steps = [...parameters.steps];
steps[index] = step;
updateReport({ parameters: { steps } });
close();
}; };
const AddUrlButton = () => { const handleRemoveStep = (index: number) => {
const steps = [...parameters.steps];
delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
};
const AddStepButton = () => {
return ( return (
<PopupTrigger> <PopupTrigger>
<Icon> <Button>
<Icons.Plus /> <Icon>
</Icon> <Icons.Plus />
<Popup position="right" alignment="start"> </Icon>
</Button>
<Popup alignment="start">
<PopupForm> <PopupForm>
<UrlAddForm onAdd={handleAddUrl} /> <FunnelStepAddForm onChange={handleAddStep} />
</PopupForm> </PopupForm>
</Popup> </Popup>
</PopupTrigger> </PopupTrigger>
@ -71,11 +86,37 @@ export function FunnelParameters() {
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton />}> <FormRow label={formatMessage(labels.steps)} action={<AddStepButton />}>
<ParameterList <ParameterList>
items={urls} {steps.map((step: { type: string; value: string }, index: number) => {
onRemove={(index: number, e: any) => handleRemoveUrl(index, e)} return (
/> <PopupTrigger key={index}>
<ParameterList.Item
className={styles.item}
onRemove={() => handleRemoveStep(index)}
>
<div className={styles.value}>
<div className={styles.type}>
<Icon>{step.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}</Icon>
</div>
<div>{step.value}</div>
</div>
</ParameterList.Item>
<Popup alignment="start">
{(close: () => void) => (
<PopupForm>
<FunnelStepAddForm
type={step.type}
value={step.value}
onChange={handleUpdateStep.bind(null, close, index)}
/>
</PopupForm>
)}
</Popup>
</PopupTrigger>
);
})}
</ParameterList>
</FormRow> </FormRow>
<FormButtons> <FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}> <SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>

View file

@ -9,7 +9,7 @@ import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = { const defaultParameters = {
type: REPORT_TYPES.funnel, type: REPORT_TYPES.funnel,
parameters: { window: 60, urls: [] }, parameters: { window: 60, steps: [] },
}; };
export default function FunnelReport({ reportId }: { reportId?: string }) { export default function FunnelReport({ reportId }: { reportId?: string }) {

View file

@ -0,0 +1,7 @@
.dropdown {
width: 140px;
}
.input {
width: 200px;
}

View file

@ -0,0 +1,80 @@
import { useState } from 'react';
import { useMessages } from 'components/hooks';
import { Button, FormRow, TextField, Flexbox, Dropdown, Item } from 'react-basics';
import styles from './FunnelStepAddForm.module.css';
export interface FunnelStepAddFormProps {
type?: string;
value?: string;
onChange?: (step: { type: string; value: string }) => void;
}
export function FunnelStepAddForm({
type: defaultType = 'url',
value: defaultValue = '',
onChange,
}: FunnelStepAddFormProps) {
const [type, setType] = useState(defaultType);
const [value, setValue] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const items = [
{ label: formatMessage(labels.url), value: 'url' },
{ label: formatMessage(labels.event), value: 'event' },
];
const isDisabled = !type || !value;
const handleSave = () => {
onChange({ type, value });
setValue('');
};
const handleChange = e => {
setValue(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
const renderTypeValue = (value: any) => {
return items.find(item => item.value === value)?.label;
};
return (
<Flexbox direction="column" gap={10}>
<FormRow label={formatMessage(defaultValue ? labels.update : labels.add)}>
<Flexbox gap={10}>
<Dropdown
className={styles.dropdown}
items={items}
value={type}
renderValue={renderTypeValue}
onChange={(value: any) => setType(value)}
>
{({ value, label }) => {
return <Item key={value}>{label}</Item>;
}}
</Dropdown>
<TextField
className={styles.input}
value={value}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Flexbox>
</FormRow>
<FormRow>
<Button variant="primary" onClick={handleSave} disabled={isDisabled}>
{formatMessage(defaultValue ? labels.update : labels.add)}
</Button>
</FormRow>
</Flexbox>
);
}
export default FunnelStepAddForm;

View file

@ -1,14 +0,0 @@
.form {
position: absolute;
background: var(--base50);
width: 300px;
padding: 30px;
margin-top: 10px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
}
.input {
width: 100%;
}

View file

@ -1,52 +0,0 @@
import { useState } from 'react';
import { useMessages } from 'components/hooks';
import { Button, Form, FormRow, TextField, Flexbox } from 'react-basics';
import styles from './UrlAddForm.module.css';
export interface UrlAddFormProps {
defaultValue?: string;
onAdd?: (url: string) => void;
}
export function UrlAddForm({ defaultValue = '', onAdd }: UrlAddFormProps) {
const [url, setUrl] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const handleSave = () => {
onAdd(url);
setUrl('');
};
const handleChange = e => {
setUrl(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
return (
<Form>
<FormRow label={formatMessage(labels.url)}>
<Flexbox gap={10}>
<TextField
className={styles.input}
value={url}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
<Button variant="primary" onClick={handleSave}>
{formatMessage(labels.add)}
</Button>
</Flexbox>
</FormRow>
</Form>
);
}
export default UrlAddForm;

View file

@ -1,88 +0,0 @@
import { useMessages, useFormat, useFilters } from 'components/hooks';
import Icons from 'components/icons';
import { useContext } from 'react';
import { Button, FormRow, Icon, Popup, PopupTrigger } from 'react-basics';
import FilterSelectForm from '../[reportId]/FilterSelectForm';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from '../[reportId]/Report';
import styles from './InsightsFilterParameters.module.css';
import { safeDecodeURIComponent } from 'next-basics';
import { OPERATORS } from 'lib/constants';
export function InsightsFilterParameters() {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const { filterLabels } = useFilters();
const { parameters } = report || {};
const { websiteId, filters } = parameters || {};
const fieldOptions = [
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'query', type: 'string', label: formatMessage(labels.query) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
{ name: 'country', type: 'string', label: formatMessage(labels.country) },
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
];
const handleAdd = (value: { name: any }) => {
if (!filters.find(({ name }) => name === value.name)) {
updateReport({ parameters: { filters: filters.concat(value) } });
}
};
const handleRemove = (name: string) => {
updateReport({ parameters: { filters: filters.filter(f => f.name !== name) } });
};
const AddButton = () => {
return (
<PopupTrigger>
<Button size="sm">
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup position="bottom" alignment="start">
<PopupForm>
<FilterSelectForm
websiteId={websiteId}
fields={fieldOptions.filter(({ name }) => !filters.find(f => f.name === name))}
onSelect={handleAdd}
/>
</PopupForm>
</Popup>
</PopupTrigger>
);
};
return (
<FormRow label={formatMessage(labels.filters)} action={<AddButton />}>
<ParameterList>
{filters.map(({ name, filter, value }) => {
const label = fieldOptions.find(f => f.name === name)?.label;
const isEquals = [OPERATORS.equals, OPERATORS.notEquals].includes(filter);
return (
<ParameterList.Item key={name} onRemove={() => handleRemove(name)}>
<div className={styles.item}>
<div className={styles.label}>{label}</div>
<div className={styles.filter}>{filterLabels[filter]}</div>
<div className={styles.value}>
{safeDecodeURIComponent(isEquals ? formatValue(value, name) : value)}
</div>
</div>
</ParameterList.Item>
);
})}
</ParameterList>
</FormRow>
);
}
export default InsightsFilterParameters;

View file

@ -3,8 +3,8 @@ import { useContext } from 'react';
import { Form, FormButtons, SubmitButton } from 'react-basics'; import { Form, FormButtons, SubmitButton } from 'react-basics';
import BaseParameters from '../[reportId]/BaseParameters'; import BaseParameters from '../[reportId]/BaseParameters';
import { ReportContext } from '../[reportId]/Report'; import { ReportContext } from '../[reportId]/Report';
import InsightsFieldParameters from './InsightsFieldParameters'; import FieldParameters from '../[reportId]/FieldParameters';
import InsightsFilterParameters from './InsightsFilterParameters'; import FilterParameters from '../[reportId]/FilterParameters';
export function InsightsParameters() { export function InsightsParameters() {
const { report, runReport, isRunning } = useContext(ReportContext); const { report, runReport, isRunning } = useContext(ReportContext);
@ -22,8 +22,8 @@ export function InsightsParameters() {
return ( return (
<Form values={parameters} onSubmit={handleSubmit}> <Form values={parameters} onSubmit={handleSubmit}>
<BaseParameters allowWebsiteSelect={!id} /> <BaseParameters allowWebsiteSelect={!id} />
{parametersSelected && <InsightsFieldParameters />} {parametersSelected && <FieldParameters />}
{parametersSelected && <InsightsFilterParameters />} {parametersSelected && <FilterParameters />}
<FormButtons> <FormButtons>
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}> <SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)} {formatMessage(labels.runQuery)}

View file

@ -1,4 +1,4 @@
import { GridColumn, GridTable, Icon, Text } from 'react-basics'; import { GridColumn, GridTable, Icon, Text, useBreakpoint } from 'react-basics';
import { useLogin, useMessages } from 'components/hooks'; import { useLogin, useMessages } from 'components/hooks';
import Icons from 'components/icons'; import Icons from 'components/icons';
import LinkButton from 'components/common/LinkButton'; import LinkButton from 'components/common/LinkButton';
@ -14,9 +14,10 @@ export function TeamWebsitesTable({
}) { }) {
const { user } = useLogin(); const { user } = useLogin();
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const breakpoint = useBreakpoint();
return ( return (
<GridTable data={data}> <GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
<GridColumn name="name" label={formatMessage(labels.name)} /> <GridColumn name="name" label={formatMessage(labels.name)} />
<GridColumn name="domain" label={formatMessage(labels.domain)} /> <GridColumn name="domain" label={formatMessage(labels.domain)} />
<GridColumn name="createdBy" label={formatMessage(labels.createdBy)}> <GridColumn name="createdBy" label={formatMessage(labels.createdBy)}>

View file

@ -34,7 +34,7 @@ export function UserAddForm({ onSave, onClose }) {
return formatMessage(labels.user); return formatMessage(labels.user);
} }
if (value === ROLES.admin) { if (value === ROLES.admin) {
return formatMessage(labels.administrator); return formatMessage(labels.admin);
} }
if (value === ROLES.viewOnly) { if (value === ROLES.viewOnly) {
return formatMessage(labels.viewOnly); return formatMessage(labels.viewOnly);
@ -58,7 +58,7 @@ export function UserAddForm({ onSave, onClose }) {
<Dropdown renderValue={renderValue}> <Dropdown renderValue={renderValue}>
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item> <Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item>
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item> <Item key={ROLES.user}>{formatMessage(labels.user)}</Item>
<Item key={ROLES.admin}>{formatMessage(labels.administrator)}</Item> <Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item>
</Dropdown> </Dropdown>
</FormInput> </FormInput>
</FormRow> </FormRow>

View file

@ -46,7 +46,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
return formatMessage(labels.user); return formatMessage(labels.user);
} }
if (value === ROLES.admin) { if (value === ROLES.admin) {
return formatMessage(labels.administrator); return formatMessage(labels.admin);
} }
if (value === ROLES.viewOnly) { if (value === ROLES.viewOnly) {
return formatMessage(labels.viewOnly); return formatMessage(labels.viewOnly);
@ -76,7 +76,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
<Dropdown renderValue={renderValue}> <Dropdown renderValue={renderValue}>
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item> <Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item>
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item> <Item key={ROLES.user}>{formatMessage(labels.user)}</Item>
<Item key={ROLES.admin}>{formatMessage(labels.administrator)}</Item> <Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item>
</Dropdown> </Dropdown>
</FormInput> </FormInput>
</FormRow> </FormRow>

View file

@ -1,13 +0,0 @@
@media screen and (max-width: 992px) {
.row {
flex-wrap: wrap;
}
.header .actions {
display: none;
}
.actions {
flex-basis: 100%;
}
}

View file

@ -35,7 +35,7 @@ export function ShareUrl({
const url = `${hostUrl || process.env.hostUrl || window?.location.origin}${ const url = `${hostUrl || process.env.hostUrl || window?.location.origin}${
process.env.basePath process.env.basePath
}/share/${id}/${encodeURIComponent(domain)}`; }/share/${id}/${domain}`;
const handleGenerate = () => { const handleGenerate = () => {
setId(generateId()); setId(generateId());

View file

@ -6,7 +6,7 @@ import WebsiteChart from './WebsiteChart';
import useDashboard from 'store/dashboard'; import useDashboard from 'store/dashboard';
import WebsiteHeader from './WebsiteHeader'; import WebsiteHeader from './WebsiteHeader';
import { WebsiteMetricsBar } from './WebsiteMetricsBar'; import { WebsiteMetricsBar } from './WebsiteMetricsBar';
import { useMessages, useLocale } from 'components/hooks'; import { useMessages, useLocale, useTeamUrl } from 'components/hooks';
export default function WebsiteChartList({ export default function WebsiteChartList({
websites, websites,
@ -19,6 +19,7 @@ export default function WebsiteChartList({
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { websiteOrder } = useDashboard(); const { websiteOrder } = useDashboard();
const { renderTeamUrl } = useTeamUrl();
const { dir } = useLocale(); const { dir } = useLocale();
const ordered = useMemo( const ordered = useMemo(
@ -35,7 +36,7 @@ export default function WebsiteChartList({
return index < limit ? ( return index < limit ? (
<div key={id}> <div key={id}>
<WebsiteHeader websiteId={id} showLinks={false}> <WebsiteHeader websiteId={id} showLinks={false}>
<Link href={`/websites/${id}`}> <Link href={renderTeamUrl(`/websites/${id}`)}>
<Button variant="primary"> <Button variant="primary">
<Text>{formatMessage(labels.viewDetails)}</Text> <Text>{formatMessage(labels.viewDetails)}</Text>
<Icon> <Icon>

View file

@ -13,20 +13,19 @@ import WebsiteTableView from './WebsiteTableView';
export default function WebsiteDetails({ websiteId }: { websiteId: string }) { export default function WebsiteDetails({ websiteId }: { websiteId: string }) {
const { data: website, isLoading, error } = useWebsite(websiteId); const { data: website, isLoading, error } = useWebsite(websiteId);
const pathname = usePathname(); const pathname = usePathname();
const showLinks = !pathname.includes('/share/'); const { query } = useNavigation();
const {
query: { view, url, referrer, query, os, browser, device, country, region, city, title },
} = useNavigation();
if (isLoading || error) { if (isLoading || error) {
return <Page isLoading={isLoading} error={error} />; return <Page isLoading={isLoading} error={error} />;
} }
const showLinks = !pathname.includes('/share/');
const { view, ...params } = query;
return ( return (
<> <>
<WebsiteHeader websiteId={websiteId} showLinks={showLinks} /> <WebsiteHeader websiteId={websiteId} showLinks={showLinks} />
<FilterTags params={{ url, referrer, query, os, browser, device, country, region, city, title }} /> <FilterTags websiteId={websiteId} params={params} />
<WebsiteMetricsBar websiteId={websiteId} sticky={true} /> <WebsiteMetricsBar websiteId={websiteId} sticky={true} />
<WebsiteChart websiteId={websiteId} /> <WebsiteChart websiteId={websiteId} />
{!website && <Loading icon="dots" style={{ minHeight: 300 }} />} {!website && <Loading icon="dots" style={{ minHeight: 300 }} />}

View file

@ -44,7 +44,6 @@ export default function WebsiteExpandedView({
const { const {
router, router,
renderUrl, renderUrl,
pathname,
query: { view }, query: { view },
} = useNavigation(); } = useNavigation();
@ -122,7 +121,12 @@ export default function WebsiteExpandedView({
return ( return (
<div className={styles.layout}> <div className={styles.layout}>
<div className={styles.menu}> <div className={styles.menu}>
<LinkButton href={pathname} className={styles.back} variant="quiet" scroll={false}> <LinkButton
href={renderUrl({ view: undefined })}
className={styles.back}
variant="quiet"
scroll={false}
>
<Icon rotate={dir === 'rtl' ? 0 : 180}> <Icon rotate={dir === 'rtl' ? 0 : 180}>
<Icons.ArrowRight /> <Icons.ArrowRight />
</Icon> </Icon>

View file

@ -0,0 +1,3 @@
.button {
font-weight: 700;
}

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