diff --git a/.gitignore b/.gitignore index 8f39d0f1..b11f4509 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ node_modules # misc .DS_Store .idea +.yarn *.iml *.log .vscode diff --git a/cypress.config.ts b/cypress.config.ts index 5bed49b8..4b01931b 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -4,4 +4,9 @@ export default defineConfig({ e2e: { baseUrl: 'http://localhost:3000', }, + // default username / password on init + env: { + umami_user: 'admin', + umami_password: 'umami', + }, }); diff --git a/cypress/docker-compose.yml b/cypress/docker-compose.yml index 3cd8f546..01a47bd8 100644 --- a/cypress/docker-compose.yml +++ b/cypress/docker-compose.yml @@ -43,9 +43,10 @@ services: - CYPRESS_umami_user=admin - CYPRESS_umami_password=umami volumes: - - ../tsconfig.json:/tsconfig.json + - ./tsconfig.json:/tsconfig.json - ../cypress.config.ts:/cypress.config.ts - ./:/cypress - ../node_modules/:/node_modules + - ../src/lib/crypto.ts:/src/lib/crypto.ts volumes: umami-db-data: diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts index 288d5c51..5831c81d 100644 --- a/cypress/e2e/login.cy.ts +++ b/cypress/e2e/login.cy.ts @@ -6,8 +6,12 @@ describe('Login tests', () => { }, () => { cy.visit('/login'); - cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user')); - cy.getDataTest('input-password').find('input').type(Cypress.env('umami_password')); + cy.getDataTest('input-username').find('input').click(); + cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user'), { delay: 50 }); + cy.getDataTest('input-password').find('input').click(); + cy.getDataTest('input-password') + .find('input') + .type(Cypress.env('umami_password'), { delay: 50 }); cy.getDataTest('button-submit').click(); cy.url().should('eq', Cypress.config().baseUrl + '/dashboard'); cy.getDataTest('button-profile').click(); diff --git a/cypress/e2e/website.cy.ts b/cypress/e2e/website.cy.ts index feec39de..b60d8e7a 100644 --- a/cypress/e2e/website.cy.ts +++ b/cypress/e2e/website.cy.ts @@ -10,8 +10,10 @@ describe('Website tests', () => { cy.visit('/settings/websites'); cy.getDataTest('button-website-add').click(); cy.contains(/Add website/i).should('be.visible'); - cy.getDataTest('input-name').find('input').wait(500).type('Add test', { delay: 50 }); - cy.getDataTest('input-domain').find('input').wait(500).type('addtest.com', { delay: 50 }); + cy.getDataTest('input-name').find('input').click(); + cy.getDataTest('input-name').find('input').type('Add test', { delay: 50 }); + cy.getDataTest('input-domain').find('input').click(); + cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 50 }); cy.getDataTest('button-submit').click(); cy.get('td[label="Name"]').should('contain.text', 'Add test'); cy.get('td[label="Domain"]').should('contain.text', 'addtest.com'); @@ -26,10 +28,10 @@ describe('Website tests', () => { cy.deleteWebsite(websiteId); }); cy.visit('/settings/websites'); - cy.contains('Add test').should('not.exist'); + cy.contains(/Add test/i).should('not.exist'); }); - it.only('Edit a website', () => { + it('Edit a website', () => { // prep data cy.addWebsite('Update test', 'updatetest.com'); cy.visit('/settings/websites'); @@ -37,16 +39,12 @@ describe('Website tests', () => { // edit website cy.getDataTest('link-button-edit').first().click(); cy.contains(/Details/i).should('be.visible'); - cy.getDataTest('input-name') - .find('input') - .wait(500) - .clear() - .type('Updated website', { delay: 50 }); - cy.getDataTest('input-domain') - .find('input') - .wait(500) - .clear() - .type('updatedwebsite.com', { delay: 50 }); + cy.getDataTest('input-name').find('input').click(); + cy.getDataTest('input-name').find('input').clear(); + cy.getDataTest('input-name').find('input').type('Updated website', { delay: 50 }); + cy.getDataTest('input-domain').find('input').click(); + cy.getDataTest('input-domain').find('input').clear(); + cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 50 }); cy.getDataTest('button-submit').click({ force: true }); cy.getDataTest('input-name').find('input').should('have.value', 'Updated website'); cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com'); @@ -69,7 +67,7 @@ describe('Website tests', () => { cy.deleteWebsite(websiteId); }); cy.visit('/settings/websites'); - cy.contains('Add test').should('not.exist'); + cy.contains(/Add test/i).should('not.exist'); }); it('Delete a website', () => { @@ -86,6 +84,6 @@ describe('Website tests', () => { cy.contains(/Type DELETE in the box below to confirm./i).should('be.visible'); cy.get('input[name="confirm"').type('DELETE'); cy.get('button[type="submit"]').click(); - cy.contains('Delete test').should('not.exist'); + cy.contains(/Delete test/i).should('not.exist'); }); }); diff --git a/db/clickhouse/migrations/02_add_visit_id.sql b/db/clickhouse/migrations/02_add_visit_id.sql new file mode 100644 index 00000000..202c0fd3 --- /dev/null +++ b/db/clickhouse/migrations/02_add_visit_id.sql @@ -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 + + */ \ No newline at end of file diff --git a/db/clickhouse/schema.sql b/db/clickhouse/schema.sql index 741f06ad..dad4f4af 100644 --- a/db/clickhouse/schema.sql +++ b/db/clickhouse/schema.sql @@ -3,6 +3,7 @@ CREATE TABLE umami.website_event ( website_id UUID, session_id UUID, + visit_id UUID, event_id UUID, --sessions hostname LowCardinality(String), diff --git a/db/mysql/migrations/05_add_visit_id/migration.sql b/db/mysql/migrations/05_add_visit_id/migration.sql new file mode 100644 index 00000000..7a833a88 --- /dev/null +++ b/db/mysql/migrations/05_add_visit_id/migration.sql @@ -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`); diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index 8e5cbbc3..152ca265 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -92,6 +92,7 @@ model WebsiteEvent { id String @id() @map("event_id") @db.VarChar(36) websiteId String @map("website_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) urlPath String @map("url_path") @db.VarChar(500) urlQuery String? @map("url_query") @db.VarChar(500) @@ -107,6 +108,7 @@ model WebsiteEvent { @@index([createdAt]) @@index([sessionId]) + @@index([visitId]) @@index([websiteId]) @@index([websiteId, createdAt]) @@index([websiteId, createdAt, urlPath]) @@ -115,6 +117,7 @@ model WebsiteEvent { @@index([websiteId, createdAt, pageTitle]) @@index([websiteId, createdAt, eventName]) @@index([websiteId, sessionId, createdAt]) + @@index([websiteId, visitId, createdAt]) @@map("website_event") } diff --git a/db/postgresql/migrations/05_add_visit_id/migration.sql b/db/postgresql/migrations/05_add_visit_id/migration.sql new file mode 100644 index 00000000..fd2f1b90 --- /dev/null +++ b/db/postgresql/migrations/05_add_visit_id/migration.sql @@ -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"); \ No newline at end of file diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index 31cc7616..0cb8ae8a 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -92,6 +92,7 @@ model WebsiteEvent { id String @id() @map("event_id") @db.Uuid websiteId String @map("website_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) urlPath String @map("url_path") @db.VarChar(500) urlQuery String? @map("url_query") @db.VarChar(500) @@ -107,6 +108,7 @@ model WebsiteEvent { @@index([createdAt]) @@index([sessionId]) + @@index([visitId]) @@index([websiteId]) @@index([websiteId, createdAt]) @@index([websiteId, createdAt, urlPath]) @@ -115,6 +117,7 @@ model WebsiteEvent { @@index([websiteId, createdAt, pageTitle]) @@index([websiteId, createdAt, eventName]) @@index([websiteId, sessionId, createdAt]) + @@index([websiteId, visitId, createdAt]) @@map("website_event") } diff --git a/next.config.js b/next.config.js index dce49100..f8850c60 100644 --- a/next.config.js +++ b/next.config.js @@ -14,6 +14,7 @@ const frameAncestors = process.env.ALLOWED_FRAME_URLS || ''; const disableLogin = process.env.DISABLE_LOGIN || ''; const disableUI = process.env.DISABLE_UI || ''; const hostURL = process.env.HOST_URL || ''; +const privateMode = process.env.PRIVATE_MODE || ''; const contentSecurityPolicy = [ `default-src 'self'`, @@ -120,6 +121,7 @@ const config = { disableLogin, disableUI, hostURL, + privateMode, }, basePath, output: 'standalone', diff --git a/package.json b/package.json index 35cdd730..bd27a4cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umami", - "version": "2.10.2", + "version": "2.11.0", "description": "A simple, fast, privacy-focused alternative to Google Analytics.", "author": "Umami Software, Inc. ", "license": "MIT", @@ -66,14 +66,14 @@ "dependencies": { "@clickhouse/client": "^0.2.2", "@fontsource/inter": "^4.5.15", - "@prisma/client": "5.9.1", + "@prisma/client": "5.11.0", "@prisma/extension-read-replicas": "^0.3.0", "@react-spring/web": "^9.7.3", - "@tanstack/react-query": "^5.12.2", + "@tanstack/react-query": "^5.28.6", "@umami/prisma-client": "^0.14.0", "@umami/redis-client": "^0.18.0", "chalk": "^4.1.1", - "chart.js": "^4.2.1", + "chart.js": "^4.4.2", "chartjs-adapter-date-fns": "^3.0.0", "classnames": "^2.3.1", "colord": "^2.9.2", @@ -98,11 +98,11 @@ "maxmind": "^4.3.6", "md5": "^2.3.0", "moment-timezone": "^0.5.35", - "next": "14.1.0", + "next": "14.1.4", "next-basics": "^0.39.0", "node-fetch": "^3.2.8", "npm-run-all": "^4.1.5", - "prisma": "5.9.1", + "prisma": "5.11.0", "react": "^18.2.0", "react-basics": "^0.123.0", "react-beautiful-dnd": "^13.1.0", @@ -115,7 +115,6 @@ "request-ip": "^3.3.0", "semver": "^7.5.4", "thenby": "^1.3.4", - "timezone-support": "^2.0.2", "uuid": "^9.0.0", "yup": "^0.32.11", "zustand": "^4.3.8" @@ -176,6 +175,6 @@ "tar": "^6.1.2", "ts-jest": "^29.1.2", "ts-node": "^10.9.1", - "typescript": "^5.1.6" + "typescript": "^5.4.3" } } diff --git a/public/intl/messages/am-ET.json b/public/intl/messages/am-ET.json index 5f8f984d..e17f35c7 100644 --- a/public/intl/messages/am-ET.json +++ b/public/intl/messages/am-ET.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/be-BY.json b/public/intl/messages/be-BY.json index 17c512b4..4fd681e1 100644 --- a/public/intl/messages/be-BY.json +++ b/public/intl/messages/be-BY.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/bn-BD.json b/public/intl/messages/bn-BD.json index 569d5680..301ce73b 100644 --- a/public/intl/messages/bn-BD.json +++ b/public/intl/messages/bn-BD.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/ca-ES.json b/public/intl/messages/ca-ES.json index d5261697..4de900c3 100644 --- a/public/intl/messages/ca-ES.json +++ b/public/intl/messages/ca-ES.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/cs-CZ.json b/public/intl/messages/cs-CZ.json index 679ac887..9037654b 100644 --- a/public/intl/messages/cs-CZ.json +++ b/public/intl/messages/cs-CZ.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/da-DK.json b/public/intl/messages/da-DK.json index 7da7e856..ecc26e87 100644 --- a/public/intl/messages/da-DK.json +++ b/public/intl/messages/da-DK.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/de-CH.json b/public/intl/messages/de-CH.json index 1cd613b8..b908b4b2 100644 --- a/public/intl/messages/de-CH.json +++ b/public/intl/messages/de-CH.json @@ -215,6 +215,12 @@ "value": "Erstellt" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/de-DE.json b/public/intl/messages/de-DE.json index 3a2856dc..0dc62bb6 100644 --- a/public/intl/messages/de-DE.json +++ b/public/intl/messages/de-DE.json @@ -215,6 +215,12 @@ "value": "Erstellt" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/el-GR.json b/public/intl/messages/el-GR.json index c76a1bbd..3ececb2c 100644 --- a/public/intl/messages/el-GR.json +++ b/public/intl/messages/el-GR.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/en-GB.json b/public/intl/messages/en-GB.json index c58975e6..5f37a540 100644 --- a/public/intl/messages/en-GB.json +++ b/public/intl/messages/en-GB.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 6a856ebf..8946c90e 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/es-ES.json b/public/intl/messages/es-ES.json index 0cb6edf0..9b0ba5bf 100644 --- a/public/intl/messages/es-ES.json +++ b/public/intl/messages/es-ES.json @@ -32,7 +32,7 @@ "label.add-member": [ { "type": 0, - "value": "Add member" + "value": "Añadir miembro" } ], "label.add-website": [ @@ -215,6 +215,12 @@ "value": "Creado" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, @@ -272,7 +278,7 @@ "label.delete-report": [ { "type": 0, - "value": "Delete report" + "value": "Eliminar reporte" } ], "label.delete-team": [ @@ -464,7 +470,7 @@ "label.insights-description": [ { "type": 0, - "value": "Dive deeper into your data by using segments and filters." + "value": "Profundice en sus datos mediante el uso de segmentos y filtros." } ], "label.is": [ @@ -482,7 +488,7 @@ "label.is-not-set": [ { "type": 0, - "value": "Is not set" + "value": "No está establecido" } ], "label.is-set": [ @@ -588,7 +594,7 @@ "label.manage": [ { "type": 0, - "value": "Manage" + "value": "Administrar" } ], "label.max": [ @@ -600,7 +606,7 @@ "label.member": [ { "type": 0, - "value": "Member" + "value": "Miembro" } ], "label.members": [ @@ -630,7 +636,7 @@ "label.my-account": [ { "type": 0, - "value": "My account" + "value": "Mi cuenta" } ], "label.my-websites": [ @@ -842,7 +848,7 @@ "label.remove-member": [ { "type": 0, - "value": "Remove member" + "value": "Eliminar miembro" } ], "label.reports": [ @@ -926,7 +932,7 @@ "label.select-role": [ { "type": 0, - "value": "Select role" + "value": "Seleccionar rol" } ], "label.select-website": [ @@ -1004,7 +1010,7 @@ "label.team-view-only": [ { "type": 0, - "value": "Team view only" + "value": "Vista solo del equipo" } ], "label.team-websites": [ @@ -1088,13 +1094,13 @@ "label.transfer": [ { "type": 0, - "value": "Transfer" + "value": "Transferir" } ], "label.transfer-website": [ { "type": 0, - "value": "Transfer website" + "value": "Transferir sitio web" } ], "label.true": [ @@ -1232,7 +1238,7 @@ "message.action-confirmation": [ { "type": 0, - "value": "Type " + "value": "Escriba " }, { "type": 1, @@ -1240,7 +1246,7 @@ }, { "type": 0, - "value": " in the box below to confirm." + "value": " en el cuadro a continuación para confirmar." } ], "message.active-users": [ @@ -1308,7 +1314,7 @@ "message.confirm-remove": [ { "type": 0, - "value": "Are you sure you want to remove " + "value": "¿Estás seguro de que desea eliminar " }, { "type": 1, @@ -1336,7 +1342,7 @@ "message.delete-team-warning": [ { "type": 0, - "value": "Deleting a team will also delete all team websites." + "value": "Al eliminar un equipo, también se eliminarán todos los sitios web del equipo." } ], "message.delete-website-warning": [ @@ -1532,7 +1538,7 @@ "message.transfer-team-website-to-user": [ { "type": 0, - "value": "Transfer this website to your account?" + "value": "¿Transferir este sitio web a su cuenta?" } ], "message.transfer-user-website-to-team": [ @@ -1544,13 +1550,13 @@ "message.transfer-website": [ { "type": 0, - "value": "Transfer website ownership to your account or another team." + "value": "Seleccione el equipo al que transferir este sitio web." } ], "message.triggered-event": [ { "type": 0, - "value": "Triggered event" + "value": "Evento lanzado" } ], "message.user-deleted": [ @@ -1562,7 +1568,7 @@ "message.viewed-page": [ { "type": 0, - "value": "Viewed page" + "value": "Página vista" } ], "message.visitor-log": [ @@ -1602,7 +1608,7 @@ "message.visitors-dropped-off": [ { "type": 0, - "value": "Visitors dropped off" + "value": "Los visitantes salieron" } ] } diff --git a/public/intl/messages/fa-IR.json b/public/intl/messages/fa-IR.json index b0bef8a1..9168ff1a 100644 --- a/public/intl/messages/fa-IR.json +++ b/public/intl/messages/fa-IR.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/fi-FI.json b/public/intl/messages/fi-FI.json index 77573105..16cf8db6 100644 --- a/public/intl/messages/fi-FI.json +++ b/public/intl/messages/fi-FI.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/fo-FO.json b/public/intl/messages/fo-FO.json index 07db5d23..b05faf6c 100644 --- a/public/intl/messages/fo-FO.json +++ b/public/intl/messages/fo-FO.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/fr-FR.json b/public/intl/messages/fr-FR.json index 3e345279..c0973249 100644 --- a/public/intl/messages/fr-FR.json +++ b/public/intl/messages/fr-FR.json @@ -32,7 +32,7 @@ "label.add-member": [ { "type": 0, - "value": "Add member" + "value": "Ajouter un membre" } ], "label.add-website": [ @@ -215,6 +215,12 @@ "value": "Créé" } ], + "label.created-by": [ + { + "type": 0, + "value": "Crée par" + } + ], "label.current-password": [ { "type": 0, @@ -272,7 +278,7 @@ "label.delete-report": [ { "type": 0, - "value": "Delete report" + "value": "Supprimer le rapport" } ], "label.delete-team": [ @@ -362,7 +368,7 @@ "label.edit-member": [ { "type": 0, - "value": "Edit member" + "value": "Modifier le membre" } ], "label.enable-share-url": [ @@ -580,7 +586,7 @@ "label.manage": [ { "type": 0, - "value": "Manage" + "value": "Gérer" } ], "label.max": [ @@ -592,7 +598,7 @@ "label.member": [ { "type": 0, - "value": "Member" + "value": "Membre" } ], "label.members": [ @@ -622,7 +628,7 @@ "label.my-account": [ { "type": 0, - "value": "My account" + "value": "Mon compte" } ], "label.my-websites": [ @@ -646,7 +652,7 @@ "label.none": [ { "type": 0, - "value": "Aucun·e" + "value": "Aucun" } ], "label.number-of-records": [ @@ -834,7 +840,7 @@ "label.remove-member": [ { "type": 0, - "value": "Remove member" + "value": "Retirer le membre" } ], "label.reports": [ @@ -918,7 +924,7 @@ "label.select-role": [ { "type": 0, - "value": "Select role" + "value": "Choisir un rôle" } ], "label.select-website": [ @@ -1080,13 +1086,13 @@ "label.transfer": [ { "type": 0, - "value": "Transfer" + "value": "Transférer" } ], "label.transfer-website": [ { "type": 0, - "value": "Transfer website" + "value": "Transférer le site" } ], "label.true": [ @@ -1224,7 +1230,7 @@ "message.action-confirmation": [ { "type": 0, - "value": "Type " + "value": "Taper " }, { "type": 1, @@ -1232,7 +1238,7 @@ }, { "type": 0, - "value": " in the box below to confirm." + "value": " ci-dessous pour confirmer." } ], "message.active-users": [ @@ -1304,7 +1310,7 @@ "message.confirm-remove": [ { "type": 0, - "value": "Are you sure you want to remove " + "value": "Êtes-vous sûr de vouloir retirer " }, { "type": 1, @@ -1312,7 +1318,7 @@ }, { "type": 0, - "value": "?" + "value": " ?" } ], "message.confirm-reset": [ @@ -1332,7 +1338,7 @@ "message.delete-team-warning": [ { "type": 0, - "value": "Deleting a team will also delete all team websites." + "value": "Supprimer une équipe supprimera aussi tous les sites de cette équipe." } ], "message.delete-website-warning": [ @@ -1520,19 +1526,19 @@ "message.transfer-team-website-to-user": [ { "type": 0, - "value": "Transfer this website to your account?" + "value": "Transférer ce site sur votre compte ?" } ], "message.transfer-user-website-to-team": [ { "type": 0, - "value": "Select the team to transfer this website to." + "value": "Choisir l'équipe à laquelle transférer ce site." } ], "message.transfer-website": [ { "type": 0, - "value": "Transfer website ownership to your account or another team." + "value": "Transférer la propriété du site sur votre compte ou à une autre équipe." } ], "message.triggered-event": [ @@ -1550,7 +1556,7 @@ "message.viewed-page": [ { "type": 0, - "value": "Viewed page" + "value": "Page vue" } ], "message.visitor-log": [ @@ -1590,7 +1596,7 @@ "message.visitors-dropped-off": [ { "type": 0, - "value": "Visitors dropped off" + "value": "Visiteurs ont abandonné" } ] } diff --git a/public/intl/messages/ga-ES.json b/public/intl/messages/ga-ES.json index 84e7c411..9e825228 100644 --- a/public/intl/messages/ga-ES.json +++ b/public/intl/messages/ga-ES.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/he-IL.json b/public/intl/messages/he-IL.json index c0f31bb9..59692293 100644 --- a/public/intl/messages/he-IL.json +++ b/public/intl/messages/he-IL.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/hi-IN.json b/public/intl/messages/hi-IN.json index dd5f1c69..ef4deb6f 100644 --- a/public/intl/messages/hi-IN.json +++ b/public/intl/messages/hi-IN.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/hr-HR.json b/public/intl/messages/hr-HR.json index ef4a351e..7259dd02 100644 --- a/public/intl/messages/hr-HR.json +++ b/public/intl/messages/hr-HR.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/hu-HU.json b/public/intl/messages/hu-HU.json index cd6aca19..f6342e3c 100644 --- a/public/intl/messages/hu-HU.json +++ b/public/intl/messages/hu-HU.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/id-ID.json b/public/intl/messages/id-ID.json index c072168c..13385bd8 100644 --- a/public/intl/messages/id-ID.json +++ b/public/intl/messages/id-ID.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/it-IT.json b/public/intl/messages/it-IT.json index a5ef8525..4008ef04 100644 --- a/public/intl/messages/it-IT.json +++ b/public/intl/messages/it-IT.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/ja-JP.json b/public/intl/messages/ja-JP.json index 0aba80b2..e1882cd3 100644 --- a/public/intl/messages/ja-JP.json +++ b/public/intl/messages/ja-JP.json @@ -32,7 +32,7 @@ "label.add-member": [ { "type": 0, - "value": "Add member" + "value": "メンバーの追加" } ], "label.add-website": [ @@ -188,7 +188,7 @@ "label.create": [ { "type": 0, - "value": "Create" + "value": "作成" } ], "label.create-report": [ @@ -215,6 +215,12 @@ "value": "作成されました" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, @@ -272,7 +278,7 @@ "label.delete-report": [ { "type": 0, - "value": "Delete report" + "value": "レポートの削除" } ], "label.delete-team": [ @@ -362,7 +368,7 @@ "label.edit-member": [ { "type": 0, - "value": "Edit member" + "value": "メンバーの編集" } ], "label.enable-share-url": [ @@ -410,13 +416,13 @@ "label.filter": [ { "type": 0, - "value": "Filter" + "value": "フィルター" } ], "label.filter-combined": [ { "type": 0, - "value": "統合" + "value": "結合" } ], "label.filter-raw": [ @@ -434,13 +440,13 @@ "label.funnel": [ { "type": 0, - "value": "分析" + "value": "ファネル" } ], "label.funnel-description": [ { "type": 0, - "value": "Understand the conversion and drop-off rate of users." + "value": "ユーザーのコンバージョン率と離脱率を分析します。" } ], "label.greater-than": [ @@ -458,13 +464,13 @@ "label.insights": [ { "type": 0, - "value": "見通し" + "value": "インサイト" } ], "label.insights-description": [ { "type": 0, - "value": "Dive deeper into your data by using segments and filters." + "value": "セグメントとフィルタを使用して、データをさらに詳しく分析します。" } ], "label.is": [ @@ -588,7 +594,7 @@ "label.manage": [ { "type": 0, - "value": "Manage" + "value": "管理" } ], "label.max": [ @@ -600,7 +606,7 @@ "label.member": [ { "type": 0, - "value": "Member" + "value": "メンバー" } ], "label.members": [ @@ -630,7 +636,7 @@ "label.my-account": [ { "type": 0, - "value": "My account" + "value": "マイアカウント" } ], "label.my-websites": [ @@ -842,7 +848,7 @@ "label.remove-member": [ { "type": 0, - "value": "Remove member" + "value": "メンバーの削除" } ], "label.reports": [ @@ -872,13 +878,13 @@ "label.retention": [ { "type": 0, - "value": "保持" + "value": "リテンション" } ], "label.retention-description": [ { "type": 0, - "value": "Measure your website stickiness by tracking how often users return." + "value": "ユーザーの再訪問回数を記録して、Webサイトのリテンション率を計測します。" } ], "label.role": [ @@ -908,13 +914,13 @@ "label.search": [ { "type": 0, - "value": "Search" + "value": "検索" } ], "label.select": [ { "type": 0, - "value": "Select" + "value": "選択" } ], "label.select-date": [ @@ -926,7 +932,7 @@ "label.select-role": [ { "type": 0, - "value": "Select role" + "value": "ロールを選択" } ], "label.select-website": [ @@ -1004,7 +1010,7 @@ "label.team-view-only": [ { "type": 0, - "value": "Team view only" + "value": "チーム表示のみ" } ], "label.team-websites": [ @@ -1088,13 +1094,13 @@ "label.transfer": [ { "type": 0, - "value": "Transfer" + "value": "移管" } ], "label.transfer-website": [ { "type": 0, - "value": "Transfer website" + "value": "Webサイトの移管" } ], "label.true": [ @@ -1232,7 +1238,7 @@ "message.action-confirmation": [ { "type": 0, - "value": "Type " + "value": "承認する場合は、下のフォームに「" }, { "type": 1, @@ -1240,7 +1246,7 @@ }, { "type": 0, - "value": " in the box below to confirm." + "value": "」と入力してください。" } ], "message.active-users": [ @@ -1298,17 +1304,13 @@ } ], "message.confirm-remove": [ - { - "type": 0, - "value": "Are you sure you want to remove " - }, { "type": 1, "value": "target" }, { "type": 0, - "value": "?" + "value": "を削除してもよろしいですか?" } ], "message.confirm-reset": [ @@ -1324,7 +1326,7 @@ "message.delete-team-warning": [ { "type": 0, - "value": "Deleting a team will also delete all team websites." + "value": "チームを削除すると、そのチームが管理しているWebサイトもすべて削除されます。" } ], "message.delete-website-warning": [ @@ -1526,25 +1528,25 @@ "message.transfer-team-website-to-user": [ { "type": 0, - "value": "Transfer this website to your account?" + "value": "このWebサイトをあなたのアカウントに移管しますか?" } ], "message.transfer-user-website-to-team": [ { "type": 0, - "value": "Select the team to transfer this website to." + "value": "このWebサイトを移管するチームを選択してください。" } ], "message.transfer-website": [ { "type": 0, - "value": "Transfer website ownership to your account or another team." + "value": "Webサイトの所有権を自分のアカウントまたは別のチームへ移管します。" } ], "message.triggered-event": [ { "type": 0, - "value": "Triggered event" + "value": "トリガーされたイベント" } ], "message.user-deleted": [ @@ -1556,7 +1558,7 @@ "message.viewed-page": [ { "type": 0, - "value": "Viewed page" + "value": "閲覧されたページ" } ], "message.visitor-log": [ @@ -1596,7 +1598,7 @@ "message.visitors-dropped-off": [ { "type": 0, - "value": "Visitors dropped off" + "value": "訪問者の離脱率" } ] } diff --git a/public/intl/messages/km-KH.json b/public/intl/messages/km-KH.json index c4986637..712bed11 100644 --- a/public/intl/messages/km-KH.json +++ b/public/intl/messages/km-KH.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/ko-KR.json b/public/intl/messages/ko-KR.json index 909c8d90..4d19a3a2 100644 --- a/public/intl/messages/ko-KR.json +++ b/public/intl/messages/ko-KR.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/lt-LT.json b/public/intl/messages/lt-LT.json index 127c6d67..e7d46838 100644 --- a/public/intl/messages/lt-LT.json +++ b/public/intl/messages/lt-LT.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/mn-MN.json b/public/intl/messages/mn-MN.json index 75e13a29..509d8b5b 100644 --- a/public/intl/messages/mn-MN.json +++ b/public/intl/messages/mn-MN.json @@ -215,6 +215,12 @@ "value": "Үүсгэсэн" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/ms-MY.json b/public/intl/messages/ms-MY.json index 1afbbc7b..997a0951 100644 --- a/public/intl/messages/ms-MY.json +++ b/public/intl/messages/ms-MY.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/my-MM.json b/public/intl/messages/my-MM.json index 07469c83..9a586ac2 100644 --- a/public/intl/messages/my-MM.json +++ b/public/intl/messages/my-MM.json @@ -215,6 +215,12 @@ "value": "ပြုလုပ်ပြီးသော" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/nb-NO.json b/public/intl/messages/nb-NO.json index c9d4354a..b2b8715d 100644 --- a/public/intl/messages/nb-NO.json +++ b/public/intl/messages/nb-NO.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/nl-NL.json b/public/intl/messages/nl-NL.json index f474f237..c992b6c8 100644 --- a/public/intl/messages/nl-NL.json +++ b/public/intl/messages/nl-NL.json @@ -215,6 +215,12 @@ "value": "Gemaakt" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/pl-PL.json b/public/intl/messages/pl-PL.json index d07e9fa5..92353448 100644 --- a/public/intl/messages/pl-PL.json +++ b/public/intl/messages/pl-PL.json @@ -215,6 +215,12 @@ "value": "Utworzony" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/pt-BR.json b/public/intl/messages/pt-BR.json index b3ffe6d9..e926a9a2 100644 --- a/public/intl/messages/pt-BR.json +++ b/public/intl/messages/pt-BR.json @@ -215,6 +215,12 @@ "value": "Criado" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/pt-PT.json b/public/intl/messages/pt-PT.json index 99df041a..ae3fb472 100644 --- a/public/intl/messages/pt-PT.json +++ b/public/intl/messages/pt-PT.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/ro-RO.json b/public/intl/messages/ro-RO.json index 9d1d4080..8236eba6 100644 --- a/public/intl/messages/ro-RO.json +++ b/public/intl/messages/ro-RO.json @@ -2,7 +2,7 @@ "label.access-code": [ { "type": 0, - "value": "Access code" + "value": "Cod de access" } ], "label.actions": [ @@ -14,25 +14,25 @@ "label.activity-log": [ { "type": 0, - "value": "Activity log" + "value": "Jurnal de activități" } ], "label.add": [ { "type": 0, - "value": "Add" + "value": "Adaugă" } ], "label.add-description": [ { "type": 0, - "value": "Add description" + "value": "Adaugă descriere" } ], "label.add-member": [ { "type": 0, - "value": "Add member" + "value": "Adaugă membru" } ], "label.add-website": [ @@ -50,7 +50,7 @@ "label.after": [ { "type": 0, - "value": "After" + "value": "După" } ], "label.all": [ @@ -62,7 +62,7 @@ "label.all-time": [ { "type": 0, - "value": "All time" + "value": "Pentru tot timpul" } ], "label.analytics": [ @@ -74,7 +74,7 @@ "label.average": [ { "type": 0, - "value": "Average" + "value": "Mediu" } ], "label.average-visit-time": [ @@ -92,7 +92,7 @@ "label.before": [ { "type": 0, - "value": "Before" + "value": "Înainte" } ], "label.bounce-rate": [ @@ -104,7 +104,7 @@ "label.breakdown": [ { "type": 0, - "value": "Breakdown" + "value": "Detaliat" } ], "label.browser": [ @@ -134,19 +134,19 @@ "label.cities": [ { "type": 0, - "value": "Cities" + "value": "Orașe" } ], "label.city": [ { "type": 0, - "value": "City" + "value": "Oraș" } ], "label.clear-all": [ { "type": 0, - "value": "Clear all" + "value": "Șterge tot" } ], "label.confirm": [ @@ -164,13 +164,13 @@ "label.contains": [ { "type": 0, - "value": "Contains" + "value": "Conține" } ], "label.continue": [ { "type": 0, - "value": "Continue" + "value": "Continuă" } ], "label.countries": [ @@ -182,37 +182,37 @@ "label.country": [ { "type": 0, - "value": "Country" + "value": "Țară" } ], "label.create": [ { "type": 0, - "value": "Create" + "value": "Crează" } ], "label.create-report": [ { "type": 0, - "value": "Create report" + "value": "Crează report" } ], "label.create-team": [ { "type": 0, - "value": "Create team" + "value": "Crează echipă" } ], "label.create-user": [ { "type": 0, - "value": "Create user" + "value": "Crează utilizator" } ], "label.created": [ { "type": 0, - "value": "Created" + "value": "Creat" } ], "label.current-password": [ @@ -236,13 +236,13 @@ "label.data": [ { "type": 0, - "value": "Data" + "value": "Date" } ], "label.date": [ { "type": 0, - "value": "Date" + "value": "Data" } ], "label.date-range": [ @@ -254,7 +254,7 @@ "label.day": [ { "type": 0, - "value": "Day" + "value": "Zi" } ], "label.default-date-range": [ @@ -272,19 +272,19 @@ "label.delete-report": [ { "type": 0, - "value": "Delete report" + "value": "Șterge raport" } ], "label.delete-team": [ { "type": 0, - "value": "Delete team" + "value": "Șterge echipă" } ], "label.delete-user": [ { "type": 0, - "value": "Delete user" + "value": "Șterge utilizator" } ], "label.delete-website": [ @@ -296,7 +296,7 @@ "label.description": [ { "type": 0, - "value": "Description" + "value": "Descriere" } ], "label.desktop": [ @@ -308,13 +308,13 @@ "label.details": [ { "type": 0, - "value": "Details" + "value": "Detalii" } ], "label.device": [ { "type": 0, - "value": "Device" + "value": "Dispozitiv" } ], "label.devices": [ @@ -332,7 +332,7 @@ "label.does-not-contain": [ { "type": 0, - "value": "Does not contain" + "value": "Nu conține" } ], "label.domain": [ @@ -344,7 +344,7 @@ "label.dropoff": [ { "type": 0, - "value": "Dropoff" + "value": "Rată de abandon" } ], "label.edit": [ @@ -356,13 +356,13 @@ "label.edit-dashboard": [ { "type": 0, - "value": "Edit dashboard" + "value": "Editare tablou de bord" } ], "label.edit-member": [ { "type": 0, - "value": "Edit member" + "value": "Editare membru" } ], "label.enable-share-url": [ @@ -374,13 +374,13 @@ "label.event": [ { "type": 0, - "value": "Event" + "value": "Eveniment" } ], "label.event-data": [ { "type": 0, - "value": "Event data" + "value": "Date despre eveniment" } ], "label.events": [ @@ -392,25 +392,25 @@ "label.false": [ { "type": 0, - "value": "False" + "value": "Fals" } ], "label.field": [ { "type": 0, - "value": "Field" + "value": "Câmp" } ], "label.fields": [ { "type": 0, - "value": "Fields" + "value": "Câmpuri" } ], "label.filter": [ { "type": 0, - "value": "Filter" + "value": "Filtru" } ], "label.filter-combined": [ @@ -428,91 +428,91 @@ "label.filters": [ { "type": 0, - "value": "Filters" + "value": "Filtre" } ], "label.funnel": [ { "type": 0, - "value": "Funnel" + "value": "Parcursul utilizatorului" } ], "label.funnel-description": [ { "type": 0, - "value": "Understand the conversion and drop-off rate of users." + "value": "Înțelege rata de conversie și rata de abandon a utilizatorilor." } ], "label.greater-than": [ { "type": 0, - "value": "Greater than" + "value": "Mai mare decât" } ], "label.greater-than-equals": [ { "type": 0, - "value": "Greater than or equals" + "value": "Mai mare sau egal cu" } ], "label.insights": [ { "type": 0, - "value": "Insights" + "value": "Perspective" } ], "label.insights-description": [ { "type": 0, - "value": "Dive deeper into your data by using segments and filters." + "value": "Aprofundează datele utilizând segmente și filtre." } ], "label.is": [ { "type": 0, - "value": "Is" + "value": "Este" } ], "label.is-not": [ { "type": 0, - "value": "Is not" + "value": "Nu este" } ], "label.is-not-set": [ { "type": 0, - "value": "Is not set" + "value": "Nu este setat" } ], "label.is-set": [ { "type": 0, - "value": "Is set" + "value": "Este setat" } ], "label.join": [ { "type": 0, - "value": "Join" + "value": "Alătură-te" } ], "label.join-team": [ { "type": 0, - "value": "Join team" + "value": "Alătură-te echipei" } ], "label.language": [ { "type": 0, - "value": "Language" + "value": "Limbă" } ], "label.languages": [ { "type": 0, - "value": "Languages" + "value": "Limbi" } ], "label.laptop": [ @@ -552,25 +552,25 @@ "label.leave": [ { "type": 0, - "value": "Leave" + "value": "Părăsește" } ], "label.leave-team": [ { "type": 0, - "value": "Leave team" + "value": "Părăsește echipa" } ], "label.less-than": [ { "type": 0, - "value": "Less than" + "value": "Mai puțin decât" } ], "label.less-than-equals": [ { "type": 0, - "value": "Less than or equals" + "value": "Mai puțin sau egal cu" } ], "label.login": [ @@ -582,13 +582,13 @@ "label.logout": [ { "type": 0, - "value": "Iesire din cont" + "value": "Ieșire din cont" } ], "label.manage": [ { "type": 0, - "value": "Manage" + "value": "Administrează" } ], "label.max": [ @@ -600,13 +600,13 @@ "label.member": [ { "type": 0, - "value": "Member" + "value": "Membru" } ], "label.members": [ { "type": 0, - "value": "Members" + "value": "Membri" } ], "label.min": [ @@ -630,13 +630,13 @@ "label.my-account": [ { "type": 0, - "value": "My account" + "value": "Contul meu" } ], "label.my-websites": [ { "type": 0, - "value": "My websites" + "value": "Website-ul meu" } ], "label.name": [ @@ -648,13 +648,13 @@ "label.new-password": [ { "type": 0, - "value": "Parola nouă" + "value": "Parolă nouă" } ], "label.none": [ { "type": 0, - "value": "None" + "value": "Niciunul" } ], "label.number-of-records": [ @@ -673,7 +673,7 @@ "value": [ { "type": 0, - "value": "record" + "value": "înregistrare" } ] }, @@ -681,7 +681,7 @@ "value": [ { "type": 0, - "value": "records" + "value": "înregistrări" } ] } @@ -706,19 +706,19 @@ "label.overview": [ { "type": 0, - "value": "Overview" + "value": "Vedere de ansamblu" } ], "label.owner": [ { "type": 0, - "value": "Owner" + "value": "Titular" } ], "label.page-of": [ { "type": 0, - "value": "Page " + "value": "Pagina " }, { "type": 1, @@ -726,7 +726,7 @@ }, { "type": 0, - "value": " of " + "value": " din " }, { "type": 1, @@ -742,7 +742,7 @@ "label.pageTitle": [ { "type": 0, - "value": "Page title" + "value": "Titlul paginii" } ], "label.pages": [ @@ -776,31 +776,31 @@ "label.queries": [ { "type": 0, - "value": "Queries" + "value": "Interogări" } ], "label.query": [ { "type": 0, - "value": "Query" + "value": "Interogare" } ], "label.query-parameters": [ { "type": 0, - "value": "Query parameters" + "value": "Parametri de interogare" } ], "label.realtime": [ { "type": 0, - "value": "Realtime" + "value": "Timp real" } ], "label.referrer": [ { "type": 0, - "value": "Referrer" + "value": "Proveniență" } ], "label.referrers": [ @@ -818,37 +818,37 @@ "label.regenerate": [ { "type": 0, - "value": "Regenerate" + "value": "Regenerează" } ], "label.region": [ { "type": 0, - "value": "Region" + "value": "Regiune" } ], "label.regions": [ { "type": 0, - "value": "Regions" + "value": "Regiuni" } ], "label.remove": [ { "type": 0, - "value": "Remove" + "value": "Îndepărtează" } ], "label.remove-member": [ { "type": 0, - "value": "Remove member" + "value": "Îndepărtează membru" } ], "label.reports": [ { "type": 0, - "value": "Reports" + "value": "Rapoarte" } ], "label.required": [ @@ -872,25 +872,25 @@ "label.retention": [ { "type": 0, - "value": "Retention" + "value": "Retenție" } ], "label.retention-description": [ { "type": 0, - "value": "Measure your website stickiness by tracking how often users return." + "value": "Măsoară atractivitatea site-ului tău prin urmărirea frecvenței cu care utilizatorii se întorc." } ], "label.role": [ { "type": 0, - "value": "Role" + "value": "Rol" } ], "label.run-query": [ { "type": 0, - "value": "Run query" + "value": "Execută interogarea" } ], "label.save": [ @@ -902,43 +902,43 @@ "label.screens": [ { "type": 0, - "value": "Screens" + "value": "Ecrane" } ], "label.search": [ { "type": 0, - "value": "Search" + "value": "Căutare" } ], "label.select": [ { "type": 0, - "value": "Select" + "value": "Selectează" } ], "label.select-date": [ { "type": 0, - "value": "Select date" + "value": "Selectează data" } ], "label.select-role": [ { "type": 0, - "value": "Select role" + "value": "Selectează rolul" } ], "label.select-website": [ { "type": 0, - "value": "Select website" + "value": "Selectează website" } ], "label.sessions": [ { "type": 0, - "value": "Sessions" + "value": "Sesiuni" } ], "label.settings": [ @@ -962,7 +962,7 @@ "label.sum": [ { "type": 0, - "value": "Sum" + "value": "Sumă" } ], "label.tablet": [ @@ -974,55 +974,55 @@ "label.team": [ { "type": 0, - "value": "Team" + "value": "Echipă" } ], "label.team-id": [ { "type": 0, - "value": "Team ID" + "value": "ID Echipa" } ], "label.team-member": [ { "type": 0, - "value": "Team member" + "value": "Membru echipă" } ], "label.team-name": [ { "type": 0, - "value": "Team name" + "value": "Nume echipă" } ], "label.team-owner": [ { "type": 0, - "value": "Team owner" + "value": "Titular echipă" } ], "label.team-view-only": [ { "type": 0, - "value": "Team view only" + "value": "Doar vizualizare echipă" } ], "label.team-websites": [ { "type": 0, - "value": "Team websites" + "value": "Website-uri echipă" } ], "label.teams": [ { "type": 0, - "value": "Teams" + "value": "Echipă" } ], "label.theme": [ { "type": 0, - "value": "Theme" + "value": "Temă" } ], "label.this-month": [ @@ -1052,7 +1052,7 @@ "label.title": [ { "type": 0, - "value": "Title" + "value": "Titlu" } ], "label.today": [ @@ -1076,7 +1076,7 @@ "label.total-records": [ { "type": 0, - "value": "Total records" + "value": "Total înregistrări" } ], "label.tracking-code": [ @@ -1100,19 +1100,19 @@ "label.true": [ { "type": 0, - "value": "True" + "value": "Adevărat" } ], "label.type": [ { "type": 0, - "value": "Type" + "value": "Tip" } ], "label.unique": [ { "type": 0, - "value": "Unique" + "value": "Unici" } ], "label.unique-visitors": [ @@ -1130,7 +1130,7 @@ "label.untitled": [ { "type": 0, - "value": "Untitled" + "value": "Fără titlu" } ], "label.url": [ @@ -1148,7 +1148,7 @@ "label.user": [ { "type": 0, - "value": "User" + "value": "Utilizator" } ], "label.username": [ @@ -1160,19 +1160,19 @@ "label.users": [ { "type": 0, - "value": "Users" + "value": "Utilizatori" } ], "label.value": [ { "type": 0, - "value": "Value" + "value": "Valoare" } ], "label.view": [ { "type": 0, - "value": "View" + "value": "Vizualizare" } ], "label.view-details": [ @@ -1184,7 +1184,7 @@ "label.view-only": [ { "type": 0, - "value": "View only" + "value": "Doar vizualizare" } ], "label.views": [ @@ -1208,7 +1208,7 @@ "label.website-id": [ { "type": 0, - "value": "Website ID" + "value": "ID Website" } ], "label.websites": [ @@ -1220,19 +1220,19 @@ "label.window": [ { "type": 0, - "value": "Window" + "value": "Fereastră" } ], "label.yesterday": [ { "type": 0, - "value": "Yesterday" + "value": "Ieri" } ], "message.action-confirmation": [ { "type": 0, - "value": "Type " + "value": "Scrie " }, { "type": 1, @@ -1240,7 +1240,7 @@ }, { "type": 0, - "value": " in the box below to confirm." + "value": " în câmpul de mai jos pentru a confirma." } ], "message.active-users": [ @@ -1280,7 +1280,7 @@ "message.confirm-delete": [ { "type": 0, - "value": "Sunteți sigur că doriți să ștergeți " + "value": "Ești sigur că vrei să ștergi " }, { "type": 1, @@ -1294,7 +1294,7 @@ "message.confirm-leave": [ { "type": 0, - "value": "Are you sure you want to leave " + "value": "Ești sigur că vrei să părăsești " }, { "type": 1, @@ -1308,7 +1308,7 @@ "message.confirm-remove": [ { "type": 0, - "value": "Are you sure you want to remove " + "value": "Ești sigur că vrei să ștergi " }, { "type": 1, @@ -1322,7 +1322,7 @@ "message.confirm-reset": [ { "type": 0, - "value": "Sunteți sigur că doriți să resetați statisticile pentru " + "value": "Ești sigur că vrei să resetezi statisticile pentru " }, { "type": 1, @@ -1336,7 +1336,7 @@ "message.delete-team-warning": [ { "type": 0, - "value": "Deleting a team will also delete all team websites." + "value": "Ștergerea unei echipe va șterge și toate website-urile echipei." } ], "message.delete-website-warning": [ @@ -1358,7 +1358,7 @@ }, { "type": 0, - "value": " on " + "value": " la " }, { "type": 1, @@ -1380,13 +1380,13 @@ "message.invalid-domain": [ { "type": 0, - "value": "Domeniu nu este valid" + "value": "Domeniul nu este valid" } ], "message.min-password-length": [ { "type": 0, - "value": "Minimum length of " + "value": "Lungimea minimă este de " }, { "type": 1, @@ -1394,13 +1394,13 @@ }, { "type": 0, - "value": " characters" + "value": " caractere" } ], "message.new-version-available": [ { "type": 0, - "value": "A new version of Umami " + "value": "O nouă versiune de Umami " }, { "type": 1, @@ -1408,19 +1408,19 @@ }, { "type": 0, - "value": " is available!" + "value": " este disponibilă!" } ], "message.no-data-available": [ { "type": 0, - "value": "Nici o informație disponibilă." + "value": "Nicio informație disponibilă." } ], "message.no-event-data": [ { "type": 0, - "value": "No event data is available." + "value": "Nu sunt disponibile date legate de eveniment." } ], "message.no-match-password": [ @@ -1432,31 +1432,31 @@ "message.no-results-found": [ { "type": 0, - "value": "No results were found." + "value": "Nu a fost găsit niciun rezultat." } ], "message.no-team-websites": [ { "type": 0, - "value": "This team does not have any websites." + "value": "Echipa aceasta nu are niciun website." } ], "message.no-teams": [ { "type": 0, - "value": "You have not created any teams." + "value": "Nu ai creat nicio echipă." } ], "message.no-users": [ { "type": 0, - "value": "There are no users." + "value": "Nu există utilizatori." } ], "message.no-websites-configured": [ { "type": 0, - "value": "Nu aveți niciun site web configurat." + "value": "Nu ai niciun site web configurat." } ], "message.page-not-found": [ @@ -1468,7 +1468,7 @@ "message.reset-website": [ { "type": 0, - "value": "To reset this website, type " + "value": "Pentru a reseta acest website, scrie " }, { "type": 1, @@ -1476,7 +1476,7 @@ }, { "type": 0, - "value": " in the box below to confirm." + "value": " În câmpul de mai jos pentru a confirma." } ], "message.reset-website-warning": [ @@ -1508,19 +1508,19 @@ "message.team-already-member": [ { "type": 0, - "value": "You are already a member of the team." + "value": "Deja ești membru al acestei echipe." } ], "message.team-not-found": [ { "type": 0, - "value": "Team not found." + "value": "Echipa nu a fost găsită." } ], "message.team-websites-info": [ { "type": 0, - "value": "Websites can be viewed by anyone on the team." + "value": "Site-urile web pot fi vizualizate de către oricare membru al echipei." } ], "message.tracking-code": [ @@ -1532,37 +1532,37 @@ "message.transfer-team-website-to-user": [ { "type": 0, - "value": "Transfer this website to your account?" + "value": "Vrei să transferi acest website pe contul tău?" } ], "message.transfer-user-website-to-team": [ { "type": 0, - "value": "Select the team to transfer this website to." + "value": "Selectează echipa căreia vrei să îi transferi site-ul." } ], "message.transfer-website": [ { "type": 0, - "value": "Transfer website ownership to your account or another team." + "value": "Transferă titulatura site-ului către tine sau către o altă echipă." } ], "message.triggered-event": [ { "type": 0, - "value": "Triggered event" + "value": "Eveniment declanșat" } ], "message.user-deleted": [ { "type": 0, - "value": "User deleted." + "value": "Utilizator șters." } ], "message.viewed-page": [ { "type": 0, - "value": "Viewed page" + "value": "Pagină vizualizată" } ], "message.visitor-log": [ @@ -1602,7 +1602,7 @@ "message.visitors-dropped-off": [ { "type": 0, - "value": "Visitors dropped off" + "value": "Vizitatori care au abandonat" } ] } diff --git a/public/intl/messages/ru-RU.json b/public/intl/messages/ru-RU.json index 3400f48d..e3929902 100644 --- a/public/intl/messages/ru-RU.json +++ b/public/intl/messages/ru-RU.json @@ -215,6 +215,12 @@ "value": "Создано" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/si-LK.json b/public/intl/messages/si-LK.json index 6507bac4..04d627fb 100644 --- a/public/intl/messages/si-LK.json +++ b/public/intl/messages/si-LK.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/sk-SK.json b/public/intl/messages/sk-SK.json index 01096f0f..690ee4c5 100644 --- a/public/intl/messages/sk-SK.json +++ b/public/intl/messages/sk-SK.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/sl-SI.json b/public/intl/messages/sl-SI.json index 3fdea043..7773c813 100644 --- a/public/intl/messages/sl-SI.json +++ b/public/intl/messages/sl-SI.json @@ -215,6 +215,12 @@ "value": "Ustvarjeno" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/sv-SE.json b/public/intl/messages/sv-SE.json index 1e139749..8b8ccf5a 100644 --- a/public/intl/messages/sv-SE.json +++ b/public/intl/messages/sv-SE.json @@ -215,6 +215,12 @@ "value": "Skapad" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/ta-IN.json b/public/intl/messages/ta-IN.json index 43080219..3566ed5c 100644 --- a/public/intl/messages/ta-IN.json +++ b/public/intl/messages/ta-IN.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/th-TH.json b/public/intl/messages/th-TH.json index a867f557..451e8e7a 100644 --- a/public/intl/messages/th-TH.json +++ b/public/intl/messages/th-TH.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/tr-TR.json b/public/intl/messages/tr-TR.json index 86883cc9..91d8d8db 100644 --- a/public/intl/messages/tr-TR.json +++ b/public/intl/messages/tr-TR.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/uk-UA.json b/public/intl/messages/uk-UA.json index b83b2ce4..d9b5bd1e 100644 --- a/public/intl/messages/uk-UA.json +++ b/public/intl/messages/uk-UA.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/ur-PK.json b/public/intl/messages/ur-PK.json index 4cc0c72c..883992dc 100644 --- a/public/intl/messages/ur-PK.json +++ b/public/intl/messages/ur-PK.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/vi-VN.json b/public/intl/messages/vi-VN.json index 25b64a85..cc76182d 100644 --- a/public/intl/messages/vi-VN.json +++ b/public/intl/messages/vi-VN.json @@ -215,6 +215,12 @@ "value": "Created" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/public/intl/messages/zh-CN.json b/public/intl/messages/zh-CN.json index 7b2189ca..41e748b0 100644 --- a/public/intl/messages/zh-CN.json +++ b/public/intl/messages/zh-CN.json @@ -32,7 +32,7 @@ "label.add-member": [ { "type": 0, - "value": "Add member" + "value": "添加成员" } ], "label.add-website": [ @@ -215,6 +215,12 @@ "value": "已创建" } ], + "label.created-by": [ + { + "type": 0, + "value": "创建者" + } + ], "label.current-password": [ { "type": 0, @@ -272,7 +278,7 @@ "label.delete-report": [ { "type": 0, - "value": "Delete report" + "value": "删除报告" } ], "label.delete-team": [ @@ -362,7 +368,7 @@ "label.edit-member": [ { "type": 0, - "value": "Edit member" + "value": "编辑成员" } ], "label.enable-share-url": [ @@ -588,7 +594,7 @@ "label.manage": [ { "type": 0, - "value": "Manage" + "value": "管理" } ], "label.max": [ @@ -600,7 +606,7 @@ "label.member": [ { "type": 0, - "value": "Member" + "value": "成员" } ], "label.members": [ @@ -630,7 +636,7 @@ "label.my-account": [ { "type": 0, - "value": "My account" + "value": "我的账户" } ], "label.my-websites": [ @@ -700,7 +706,7 @@ "label.os": [ { "type": 0, - "value": "OS" + "value": "操作系统" } ], "label.overview": [ @@ -718,7 +724,7 @@ "label.page-of": [ { "type": 0, - "value": "总" + "value": "总 " }, { "type": 1, @@ -726,7 +732,7 @@ }, { "type": 0, - "value": "中的第" + "value": " 中的第 " }, { "type": 1, @@ -734,7 +740,7 @@ }, { "type": 0, - "value": "页" + "value": " 页" } ], "label.page-views": [ @@ -850,7 +856,7 @@ "label.remove-member": [ { "type": 0, - "value": "Remove member" + "value": "移除成员" } ], "label.reports": [ @@ -922,7 +928,7 @@ "label.select": [ { "type": 0, - "value": "Select" + "value": "选择" } ], "label.select-date": [ @@ -934,7 +940,7 @@ "label.select-role": [ { "type": 0, - "value": "Select role" + "value": "选择角色" } ], "label.select-website": [ @@ -1012,7 +1018,7 @@ "label.team-view-only": [ { "type": 0, - "value": "Team view only" + "value": "仅团队视图" } ], "label.team-websites": [ @@ -1096,13 +1102,13 @@ "label.transfer": [ { "type": 0, - "value": "Transfer" + "value": "转移" } ], "label.transfer-website": [ { "type": 0, - "value": "Transfer website" + "value": "转移网站" } ], "label.true": [ @@ -1240,7 +1246,7 @@ "message.action-confirmation": [ { "type": 0, - "value": "Type " + "value": "在下面的框中输入 " }, { "type": 1, @@ -1248,7 +1254,7 @@ }, { "type": 0, - "value": " in the box below to confirm." + "value": " 以确认。" } ], "message.active-users": [ @@ -1296,7 +1302,7 @@ "message.confirm-remove": [ { "type": 0, - "value": "Are you sure you want to remove " + "value": "您确定要移除 " }, { "type": 1, @@ -1304,7 +1310,7 @@ }, { "type": 0, - "value": "?" + "value": " ?" } ], "message.confirm-reset": [ @@ -1318,13 +1324,13 @@ }, { "type": 0, - "value": " 的数据吗?" + "value": " 的数据吗?" } ], "message.delete-team-warning": [ { "type": 0, - "value": "Deleting a team will also delete all team websites." + "value": "删除团队也会删除所有团队的网站。" } ], "message.delete-website-warning": [ @@ -1346,7 +1352,7 @@ }, { "type": 0, - "value": "上的" + "value": " 上的 " }, { "type": 1, @@ -1388,7 +1394,7 @@ "message.new-version-available": [ { "type": 0, - "value": "Umami的新版本" + "value": "Umami 的新版本 " }, { "type": 1, @@ -1396,7 +1402,7 @@ }, { "type": 0, - "value": "已推出!" + "value": " 已推出!" } ], "message.no-data-available": [ @@ -1456,7 +1462,7 @@ "message.reset-website": [ { "type": 0, - "value": "如果确定重置该网站, 请在下面的输入框中输入 " + "value": "如果确定重置该网站,请在下面的输入框中输入 " }, { "type": 1, @@ -1520,25 +1526,25 @@ "message.transfer-team-website-to-user": [ { "type": 0, - "value": "Transfer this website to your account?" + "value": "将该网站转入您的账户?" } ], "message.transfer-user-website-to-team": [ { "type": 0, - "value": "Select the team to transfer this website to." + "value": "选择要将该网站转移到哪个团队。" } ], "message.transfer-website": [ { "type": 0, - "value": "Transfer website ownership to your account or another team." + "value": "将网站所有权转移到您的账户或其他团队。" } ], "message.triggered-event": [ { "type": 0, - "value": "Triggered event" + "value": "触发事件" } ], "message.user-deleted": [ @@ -1550,13 +1556,13 @@ "message.viewed-page": [ { "type": 0, - "value": "Viewed page" + "value": "已浏览页面" } ], "message.visitor-log": [ { "type": 0, - "value": "来自" + "value": "来自 " }, { "type": 1, @@ -1564,7 +1570,7 @@ }, { "type": 0, - "value": "的访客在搭载 " + "value": " 的访客在搭载 " }, { "type": 1, @@ -1572,7 +1578,7 @@ }, { "type": 0, - "value": " 的" + "value": " 的 " }, { "type": 1, @@ -1580,7 +1586,7 @@ }, { "type": 0, - "value": "上使用 " + "value": " 上使用 " }, { "type": 1, @@ -1594,7 +1600,7 @@ "message.visitors-dropped-off": [ { "type": 0, - "value": "Visitors dropped off" + "value": "访客减少" } ] } diff --git a/public/intl/messages/zh-TW.json b/public/intl/messages/zh-TW.json index b4ad7cf5..bd9a7d3a 100644 --- a/public/intl/messages/zh-TW.json +++ b/public/intl/messages/zh-TW.json @@ -215,6 +215,12 @@ "value": "已建立" } ], + "label.created-by": [ + { + "type": 0, + "value": "Created By" + } + ], "label.current-password": [ { "type": 0, diff --git a/rollup.tracker.config.mjs b/rollup.tracker.config.mjs index 6a692efa..05df2879 100644 --- a/rollup.tracker.config.mjs +++ b/rollup.tracker.config.mjs @@ -10,7 +10,8 @@ export default { }, plugins: [ replace({ - '/api/send': process.env.COLLECT_API_ENDPOINT || '/api/send', + '__COLLECT_API_HOST__': process.env.COLLECT_API_HOST || '', + '__COLLECT_API_ENDPOINT__': process.env.COLLECT_API_ENDPOINT || '/api/send', delimiters: ['', ''], preventAssignment: true, }), diff --git a/src/app/(main)/NavBar.tsx b/src/app/(main)/NavBar.tsx index 08007b1c..5e0e3da2 100644 --- a/src/app/(main)/NavBar.tsx +++ b/src/app/(main)/NavBar.tsx @@ -14,7 +14,7 @@ import styles from './NavBar.module.css'; export function NavBar() { const { formatMessage, labels } = useMessages(); const { pathname, router } = useNavigation(); - const { renderTeamUrl } = useTeamUrl(); + const { teamId, renderTeamUrl } = useTeamUrl(); const cloudMode = !!process.env.cloudMode; @@ -34,25 +34,38 @@ export function NavBar() { label: formatMessage(labels.settings), url: renderTeamUrl('/settings'), children: [ + ...(teamId + ? [ + { + label: formatMessage(labels.team), + url: renderTeamUrl('/settings/team'), + }, + ] + : []), { label: formatMessage(labels.websites), - url: '/settings/websites', - }, - { - label: formatMessage(labels.teams), - url: '/settings/teams', - }, - { - label: formatMessage(labels.users), - url: '/settings/users', - }, - { - label: formatMessage(labels.profile), - url: '/profile', + url: renderTeamUrl('/settings/websites'), }, + ...(!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), url: '/profile', }, @@ -94,6 +107,7 @@ export function NavBar() {
+
diff --git a/src/app/(main)/UpdateNotice.module.css b/src/app/(main)/UpdateNotice.module.css index 261a3169..fec0962c 100644 --- a/src/app/(main)/UpdateNotice.module.css +++ b/src/app/(main)/UpdateNotice.module.css @@ -1,14 +1,17 @@ .notice { position: absolute; + display: flex; + justify-content: space-between; + width: 100%; max-width: 800px; gap: 20px; - margin: 80px auto; + margin: 60px auto; align-self: center; background: var(--base50); padding: 20px; border: 1px solid var(--base300); 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); } diff --git a/src/app/(main)/UpdateNotice.tsx b/src/app/(main)/UpdateNotice.tsx index 54ad05c9..553e1138 100644 --- a/src/app/(main)/UpdateNotice.tsx +++ b/src/app/(main)/UpdateNotice.tsx @@ -4,9 +4,9 @@ import { Button } from 'react-basics'; import { setItem } from 'next-basics'; import useStore, { checkVersion } from 'store/version'; import { REPO_URL, VERSION_CHECK } from 'lib/constants'; -import styles from './UpdateNotice.module.css'; import { useMessages } from 'components/hooks'; import { usePathname } from 'next/navigation'; +import styles from './UpdateNotice.module.css'; export function UpdateNotice({ user, config }) { const { formatMessage, labels, messages } = useMessages(); @@ -16,8 +16,9 @@ export function UpdateNotice({ user, config }) { const allowUpdate = user?.isAdmin && !config?.updatesDisabled && - !config?.cloudMode && !pathname.includes('/share/') && + !process.env.cloudMode && + !process.env.privateMode && !dismissed; const updateCheck = useCallback(() => { diff --git a/src/app/(main)/profile/DateRangeSetting.module.css b/src/app/(main)/profile/DateRangeSetting.module.css new file mode 100644 index 00000000..9de13efe --- /dev/null +++ b/src/app/(main)/profile/DateRangeSetting.module.css @@ -0,0 +1,3 @@ +.field { + width: 200px; +} diff --git a/src/app/(main)/profile/DateRangeSetting.tsx b/src/app/(main)/profile/DateRangeSetting.tsx index a1ae7bc7..c57a209a 100644 --- a/src/app/(main)/profile/DateRangeSetting.tsx +++ b/src/app/(main)/profile/DateRangeSetting.tsx @@ -3,6 +3,7 @@ import { Button, Flexbox } from 'react-basics'; import { useDateRange, useMessages } from 'components/hooks'; import { DEFAULT_DATE_RANGE } from 'lib/constants'; import { DateRange } from 'lib/types'; +import styles from './DateRangeSetting.module.css'; export function DateRangeSetting() { const { formatMessage, labels } = useMessages(); @@ -13,8 +14,9 @@ export function DateRangeSetting() { const handleReset = () => setDateRange(DEFAULT_DATE_RANGE); return ( - + saveLocale(DEFAULT_LOCALE); - const renderValue = (value: string | number) => languages[value].label; + const renderValue = (value: string | number) => languages?.[value]?.label; return ( diff --git a/src/app/(main)/profile/TimezoneSetting.module.css b/src/app/(main)/profile/TimezoneSetting.module.css index 141445ec..31601641 100644 --- a/src/app/(main)/profile/TimezoneSetting.module.css +++ b/src/app/(main)/profile/TimezoneSetting.module.css @@ -1,3 +1,7 @@ +.dropdown { + width: 200px; +} + div.menu { max-height: 300px; width: 300px; diff --git a/src/app/(main)/profile/TimezoneSetting.tsx b/src/app/(main)/profile/TimezoneSetting.tsx index 671125f4..02f3e186 100644 --- a/src/app/(main)/profile/TimezoneSetting.tsx +++ b/src/app/(main)/profile/TimezoneSetting.tsx @@ -1,26 +1,29 @@ import { useState } from 'react'; 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 { getTimezone } from 'lib/date'; import styles from './TimezoneSetting.module.css'; +const timezones = moment.tz.names(); + export function TimezoneSetting() { const [search, setSearch] = useState(''); const { formatMessage, labels } = useMessages(); - const [timezone, saveTimezone] = useTimezone(); + const { timezone, saveTimezone } = useTimezone(); const options = search - ? listTimeZones().filter(n => n.toLowerCase().includes(search.toLowerCase())) - : listTimeZones(); + ? timezones.filter(n => n.toLowerCase().includes(search.toLowerCase())) + : timezones; const handleReset = () => saveTimezone(getTimezone()); return ( saveTimezone(value)} menuProps={{ className: styles.menu }} allowSearch={true} onSearch={setSearch} diff --git a/src/app/(main)/reports/ReportsPage.tsx b/src/app/(main)/reports/ReportsPage.tsx index 6a63c2cb..a76a6a47 100644 --- a/src/app/(main)/reports/ReportsPage.tsx +++ b/src/app/(main)/reports/ReportsPage.tsx @@ -1,4 +1,5 @@ 'use client'; +import { Metadata } from 'next'; import ReportsHeader from './ReportsHeader'; import ReportsDataTable from './ReportsDataTable'; @@ -10,6 +11,7 @@ export default function ReportsPage({ teamId }: { teamId: string }) { ); } -export const metadata = { + +export const metadata: Metadata = { title: 'Reports', }; diff --git a/src/app/(main)/reports/[reportId]/FieldAddForm.module.css b/src/app/(main)/reports/[reportId]/FieldAddForm.module.css deleted file mode 100644 index 5c5aaa4f..00000000 --- a/src/app/(main)/reports/[reportId]/FieldAddForm.module.css +++ /dev/null @@ -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; -} diff --git a/src/app/(main)/reports/[reportId]/FieldAddForm.tsx b/src/app/(main)/reports/[reportId]/FieldAddForm.tsx index 9db472d8..9217ce4d 100644 --- a/src/app/(main)/reports/[reportId]/FieldAddForm.tsx +++ b/src/app/(main)/reports/[reportId]/FieldAddForm.tsx @@ -3,9 +3,6 @@ import { createPortal } from 'react-dom'; import { REPORT_PARAMETERS } from 'lib/constants'; import PopupForm from './PopupForm'; import FieldSelectForm from './FieldSelectForm'; -import FieldAggregateForm from './FieldAggregateForm'; -import FieldFilterForm from './FieldFilterForm'; -import styles from './FieldAddForm.module.css'; export function FieldAddForm({ fields = [], @@ -18,7 +15,11 @@ export function FieldAddForm({ onAdd: (group: string, value: string) => 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 { type } = value; @@ -38,14 +39,8 @@ export function FieldAddForm({ }; return createPortal( - + {!selected && } - {selected && group === REPORT_PARAMETERS.fields && ( - - )} - {selected && group === REPORT_PARAMETERS.filters && ( - - )} , document.body, ); diff --git a/src/app/(main)/reports/[reportId]/FieldFilterEditForm.module.css b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.module.css new file mode 100644 index 00000000..43a34438 --- /dev/null +++ b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.module.css @@ -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; +} diff --git a/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx new file mode 100644 index 00000000..dc10b724 --- /dev/null +++ b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx @@ -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 ( +
+ + + {allowFilterSelect && ( + f.type === type)} + value={operator} + renderValue={renderFilterValue} + onChange={handleOperatorChange} + > + {({ value, label }) => { + return {label}; + }} + + )} + {selected && isEquals && ( +
+ {formatValue(selected, name)} + + + +
+ )} + {!selected && isEquals && ( +
+ setValue(e.target.value)} + onSearch={handleSearch} + delay={500} + onFocus={() => setShowMenu(true)} + onBlur={handleBlur} + /> + {showMenu && ( + + )} +
+ )} + {!selected && !isEquals && ( + setValue(e.target.value)} + /> + )} +
+ +
+
+ ); +} + +const ResultsMenu = ({ values, type, isLoading, onSelect }) => { + const { formatValue } = useFormat(); + if (isLoading) { + return ( + + + + + + ); + } + + if (!values?.length) { + return null; + } + + return ( + + {values?.map((value: any) => { + return {formatValue(value, type)}; + })} + + ); +}; diff --git a/src/app/(main)/reports/[reportId]/FieldFilterForm.module.css b/src/app/(main)/reports/[reportId]/FieldFilterForm.module.css deleted file mode 100644 index 826cc949..00000000 --- a/src/app/(main)/reports/[reportId]/FieldFilterForm.module.css +++ /dev/null @@ -1,24 +0,0 @@ -.popup { - display: flex; - max-width: 300px; - max-height: 400px; - overflow-x: hidden; -} - -.popup > div { - overflow-y: auto; -} - -.filter { - display: flex; - flex-direction: column; - gap: 20px; -} - -.dropdown { - min-width: 180px; -} - -.menu { - min-width: 200px; -} diff --git a/src/app/(main)/reports/[reportId]/FieldFilterForm.tsx b/src/app/(main)/reports/[reportId]/FieldFilterForm.tsx deleted file mode 100644 index 932f302b..00000000 --- a/src/app/(main)/reports/[reportId]/FieldFilterForm.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState, useMemo } from 'react'; -import { Form, FormRow, Item, Flexbox, Dropdown, Button } from 'react-basics'; -import { useMessages, useFilters, useFormat, useLocale } from 'components/hooks'; -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 [search, setSearch] = useState(''); - - 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 search ? values.filter(n => n.includes(search)) : values; - }, [search, formattedValues]); - - const renderFilterValue = value => { - return filters.find(f => f.value === value)?.label; - }; - - const renderValue = value => { - return formattedValues[value]; - }; - - const handleAdd = () => { - onSelect({ name, type, filter, value }); - }; - - return ( -
- - - {allowFilterSelect && ( - setFilter(key)} - > - {({ value, label }) => { - return {label}; - }} - - )} - setValue(key)} - allowSearch={true} - onSearch={setSearch} - > - {(value: string) => { - return {formattedValues[value]}; - }} - - - - -
- ); -} diff --git a/src/app/(main)/reports/[reportId]/FieldParameters.tsx b/src/app/(main)/reports/[reportId]/FieldParameters.tsx new file mode 100644 index 00000000..36cfbda9 --- /dev/null +++ b/src/app/(main)/reports/[reportId]/FieldParameters.tsx @@ -0,0 +1,63 @@ +import { useFields, useMessages } from 'components/hooks'; +import Icons from 'components/icons'; +import { useContext } from 'react'; +import { Button, FormRow, Icon, Popup, PopupTrigger } from 'react-basics'; +import FieldSelectForm from '../[reportId]/FieldSelectForm'; +import ParameterList from '../[reportId]/ParameterList'; +import PopupForm from '../[reportId]/PopupForm'; +import { ReportContext } from './Report'; + +export function FieldParameters() { + const { report, updateReport } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + const { parameters } = report || {}; + const { fields } = parameters || {}; + const { fields: fieldOptions } = useFields(); + + const handleAdd = (value: { name: any }) => { + if (!fields.find(({ name }) => name === value.name)) { + updateReport({ parameters: { fields: fields.concat(value) } }); + } + }; + + const handleRemove = (name: string) => { + updateReport({ parameters: { fields: fields.filter(f => f.name !== name) } }); + }; + + const AddButton = () => { + return ( + + + + + !fields.find(f => f.name === name))} + onSelect={handleAdd} + showType={false} + /> + + + + ); + }; + + return ( + }> + + {fields.map(({ name }) => { + return ( + handleRemove(name)}> + {fieldOptions.find(f => f.name === name)?.label} + + ); + })} + + + ); +} + +export default FieldParameters; diff --git a/src/app/(main)/reports/[reportId]/FilterParameters.module.css b/src/app/(main)/reports/[reportId]/FilterParameters.module.css new file mode 100644 index 00000000..939d0652 --- /dev/null +++ b/src/app/(main)/reports/[reportId]/FilterParameters.module.css @@ -0,0 +1,40 @@ +.item { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + overflow: hidden; +} + +.label { + color: var(--base800); + border: 1px solid var(--base300); + font-weight: 900; + padding: 2px 8px; + border-radius: 5px; + white-space: nowrap; +} + +.op { + color: var(--blue900); + background-color: var(--blue100); + font-size: 12px; + font-weight: 900; + padding: 2px 8px; + border-radius: 5px; + text-transform: uppercase; + white-space: nowrap; +} + +.value { + color: var(--base900); + background-color: var(--base100); + font-weight: 900; + padding: 2px 8px; + border-radius: 5px; + white-space: nowrap; +} + +.edit { + margin-top: 20px; +} diff --git a/src/app/(main)/reports/[reportId]/FilterParameters.tsx b/src/app/(main)/reports/[reportId]/FilterParameters.tsx new file mode 100644 index 00000000..3118a6f4 --- /dev/null +++ b/src/app/(main)/reports/[reportId]/FilterParameters.tsx @@ -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 ( + + + + + !filters.find(f => f.name === name))} + onChange={handleAdd} + /> + + + + ); + }; + + return ( + }> + + {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 ( + handleRemove(name)}> + + + ); + }, + )} + + + ); +} + +const FilterParameter = ({ + websiteId, + name, + label, + operator, + value, + type = 'string', + startDate, + endDate, + onChange, +}) => { + const { operatorLabels } = useFilters(); + + return ( + +
+
{label}
+
{operatorLabels[operator]}
+
{value}
+
+ + {(close: any) => ( + + + + )} + +
+ ); +}; + +export default FilterParameters; diff --git a/src/app/(main)/reports/[reportId]/FilterSelectForm.tsx b/src/app/(main)/reports/[reportId]/FilterSelectForm.tsx index 4f9b9264..b81c8576 100644 --- a/src/app/(main)/reports/[reportId]/FilterSelectForm.tsx +++ b/src/app/(main)/reports/[reportId]/FilterSelectForm.tsx @@ -1,59 +1,41 @@ import { useState } from 'react'; -import { Loading } from 'react-basics'; -import { subDays } from 'date-fns'; import FieldSelectForm from './FieldSelectForm'; -import FieldFilterForm from './FieldFilterForm'; -import { useApi } 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 }; -} +import FieldFilterEditForm from './FieldFilterEditForm'; +import { useDateRange } from 'components/hooks'; export interface FilterSelectFormProps { - websiteId: string; - items: any[]; - onSelect?: (key: any) => void; + websiteId?: string; + fields: any[]; + onChange?: (filter: { name: string; type: string; operator: string; value: string }) => void; allowFilterSelect?: boolean; } export default function FilterSelectForm({ websiteId, - items, - onSelect, + fields, + onChange, allowFilterSelect, }: FilterSelectFormProps) { const [field, setField] = useState<{ name: string; label: string; type: string }>(); - const { data, isLoading } = useValues(websiteId, field?.name); + const [{ startDate, endDate }] = useDateRange(websiteId); if (!field) { - return ; + return ; } - if (isLoading) { - return ; - } + const { name, label, type } = field; return ( - ); } diff --git a/src/app/(main)/reports/[reportId]/ParameterList.module.css b/src/app/(main)/reports/[reportId]/ParameterList.module.css index 0f85fa0f..fca087ca 100644 --- a/src/app/(main)/reports/[reportId]/ParameterList.module.css +++ b/src/app/(main)/reports/[reportId]/ParameterList.module.css @@ -13,9 +13,4 @@ border: 1px solid var(--base400); border-radius: var(--border-radius); box-shadow: 1px 1px 1px var(--base400); - gap: 10px; -} - -.icon { - align-self: center; } diff --git a/src/app/(main)/reports/[reportId]/ParameterList.tsx b/src/app/(main)/reports/[reportId]/ParameterList.tsx index eb1a646a..f2ac988f 100644 --- a/src/app/(main)/reports/[reportId]/ParameterList.tsx +++ b/src/app/(main)/reports/[reportId]/ParameterList.tsx @@ -1,40 +1,47 @@ import { ReactNode } from 'react'; -import { Icon, TooltipPopup } from 'react-basics'; +import { Icon } from 'react-basics'; import Icons from 'components/icons'; import Empty from 'components/common/Empty'; import { useMessages } from 'components/hooks'; import styles from './ParameterList.module.css'; +import classNames from 'classnames'; export interface ParameterListProps { - items: any[]; - children?: ReactNode | ((item: any) => ReactNode); - onRemove: (index: number, e: any) => void; + children?: ReactNode; } -export function ParameterList({ items = [], children, onRemove }: ParameterListProps) { +export function ParameterList({ children }: ParameterListProps) { const { formatMessage, labels } = useMessages(); return (
- {!items.length && } - {items.map((item, index) => { - return ( -
- {typeof children === 'function' ? children(item) : item} - - - - - -
- ); - })} + {!children && } + {children}
); } +const Item = ({ + children, + className, + onClick, + onRemove, +}: { + children?: ReactNode; + className?: string; + onClick?: () => void; + onRemove?: () => void; +}) => { + return ( +
+ {children} + + + +
+ ); +}; + +ParameterList.Item = Item; + export default ParameterList; diff --git a/src/app/(main)/reports/[reportId]/PopupForm.module.css b/src/app/(main)/reports/[reportId]/PopupForm.module.css index 94d98b38..5d069dd4 100644 --- a/src/app/(main)/reports/[reportId]/PopupForm.module.css +++ b/src/app/(main)/reports/[reportId]/PopupForm.module.css @@ -1,5 +1,4 @@ .form { - position: absolute; background: var(--base50); min-width: 300px; padding: 20px; diff --git a/src/app/(main)/reports/[reportId]/Report.module.css b/src/app/(main)/reports/[reportId]/Report.module.css index 18153655..be5bb815 100644 --- a/src/app/(main)/reports/[reportId]/Report.module.css +++ b/src/app/(main)/reports/[reportId]/Report.module.css @@ -2,4 +2,5 @@ display: grid; grid-template-rows: max-content 1fr; grid-template-columns: max-content 1fr; + margin-bottom: 60px; } diff --git a/src/app/(main)/reports/[reportId]/Report.tsx b/src/app/(main)/reports/[reportId]/Report.tsx index 76f73595..d6de9d42 100644 --- a/src/app/(main)/reports/[reportId]/Report.tsx +++ b/src/app/(main)/reports/[reportId]/Report.tsx @@ -13,7 +13,7 @@ export function Report({ className, }: { reportId: string; - defaultParameters: { [key: string]: any }; + defaultParameters: { type: string; parameters: { [key: string]: any } }; children: ReactNode; className?: string; }) { diff --git a/src/app/(main)/reports/[reportId]/ReportDetails.tsx b/src/app/(main)/reports/[reportId]/ReportDetails.tsx deleted file mode 100644 index 40d58e92..00000000 --- a/src/app/(main)/reports/[reportId]/ReportDetails.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import FunnelReport from '../funnel/FunnelReport'; -import EventDataReport from '../event-data/EventDataReport'; -import InsightsReport from '../insights/InsightsReport'; -import RetentionReport from '../retention/RetentionReport'; -import { useApi } from 'components/hooks'; - -const reports = { - funnel: FunnelReport, - 'event-data': EventDataReport, - insights: InsightsReport, - retention: RetentionReport, -}; - -export default function ReportDetails({ reportId }: { reportId: string }) { - const { get, useQuery } = useApi(); - const { data: report } = useQuery({ - queryKey: ['reports', reportId], - queryFn: () => get(`/reports/${reportId}`), - }); - - if (!report) { - return null; - } - - const ReportComponent = reports[report.type]; - - return ; -} diff --git a/src/app/(main)/reports/[reportId]/ReportPage.tsx b/src/app/(main)/reports/[reportId]/ReportPage.tsx index 227efa8d..7ecebd31 100644 --- a/src/app/(main)/reports/[reportId]/ReportPage.tsx +++ b/src/app/(main)/reports/[reportId]/ReportPage.tsx @@ -1,6 +1,27 @@ 'use client'; -import ReportDetails from './ReportDetails'; +import FunnelReport from '../funnel/FunnelReport'; +import EventDataReport from '../event-data/EventDataReport'; +import InsightsReport from '../insights/InsightsReport'; +import RetentionReport from '../retention/RetentionReport'; +import UTMReport from '../utm/UTMReport'; +import { useReport } from 'components/hooks'; -export default function ReportPage({ reportId }) { - return ; +const reports = { + funnel: FunnelReport, + 'event-data': EventDataReport, + insights: InsightsReport, + retention: RetentionReport, + utm: UTMReport, +}; + +export default function ReportPage({ reportId }: { reportId: string }) { + const { report } = useReport(reportId); + + if (!report) { + return null; + } + + const ReportComponent = reports[report.type]; + + return ; } diff --git a/src/app/(main)/reports/create/ReportTemplates.tsx b/src/app/(main)/reports/create/ReportTemplates.tsx index ae84046a..1bd84aec 100644 --- a/src/app/(main)/reports/create/ReportTemplates.tsx +++ b/src/app/(main)/reports/create/ReportTemplates.tsx @@ -4,6 +4,7 @@ import PageHeader from 'components/layout/PageHeader'; import Funnel from 'assets/funnel.svg'; import Lightbulb from 'assets/lightbulb.svg'; import Magnet from 'assets/magnet.svg'; +import Tag from 'assets/tag.svg'; import styles from './ReportTemplates.module.css'; import { useMessages, useTeamUrl } from 'components/hooks'; @@ -30,6 +31,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean }) url: renderTeamUrl('/reports/retention'), icon: , }, + { + title: formatMessage(labels.utm), + description: formatMessage(labels.utmDescription), + url: renderTeamUrl('/reports/utm'), + icon: , + }, ]; return ( diff --git a/src/app/(main)/reports/event-data/EventDataParameters.tsx b/src/app/(main)/reports/event-data/EventDataParameters.tsx index efa9fb67..adc18274 100644 --- a/src/app/(main)/reports/event-data/EventDataParameters.tsx +++ b/src/app/(main)/reports/event-data/EventDataParameters.tsx @@ -60,10 +60,9 @@ export function EventDataParameters() { } }; - const handleRemove = (group: string, index: number) => { + const handleRemove = (group: string) => { const data = [...parameterData[group]]; - data.splice(index, 1); - updateReport({ parameters: { [group]: data } }); + updateReport({ parameters: { [group]: data.filter(({ name }) => name !== group) } }); }; const AddButton = ({ group, onAdd }) => { @@ -104,29 +103,28 @@ export function EventDataParameters() { label={label} action={} > - handleRemove(group, index)} - > - {({ name, value }) => { + + {parameterData[group].map(({ name, value }) => { return ( -
- {group === REPORT_PARAMETERS.fields && ( - <> -
{name}
-
{value}
- - )} - {group === REPORT_PARAMETERS.filters && ( - <> -
{name}
-
{value[0]}
-
{value[1]}
- - )} -
+ handleRemove(group)}> +
+ {group === REPORT_PARAMETERS.fields && ( + <> +
{name}
+
{value}
+ + )} + {group === REPORT_PARAMETERS.filters && ( + <> +
{name}
+
{value[0]}
+
{value[1]}
+ + )} +
+
); - }} + })}
); diff --git a/src/app/(main)/reports/funnel/FunnelChart.module.css b/src/app/(main)/reports/funnel/FunnelChart.module.css index 0279ea03..81b22c78 100644 --- a/src/app/(main)/reports/funnel/FunnelChart.module.css +++ b/src/app/(main)/reports/funnel/FunnelChart.module.css @@ -37,12 +37,12 @@ .card { display: grid; gap: 20px; + margin-top: 14px; } .header { display: flex; - align-items: center; - font-weight: 700; + flex-direction: column; gap: 20px; } @@ -51,19 +51,16 @@ align-items: center; justify-content: flex-end; background: var(--base900); - height: 50px; + height: 30px; border-radius: 5px; overflow: hidden; position: relative; } .label { - color: var(--base700); -} - -.value { - color: var(--base50); - margin-inline-end: 20px; + color: var(--base600); + font-weight: 700; + text-transform: uppercase; } .track { @@ -72,13 +69,33 @@ } .info { - display: flex; - justify-content: space-between; text-transform: lowercase; } .item { - padding: 6px 10px; - border-radius: 4px; - border: 1px solid var(--base300); + font-size: 20px; + color: var(--base900); + 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; } diff --git a/src/app/(main)/reports/funnel/FunnelChart.tsx b/src/app/(main)/reports/funnel/FunnelChart.tsx index 6207a177..0da71d6f 100644 --- a/src/app/(main)/reports/funnel/FunnelChart.tsx +++ b/src/app/(main)/reports/funnel/FunnelChart.tsx @@ -2,8 +2,8 @@ import { useContext } from 'react'; import classNames from 'classnames'; import { useMessages } from 'components/hooks'; import { ReportContext } from '../[reportId]/Report'; -import styles from './FunnelChart.module.css'; import { formatLongNumber } from 'lib/format'; +import styles from './FunnelChart.module.css'; export interface FunnelChartProps { className?: string; @@ -18,35 +18,33 @@ export function FunnelChart({ className }: FunnelChartProps) { return (
- {data?.map(({ url, visitors, dropped, dropoff, remaining }, index: number) => { + {data?.map(({ type, value, visitors, dropped, dropoff, remaining }, index: number) => { return ( -
+
{index + 1}
- {formatMessage(labels.viewedPage)}: - {url} + + {formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)} + + {value} +
+
+
+ {formatLongNumber(visitors)} + {formatMessage(labels.visitors)} +
+
{(remaining * 100).toFixed(2)}%
-
- - {remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`} - -
+
-
-
- {formatLongNumber(visitors)} - {formatMessage(labels.visitors)} - ({(remaining * 100).toFixed(2)}%) + {dropoff > 0 && ( +
+ {formatLongNumber(dropped)} {formatMessage(labels.visitorsDroppedOff)} ( + {(dropoff * 100).toFixed(2)}%)
- {dropoff > 0 && ( -
- {formatLongNumber(dropped)} {formatMessage(labels.visitorsDroppedOff)} ( - {(dropoff * 100).toFixed(2)}%) -
- )} -
+ )}
); diff --git a/src/app/(main)/reports/funnel/FunnelParameters.module.css b/src/app/(main)/reports/funnel/FunnelParameters.module.css new file mode 100644 index 00000000..219b9807 --- /dev/null +++ b/src/app/(main)/reports/funnel/FunnelParameters.module.css @@ -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; +} diff --git a/src/app/(main)/reports/funnel/FunnelParameters.tsx b/src/app/(main)/reports/funnel/FunnelParameters.tsx index 6eefbaae..7b1fb0c8 100644 --- a/src/app/(main)/reports/funnel/FunnelParameters.tsx +++ b/src/app/(main)/reports/funnel/FunnelParameters.tsx @@ -10,50 +10,65 @@ import { Popup, SubmitButton, TextField, + Button, } from 'react-basics'; import Icons from 'components/icons'; -import UrlAddForm from './UrlAddForm'; +import FunnelStepAddForm from './FunnelStepAddForm'; import { ReportContext } from '../[reportId]/Report'; import BaseParameters from '../[reportId]/BaseParameters'; import ParameterList from '../[reportId]/ParameterList'; import PopupForm from '../[reportId]/PopupForm'; +import styles from './FunnelParameters.module.css'; export function FunnelParameters() { const { report, runReport, updateReport, isRunning } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); const { id, parameters } = report || {}; - const { websiteId, dateRange, urls } = parameters || {}; - const queryDisabled = !websiteId || !dateRange || urls?.length < 2; + const { websiteId, dateRange, steps } = parameters || {}; + const queryDisabled = !websiteId || !dateRange || steps?.length < 2; const handleSubmit = (data: any, e: any) => { e.stopPropagation(); e.preventDefault(); + if (!queryDisabled) { runReport(data); } }; - const handleAddUrl = (url: string) => { - updateReport({ parameters: { urls: parameters.urls.concat(url) } }); + const handleAddStep = (step: { type: string; value: string }) => { + updateReport({ parameters: { steps: parameters.steps.concat(step) } }); }; - const handleRemoveUrl = (index: number, e: any) => { - e.stopPropagation(); - const urls = [...parameters.urls]; - urls.splice(index, 1); - updateReport({ parameters: { urls } }); + const handleUpdateStep = ( + close: () => void, + index: number, + step: { type: string; value: string }, + ) => { + 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 ( - - - - + + - + @@ -71,11 +86,37 @@ export function FunnelParameters() { - }> - handleRemoveUrl(index, e)} - /> + }> + + {steps.map((step: { type: string; value: string }, index: number) => { + return ( + + handleRemoveStep(index)} + > +
+
+ {step.type === 'url' ? : } +
+
{step.value}
+
+
+ + {(close: () => void) => ( + + + + )} + +
+ ); + })} +
diff --git a/src/app/(main)/reports/funnel/FunnelReport.tsx b/src/app/(main)/reports/funnel/FunnelReport.tsx index 7b9a6677..850bbd90 100644 --- a/src/app/(main)/reports/funnel/FunnelReport.tsx +++ b/src/app/(main)/reports/funnel/FunnelReport.tsx @@ -9,7 +9,7 @@ import { REPORT_TYPES } from 'lib/constants'; const defaultParameters = { type: REPORT_TYPES.funnel, - parameters: { window: 60, urls: [] }, + parameters: { window: 60, steps: [] }, }; export default function FunnelReport({ reportId }: { reportId?: string }) { diff --git a/src/app/(main)/reports/funnel/FunnelStepAddForm.module.css b/src/app/(main)/reports/funnel/FunnelStepAddForm.module.css new file mode 100644 index 00000000..a254ff08 --- /dev/null +++ b/src/app/(main)/reports/funnel/FunnelStepAddForm.module.css @@ -0,0 +1,7 @@ +.dropdown { + width: 140px; +} + +.input { + width: 200px; +} diff --git a/src/app/(main)/reports/funnel/FunnelStepAddForm.tsx b/src/app/(main)/reports/funnel/FunnelStepAddForm.tsx new file mode 100644 index 00000000..7d77b0c7 --- /dev/null +++ b/src/app/(main)/reports/funnel/FunnelStepAddForm.tsx @@ -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 ( + + + + setType(value)} + > + {({ value, label }) => { + return {label}; + }} + + + + + + + + + ); +} + +export default FunnelStepAddForm; diff --git a/src/app/(main)/reports/funnel/UrlAddForm.module.css b/src/app/(main)/reports/funnel/UrlAddForm.module.css deleted file mode 100644 index 6a3e03b5..00000000 --- a/src/app/(main)/reports/funnel/UrlAddForm.module.css +++ /dev/null @@ -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%; -} diff --git a/src/app/(main)/reports/funnel/UrlAddForm.tsx b/src/app/(main)/reports/funnel/UrlAddForm.tsx deleted file mode 100644 index 88c27ae9..00000000 --- a/src/app/(main)/reports/funnel/UrlAddForm.tsx +++ /dev/null @@ -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 ( -
- - - - - - -
- ); -} - -export default UrlAddForm; diff --git a/src/app/(main)/reports/insights/InsightsParameters.module.css b/src/app/(main)/reports/insights/InsightsParameters.module.css deleted file mode 100644 index ba089b9a..00000000 --- a/src/app/(main)/reports/insights/InsightsParameters.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.parameter { - display: flex; - gap: 10px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - min-width: 0; -} - -.op { - font-weight: bold; -} - -.popup { - margin-top: -10px; - margin-inline-start: 30px; -} diff --git a/src/app/(main)/reports/insights/InsightsParameters.tsx b/src/app/(main)/reports/insights/InsightsParameters.tsx index 4aaa9abd..7f58de6a 100644 --- a/src/app/(main)/reports/insights/InsightsParameters.tsx +++ b/src/app/(main)/reports/insights/InsightsParameters.tsx @@ -1,136 +1,29 @@ -import { useFilters, useFormat, useMessages } from 'components/hooks'; -import Icons from 'components/icons'; +import { useMessages } from 'components/hooks'; import { useContext } from 'react'; -import { - Form, - FormButtons, - FormRow, - Icon, - Popup, - PopupTrigger, - SubmitButton, - TooltipPopup, -} from 'react-basics'; +import { Form, FormButtons, SubmitButton } from 'react-basics'; import BaseParameters from '../[reportId]/BaseParameters'; -import FieldSelectForm from '../[reportId]/FieldSelectForm'; -import FilterSelectForm from '../[reportId]/FilterSelectForm'; -import ParameterList from '../[reportId]/ParameterList'; -import PopupForm from '../[reportId]/PopupForm'; import { ReportContext } from '../[reportId]/Report'; -import styles from './InsightsParameters.module.css'; +import FieldParameters from '../[reportId]/FieldParameters'; +import FilterParameters from '../[reportId]/FilterParameters'; export function InsightsParameters() { - const { report, runReport, updateReport, isRunning } = useContext(ReportContext); + const { report, runReport, isRunning } = useContext(ReportContext); const { formatMessage, labels } = useMessages(); - const { formatValue } = useFormat(); - const { filterLabels } = useFilters(); const { id, parameters } = report || {}; const { websiteId, dateRange, fields, filters } = parameters || {}; const { startDate, endDate } = dateRange || {}; const parametersSelected = websiteId && startDate && endDate; const queryEnabled = websiteId && dateRange && (fields?.length || filters?.length); - 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 parameterGroups = [ - { id: 'fields', label: formatMessage(labels.fields) }, - { id: 'filters', label: formatMessage(labels.filters) }, - ]; - - const parameterData = { - fields, - filters, - }; - - const handleSubmit = values => { + const handleSubmit = (values: any) => { runReport(values); }; - const handleAdd = (id, value) => { - const data = parameterData[id]; - - if (!data.find(({ name }) => name === value.name)) { - updateReport({ parameters: { [id]: data.concat(value) } }); - } - }; - - const handleRemove = (id, index) => { - const data = [...parameterData[id]]; - data.splice(index, 1); - updateReport({ parameters: { [id]: data } }); - }; - - const AddButton = ({ id, onAdd }) => { - return ( - - - - - - - - - {id === 'fields' && ( - - )} - {id === 'filters' && ( - - )} - - - - ); - }; - return (
- {parametersSelected && - parameterGroups.map(({ id, label }) => { - return ( - }> - handleRemove(id, index)}> - {({ name, filter, value }) => { - return ( -
- {id === 'fields' && ( - <> -
{fieldOptions.find(f => f.name === name)?.label}
- - )} - {id === 'filters' && ( - <> -
{fieldOptions.find(f => f.name === name)?.label}
-
{filterLabels[filter]}
-
{formatValue(value, name)}
- - )} -
- ); - }} -
-
- ); - })} + {parametersSelected && } + {parametersSelected && } {formatMessage(labels.runQuery)} diff --git a/src/app/(main)/reports/retention/RetentionReportPage.tsx b/src/app/(main)/reports/retention/RetentionReportPage.tsx index f2500fb2..4d3e19e9 100644 --- a/src/app/(main)/reports/retention/RetentionReportPage.tsx +++ b/src/app/(main)/reports/retention/RetentionReportPage.tsx @@ -1,6 +1,11 @@ 'use client'; +import { Metadata } from 'next'; import RetentionReport from './RetentionReport'; export default function RetentionReportPage() { return ; } + +export const metadata: Metadata = { + title: 'Retention Report', +}; diff --git a/src/app/(main)/reports/utm/UTMParameters.tsx b/src/app/(main)/reports/utm/UTMParameters.tsx new file mode 100644 index 00000000..c76df77d --- /dev/null +++ b/src/app/(main)/reports/utm/UTMParameters.tsx @@ -0,0 +1,36 @@ +import { useContext } from 'react'; +import { useMessages } from 'components/hooks'; +import { Form, FormButtons, SubmitButton } from 'react-basics'; +import { ReportContext } from '../[reportId]/Report'; +import BaseParameters from '../[reportId]/BaseParameters'; + +export function UTMParameters() { + const { report, runReport, isRunning } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + + const { id, parameters } = report || {}; + const { websiteId, dateRange } = parameters || {}; + const queryDisabled = !websiteId || !dateRange; + + const handleSubmit = (data: any, e: any) => { + e.stopPropagation(); + e.preventDefault(); + + if (!queryDisabled) { + runReport(data); + } + }; + + return ( + + + + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default UTMParameters; diff --git a/src/app/(main)/reports/utm/UTMReport.tsx b/src/app/(main)/reports/utm/UTMReport.tsx new file mode 100644 index 00000000..7183b9f7 --- /dev/null +++ b/src/app/(main)/reports/utm/UTMReport.tsx @@ -0,0 +1,28 @@ +'use client'; +import Report from '../[reportId]/Report'; +import ReportHeader from '../[reportId]/ReportHeader'; +import ReportMenu from '../[reportId]/ReportMenu'; +import ReportBody from '../[reportId]/ReportBody'; +import UTMParameters from './UTMParameters'; +import UTMView from './UTMView'; +import Tag from 'assets/tag.svg'; +import { REPORT_TYPES } from 'lib/constants'; + +const defaultParameters = { + type: REPORT_TYPES.utm, + parameters: {}, +}; + +export default function UTMReport({ reportId }: { reportId?: string }) { + return ( + + } /> + + + + + + + + ); +} diff --git a/src/app/(main)/reports/utm/UTMReportPage.tsx b/src/app/(main)/reports/utm/UTMReportPage.tsx new file mode 100644 index 00000000..926a4263 --- /dev/null +++ b/src/app/(main)/reports/utm/UTMReportPage.tsx @@ -0,0 +1,5 @@ +import UTMReport from './UTMReport'; + +export default function UTMReportPage() { + return ; +} diff --git a/src/app/(main)/reports/utm/UTMView.module.css b/src/app/(main)/reports/utm/UTMView.module.css new file mode 100644 index 00000000..fa7cc0b4 --- /dev/null +++ b/src/app/(main)/reports/utm/UTMView.module.css @@ -0,0 +1,14 @@ +.title { + font-size: 24px; + line-height: 36px; + font-weight: 700; +} + +.row { + display: grid; + grid-template-columns: 50% 50%; + gap: 20px; + border-bottom: 1px solid var(--base300); + padding-bottom: 30px; + margin-bottom: 30px; +} diff --git a/src/app/(main)/reports/utm/UTMView.tsx b/src/app/(main)/reports/utm/UTMView.tsx new file mode 100644 index 00000000..e59b60eb --- /dev/null +++ b/src/app/(main)/reports/utm/UTMView.tsx @@ -0,0 +1,65 @@ +import { useContext } from 'react'; +import { firstBy } from 'thenby'; +import { ReportContext } from '../[reportId]/Report'; +import { CHART_COLORS, UTM_PARAMS } from 'lib/constants'; +import PieChart from 'components/charts/PieChart'; +import ListTable from 'components/metrics/ListTable'; +import styles from './UTMView.module.css'; +import { useMessages } from 'components/hooks'; + +function toArray(data: { [key: string]: number } = {}) { + return Object.keys(data) + .map(key => { + return { name: key, value: data[key] }; + }) + .sort(firstBy('value', -1)); +} + +export default function UTMView() { + const { formatMessage, labels } = useMessages(); + const { report } = useContext(ReportContext); + const { data } = report || {}; + + if (!data) { + return null; + } + + return ( +
+ {UTM_PARAMS.map(param => { + const items = toArray(data[param]); + const chartData = { + labels: items.map(({ name }) => name), + datasets: [ + { + data: items.map(({ value }) => value), + backgroundColor: CHART_COLORS, + }, + ], + }; + const total = items.reduce((sum, { value }) => { + return +sum + +value; + }, 0); + + return ( +
+
+
{param.replace(/^utm_/, '')}
+ ({ + x: name, + y: value, + z: (value / total) * 100, + }))} + /> +
+
+ +
+
+ ); + })} +
+ ); +} diff --git a/src/app/(main)/reports/utm/page.tsx b/src/app/(main)/reports/utm/page.tsx new file mode 100644 index 00000000..12b6cc5b --- /dev/null +++ b/src/app/(main)/reports/utm/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from 'next'; +import UTMReportPage from './UTMReportPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'UTM Report', +}; diff --git a/src/app/(main)/settings/teams/[teamId]/websites/TeamWebsitesTable.tsx b/src/app/(main)/settings/teams/[teamId]/websites/TeamWebsitesTable.tsx index dc6760a6..c733e3e3 100644 --- a/src/app/(main)/settings/teams/[teamId]/websites/TeamWebsitesTable.tsx +++ b/src/app/(main)/settings/teams/[teamId]/websites/TeamWebsitesTable.tsx @@ -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 Icons from 'components/icons'; import LinkButton from 'components/common/LinkButton'; @@ -14,9 +14,10 @@ export function TeamWebsitesTable({ }) { const { user } = useLogin(); const { formatMessage, labels } = useMessages(); + const breakpoint = useBreakpoint(); return ( - + diff --git a/src/app/(main)/settings/websites/WebsitesTable.module.css b/src/app/(main)/settings/websites/WebsitesTable.module.css deleted file mode 100644 index a26c349f..00000000 --- a/src/app/(main)/settings/websites/WebsitesTable.module.css +++ /dev/null @@ -1,13 +0,0 @@ -@media screen and (max-width: 992px) { - .row { - flex-wrap: wrap; - } - - .header .actions { - display: none; - } - - .actions { - flex-basis: 100%; - } -} diff --git a/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx b/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx index 640c519b..989f4def 100644 --- a/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx +++ b/src/app/(main)/settings/websites/[websiteId]/ShareUrl.tsx @@ -35,7 +35,7 @@ export function ShareUrl({ const url = `${hostUrl || process.env.hostUrl || window?.location.origin}${ process.env.basePath - }/share/${id}/${encodeURIComponent(domain)}`; + }/share/${id}/${domain}`; const handleGenerate = () => { setId(generateId()); diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChartList.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChartList.tsx index b35b6f1f..6484e383 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteChartList.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteChartList.tsx @@ -6,7 +6,7 @@ import WebsiteChart from './WebsiteChart'; import useDashboard from 'store/dashboard'; import WebsiteHeader from './WebsiteHeader'; import { WebsiteMetricsBar } from './WebsiteMetricsBar'; -import { useMessages, useLocale } from 'components/hooks'; +import { useMessages, useLocale, useTeamUrl } from 'components/hooks'; export default function WebsiteChartList({ websites, @@ -19,6 +19,7 @@ export default function WebsiteChartList({ }) { const { formatMessage, labels } = useMessages(); const { websiteOrder } = useDashboard(); + const { renderTeamUrl } = useTeamUrl(); const { dir } = useLocale(); const ordered = useMemo( @@ -35,7 +36,7 @@ export default function WebsiteChartList({ return index < limit ? (
- + - + {(close: () => void) => { return ( { + fields={fields} + onChange={value => { handleAddFilter(value); close(); }} - allowFilterSelect={false} /> ); diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx index 2dcb2b81..e4acea3b 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx @@ -21,11 +21,12 @@ export function WebsiteMetricsBar({ const { ref, isSticky } = useSticky({ enabled: sticky }); const { data, isLoading, isFetched, error } = useWebsiteStats(websiteId); - const { pageviews, uniques, bounces, totaltime } = data || {}; - const num = Math.min(data && uniques.value, data && bounces.value); + const { pageviews, visitors, visits, bounces, totaltime } = data || {}; + const num = Math.min(data && visitors.value, data && bounces.value); const diffs = data && { pageviews: pageviews.value - pageviews.change, - uniques: uniques.value - uniques.change, + visitors: visitors.value - visitors.change, + visits: visits.value - visits.change, bounces: bounces.value - bounces.change, totaltime: totaltime.value - totaltime.change, }; @@ -39,25 +40,30 @@ export function WebsiteMetricsBar({ })} > - {pageviews && uniques && ( + {pageviews && visitors && ( <> + Number(n).toFixed(0) + '%'} diff --git a/src/app/(main)/websites/[websiteId]/realtime/Realtime.module.css b/src/app/(main)/websites/[websiteId]/realtime/Realtime.module.css deleted file mode 100644 index 465be551..00000000 --- a/src/app/(main)/websites/[websiteId]/realtime/Realtime.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.container { - display: flex; -} - -.chart { - margin-bottom: 30px; -} - -.sticky { - position: fixed; - top: 0; - background: var(--base50); - border-bottom: 1px solid var(--base300); - z-index: 1; - padding: 10px 0; -} diff --git a/src/app/(main)/websites/[websiteId]/realtime/Realtime.tsx b/src/app/(main)/websites/[websiteId]/realtime/Realtime.tsx deleted file mode 100644 index 6314fbb8..00000000 --- a/src/app/(main)/websites/[websiteId]/realtime/Realtime.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useMemo, useState, useEffect } from 'react'; -import { subMinutes, startOfMinute } from 'date-fns'; -import thenby from 'thenby'; -import { Grid, GridRow } from 'components/layout/Grid'; -import Page from 'components/layout/Page'; -import RealtimeChart from 'components/metrics/RealtimeChart'; -import WorldMap from 'components/metrics/WorldMap'; -import { useApi, useWebsite } from 'components/hooks'; -import { percentFilter } from 'lib/filters'; -import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants'; -import { RealtimeData } from 'lib/types'; -import RealtimeLog from './RealtimeLog'; -import RealtimeHeader from './RealtimeHeader'; -import RealtimeUrls from './RealtimeUrls'; -import RealtimeCountries from './RealtimeCountries'; -import WebsiteHeader from '../WebsiteHeader'; -import styles from './Realtime.module.css'; - -function mergeData(state = [], data = [], time: number) { - const ids = state.map(({ id }) => id); - return state - .concat(data.filter(({ id }) => !ids.includes(id))) - .filter(({ timestamp }) => timestamp >= time); -} - -export function Realtime({ websiteId }) { - const [currentData, setCurrentData] = useState(); - const { get, useQuery } = useApi(); - const { data: website } = useWebsite(websiteId); - const { data, isLoading, error } = useQuery({ - queryKey: ['realtime', websiteId], - queryFn: () => get(`/realtime/${websiteId}`, { startAt: currentData?.timestamp || 0 }), - enabled: !!(websiteId && website), - refetchInterval: REALTIME_INTERVAL, - }); - - useEffect(() => { - if (data) { - const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE); - const time = date.getTime(); - const { pageviews, sessions, events, timestamp } = data; - - setCurrentData(state => ({ - pageviews: mergeData(state?.pageviews, pageviews, time), - sessions: mergeData(state?.sessions, sessions, time), - events: mergeData(state?.events, events, time), - timestamp, - })); - } - }, [data]); - - const realtimeData: RealtimeData = useMemo(() => { - if (!currentData) { - return { pageviews: [], sessions: [], events: [], countries: [], visitors: [], timestamp: 0 }; - } - - currentData.countries = percentFilter( - currentData.sessions - .reduce((arr, data) => { - if (!arr.find(({ id }) => id === data.id)) { - return arr.concat(data); - } - return arr; - }, []) - .reduce((arr: { x: any; y: number }[], { country }: any) => { - if (country) { - const row = arr.find(({ x }) => x === country); - - if (!row) { - arr.push({ x: country, y: 1 }); - } else { - row.y += 1; - } - } - return arr; - }, []) - .sort(thenby.firstBy('y', -1)), - ); - - currentData.visitors = currentData.sessions.reduce((arr, val) => { - if (!arr.find(({ id }) => id === val.id)) { - return arr.concat(val); - } - return arr; - }, []); - - return currentData; - }, [currentData]); - - if (isLoading || error) { - return ; - } - - return ( - <> - - - - - - - - - - - - - - - ); -} - -export default Realtime; diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.module.css b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.module.css index fb5fdecf..19d02384 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.module.css +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.module.css @@ -35,11 +35,6 @@ overflow: hidden; } -.website { - text-align: right; - padding: 0 20px; -} - .detail { display: flex; align-items: center; diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx index d9aad35b..c26d0629 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useContext, useMemo, useState } from 'react'; import { StatusLight, Icon, Text, SearchField } from 'react-basics'; import { FixedSizeList } from 'react-window'; import { format } from 'date-fns'; @@ -11,6 +11,8 @@ import Icons from 'components/icons'; import useFormat from 'components//hooks/useFormat'; import { BROWSERS } from 'lib/constants'; import { stringToColor } from 'lib/format'; +import { RealtimeData } from 'lib/types'; +import { WebsiteContext } from '../WebsiteProvider'; import styles from './RealtimeLog.module.css'; const TYPE_ALL = 'all'; @@ -24,7 +26,8 @@ const icons = { [TYPE_EVENT]: , }; -export function RealtimeLog({ data, websiteDomain }) { +export function RealtimeLog({ data }: { data: RealtimeData }) { + const website = useContext(WebsiteContext); const [search, setSearch] = useState(''); const { formatMessage, labels, messages, FormattedMessage } = useMessages(); const { formatValue } = useFormat(); @@ -76,7 +79,7 @@ export function RealtimeLog({ data, websiteDomain }) { event: {eventName || formatMessage(labels.unknown)}, url: ( (FILTER_REFERRERS); @@ -31,7 +27,7 @@ export function RealtimeUrls({ ]; const renderLink = ({ x }) => { - const domain = x.startsWith('/') ? websiteDomain : ''; + const domain = x.startsWith('/') ? website?.domain : ''; return ( {x} diff --git a/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx index 7e538812..8c1e3800 100644 --- a/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx +++ b/src/app/(main)/websites/[websiteId]/realtime/WebsiteRealtimePage.tsx @@ -1,6 +1,40 @@ 'use client'; -import Realtime from './Realtime'; +import { Grid, GridRow } from 'components/layout/Grid'; +import Page from 'components/layout/Page'; +import RealtimeChart from 'components/metrics/RealtimeChart'; +import WorldMap from 'components/metrics/WorldMap'; +import { useRealtime } from 'components/hooks'; +import RealtimeLog from './RealtimeLog'; +import RealtimeHeader from './RealtimeHeader'; +import RealtimeUrls from './RealtimeUrls'; +import RealtimeCountries from './RealtimeCountries'; +import WebsiteHeader from '../WebsiteHeader'; +import WebsiteProvider from '../WebsiteProvider'; -export default function WebsiteRealtimePage({ websiteId }) { - return ; +export function WebsiteRealtimePage({ websiteId }) { + const { data, isLoading, error } = useRealtime(websiteId); + + if (isLoading || error) { + return ; + } + + return ( + + + + + + + + + + + + + + + + ); } + +export default WebsiteRealtimePage; diff --git a/src/assets/bookmark.svg b/src/assets/bookmark.svg new file mode 100644 index 00000000..5abc5ed2 --- /dev/null +++ b/src/assets/bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/flag.svg b/src/assets/flag.svg new file mode 100644 index 00000000..c3750585 --- /dev/null +++ b/src/assets/flag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/speaker.svg b/src/assets/speaker.svg new file mode 100644 index 00000000..f243a494 --- /dev/null +++ b/src/assets/speaker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/tag.svg b/src/assets/tag.svg new file mode 100644 index 00000000..0e0f3668 --- /dev/null +++ b/src/assets/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/visitor.svg b/src/assets/visitor.svg index 4aeceafc..829eb8e1 100644 --- a/src/assets/visitor.svg +++ b/src/assets/visitor.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx new file mode 100644 index 00000000..635bb10d --- /dev/null +++ b/src/components/charts/BarChart.tsx @@ -0,0 +1,88 @@ +import { useMemo } from 'react'; +import { useTheme } from 'components/hooks'; +import Chart, { ChartProps } from 'components/charts/Chart'; +import { renderNumberLabels } from 'lib/charts'; +import { useState } from 'react'; +import BarChartTooltip from 'components/charts/BarChartTooltip'; + +export interface BarChartProps extends ChartProps { + unit: string; + stacked?: boolean; + renderXLabel?: (label: string, index: number, values: any[]) => string; + renderYLabel?: (label: string, index: number, values: any[]) => string; + XAxisType?: string; + YAxisType?: string; +} + +export function BarChart(props: BarChartProps) { + const [tooltip, setTooltip] = useState(null); + const { colors } = useTheme(); + const { + renderXLabel, + renderYLabel, + unit, + XAxisType = 'time', + YAxisType = 'linear', + stacked = false, + } = props; + + const options = useMemo(() => { + return { + scales: { + x: { + type: XAxisType, + stacked: true, + time: { + unit, + }, + grid: { + display: false, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + autoSkip: false, + maxRotation: 0, + callback: renderXLabel, + }, + }, + y: { + type: YAxisType, + min: 0, + beginAtZero: true, + stacked, + grid: { + color: colors.chart.line, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + callback: renderYLabel || renderNumberLabels, + }, + }, + }, + }; + }, [colors, unit, stacked, renderXLabel, renderYLabel]); + + const handleTooltip = ({ tooltip }: { tooltip: any }) => { + const { opacity } = tooltip; + + setTooltip(opacity ? : null); + }; + + return ( + + ); +} + +export default BarChart; diff --git a/src/components/charts/BarChartTooltip.tsx b/src/components/charts/BarChartTooltip.tsx new file mode 100644 index 00000000..b81d55fe --- /dev/null +++ b/src/components/charts/BarChartTooltip.tsx @@ -0,0 +1,32 @@ +import { formatDate } from 'lib/date'; +import { Flexbox, StatusLight } from 'react-basics'; +import { formatLongNumber } from 'lib/format'; +import { useLocale } from 'components/hooks'; + +const formats = { + millisecond: 'T', + second: 'pp', + minute: 'p', + hour: 'h:mm aaa - PP', + day: 'PPPP', + week: 'PPPP', + month: 'LLLL yyyy', + quarter: 'qqq', + year: 'yyyy', +}; + +export default function BarChartTooltip({ tooltip, unit }) { + const { locale } = useLocale(); + const { labelColors, dataPoints } = tooltip; + + return ( + +
{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}
+
+ + {formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label} + +
+
+ ); +} diff --git a/src/components/metrics/BarChart.module.css b/src/components/charts/Chart.module.css similarity index 62% rename from src/components/metrics/BarChart.module.css rename to src/components/charts/Chart.module.css index 6af22abe..ee61f29b 100644 --- a/src/components/metrics/BarChart.module.css +++ b/src/components/charts/Chart.module.css @@ -1,7 +1,3 @@ -.container { - display: grid; -} - .chart { position: relative; height: 400px; @@ -13,7 +9,3 @@ flex-direction: column; gap: 10px; } - -.tooltip .value { - text-transform: lowercase; -} diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx new file mode 100644 index 00000000..993618c2 --- /dev/null +++ b/src/components/charts/Chart.tsx @@ -0,0 +1,144 @@ +import { useState, useRef, useEffect, useMemo, ReactNode } from 'react'; +import { Loading } from 'react-basics'; +import classNames from 'classnames'; +import ChartJS, { LegendItem } from 'chart.js/auto'; +import HoverTooltip from 'components/common/HoverTooltip'; +import Legend from 'components/metrics/Legend'; +import { DEFAULT_ANIMATION_DURATION } from 'lib/constants'; +import styles from './Chart.module.css'; + +export interface ChartProps { + type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter'; + data?: object; + isLoading?: boolean; + animationDuration?: number; + updateMode?: string; + onCreate?: (chart: any) => void; + onUpdate?: (chart: any) => void; + onTooltip?: (model: any) => void; + className?: string; + chartOptions?: { [key: string]: any }; + tooltip?: ReactNode; +} + +export function Chart({ + type, + data, + isLoading = false, + animationDuration = DEFAULT_ANIMATION_DURATION, + tooltip, + updateMode, + onCreate, + onUpdate, + onTooltip, + className, + chartOptions, +}: ChartProps) { + const canvas = useRef(); + const chart = useRef(null); + const [legendItems, setLegendItems] = useState([]); + + const options = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: animationDuration, + resize: { + duration: 0, + }, + active: { + duration: 0, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + external: onTooltip, + }, + }, + ...chartOptions, + }; + }, [chartOptions]); + + const createChart = (data: any) => { + ChartJS.defaults.font.family = 'Inter'; + + chart.current = new ChartJS(canvas.current, { + type, + data, + options, + }); + + onCreate?.(chart.current); + + setLegendItems(chart.current.legend.legendItems); + }; + + const updateChart = (data: any) => { + chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => { + dataset.data = data?.datasets[index]?.data; + chart.current.legend.legendItems[index].text = data?.datasets[index].label; + }); + + chart.current.options = options; + + // Allow config changes before update + onUpdate?.(chart.current); + + setLegendItems(chart.current.legend.legendItems); + + chart.current.update(updateMode); + }; + + useEffect(() => { + if (data) { + if (!chart.current) { + createChart(data); + } else { + updateChart(data); + } + } + }, [data, options]); + + const handleLegendClick = (item: LegendItem) => { + if (type === 'bar') { + const { datasetIndex } = item; + const meta = chart.current.getDatasetMeta(datasetIndex); + + meta.hidden = + meta.hidden === null ? !chart.current.data.datasets[datasetIndex]?.hidden : null; + } else { + const { index } = item; + const meta = chart.current.getDatasetMeta(0); + const hidden = !!meta?.data?.[index]?.hidden; + + meta.data[index].hidden = !hidden; + chart.current.legend.legendItems[index].hidden = !hidden; + } + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + }; + + return ( + <> +
+ {isLoading && } + +
+ + {tooltip && ( + +
{tooltip}
+
+ )} + + ); +} + +export default Chart; diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx new file mode 100644 index 00000000..11ad125c --- /dev/null +++ b/src/components/charts/PieChart.tsx @@ -0,0 +1,27 @@ +import { Chart, ChartProps } from 'components/charts/Chart'; +import { useState } from 'react'; +import { StatusLight } from 'react-basics'; +import { formatLongNumber } from 'lib/format'; + +export interface PieChartProps extends ChartProps { + type?: 'doughnut' | 'pie'; +} + +export default function PieChart(props: PieChartProps) { + const [tooltip, setTooltip] = useState(null); + const { type } = props; + + const handleTooltip = ({ tooltip }) => { + const { labelColors, dataPoints } = tooltip; + + setTooltip( + tooltip.opacity ? ( + + {formatLongNumber(dataPoints?.[0]?.raw)} {dataPoints?.[0]?.label} + + ) : null, + ); + }; + + return ; +} diff --git a/src/components/common/Breadcrumb.module.css b/src/components/common/Breadcrumb.module.css index 70d88f36..81e7524f 100644 --- a/src/components/common/Breadcrumb.module.css +++ b/src/components/common/Breadcrumb.module.css @@ -1,5 +1,5 @@ .bar { - font-size: 11px; + font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--base600); diff --git a/src/components/common/Breadcrumb.tsx b/src/components/common/Breadcrumb.tsx index fa7bde15..ebdce497 100644 --- a/src/components/common/Breadcrumb.tsx +++ b/src/components/common/Breadcrumb.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { Flexbox, Icon, Icons, Text } from 'react-basics'; import styles from './Breadcrumb.module.css'; +import { Fragment } from 'react'; export interface BreadcrumbProps { data: { @@ -14,7 +15,7 @@ export function Breadcrumb({ data }: BreadcrumbProps) { {data.map((a, i) => { return ( - <> + {a.url ? ( {a.label} @@ -27,7 +28,7 @@ export function Breadcrumb({ data }: BreadcrumbProps) { ) : null} - + ); })} diff --git a/src/components/common/Favicon.tsx b/src/components/common/Favicon.tsx index 2bf43c77..cdaeaf4b 100644 --- a/src/components/common/Favicon.tsx +++ b/src/components/common/Favicon.tsx @@ -6,13 +6,18 @@ function getHostName(url: string) { } export function Favicon({ domain, ...props }) { + if (process.env.privateMode) { + return null; + } + const hostName = domain ? getHostName(domain) : null; return hostName ? ( diff --git a/src/components/common/FilterLink.tsx b/src/components/common/FilterLink.tsx index 2a4747ff..ef278ed2 100644 --- a/src/components/common/FilterLink.tsx +++ b/src/components/common/FilterLink.tsx @@ -1,10 +1,9 @@ +import classNames from 'classnames'; +import { useMessages, useNavigation } from 'components/hooks'; +import { safeDecodeURIComponent } from 'next-basics'; +import Link from 'next/link'; import { ReactNode } from 'react'; import { Icon, Icons } from 'react-basics'; -import classNames from 'classnames'; -import Link from 'next/link'; -import { safeDecodeURI } from 'next-basics'; -import { useNavigation } from 'components/hooks'; -import { useMessages } from 'components/hooks'; import styles from './FilterLink.module.css'; export interface FilterLinkProps { @@ -40,7 +39,7 @@ export function FilterLink({ {!value && `(${label || formatMessage(labels.unknown)})`} {value && ( - {safeDecodeURI(label || value)} + {safeDecodeURIComponent(label || value)} )} {externalUrl && ( diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 560d48a0..df4fbd88 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -2,6 +2,7 @@ export * from './queries/useApi'; export * from './queries/useConfig'; export * from './queries/useFilterQuery'; export * from './queries/useLogin'; +export * from './queries/useRealtime'; export * from './queries/useReport'; export * from './queries/useReports'; export * from './queries/useShareToken'; @@ -15,10 +16,12 @@ export * from './queries/useWebsite'; export * from './queries/useWebsites'; export * from './queries/useWebsiteEvents'; export * from './queries/useWebsiteMetrics'; +export * from './queries/useWebsiteValues'; export * from './useCountryNames'; export * from './useDateRange'; export * from './useDocumentClick'; export * from './useEscapeKey'; +export * from './useFields'; export * from './useFilters'; export * from './useForceUpdate'; export * from './useFormat'; diff --git a/src/components/hooks/queries/useFilterQuery.ts b/src/components/hooks/queries/useFilterQuery.ts index e51d70a1..1ac6564b 100644 --- a/src/components/hooks/queries/useFilterQuery.ts +++ b/src/components/hooks/queries/useFilterQuery.ts @@ -7,7 +7,7 @@ export function useFilterQuery({ queryKey, queryFn, ...options -}: UseQueryOptions): FilterQueryResult { +}: Omit & { queryFn: (params?: object) => any }): FilterQueryResult { const [params, setParams] = useState({ query: '', page: 1, diff --git a/src/components/hooks/queries/useRealtime.ts b/src/components/hooks/queries/useRealtime.ts new file mode 100644 index 00000000..ccf6a62d --- /dev/null +++ b/src/components/hooks/queries/useRealtime.ts @@ -0,0 +1,87 @@ +import { useMemo, useRef } from 'react'; +import { RealtimeData } from 'lib/types'; +import { useApi } from 'components/hooks'; +import { REALTIME_INTERVAL, REALTIME_RANGE } from 'lib/constants'; +import { startOfMinute, subMinutes } from 'date-fns'; +import { percentFilter } from 'lib/filters'; +import thenby from 'thenby'; + +function mergeData(state = [], data = [], time: number) { + const ids = state.map(({ id }) => id); + return state + .concat(data.filter(({ id }) => !ids.includes(id))) + .filter(({ timestamp }) => timestamp >= time); +} + +export function useRealtime(websiteId: string) { + const currentData = useRef({ + pageviews: [], + sessions: [], + events: [], + countries: [], + visitors: [], + timestamp: 0, + }); + const { get, useQuery } = useApi(); + const { data, isLoading, error } = useQuery({ + queryKey: ['realtime', websiteId], + queryFn: async () => { + const state = currentData.current; + const data = await get(`/realtime/${websiteId}`, { startAt: state?.timestamp || 0 }); + const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE); + const time = date.getTime(); + const { pageviews, sessions, events, timestamp } = data; + + return { + pageviews: mergeData(state?.pageviews, pageviews, time), + sessions: mergeData(state?.sessions, sessions, time), + events: mergeData(state?.events, events, time), + timestamp, + }; + }, + enabled: !!websiteId, + refetchInterval: REALTIME_INTERVAL, + }); + + const realtimeData: RealtimeData = useMemo(() => { + if (!data) { + return { pageviews: [], sessions: [], events: [], countries: [], visitors: [], timestamp: 0 }; + } + + data.countries = percentFilter( + data.sessions + .reduce((arr, data) => { + if (!arr.find(({ id }) => id === data.id)) { + return arr.concat(data); + } + return arr; + }, []) + .reduce((arr: { x: any; y: number }[], { country }: any) => { + if (country) { + const row = arr.find(({ x }) => x === country); + + if (!row) { + arr.push({ x: country, y: 1 }); + } else { + row.y += 1; + } + } + return arr; + }, []) + .sort(thenby.firstBy('y', -1)), + ); + + data.visitors = data.sessions.reduce((arr, val) => { + if (!arr.find(({ id }) => id === val.id)) { + return arr.concat(val); + } + return arr; + }, []); + + return data; + }, [data]); + + return { data: realtimeData, isLoading, error }; +} + +export default useRealtime; diff --git a/src/components/hooks/queries/useReport.ts b/src/components/hooks/queries/useReport.ts index 38061761..3aacabb4 100644 --- a/src/components/hooks/queries/useReport.ts +++ b/src/components/hooks/queries/useReport.ts @@ -4,11 +4,14 @@ import { useApi } from './useApi'; import { useTimezone } from '../useTimezone'; import { useMessages } from '../useMessages'; -export function useReport(reportId: string, defaultParameters: { [key: string]: any }) { +export function useReport( + reportId: string, + defaultParameters: { type: string; parameters: { [key: string]: any } }, +) { const [report, setReport] = useState(null); const [isRunning, setIsRunning] = useState(false); const { get, post } = useApi(); - const [timezone] = useTimezone(); + const { timezone } = useTimezone(); const { formatMessage, labels } = useMessages(); const baseParameters = { @@ -28,6 +31,8 @@ export function useReport(reportId: string, defaultParameters: { [key: string]: dateRange.endDate = new Date(endDate); } + data.parameters = { ...defaultParameters?.parameters, ...data.parameters }; + setReport(data); }; @@ -41,7 +46,7 @@ export function useReport(reportId: string, defaultParameters: { [key: string]: setReport( produce((state: any) => { - state.parameters = parameters; + state.parameters = { ...defaultParameters?.parameters, ...parameters }; state.data = data; return state; @@ -60,7 +65,11 @@ export function useReport(reportId: string, defaultParameters: { [key: string]: const { parameters, ...rest } = data; if (parameters) { - state.parameters = { ...state.parameters, ...parameters }; + state.parameters = { + ...defaultParameters?.parameters, + ...state.parameters, + ...parameters, + }; } for (const key in rest) { @@ -80,7 +89,7 @@ export function useReport(reportId: string, defaultParameters: { [key: string]: } else { loadReport(reportId); } - }, []); + }, [reportId]); return { report, runReport, updateReport, isRunning }; } diff --git a/src/components/hooks/queries/useWebsiteEvents.ts b/src/components/hooks/queries/useWebsiteEvents.ts index de18a1f9..99281470 100644 --- a/src/components/hooks/queries/useWebsiteEvents.ts +++ b/src/components/hooks/queries/useWebsiteEvents.ts @@ -1,12 +1,29 @@ import useApi from './useApi'; import { UseQueryOptions } from '@tanstack/react-query'; +import { useDateRange, useNavigation, useTimezone } from 'components/hooks'; +import { zonedTimeToUtc } from 'date-fns-tz'; export function useWebsiteEvents( websiteId: string, - params?: { [key: string]: any }, options?: Omit, ) { const { get, useQuery } = useApi(); + const [dateRange] = useDateRange(websiteId); + const { startDate, endDate, unit, offset } = dateRange; + const { timezone } = useTimezone(); + const { + query: { url, event }, + } = useNavigation(); + + const params = { + startAt: +zonedTimeToUtc(startDate, timezone), + endAt: +zonedTimeToUtc(endDate, timezone), + unit, + offset, + timezone, + url, + event, + }; return useQuery({ queryKey: ['events', { ...params }], diff --git a/src/components/hooks/queries/useWebsitePageviews.ts b/src/components/hooks/queries/useWebsitePageviews.ts index 91db3717..2d6e3428 100644 --- a/src/components/hooks/queries/useWebsitePageviews.ts +++ b/src/components/hooks/queries/useWebsitePageviews.ts @@ -1,17 +1,18 @@ +import { zonedTimeToUtc } from 'date-fns-tz'; import { useApi, useDateRange, useNavigation, useTimezone } from 'components/hooks'; export function useWebsitePageviews(websiteId: string, options?: { [key: string]: string }) { const { get, useQuery } = useApi(); const [dateRange] = useDateRange(websiteId); const { startDate, endDate, unit } = dateRange; - const [timezone] = useTimezone(); + const { timezone } = useTimezone(); const { query: { url, referrer, os, browser, device, country, region, city, title }, } = useNavigation(); const params = { - startAt: +startDate, - endAt: +endDate, + startAt: +zonedTimeToUtc(startDate, timezone), + endAt: +zonedTimeToUtc(endDate, timezone), unit, timezone, url, diff --git a/src/components/hooks/queries/useWebsiteValues.ts b/src/components/hooks/queries/useWebsiteValues.ts new file mode 100644 index 00000000..02e26fc3 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteValues.ts @@ -0,0 +1,31 @@ +import { useApi } from 'components/hooks'; + +export function useWebsiteValues({ + websiteId, + type, + startDate, + endDate, + search, +}: { + websiteId: string; + type: string; + startDate: Date; + endDate: Date; + search?: string; +}) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['websites:values', { websiteId, type, startDate, endDate, search }], + queryFn: () => + get(`/websites/${websiteId}/values`, { + type, + startAt: +startDate, + endAt: +endDate, + search, + }), + enabled: !!(websiteId && type && startDate && endDate), + }); +} + +export default useWebsiteValues; diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts new file mode 100644 index 00000000..05d2b458 --- /dev/null +++ b/src/components/hooks/useFields.ts @@ -0,0 +1,22 @@ +import { useMessages } from './useMessages'; + +export function useFields() { + const { formatMessage, labels } = useMessages(); + + const fields = [ + { 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) }, + ]; + + return { fields }; +} + +export default useFields; diff --git a/src/components/hooks/useFilters.ts b/src/components/hooks/useFilters.ts index e1a9a885..5f89eca4 100644 --- a/src/components/hooks/useFilters.ts +++ b/src/components/hooks/useFilters.ts @@ -4,7 +4,7 @@ import { OPERATORS } from 'lib/constants'; export function useFilters() { const { formatMessage, labels } = useMessages(); - const filterLabels = { + const operatorLabels = { [OPERATORS.equals]: formatMessage(labels.is), [OPERATORS.notEquals]: formatMessage(labels.isNot), [OPERATORS.set]: formatMessage(labels.isSet), @@ -22,7 +22,7 @@ export function useFilters() { }; const typeFilters = { - string: [OPERATORS.equals, OPERATORS.notEquals], + string: [OPERATORS.equals, OPERATORS.notEquals, OPERATORS.contains, OPERATORS.doesNotContain], array: [OPERATORS.contains, OPERATORS.doesNotContain], boolean: [OPERATORS.true, OPERATORS.false], number: [ @@ -37,11 +37,17 @@ export function useFilters() { uuid: [OPERATORS.equals], }; + const filters = Object.keys(typeFilters).flatMap(key => { + return ( + typeFilters[key]?.map(value => ({ type: key, value, label: operatorLabels[value] })) ?? [] + ); + }); + const getFilters = type => { - return typeFilters[type]?.map(key => ({ type, value: key, label: filterLabels[key] })) ?? []; + return typeFilters[type]?.map(key => ({ type, value: key, label: operatorLabels[key] })) ?? []; }; - return { getFilters, filterLabels, typeFilters }; + return { filters, operatorLabels, typeFilters, getFilters }; } export default useFilters; diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts index 0ff7155a..a2c1167a 100644 --- a/src/components/hooks/useNavigation.ts +++ b/src/components/hooks/useNavigation.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { buildUrl } from 'next-basics'; +import { buildUrl, safeDecodeURIComponent } from 'next-basics'; export function useNavigation(): { pathname: string; @@ -16,7 +16,7 @@ export function useNavigation(): { const obj = {}; for (const [key, value] of params.entries()) { - obj[key] = decodeURIComponent(value); + obj[key] = safeDecodeURIComponent(value); } return obj; diff --git a/src/components/hooks/useTheme.ts b/src/components/hooks/useTheme.ts index 099bf962..aa2b1d38 100644 --- a/src/components/hooks/useTheme.ts +++ b/src/components/hooks/useTheme.ts @@ -1,53 +1,49 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import useStore, { setTheme } from 'store/app'; import { getItem, setItem } from 'next-basics'; -import { THEME_COLORS, THEME_CONFIG } from 'lib/constants'; +import { DEFAULT_THEME, THEME_COLORS, THEME_CONFIG } from 'lib/constants'; import { colord } from 'colord'; const selector = (state: { theme: string }) => state.theme; export function useTheme() { - const defaultTheme = - typeof window !== 'undefined' - ? window?.matchMedia('(prefers-color-scheme: dark)')?.matches - ? 'dark' - : 'light' - : 'light'; - const theme = useStore(selector) || getItem(THEME_CONFIG) || defaultTheme; + const theme = useStore(selector) || getItem(THEME_CONFIG) || DEFAULT_THEME; const primaryColor = colord(THEME_COLORS[theme].primary); - const colors = { - theme: { - ...THEME_COLORS[theme], - }, - chart: { - text: THEME_COLORS[theme].gray700, - line: THEME_COLORS[theme].gray200, - views: { - hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(), - backgroundColor: primaryColor.alpha(0.4).toRgbString(), - borderColor: primaryColor.alpha(0.7).toRgbString(), - hoverBorderColor: primaryColor.toRgbString(), + const colors = useMemo(() => { + return { + theme: { + ...THEME_COLORS[theme], }, - visitors: { - hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(), - backgroundColor: primaryColor.alpha(0.6).toRgbString(), - borderColor: primaryColor.alpha(0.9).toRgbString(), - hoverBorderColor: primaryColor.toRgbString(), + chart: { + text: THEME_COLORS[theme].gray700, + line: THEME_COLORS[theme].gray200, + views: { + hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(), + backgroundColor: primaryColor.alpha(0.4).toRgbString(), + borderColor: primaryColor.alpha(0.7).toRgbString(), + hoverBorderColor: primaryColor.toRgbString(), + }, + visitors: { + hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(), + backgroundColor: primaryColor.alpha(0.6).toRgbString(), + borderColor: primaryColor.alpha(0.9).toRgbString(), + hoverBorderColor: primaryColor.toRgbString(), + }, }, - }, - map: { - baseColor: THEME_COLORS[theme].primary, - fillColor: THEME_COLORS[theme].gray100, - strokeColor: THEME_COLORS[theme].primary, - hoverColor: THEME_COLORS[theme].primary, - }, - }; + map: { + baseColor: THEME_COLORS[theme].primary, + fillColor: THEME_COLORS[theme].gray100, + strokeColor: THEME_COLORS[theme].primary, + hoverColor: THEME_COLORS[theme].primary, + }, + }; + }, [theme]); - function saveTheme(value) { + const saveTheme = (value: string) => { setItem(THEME_CONFIG, value); setTheme(value); - } + }; useEffect(() => { document.body.setAttribute('data-theme', theme); diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts index 3dbb52b3..8bd76504 100644 --- a/src/components/hooks/useTimezone.ts +++ b/src/components/hooks/useTimezone.ts @@ -1,20 +1,18 @@ -import { useState, useCallback } from 'react'; -import { getTimezone } from 'lib/date'; -import { getItem, setItem } from 'next-basics'; +import { setItem } from 'next-basics'; import { TIMEZONE_CONFIG } from 'lib/constants'; +import useStore, { setTimezone } from 'store/app'; + +const selector = (state: { timezone: string }) => state.timezone; export function useTimezone() { - const [timezone, setTimezone] = useState(getItem(TIMEZONE_CONFIG) || getTimezone()); + const timezone = useStore(selector); - const saveTimezone = useCallback( - (value: string) => { - setItem(TIMEZONE_CONFIG, value); - setTimezone(value); - }, - [setTimezone], - ); + const saveTimezone = (value: string) => { + setItem(TIMEZONE_CONFIG, value); + setTimezone(value); + }; - return [timezone, saveTimezone]; + return { timezone, saveTimezone }; } export default useTimezone; diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx index afe44e6f..6e7c099a 100644 --- a/src/components/input/DateFilter.tsx +++ b/src/components/input/DateFilter.tsx @@ -32,18 +32,14 @@ export function DateFilter({ const { locale } = useLocale(); const options = [ - { label: formatMessage(labels.today), value: '1day' }, + { label: formatMessage(labels.today), value: '0day' }, { label: formatMessage(labels.lastHours, { x: 24 }), value: '24hour', }, - { - label: formatMessage(labels.yesterday), - value: '-1day', - }, { label: formatMessage(labels.thisWeek), - value: '1week', + value: '0week', divider: true, }, { @@ -52,7 +48,7 @@ export function DateFilter({ }, { label: formatMessage(labels.thisMonth), - value: '1month', + value: '0month', divider: true, }, { @@ -63,7 +59,15 @@ export function DateFilter({ label: formatMessage(labels.lastDays, { x: 90 }), value: '90day', }, - { label: formatMessage(labels.thisYear), value: '1year' }, + { label: formatMessage(labels.thisYear), value: '0year', divider: true }, + { + label: formatMessage(labels.lastMonths, { x: 6 }), + value: '6month', + }, + { + label: formatMessage(labels.lastMonths, { x: 12 }), + value: '12month', + }, showAllTime && { label: formatMessage(labels.allTime), value: 'all', @@ -113,7 +117,7 @@ export function DateFilter({ ); } - return options.find(e => e.value === value).label; + return options.find(e => e.value === value)?.label; }; return ( diff --git a/src/components/input/ProfileButton.tsx b/src/components/input/ProfileButton.tsx index ff3ee63e..b1875165 100644 --- a/src/components/input/ProfileButton.tsx +++ b/src/components/input/ProfileButton.tsx @@ -11,7 +11,7 @@ export function ProfileButton() { const { user } = useLogin(); const router = useRouter(); const { dir } = useLocale(); - const cloudMode = Boolean(process.env.cloudMode); + const cloudMode = !!process.env.cloudMode; const handleSelect = (key: Key, close: () => void) => { if (key === 'profile') { diff --git a/src/components/input/TeamsButton.tsx b/src/components/input/TeamsButton.tsx index e3b5a3a8..1f6270b4 100644 --- a/src/components/input/TeamsButton.tsx +++ b/src/components/input/TeamsButton.tsx @@ -7,9 +7,11 @@ import styles from './TeamsButton.module.css'; export function TeamsButton({ className, + showText = true, onChange, }: { className?: string; + showText?: boolean; onChange?: (value: string) => void; }) { const { user } = useLogin(); @@ -31,7 +33,7 @@ export function TeamsButton({