Merge pull request #2641 from umami-software/analytics

v2.11.0
This commit is contained in:
Mike Cao 2024-04-04 11:54:17 -07:00 committed by GitHub
commit 88da20ea7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
207 changed files with 4330 additions and 2814 deletions

1
.gitignore vendored
View File

@ -22,6 +22,7 @@ node_modules
# misc # misc
.DS_Store .DS_Store
.idea .idea
.yarn
*.iml *.iml
*.log *.log
.vscode .vscode

View File

@ -4,4 +4,9 @@ export default defineConfig({
e2e: { e2e: {
baseUrl: 'http://localhost:3000', baseUrl: 'http://localhost:3000',
}, },
// default username / password on init
env: {
umami_user: 'admin',
umami_password: 'umami',
},
}); });

View File

@ -43,9 +43,10 @@ services:
- CYPRESS_umami_user=admin - CYPRESS_umami_user=admin
- CYPRESS_umami_password=umami - CYPRESS_umami_password=umami
volumes: volumes:
- ../tsconfig.json:/tsconfig.json - ./tsconfig.json:/tsconfig.json
- ../cypress.config.ts:/cypress.config.ts - ../cypress.config.ts:/cypress.config.ts
- ./:/cypress - ./:/cypress
- ../node_modules/:/node_modules - ../node_modules/:/node_modules
- ../src/lib/crypto.ts:/src/lib/crypto.ts
volumes: volumes:
umami-db-data: umami-db-data:

View File

