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
.DS_Store
.idea
.yarn
*.iml
*.log
.vscode

View File

@ -4,4 +4,9 @@ export default defineConfig({
e2e: {
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_password=umami
volumes:
- ../tsconfig.json:/tsconfig.json
- ./tsconfig.json:/tsconfig.json
- ../cypress.config.ts:/cypress.config.ts
- ./:/cypress
- ../node_modules/:/node_modules
- ../src/lib/crypto.ts:/src/lib/crypto.ts
volumes:
umami-db-data:

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,8 @@ export default {
},
plugins: [
replace({
'/api/send': process.env.COLLECT_API_ENDPOINT || '/api/send',
'__COLLECT_API_HOST__': process.env.COLLECT_API_HOST || '',
'__COLLECT_API_ENDPOINT__': process.env.COLLECT_API_ENDPOINT || '/api/send',
delimiters: ['', ''],
preventAssignment: true,
}),

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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 PopupForm from './PopupForm';
import FieldSelectForm from './FieldSelectForm';
import FieldAggregateForm from './FieldAggregateForm';
import FieldFilterForm from './FieldFilterForm';
import styles from './FieldAddForm.module.css';
export function FieldAddForm({
fields = [],
@ -18,7 +15,11 @@ export function FieldAddForm({
onAdd: (group: string, value: string) => void;
onClose: () => void;
}) {
const [selected, setSelected] = useState<{ name: string; type: string; value: string }>();
const [selected, setSelected] = useState<{
name: string;
type: string;
value: string;
}>();
const handleSelect = (value: any) => {
const { type } = value;
@ -38,14 +39,8 @@ export function FieldAddForm({
};
return createPortal(
<PopupForm className={styles.popup}>
<PopupForm>
{!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>,
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 { Loading } from 'react-basics';
import { subDays } from 'date-fns';
import FieldSelectForm from './FieldSelectForm';
import FieldFilterForm from './FieldFilterForm';
import { useApi } from 'components/hooks';
function useValues(websiteId: string, type: string) {
const now = Date.now();
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery({
queryKey: ['websites:values', websiteId, type],
queryFn: () =>
get(`/websites/${websiteId}/values`, {
type,
startAt: +subDays(now, 90),
endAt: now,
}),
enabled: !!(websiteId && type),
});
return { data, error, isLoading };
}
import FieldFilterEditForm from './FieldFilterEditForm';
import { useDateRange } from 'components/hooks';
export interface FilterSelectFormProps {
websiteId: string;
items: any[];
onSelect?: (key: any) => void;
websiteId?: string;
fields: any[];
onChange?: (filter: { name: string; type: string; operator: string; value: string }) => void;
allowFilterSelect?: boolean;
}
export default function FilterSelectForm({
websiteId,
items,
onSelect,
fields,
onChange,
allowFilterSelect,
}: FilterSelectFormProps) {
const [field, setField] = useState<{ name: string; label: string; type: string }>();
const { data, isLoading } = useValues(websiteId, field?.name);
const [{ startDate, endDate }] = useDateRange(websiteId);
if (!field) {
return <FieldSelectForm fields={items} onSelect={setField} showType={false} />;
return <FieldSelectForm fields={fields} onSelect={setField} showType={false} />;
}
if (isLoading) {
return <Loading position="center" icon="dots" />;
}
const { name, label, type } = field;
return (
<FieldFilterForm
name={field?.name}
label={field?.label}
type={field?.type}
values={data}
onSelect={onSelect}
<FieldFilterEditForm
websiteId={websiteId}
name={name}
label={label}
type={type}
startDate={startDate}
endDate={endDate}
onChange={onChange}
allowFilterSelect={allowFilterSelect}
isNew={true}
/>
);
}

View File

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

View File

@ -1,40 +1,47 @@
import { ReactNode } from 'react';
import { Icon, TooltipPopup } from 'react-basics';
import { Icon } from 'react-basics';
import Icons from 'components/icons';
import Empty from 'components/common/Empty';
import { useMessages } from 'components/hooks';
import styles from './ParameterList.module.css';
import classNames from 'classnames';
export interface ParameterListProps {
items: any[];
children?: ReactNode | ((item: any) => ReactNode);
onRemove: (index: number, e: any) => void;
children?: ReactNode;
}
export function ParameterList({ items = [], children, onRemove }: ParameterListProps) {
export function ParameterList({ children }: ParameterListProps) {
const { formatMessage, labels } = useMessages();
return (
<div className={styles.list}>
{!items.length && <Empty message={formatMessage(labels.none)} />}
{items.map((item, index) => {
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>
);
})}
{!children && <Empty message={formatMessage(labels.none)} />}
{children}
</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;

View File

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

View File

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

View File

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

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';
import ReportDetails from './ReportDetails';
import FunnelReport from '../funnel/FunnelReport';
import EventDataReport from '../event-data/EventDataReport';
import InsightsReport from '../insights/InsightsReport';
import RetentionReport from '../retention/RetentionReport';
import UTMReport from '../utm/UTMReport';
import { useReport } from 'components/hooks';
export default function ReportPage({ reportId }) {
return <ReportDetails reportId={reportId} />;
const reports = {
funnel: FunnelReport,
'event-data': EventDataReport,
insights: InsightsReport,
retention: RetentionReport,
utm: UTMReport,
};
export default function ReportPage({ reportId }: { reportId: string }) {
const { report } = useReport(reportId);
if (!report) {
return null;
}
const ReportComponent = reports[report.type];
return <ReportComponent reportId={reportId} />;
}

View File

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

View File

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

View File

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

View File

@ -2,8 +2,8 @@ import { useContext } from 'react';
import classNames from 'classnames';
import { useMessages } from 'components/hooks';
import { ReportContext } from '../[reportId]/Report';
import styles from './FunnelChart.module.css';
import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css';
export interface FunnelChartProps {
className?: string;
@ -18,35 +18,33 @@ export function FunnelChart({ className }: FunnelChartProps) {
return (
<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 (
<div key={url} className={styles.step}>
<div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div>
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.label}>{formatMessage(labels.viewedPage)}:</span>
<span className={styles.item}>{url}</span>
<span className={styles.label}>
{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 className={styles.track}>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}>
<span className={styles.value}>
{remaining > 0.1 && `${(remaining * 100).toFixed(2)}%`}
</span>
</div>
<div className={styles.bar} style={{ width: `${remaining * 100}%` }}></div>
</div>
<div className={styles.info}>
<div>
<b>{formatLongNumber(visitors)}</b>
<span> {formatMessage(labels.visitors)}</span>
<span> ({(remaining * 100).toFixed(2)}%)</span>
{dropoff > 0 && (
<div className={styles.info}>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%)
</div>
{dropoff > 0 && (
<div>
<b>{formatLongNumber(dropped)}</b> {formatMessage(labels.visitorsDroppedOff)} (
{(dropoff * 100).toFixed(2)}%)
</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,
SubmitButton,
TextField,
Button,
} from 'react-basics';
import Icons from 'components/icons';
import UrlAddForm from './UrlAddForm';
import FunnelStepAddForm from './FunnelStepAddForm';
import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import styles from './FunnelParameters.module.css';
export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2;
const { websiteId, dateRange, steps } = parameters || {};
const queryDisabled = !websiteId || !dateRange || steps?.length < 2;
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
const handleAddUrl = (url: string) => {
updateReport({ parameters: { urls: parameters.urls.concat(url) } });
const handleAddStep = (step: { type: string; value: string }) => {
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
const handleRemoveUrl = (index: number, e: any) => {
e.stopPropagation();
const urls = [...parameters.urls];
urls.splice(index, 1);
updateReport({ parameters: { urls } });
const handleUpdateStep = (
close: () => void,
index: number,
step: { type: string; value: string },
) => {
const steps = [...parameters.steps];
steps[index] = step;
updateReport({ parameters: { steps } });
close();
};
const AddUrlButton = () => {
const handleRemoveStep = (index: number) => {
const steps = [...parameters.steps];
delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
};
const AddStepButton = () => {
return (
<PopupTrigger>
<Icon>
<Icons.Plus />
</Icon>
<Popup position="right" alignment="start">
<Button>
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup alignment="start">
<PopupForm>
<UrlAddForm onAdd={handleAddUrl} />
<FunnelStepAddForm onChange={handleAddStep} />
</PopupForm>
</Popup>
</PopupTrigger>
@ -71,11 +86,37 @@ export function FunnelParameters() {
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton />}>
<ParameterList
items={urls}
onRemove={(index: number, e: any) => handleRemoveUrl(index, e)}
/>
<FormRow label={formatMessage(labels.steps)} action={<AddStepButton />}>
<ParameterList>
{steps.map((step: { type: string; value: string }, index: number) => {
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>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>

View File

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

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