@ -6,8 +6,12 @@ describe('Login tests', () => {
}, },
() => { () => {
cy.visit('/login'); cy.visit('/login');
cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user')); cy.getDataTest('input-username').find('input').click();
cy.getDataTest('input-password').find('input').type(Cypress.env('umami_password')); 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.getDataTest('button-submit').click();
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard'); cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
cy.getDataTest('button-profile').click(); cy.getDataTest('button-profile').click();

View File

@ -10,8 +10,10 @@ describe('Website tests', () => {
cy.visit('/settings/websites'); cy.visit('/settings/websites');
cy.getDataTest('button-website-add').click(); cy.getDataTest('button-website-add').click();
cy.contains(/Add website/i).should('be.visible'); cy.contains(/Add website/i).should('be.visible');
cy.getDataTest('input-name').find('input').wait(500).type('Add test', { delay: 50 }); cy.getDataTest('input-name').find('input').click();
cy.getDataTest('input-domain').find('input').wait(500).type('addtest.com', { delay: 50 }); 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.getDataTest('button-submit').click();
cy.get('td[label="Name"]').should('contain.text', 'Add test'); cy.get('td[label="Name"]').should('contain.text', 'Add test');
cy.get('td[label="Domain"]').should('contain.text', 'addtest.com'); cy.get('td[label="Domain"]').should('contain.text', 'addtest.com');
@ -26,10 +28,10 @@ describe('Website tests', () => {
cy.deleteWebsite(websiteId); cy.deleteWebsite(websiteId);
}); });
cy.visit('/settings/websites'); 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 // prep data
cy.addWebsite('Update test', 'updatetest.com'); cy.addWebsite('Update test', 'updatetest.com');
cy.visit('/settings/websites'); cy.visit('/settings/websites');
@ -37,16 +39,12 @@ describe('Website tests', () => {
// edit website // edit website
cy.getDataTest('link-button-edit').first().click(); cy.getDataTest('link-button-edit').first().click();
cy.contains(/Details/i).should('be.visible'); cy.contains(/Details/i).should('be.visible');
cy.getDataTest('input-name') cy.getDataTest('input-name').find('input').click();
.find('input') cy.getDataTest('input-name').find('input').clear();
.wait(500) cy.getDataTest('input-name').find('input').type('Updated website', { delay: 50 });
.clear() cy.getDataTest('input-domain').find('input').click();
.type('Updated website', { delay: 50 }); cy.getDataTest('input-domain').find('input').clear();
cy.getDataTest('input-domain') cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 50 });
.find('input')
.wait(500)
.clear()
.type('updatedwebsite.com', { delay: 50 });
cy.getDataTest('button-submit').click({ force: true }); cy.getDataTest('button-submit').click({ force: true });
cy.getDataTest('input-name').find('input').should('have.value', 'Updated website'); cy.getDataTest('input-name').find('input').should('have.value', 'Updated website');
cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com'); cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com');
@ -69,7 +67,7 @@ describe('Website tests', () => {
cy.deleteWebsite(websiteId); cy.deleteWebsite(websiteId);
}); });
cy.visit('/settings/websites'); cy.visit('/settings/websites');
cy.contains('Add test').should('not.exist'); cy.contains(/Add test/i).should('not.exist');
}); });
it('Delete a website', () => { 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.contains(/Type DELETE in the box below to confirm./i).should('be.visible');
cy.get('input[name="confirm"').type('DELETE'); cy.get('input[name="confirm"').type('DELETE');
cy.get('button[type="submit"]').click(); cy.get('button[type="submit"]').click();
cy.contains('Delete test').should('not.exist'); cy.contains(/Delete test/i).should('not.exist');
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "umami", "name": "umami",
"version": "2.10.2", "version": "2.11.0",
"description": "A simple, fast, privacy-focused alternative to Google Analytics.", "description": "A simple, fast, privacy-focused alternative to Google Analytics.",
"author": "Umami Software, Inc. <hello@umami.is>", "author": "Umami Software, Inc. <hello@umami.is>",
"license": "MIT", "license": "MIT",
@ -66,14 +66,14 @@
"dependencies": { "dependencies": {
"@clickhouse/client": "^0.2.2", "@clickhouse/client": "^0.2.2",
"@fontsource/inter": "^4.5.15", "@fontsource/inter": "^4.5.15",
"@prisma/client": "5.9.1", "@prisma/client": "5.11.0",
"@prisma/extension-read-replicas": "^0.3.0", "@prisma/extension-read-replicas": "^0.3.0",
"@react-spring/web": "^9.7.3", "@react-spring/web": "^9.7.3",
"@tanstack/react-query": "^5.12.2", "@tanstack/react-query": "^5.28.6",
"@umami/prisma-client": "^0.14.0", "@umami/prisma-client": "^0.14.0",
"@umami/redis-client": "^0.18.0", "@umami/redis-client": "^0.18.0",
"chalk": "^4.1.1", "chalk": "^4.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.4.2",
"chartjs-adapter-date-fns": "^3.0.0", "chartjs-adapter-date-fns": "^3.0.0",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"colord": "^2.9.2", "colord": "^2.9.2",
@ -98,11 +98,11 @@
"maxmind": "^4.3.6", "maxmind": "^4.3.6",
"md5": "^2.3.0", "md5": "^2.3.0",
"moment-timezone": "^0.5.35", "moment-timezone": "^0.5.35",
"next": "14.1.0", "next": "14.1.4",
"next-basics": "^0.39.0", "next-basics": "^0.39.0",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prisma": "5.9.1", "prisma": "5.11.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-basics": "^0.123.0", "react-basics": "^0.123.0",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
@ -115,7 +115,6 @@
"request-ip": "^3.3.0", "request-ip": "^3.3.0",
"semver": "^7.5.4", "semver": "^7.5.4",
"thenby": "^1.3.4", "thenby": "^1.3.4",
"timezone-support": "^2.0.2",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"yup": "^0.32.11", "yup": "^0.32.11",
"zustand": "^4.3.8" "zustand": "^4.3.8"
@ -176,6 +175,6 @@
"tar": "^6.1.2", "tar": "^6.1.2",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.1.6" "typescript": "^5.4.3"
} }
} }

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Erstellt" "value": "Erstellt"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Erstellt" "value": "Erstellt"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -32,7 +32,7 @@
"label.add-member": [ "label.add-member": [
{ {
"type": 0, "type": 0,
"value": "Add member" "value": "Añadir miembro"
} }
], ],
"label.add-website": [ "label.add-website": [
@ -215,6 +215,12 @@
"value": "Creado" "value": "Creado"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,
@ -272,7 +278,7 @@
"label.delete-report": [ "label.delete-report": [
{ {
"type": 0, "type": 0,
"value": "Delete report" "value": "Eliminar reporte"
} }
], ],
"label.delete-team": [ "label.delete-team": [
@ -464,7 +470,7 @@
"label.insights-description": [ "label.insights-description": [
{ {
"type": 0, "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": [ "label.is": [
@ -482,7 +488,7 @@
"label.is-not-set": [ "label.is-not-set": [
{ {
"type": 0, "type": 0,
"value": "Is not set" "value": "No está establecido"
} }
], ],
"label.is-set": [ "label.is-set": [
@ -588,7 +594,7 @@
"label.manage": [ "label.manage": [
{ {
"type": 0, "type": 0,
"value": "Manage" "value": "Administrar"
} }
], ],
"label.max": [ "label.max": [
@ -600,7 +606,7 @@
"label.member": [ "label.member": [
{ {
"type": 0, "type": 0,
"value": "Member" "value": "Miembro"
} }
], ],
"label.members": [ "label.members": [
@ -630,7 +636,7 @@
"label.my-account": [ "label.my-account": [
{ {
"type": 0, "type": 0,
"value": "My account" "value": "Mi cuenta"
} }
], ],
"label.my-websites": [ "label.my-websites": [
@ -842,7 +848,7 @@
"label.remove-member": [ "label.remove-member": [
{ {
"type": 0, "type": 0,
"value": "Remove member" "value": "Eliminar miembro"
} }
], ],
"label.reports": [ "label.reports": [
@ -926,7 +932,7 @@
"label.select-role": [ "label.select-role": [
{ {
"type": 0, "type": 0,
"value": "Select role" "value": "Seleccionar rol"
} }
], ],
"label.select-website": [ "label.select-website": [
@ -1004,7 +1010,7 @@
"label.team-view-only": [ "label.team-view-only": [
{ {
"type": 0, "type": 0,
"value": "Team view only" "value": "Vista solo del equipo"
} }
], ],
"label.team-websites": [ "label.team-websites": [
@ -1088,13 +1094,13 @@
"label.transfer": [ "label.transfer": [
{ {
"type": 0, "type": 0,
"value": "Transfer" "value": "Transferir"
} }
], ],
"label.transfer-website": [ "label.transfer-website": [
{ {
"type": 0, "type": 0,
"value": "Transfer website" "value": "Transferir sitio web"
} }
], ],
"label.true": [ "label.true": [
@ -1232,7 +1238,7 @@
"message.action-confirmation": [ "message.action-confirmation": [
{ {
"type": 0, "type": 0,
"value": "Type " "value": "Escriba "
}, },
{ {
"type": 1, "type": 1,
@ -1240,7 +1246,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": " in the box below to confirm." "value": " en el cuadro a continuación para confirmar."
} }
], ],
"message.active-users": [ "message.active-users": [
@ -1308,7 +1314,7 @@
"message.confirm-remove": [ "message.confirm-remove": [
{ {
"type": 0, "type": 0,
"value": "Are you sure you want to remove " "value": "¿Estás seguro de que desea eliminar "
}, },
{ {
"type": 1, "type": 1,
@ -1336,7 +1342,7 @@
"message.delete-team-warning": [ "message.delete-team-warning": [
{ {
"type": 0, "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": [ "message.delete-website-warning": [
@ -1532,7 +1538,7 @@
"message.transfer-team-website-to-user": [ "message.transfer-team-website-to-user": [
{ {
"type": 0, "type": 0,
"value": "Transfer this website to your account?" "value": "¿Transferir este sitio web a su cuenta?"
} }
], ],
"message.transfer-user-website-to-team": [ "message.transfer-user-website-to-team": [
@ -1544,13 +1550,13 @@
"message.transfer-website": [ "message.transfer-website": [
{ {
"type": 0, "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": [ "message.triggered-event": [
{ {
"type": 0, "type": 0,
"value": "Triggered event" "value": "Evento lanzado"
} }
], ],
"message.user-deleted": [ "message.user-deleted": [
@ -1562,7 +1568,7 @@
"message.viewed-page": [ "message.viewed-page": [
{ {
"type": 0, "type": 0,
"value": "Viewed page" "value": "Página vista"
} }
], ],
"message.visitor-log": [ "message.visitor-log": [
@ -1602,7 +1608,7 @@
"message.visitors-dropped-off": [ "message.visitors-dropped-off": [
{ {
"type": 0, "type": 0,
"value": "Visitors dropped off" "value": "Los visitantes salieron"
} }
] ]
} }

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -32,7 +32,7 @@
"label.add-member": [ "label.add-member": [
{ {
"type": 0, "type": 0,
"value": "Add member" "value": "Ajouter un membre"
} }
], ],
"label.add-website": [ "label.add-website": [
@ -215,6 +215,12 @@
"value": "Créé" "value": "Créé"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Crée par"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,
@ -272,7 +278,7 @@
"label.delete-report": [ "label.delete-report": [
{ {
"type": 0, "type": 0,
"value": "Delete report" "value": "Supprimer le rapport"
} }
], ],
"label.delete-team": [ "label.delete-team": [
@ -362,7 +368,7 @@
"label.edit-member": [ "label.edit-member": [
{ {
"type": 0, "type": 0,
"value": "Edit member" "value": "Modifier le membre"
} }
], ],
"label.enable-share-url": [ "label.enable-share-url": [
@ -580,7 +586,7 @@
"label.manage": [ "label.manage": [
{ {
"type": 0, "type": 0,
"value": "Manage" "value": "Gérer"
} }
], ],
"label.max": [ "label.max": [
@ -592,7 +598,7 @@
"label.member": [ "label.member": [
{ {
"type": 0, "type": 0,
"value": "Member" "value": "Membre"
} }
], ],
"label.members": [ "label.members": [
@ -622,7 +628,7 @@
"label.my-account": [ "label.my-account": [
{ {
"type": 0, "type": 0,
"value": "My account" "value": "Mon compte"
} }
], ],
"label.my-websites": [ "label.my-websites": [
@ -646,7 +652,7 @@
"label.none": [ "label.none": [
{ {
"type": 0, "type": 0,
"value": "Aucun·e" "value": "Aucun"
} }
], ],
"label.number-of-records": [ "label.number-of-records": [
@ -834,7 +840,7 @@
"label.remove-member": [ "label.remove-member": [
{ {
"type": 0, "type": 0,
"value": "Remove member" "value": "Retirer le membre"
} }
], ],
"label.reports": [ "label.reports": [
@ -918,7 +924,7 @@
"label.select-role": [ "label.select-role": [
{ {
"type": 0, "type": 0,
"value": "Select role" "value": "Choisir un rôle"
} }
], ],
"label.select-website": [ "label.select-website": [
@ -1080,13 +1086,13 @@
"label.transfer": [ "label.transfer": [
{ {
"type": 0, "type": 0,
"value": "Transfer" "value": "Transférer"
} }
], ],
"label.transfer-website": [ "label.transfer-website": [
{ {
"type": 0, "type": 0,
"value": "Transfer website" "value": "Transférer le site"
} }
], ],
"label.true": [ "label.true": [
@ -1224,7 +1230,7 @@
"message.action-confirmation": [ "message.action-confirmation": [
{ {
"type": 0, "type": 0,
"value": "Type " "value": "Taper "
}, },
{ {
"type": 1, "type": 1,
@ -1232,7 +1238,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": " in the box below to confirm." "value": " ci-dessous pour confirmer."
} }
], ],
"message.active-users": [ "message.active-users": [
@ -1304,7 +1310,7 @@
"message.confirm-remove": [ "message.confirm-remove": [
{ {
"type": 0, "type": 0,
"value": "Are you sure you want to remove " "value": "Êtes-vous sûr de vouloir retirer "
}, },
{ {
"type": 1, "type": 1,
@ -1312,7 +1318,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": "?" "value": " ?"
} }
], ],
"message.confirm-reset": [ "message.confirm-reset": [
@ -1332,7 +1338,7 @@
"message.delete-team-warning": [ "message.delete-team-warning": [
{ {
"type": 0, "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": [ "message.delete-website-warning": [
@ -1520,19 +1526,19 @@
"message.transfer-team-website-to-user": [ "message.transfer-team-website-to-user": [
{ {
"type": 0, "type": 0,
"value": "Transfer this website to your account?" "value": "Transférer ce site sur votre compte ?"
} }
], ],
"message.transfer-user-website-to-team": [ "message.transfer-user-website-to-team": [
{ {
"type": 0, "type": 0,
"value": "Select the team to transfer this website to." "value": "Choisir l'équipe à laquelle transférer ce site."
} }
], ],
"message.transfer-website": [ "message.transfer-website": [
{ {
"type": 0, "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": [ "message.triggered-event": [
@ -1550,7 +1556,7 @@
"message.viewed-page": [ "message.viewed-page": [
{ {
"type": 0, "type": 0,
"value": "Viewed page" "value": "Page vue"
} }
], ],
"message.visitor-log": [ "message.visitor-log": [
@ -1590,7 +1596,7 @@
"message.visitors-dropped-off": [ "message.visitors-dropped-off": [
{ {
"type": 0, "type": 0,
"value": "Visitors dropped off" "value": "Visiteurs ont abandonné"
} }
] ]
} }

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -32,7 +32,7 @@
"label.add-member": [ "label.add-member": [
{ {
"type": 0, "type": 0,
"value": "Add member" "value": "メンバーの追加"
} }
], ],
"label.add-website": [ "label.add-website": [
@ -188,7 +188,7 @@
"label.create": [ "label.create": [
{ {
"type": 0, "type": 0,
"value": "Create" "value": "作成"
} }
], ],
"label.create-report": [ "label.create-report": [
@ -215,6 +215,12 @@
"value": "作成されました" "value": "作成されました"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,
@ -272,7 +278,7 @@
"label.delete-report": [ "label.delete-report": [
{ {
"type": 0, "type": 0,
"value": "Delete report" "value": "レポートの削除"
} }
], ],
"label.delete-team": [ "label.delete-team": [
@ -362,7 +368,7 @@
"label.edit-member": [ "label.edit-member": [
{ {
"type": 0, "type": 0,
"value": "Edit member" "value": "メンバーの編集"
} }
], ],
"label.enable-share-url": [ "label.enable-share-url": [
@ -410,13 +416,13 @@
"label.filter": [ "label.filter": [
{ {
"type": 0, "type": 0,
"value": "Filter" "value": "フィルター"
} }
], ],
"label.filter-combined": [ "label.filter-combined": [
{ {
"type": 0, "type": 0,
"value": "合" "value": "合"
} }
], ],
"label.filter-raw": [ "label.filter-raw": [
@ -434,13 +440,13 @@
"label.funnel": [ "label.funnel": [
{ {
"type": 0, "type": 0,
"value": "分析" "value": "ファネル"
} }
], ],
"label.funnel-description": [ "label.funnel-description": [
{ {
"type": 0, "type": 0,
"value": "Understand the conversion and drop-off rate of users." "value": "ユーザーのコンバージョン率と離脱率を分析します。"
} }
], ],
"label.greater-than": [ "label.greater-than": [
@ -458,13 +464,13 @@
"label.insights": [ "label.insights": [
{ {
"type": 0, "type": 0,
"value": "見通し" "value": "インサイト"
} }
], ],
"label.insights-description": [ "label.insights-description": [
{ {
"type": 0, "type": 0,
"value": "Dive deeper into your data by using segments and filters." "value": "セグメントとフィルタを使用して、データをさらに詳しく分析します。"
} }
], ],
"label.is": [ "label.is": [
@ -588,7 +594,7 @@
"label.manage": [ "label.manage": [
{ {
"type": 0, "type": 0,
"value": "Manage" "value": "管理"
} }
], ],
"label.max": [ "label.max": [
@ -600,7 +606,7 @@
"label.member": [ "label.member": [
{ {
"type": 0, "type": 0,
"value": "Member" "value": "メンバー"
} }
], ],
"label.members": [ "label.members": [
@ -630,7 +636,7 @@
"label.my-account": [ "label.my-account": [
{ {
"type": 0, "type": 0,
"value": "My account" "value": "マイアカウント"
} }
], ],
"label.my-websites": [ "label.my-websites": [
@ -842,7 +848,7 @@
"label.remove-member": [ "label.remove-member": [
{ {
"type": 0, "type": 0,
"value": "Remove member" "value": "メンバーの削除"
} }
], ],
"label.reports": [ "label.reports": [
@ -872,13 +878,13 @@
"label.retention": [ "label.retention": [
{ {
"type": 0, "type": 0,
"value": "保持" "value": "リテンション"
} }
], ],
"label.retention-description": [ "label.retention-description": [
{ {
"type": 0, "type": 0,
"value": "Measure your website stickiness by tracking how often users return." "value": "ユーザーの再訪問回数を記録して、Webサイトのリテンション率を計測します。"
} }
], ],
"label.role": [ "label.role": [
@ -908,13 +914,13 @@
"label.search": [ "label.search": [
{ {
"type": 0, "type": 0,
"value": "Search" "value": "検索"
} }
], ],
"label.select": [ "label.select": [
{ {
"type": 0, "type": 0,
"value": "Select" "value": "選択"
} }
], ],
"label.select-date": [ "label.select-date": [
@ -926,7 +932,7 @@
"label.select-role": [ "label.select-role": [
{ {
"type": 0, "type": 0,
"value": "Select role" "value": "ロールを選択"
} }
], ],
"label.select-website": [ "label.select-website": [
@ -1004,7 +1010,7 @@
"label.team-view-only": [ "label.team-view-only": [
{ {
"type": 0, "type": 0,
"value": "Team view only" "value": "チーム表示のみ"
} }
], ],
"label.team-websites": [ "label.team-websites": [
@ -1088,13 +1094,13 @@
"label.transfer": [ "label.transfer": [
{ {
"type": 0, "type": 0,
"value": "Transfer" "value": "移管"
} }
], ],
"label.transfer-website": [ "label.transfer-website": [
{ {
"type": 0, "type": 0,
"value": "Transfer website" "value": "Webサイトの移管"
} }
], ],
"label.true": [ "label.true": [
@ -1232,7 +1238,7 @@
"message.action-confirmation": [ "message.action-confirmation": [
{ {
"type": 0, "type": 0,
"value": "Type " "value": "承認する場合は、下のフォームに「"
}, },
{ {
"type": 1, "type": 1,
@ -1240,7 +1246,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": " in the box below to confirm." "value": "」と入力してください。"
} }
], ],
"message.active-users": [ "message.active-users": [
@ -1298,17 +1304,13 @@
} }
], ],
"message.confirm-remove": [ "message.confirm-remove": [
{
"type": 0,
"value": "Are you sure you want to remove "
},
{ {
"type": 1, "type": 1,
"value": "target" "value": "target"
}, },
{ {
"type": 0, "type": 0,
"value": "?" "value": "を削除してもよろしいですか?"
} }
], ],
"message.confirm-reset": [ "message.confirm-reset": [
@ -1324,7 +1326,7 @@
"message.delete-team-warning": [ "message.delete-team-warning": [
{ {
"type": 0, "type": 0,
"value": "Deleting a team will also delete all team websites." "value": "チームを削除すると、そのチームが管理しているWebサイトもすべて削除されます。"
} }
], ],
"message.delete-website-warning": [ "message.delete-website-warning": [
@ -1526,25 +1528,25 @@
"message.transfer-team-website-to-user": [ "message.transfer-team-website-to-user": [
{ {
"type": 0, "type": 0,
"value": "Transfer this website to your account?" "value": "このWebサイトをあなたのアカウントに移管しますか"
} }
], ],
"message.transfer-user-website-to-team": [ "message.transfer-user-website-to-team": [
{ {
"type": 0, "type": 0,
"value": "Select the team to transfer this website to." "value": "このWebサイトを移管するチームを選択してください。"
} }
], ],
"message.transfer-website": [ "message.transfer-website": [
{ {
"type": 0, "type": 0,
"value": "Transfer website ownership to your account or another team." "value": "Webサイトの所有権を自分のアカウントまたは別のチームへ移管します。"
} }
], ],
"message.triggered-event": [ "message.triggered-event": [
{ {
"type": 0, "type": 0,
"value": "Triggered event" "value": "トリガーされたイベント"
} }
], ],
"message.user-deleted": [ "message.user-deleted": [
@ -1556,7 +1558,7 @@
"message.viewed-page": [ "message.viewed-page": [
{ {
"type": 0, "type": 0,
"value": "Viewed page" "value": "閲覧されたページ"
} }
], ],
"message.visitor-log": [ "message.visitor-log": [
@ -1596,7 +1598,7 @@
"message.visitors-dropped-off": [ "message.visitors-dropped-off": [
{ {
"type": 0, "type": 0,
"value": "Visitors dropped off" "value": "訪問者の離脱率"
} }
] ]
} }

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Үүсгэсэн" "value": "Үүсгэсэн"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "ပြုလုပ်ပြီးသော" "value": "ပြုလုပ်ပြီးသော"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Gemaakt" "value": "Gemaakt"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Utworzony" "value": "Utworzony"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Criado" "value": "Criado"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

File diff suppressed because it is too large Load Diff

View File

@ -215,6 +215,12 @@
"value": "Создано" "value": "Создано"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Ustvarjeno" "value": "Ustvarjeno"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Skapad" "value": "Skapad"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -215,6 +215,12 @@
"value": "Created" "value": "Created"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -32,7 +32,7 @@
"label.add-member": [ "label.add-member": [
{ {
"type": 0, "type": 0,
"value": "Add member" "value": "添加成员"
} }
], ],
"label.add-website": [ "label.add-website": [
@ -215,6 +215,12 @@
"value": "已创建" "value": "已创建"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "创建者"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,
@ -272,7 +278,7 @@
"label.delete-report": [ "label.delete-report": [
{ {
"type": 0, "type": 0,
"value": "Delete report" "value": "删除报告"
} }
], ],
"label.delete-team": [ "label.delete-team": [
@ -362,7 +368,7 @@
"label.edit-member": [ "label.edit-member": [
{ {
"type": 0, "type": 0,
"value": "Edit member" "value": "编辑成员"
} }
], ],
"label.enable-share-url": [ "label.enable-share-url": [
@ -588,7 +594,7 @@
"label.manage": [ "label.manage": [
{ {
"type": 0, "type": 0,
"value": "Manage" "value": "管理"
} }
], ],
"label.max": [ "label.max": [
@ -600,7 +606,7 @@
"label.member": [ "label.member": [
{ {
"type": 0, "type": 0,
"value": "Member" "value": "成员"
} }
], ],
"label.members": [ "label.members": [
@ -630,7 +636,7 @@
"label.my-account": [ "label.my-account": [
{ {
"type": 0, "type": 0,
"value": "My account" "value": "我的账户"
} }
], ],
"label.my-websites": [ "label.my-websites": [
@ -700,7 +706,7 @@
"label.os": [ "label.os": [
{ {
"type": 0, "type": 0,
"value": "OS" "value": "操作系统"
} }
], ],
"label.overview": [ "label.overview": [
@ -718,7 +724,7 @@
"label.page-of": [ "label.page-of": [
{ {
"type": 0, "type": 0,
"value": "总" "value": "总 "
}, },
{ {
"type": 1, "type": 1,
@ -726,7 +732,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": "中的第" "value": " 中的第 "
}, },
{ {
"type": 1, "type": 1,
@ -734,7 +740,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": "页" "value": " 页"
} }
], ],
"label.page-views": [ "label.page-views": [
@ -850,7 +856,7 @@
"label.remove-member": [ "label.remove-member": [
{ {
"type": 0, "type": 0,
"value": "Remove member" "value": "移除成员"
} }
], ],
"label.reports": [ "label.reports": [
@ -922,7 +928,7 @@
"label.select": [ "label.select": [
{ {
"type": 0, "type": 0,
"value": "Select" "value": "选择"
} }
], ],
"label.select-date": [ "label.select-date": [
@ -934,7 +940,7 @@
"label.select-role": [ "label.select-role": [
{ {
"type": 0, "type": 0,
"value": "Select role" "value": "选择角色"
} }
], ],
"label.select-website": [ "label.select-website": [
@ -1012,7 +1018,7 @@
"label.team-view-only": [ "label.team-view-only": [
{ {
"type": 0, "type": 0,
"value": "Team view only" "value": "仅团队视图"
} }
], ],
"label.team-websites": [ "label.team-websites": [
@ -1096,13 +1102,13 @@
"label.transfer": [ "label.transfer": [
{ {
"type": 0, "type": 0,
"value": "Transfer" "value": "转移"
} }
], ],
"label.transfer-website": [ "label.transfer-website": [
{ {
"type": 0, "type": 0,
"value": "Transfer website" "value": "转移网站"
} }
], ],
"label.true": [ "label.true": [
@ -1240,7 +1246,7 @@
"message.action-confirmation": [ "message.action-confirmation": [
{ {
"type": 0, "type": 0,
"value": "Type " "value": "在下面的框中输入 "
}, },
{ {
"type": 1, "type": 1,
@ -1248,7 +1254,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": " in the box below to confirm." "value": " 以确认。"
} }
], ],
"message.active-users": [ "message.active-users": [
@ -1296,7 +1302,7 @@
"message.confirm-remove": [ "message.confirm-remove": [
{ {
"type": 0, "type": 0,
"value": "Are you sure you want to remove " "value": "您确定要移除 "
}, },
{ {
"type": 1, "type": 1,
@ -1304,7 +1310,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": "?" "value": " "
} }
], ],
"message.confirm-reset": [ "message.confirm-reset": [
@ -1318,13 +1324,13 @@
}, },
{ {
"type": 0, "type": 0,
"value": " 的数据吗?" "value": " 的数据吗"
} }
], ],
"message.delete-team-warning": [ "message.delete-team-warning": [
{ {
"type": 0, "type": 0,
"value": "Deleting a team will also delete all team websites." "value": "删除团队也会删除所有团队的网站。"
} }
], ],
"message.delete-website-warning": [ "message.delete-website-warning": [
@ -1346,7 +1352,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": "上的" "value": " 上的 "
}, },
{ {
"type": 1, "type": 1,
@ -1388,7 +1394,7 @@
"message.new-version-available": [ "message.new-version-available": [
{ {
"type": 0, "type": 0,
"value": "Umami的新版本" "value": "Umami 的新版本 "
}, },
{ {
"type": 1, "type": 1,
@ -1396,7 +1402,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": "已推出!" "value": " 已推出!"
} }
], ],
"message.no-data-available": [ "message.no-data-available": [
@ -1456,7 +1462,7 @@
"message.reset-website": [ "message.reset-website": [
{ {
"type": 0, "type": 0,
"value": "如果确定重置该网站, 请在下面的输入框中输入 " "value": "如果确定重置该网站请在下面的输入框中输入 "
}, },
{ {
"type": 1, "type": 1,
@ -1520,25 +1526,25 @@
"message.transfer-team-website-to-user": [ "message.transfer-team-website-to-user": [
{ {
"type": 0, "type": 0,
"value": "Transfer this website to your account?" "value": "将该网站转入您的账户?"
} }
], ],
"message.transfer-user-website-to-team": [ "message.transfer-user-website-to-team": [
{ {
"type": 0, "type": 0,
"value": "Select the team to transfer this website to." "value": "选择要将该网站转移到哪个团队。"
} }
], ],
"message.transfer-website": [ "message.transfer-website": [
{ {
"type": 0, "type": 0,
"value": "Transfer website ownership to your account or another team." "value": "将网站所有权转移到您的账户或其他团队。"
} }
], ],
"message.triggered-event": [ "message.triggered-event": [
{ {
"type": 0, "type": 0,
"value": "Triggered event" "value": "触发事件"
} }
], ],
"message.user-deleted": [ "message.user-deleted": [
@ -1550,13 +1556,13 @@
"message.viewed-page": [ "message.viewed-page": [
{ {
"type": 0, "type": 0,
"value": "Viewed page" "value": "已浏览页面"
} }
], ],
"message.visitor-log": [ "message.visitor-log": [
{ {
"type": 0, "type": 0,
"value": "来自" "value": "来自 "
}, },
{ {
"type": 1, "type": 1,
@ -1564,7 +1570,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": "的访客在搭载 " "value": " 的访客在搭载 "
}, },
{ {
"type": 1, "type": 1,
@ -1572,7 +1578,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": " 的" "value": " 的 "
}, },
{ {
"type": 1, "type": 1,
@ -1580,7 +1586,7 @@
}, },
{ {
"type": 0, "type": 0,
"value": "上使用 " "value": " 上使用 "
}, },
{ {
"type": 1, "type": 1,
@ -1594,7 +1600,7 @@
"message.visitors-dropped-off": [ "message.visitors-dropped-off": [
{ {
"type": 0, "type": 0,
"value": "Visitors dropped off" "value": "访客减少"
} }
] ]
} }

View File

@ -215,6 +215,12 @@
"value": "已建立" "value": "已建立"
} }
], ],
"label.created-by": [
{
"type": 0,
"value": "Created By"
}
],
"label.current-password": [ "label.current-password": [
{ {
"type": 0, "type": 0,

View File

@ -10,7 +10,8 @@ export default {
}, },
plugins: [ plugins: [
replace({ 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: ['', ''], delimiters: ['', ''],
preventAssignment: true, preventAssignment: true,
}), }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ export function LanguageSetting() {
const handleReset = () => saveLocale(DEFAULT_LOCALE); const handleReset = () => saveLocale(DEFAULT_LOCALE);
const renderValue = (value: string | number) => languages[value].label; const renderValue = (value: string | number) => languages?.[value]?.label;
return ( return (
<Flexbox gap={10}> <Flexbox gap={10}>

View File

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

View File

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

View File

@ -1,4 +1,5 @@
'use client'; 'use client';
import { Metadata } from 'next';
import ReportsHeader from './ReportsHeader'; import ReportsHeader from './ReportsHeader';
import ReportsDataTable from './ReportsDataTable'; import ReportsDataTable from './ReportsDataTable';
@ -10,6 +11,7 @@ export default function ReportsPage({ teamId }: { teamId: string }) {
</> </>
); );
} }
export const metadata = {
export const metadata: Metadata = {
title: 'Reports', title: 'Reports',
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +0,0 @@
.popup {
display: flex;
max-width: 300px;
max-height: 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;
}

View File

@ -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 (
<Form>
<FormRow label={label} className={styles.filter}>
<Flexbox gap={10}>
{allowFilterSelect && (
<Dropdown
className={styles.dropdown}
items={filters}
value={filter}
renderValue={renderFilterValue}
onChange={(key: any) => setFilter(key)}
>
{({ value, label }) => {
return <Item key={value}>{label}</Item>;
}}
</Dropdown>
)}
<Dropdown
className={styles.dropdown}
popupProps={{ className: styles.popup }}
menuProps={{ className: styles.menu }}
items={filteredValues}
value={value}
renderValue={renderValue}
onChange={(key: any) => setValue(key)}
allowSearch={true}
onSearch={setSearch}
>
{(value: string) => {
return <Item key={value}>{formattedValues[value]}</Item>;
}}
</Dropdown>
</Flexbox>
<Button variant="primary" onClick={handleAdd} disabled={!filter || !value}>
{formatMessage(labels.add)}
</Button>
</FormRow>
</Form>
);
}

View File

@ -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 (
<PopupTrigger>
<Button size="sm">
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup position="bottom" alignment="start">
<PopupForm>
<FieldSelectForm
fields={fieldOptions.filter(({ name }) => !fields.find(f => f.name === name))}
onSelect={handleAdd}
showType={false}
/>
</PopupForm>
</Popup>
</PopupTrigger>
);
};
return (
<FormRow label={formatMessage(labels.fields)} action={<AddButton />}>
<ParameterList>
{fields.map(({ name }) => {
return (
<ParameterList.Item key={name} onRemove={() => handleRemove(name)}>
{fieldOptions.find(f => f.name === name)?.label}
</ParameterList.Item>
);
})}
</ParameterList>
</FormRow>
);
}
export default FieldParameters;

View File

@ -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;
}

View File

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

View File

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

View File

@ -13,9 +13,4 @@
border: 1px solid var(--base400); border: 1px solid var(--base400);
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: 1px 1px 1px var(--base400); box-shadow: 1px 1px 1px var(--base400);
gap: 10px;
}
.icon {
align-self: center;
} }

View File

@ -1,40 +1,47 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Icon, TooltipPopup } from 'react-basics'; import { Icon } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import Empty from 'components/common/Empty'; import Empty from 'components/common/Empty';
import { useMessages } from 'components/hooks'; import { useMessages } from 'components/hooks';
import styles from './ParameterList.module.css'; import styles from './ParameterList.module.css';
import classNames from 'classnames';
export interface ParameterListProps { export interface ParameterListProps {
items: any[]; children?: ReactNode;
children?: ReactNode | ((item: any) => ReactNode);
onRemove: (index: number, e: any) => void;
} }
export function ParameterList({ items = [], children, onRemove }: ParameterListProps) { export function ParameterList({ children }: ParameterListProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
return ( return (
<div className={styles.list}> <div className={styles.list}>
{!items.length && <Empty message={formatMessage(labels.none)} />} {!children && <Empty message={formatMessage(labels.none)} />}
{items.map((item, index) => { {children}
return (
<div key={index} className={styles.item}>
{typeof children === 'function' ? children(item) : item}
<TooltipPopup
className={styles.icon}
label={formatMessage(labels.remove)}
position="right"
>
<Icon onClick={onRemove.bind(null, index)}>
<Icons.Close />
</Icon>
</TooltipPopup>
</div>
);
})}
</div> </div>
); );
} }
const Item = ({
children,
className,
onClick,
onRemove,
}: {
children?: ReactNode;
className?: string;
onClick?: () => void;
onRemove?: () => void;
}) => {
return (
<div className={classNames(styles.item, className)} onClick={onClick}>
{children}
<Icon onClick={onRemove}>
<Icons.Close />
</Icon>
</div>
);
};
ParameterList.Item = Item;
export default ParameterList; export default ParameterList;

View File

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

View File

@ -2,4 +2,5 @@
display: grid; display: grid;
grid-template-rows: max-content 1fr; grid-template-rows: max-content 1fr;
grid-template-columns: max-content 1fr; grid-template-columns: max-content 1fr;
margin-bottom: 60px;
} }

View File

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

View File

@ -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 <ReportComponent reportId={reportId} />;
}

View File

@ -1,6 +1,27 @@
'use client'; '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 }) { const reports = {
return <ReportDetails reportId={reportId} />; 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 <ReportComponent reportId={reportId} />;
} }

View File

@ -4,6 +4,7 @@ import PageHeader from 'components/layout/PageHeader';
import Funnel from 'assets/funnel.svg'; import Funnel from 'assets/funnel.svg';
import Lightbulb from 'assets/lightbulb.svg'; import Lightbulb from 'assets/lightbulb.svg';
import Magnet from 'assets/magnet.svg'; import Magnet from 'assets/magnet.svg';
import Tag from 'assets/tag.svg';
import styles from './ReportTemplates.module.css'; import styles from './ReportTemplates.module.css';
import { useMessages, useTeamUrl } from 'components/hooks'; import { useMessages, useTeamUrl } from 'components/hooks';
@ -30,6 +31,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
url: renderTeamUrl('/reports/retention'), url: renderTeamUrl('/reports/retention'),
icon: <Magnet />, icon: <Magnet />,
}, },
{
title: formatMessage(labels.utm),
description: formatMessage(labels.utmDescription),
url: renderTeamUrl('/reports/utm'),
icon: <Tag />,
},
]; ];
return ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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