Merge branch 'dev' into analytics

This commit is contained in:
Mike Cao 2024-03-20 16:56:20 -07:00
commit df790cffd2
118 changed files with 2373 additions and 1610 deletions

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

@ -66,14 +66,14 @@
"dependencies": {
"@clickhouse/client": "^0.2.2",
"@fontsource/inter": "^4.5.15",
"@prisma/client": "5.9.1",
"@prisma/client": "5.10.2",
"@prisma/extension-read-replicas": "^0.3.0",
"@react-spring/web": "^9.7.3",
"@tanstack/react-query": "^5.12.2",
"@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.3",
"next-basics": "^0.39.0",
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"prisma": "5.9.1",
"prisma": "5.10.2",
"react": "^18.2.0",
"react-basics": "^0.123.0",
"react-beautiful-dnd": "^13.1.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

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

@ -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,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

@ -53,11 +53,11 @@ export function InsightsParameters() {
filters,
};
const handleSubmit = values => {
const handleSubmit = (values: any) => {
runReport(values);
};
const handleAdd = (id, value) => {
const handleAdd = (id: string | number, value: { name: any }) => {
const data = parameterData[id];
if (!data.find(({ name }) => name === value.name)) {
@ -65,7 +65,7 @@ export function InsightsParameters() {
}
};
const handleRemove = (id, index) => {
const handleRemove = (id: string, index: number) => {
const data = [...parameterData[id]];
data.splice(index, 1);
updateReport({ parameters: { [id]: data } });

View File

@ -1,6 +1,11 @@
'use client';
import { Metadata } from 'next';
import RetentionReport from './RetentionReport';
export default function RetentionReportPage() {
return <RetentionReport />;
}
export const metadata: Metadata = {
title: 'Retention Report',
};

View File

@ -0,0 +1,36 @@
import { useContext } from 'react';
import { useMessages } from 'components/hooks';
import { Form, FormButtons, SubmitButton } from 'react-basics';
import { ReportContext } from '../[reportId]/Report';
import BaseParameters from '../[reportId]/BaseParameters';
export function UTMParameters() {
const { report, runReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const queryDisabled = !websiteId || !dateRange;
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default UTMParameters;

View File

@ -0,0 +1,28 @@
'use client';
import Report from '../[reportId]/Report';
import ReportHeader from '../[reportId]/ReportHeader';
import ReportMenu from '../[reportId]/ReportMenu';
import ReportBody from '../[reportId]/ReportBody';
import UTMParameters from './UTMParameters';
import UTMView from './UTMView';
import Tag from 'assets/tag.svg';
import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = {
type: REPORT_TYPES.utm,
parameters: {},
};
export default function UTMReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Tag />} />
<ReportMenu>
<UTMParameters />
</ReportMenu>
<ReportBody>
<UTMView />
</ReportBody>
</Report>
);
}

View File

@ -0,0 +1,5 @@
import UTMReport from './UTMReport';
export default function UTMReportPage() {
return <UTMReport />;
}

View File

@ -0,0 +1,14 @@
.title {
font-size: 18px;
line-height: 36px;
font-weight: 700;
}
.row {
display: grid;
grid-template-columns: 50% 50%;
gap: 20px;
border-bottom: 1px solid var(--base300);
padding-bottom: 30px;
margin-bottom: 30px;
}

View File

@ -0,0 +1,65 @@
import { useContext } from 'react';
import { firstBy } from 'thenby';
import { ReportContext } from '../[reportId]/Report';
import { CHART_COLORS, UTM_PARAMS } from 'lib/constants';
import PieChart from 'components/charts/PieChart';
import ListTable from 'components/metrics/ListTable';
import styles from './UTMView.module.css';
import { useMessages } from 'components/hooks';
function toArray(data: { [key: string]: number }) {
return Object.keys(data)
.map(key => {
return { name: key, value: data[key] };
})
.sort(firstBy('value', -1));
}
export default function UTMView() {
const { formatMessage, labels } = useMessages();
const { report } = useContext(ReportContext);
const { data } = report || {};
if (!data) {
return null;
}
return (
<div>
{UTM_PARAMS.map(key => {
const items = toArray(data[key]);
const chartData = {
labels: items.map(({ name }) => name),
datasets: [
{
data: items.map(({ value }) => value),
backgroundColor: CHART_COLORS,
},
],
};
const total = items.reduce((sum, { value }) => {
return +sum + +value;
}, 0);
return (
<div key={key} className={styles.row}>
<div>
<div className={styles.title}>{key}</div>
<ListTable
metric={formatMessage(labels.views)}
data={items.map(({ name, value }) => ({
x: name,
y: value,
z: (value / total) * 100,
}))}
/>
</div>
<div>
<PieChart type="doughnut" data={chartData} />
</div>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,10 @@
import { Metadata } from 'next';
import UTMReportPage from './UTMReportPage';
export default function () {
return <UTMReportPage />;
}
export const metadata: Metadata = {
title: 'UTM Report',
};

View File

@ -1,16 +0,0 @@
.container {
display: flex;
}
.chart {
margin-bottom: 30px;
}
.sticky {
position: fixed;
top: 0;
background: var(--base50);
border-bottom: 1px solid var(--base300);
z-index: 1;
padding: 10px 0;
}

View File

@ -1,113 +0,0 @@
import { useMemo, useState, useEffect } from 'react';
import { subMinutes, startOfMinute } from 'date-fns';
import thenby from 'thenby';
import { Grid, GridRow } from 'components/layout/Grid';
import Page from 'components/layout/Page';
import RealtimeChart from 'components/metrics/RealtimeChart';
import WorldMap from 'components/metrics/WorldMap';
import { useApi, useWebsite } from 'components/hooks';
import { percentFilter } from 'lib/filters';
import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
import { RealtimeData } from 'lib/types';
import RealtimeLog from './RealtimeLog';
import RealtimeHeader from './RealtimeHeader';
import RealtimeUrls from './RealtimeUrls';
import RealtimeCountries from './RealtimeCountries';
import WebsiteHeader from '../WebsiteHeader';
import styles from './Realtime.module.css';
function mergeData(state = [], data = [], time: number) {
const ids = state.map(({ id }) => id);
return state
.concat(data.filter(({ id }) => !ids.includes(id)))
.filter(({ timestamp }) => timestamp >= time);
}
export function Realtime({ websiteId }) {
const [currentData, setCurrentData] = useState<RealtimeData>();
const { get, useQuery } = useApi();
const { data: website } = useWebsite(websiteId);
const { data, isLoading, error } = useQuery({
queryKey: ['realtime', websiteId],
queryFn: () => get(`/realtime/${websiteId}`, { startAt: currentData?.timestamp || 0 }),
enabled: !!(websiteId && website),
refetchInterval: REALTIME_INTERVAL,
});
useEffect(() => {
if (data) {
const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
const time = date.getTime();
const { pageviews, sessions, events, timestamp } = data;
setCurrentData(state => ({
pageviews: mergeData(state?.pageviews, pageviews, time),
sessions: mergeData(state?.sessions, sessions, time),
events: mergeData(state?.events, events, time),
timestamp,
}));
}
}, [data]);
const realtimeData: RealtimeData = useMemo(() => {
if (!currentData) {
return { pageviews: [], sessions: [], events: [], countries: [], visitors: [], timestamp: 0 };
}
currentData.countries = percentFilter(
currentData.sessions
.reduce((arr, data) => {
if (!arr.find(({ id }) => id === data.id)) {
return arr.concat(data);
}
return arr;
}, [])
.reduce((arr: { x: any; y: number }[], { country }: any) => {
if (country) {
const row = arr.find(({ x }) => x === country);
if (!row) {
arr.push({ x: country, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(thenby.firstBy('y', -1)),
);
currentData.visitors = currentData.sessions.reduce((arr, val) => {
if (!arr.find(({ id }) => id === val.id)) {
return arr.concat(val);
}
return arr;
}, []);
return currentData;
}, [currentData]);
if (isLoading || error) {
return <Page isLoading={isLoading} error={error} />;
}
return (
<>
<WebsiteHeader websiteId={websiteId} />
<RealtimeHeader data={realtimeData} />
<RealtimeChart className={styles.chart} data={realtimeData} unit="minute" />
<Grid>
<GridRow columns="one-two">
<RealtimeUrls websiteDomain={website?.domain} data={realtimeData} />
<RealtimeLog websiteDomain={website?.domain} data={realtimeData} />
</GridRow>
<GridRow columns="one-two">
<RealtimeCountries data={realtimeData?.countries} />
<WorldMap data={realtimeData?.countries} />
</GridRow>
</Grid>
</>
);
}
export default Realtime;

View File

@ -35,11 +35,6 @@
overflow: hidden;
}
.website {
text-align: right;
padding: 0 20px;
}
.detail {
display: flex;
align-items: center;

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useContext, useMemo, useState } from 'react';
import { StatusLight, Icon, Text, SearchField } from 'react-basics';
import { FixedSizeList } from 'react-window';
import { format } from 'date-fns';
@ -11,6 +11,8 @@ import Icons from 'components/icons';
import useFormat from 'components//hooks/useFormat';
import { BROWSERS } from 'lib/constants';
import { stringToColor } from 'lib/format';
import { RealtimeData } from 'lib/types';
import { WebsiteContext } from '../WebsiteProvider';
import styles from './RealtimeLog.module.css';
const TYPE_ALL = 'all';
@ -24,7 +26,8 @@ const icons = {
[TYPE_EVENT]: <Icons.Bolt />,
};
export function RealtimeLog({ data, websiteDomain }) {
export function RealtimeLog({ data }: { data: RealtimeData }) {
const website = useContext(WebsiteContext);
const [search, setSearch] = useState('');
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { formatValue } = useFormat();
@ -76,7 +79,7 @@ export function RealtimeLog({ data, websiteDomain }) {
event: <b>{eventName || formatMessage(labels.unknown)}</b>,
url: (
<a
href={`//${websiteDomain}${url}`}
href={`//${website?.domain}${url}`}
className={styles.link}
target="_blank"
rel="noreferrer noopener"
@ -92,7 +95,7 @@ export function RealtimeLog({ data, websiteDomain }) {
if (__type === TYPE_PAGEVIEW) {
return (
<a
href={`//${websiteDomain}${url}`}
href={`//${website?.domain}${url}`}
className={styles.link}
target="_blank"
rel="noreferrer noopener"

View File

@ -1,4 +1,4 @@
import { Key, useMemo, useState } from 'react';
import { Key, useContext, useMemo, useState } from 'react';
import { ButtonGroup, Button, Flexbox } from 'react-basics';
import thenby from 'thenby';
import { percentFilter } from 'lib/filters';
@ -6,14 +6,10 @@ import ListTable from 'components/metrics/ListTable';
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
import { useMessages } from 'components/hooks';
import { RealtimeData } from 'lib/types';
import { WebsiteContext } from '../WebsiteProvider';
export function RealtimeUrls({
websiteDomain,
data,
}: {
websiteDomain: string;
data: RealtimeData;
}) {
export function RealtimeUrls({ data }: { data: RealtimeData }) {
const website = useContext(WebsiteContext);
const { formatMessage, labels } = useMessages();
const { pageviews } = data || {};
const [filter, setFilter] = useState<Key>(FILTER_REFERRERS);
@ -31,7 +27,7 @@ export function RealtimeUrls({
];
const renderLink = ({ x }) => {
const domain = x.startsWith('/') ? websiteDomain : '';
const domain = x.startsWith('/') ? website?.domain : '';
return (
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
{x}

View File

@ -1,6 +1,40 @@
'use client';
import Realtime from './Realtime';
import { Grid, GridRow } from 'components/layout/Grid';
import Page from 'components/layout/Page';
import RealtimeChart from 'components/metrics/RealtimeChart';
import WorldMap from 'components/metrics/WorldMap';
import { useRealtime } from 'components/hooks';
import RealtimeLog from './RealtimeLog';
import RealtimeHeader from './RealtimeHeader';
import RealtimeUrls from './RealtimeUrls';
import RealtimeCountries from './RealtimeCountries';
import WebsiteHeader from '../WebsiteHeader';
import WebsiteProvider from '../WebsiteProvider';
export default function WebsiteRealtimePage({ websiteId }) {
return <Realtime websiteId={websiteId} />;
export function WebsiteRealtimePage({ websiteId }) {
const { data, isLoading, error } = useRealtime(websiteId);
if (isLoading || error) {
return <Page isLoading={isLoading} error={error} />;
}
return (
<WebsiteProvider websiteId={websiteId}>
<WebsiteHeader websiteId={websiteId} />
<RealtimeHeader data={data} />
<RealtimeChart data={data} unit="minute" />
<Grid>
<GridRow columns="one-two">
<RealtimeUrls data={data} />
<RealtimeLog data={data} />
</GridRow>
<GridRow columns="one-two">
<RealtimeCountries data={data?.countries} />
<WorldMap data={data?.countries} />
</GridRow>
</Grid>
</WebsiteProvider>
);
}
export default WebsiteRealtimePage;

1
src/assets/bookmark.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M3.515 22.875a1 1 0 0 0 1.015-.027L12 18.179l7.47 4.669A1 1 0 0 0 21 22V4a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v18a1 1 0 0 0 .515.875zM5 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v16.2l-6.47-4.044a1 1 0 0 0-1.06 0L5 20.2z"/></svg>

After

Width:  |  Height:  |  Size: 306 B

1
src/assets/flag.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 510 510" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m393.159 121.41 69.152-86.44c-16.753-2.022-149.599-37.363-282.234-8.913V0h-30v361.898c-25.85 6.678-45 30.195-45 58.102v1.509c-34.191 6.969-60 37.272-60 73.491v15h240v-15c0-36.22-25.809-66.522-60-73.491V420c0-27.906-19.15-51.424-45-58.102V237.165c153.335-30.989 264.132 7.082 284.847 9.834zM252.506 480H77.647c6.19-17.461 22.873-30 42.43-30h90c19.556 0 36.238 12.539 42.429 30zm-57.429-60h-60c0-16.542 13.458-30 30-30s30 13.458 30 30zm-15-213.427V56.771c66.329-15.269 141.099-15.756 227.537-1.455l-50.619 63.274 48.8 85.4c-75.047-12.702-150.759-11.841-225.718 2.583z"/></svg>

After

Width:  |  Height:  |  Size: 670 B

1
src/assets/speaker.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M232.011 88.828c-5.664-5.664-13.217-8.784-21.269-8.784s-15.605 3.12-21.269 8.783c-9.917 9.917-11.446 25.09-4.593 36.632-23.293 86.372-34.167 96.094-78.604 135.776-15.831 14.138-35.533 31.731-61.302 57.5-5.434 5.434-8.426 12.673-8.426 20.383s2.993 14.949 8.426 20.383l70.981 70.98c5.434 5.435 12.672 8.427 20.382 8.427a28.7 28.7 0 0 0 14.046-3.637l72.768 72.768c2.574 2.574 6.09 3.962 9.896 3.961.789 0 1.59-.06 2.398-.181 3.883-.581 7.662-2.543 10.641-5.521l25.329-25.329c6.918-6.919 7.684-16.993 1.741-22.936l-39.164-39.164c11.586-20.762 9.203-46.431-6.187-64.762 29.684-32.251 46.532-43.128 122.192-63.532a30.076 30.076 0 0 0 15.361 4.203c7.703 0 15.405-2.933 21.269-8.796 11.728-11.729 11.728-30.811 0-42.539zM127.268 419.167l-70.981-70.981c-2.412-2.411-3.74-5.632-3.74-9.068s1.328-6.657 3.74-9.068c17.786-17.786 32.665-31.645 45.371-43.163l86.911 86.911c-11.519 12.706-25.378 27.585-43.164 45.371-2.412 2.411-5.632 3.74-9.068 3.74-3.437-.001-6.657-1.33-9.069-3.742zM260.1 469.653l-25.33 25.33a4.096 4.096 0 0 1-1.197.85L162.45 424.71a1243.745 1243.745 0 0 0 26.786-27.968l71.714 71.713a4.047 4.047 0 0 1-.85 1.198zm-38.055-62.731-21.982-21.981a2607.916 2607.916 0 0 0 14.157-15.763l2.712-3.035c8.895 11.831 10.752 27.329 5.113 40.779zm-19.759-48.401-3.004 3.362-85.711-85.711 3.361-3.003c44.419-39.665 57.85-51.661 80.687-133.656l138.322 138.322c-81.993 22.837-93.99 36.268-133.655 80.686zm173.027-83.854c-5.489 5.49-14.422 5.49-19.911 0L200.786 120.052c-5.489-5.489-5.489-14.421 0-19.91 2.642-2.643 6.178-4.098 9.956-4.098s7.313 1.455 9.955 4.098l154.616 154.615c5.489 5.489 5.489 14.421 0 19.91zm-22.558-151.968a8 8 0 0 1 0-11.314l43.904-43.904a8 8 0 0 1 11.313 11.314l-43.904 43.904c-1.562 1.562-3.609 2.343-5.657 2.343s-4.094-.781-5.656-2.343zm122.699 107.695a8 8 0 0 1-8 8h-62.09a8 8 0 0 1 0-16h62.09a8 8 0 0 1 8 8zM237.061 70.09V8a8 8 0 0 1 16 0v62.09a8 8 0 0 1-16 0z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

1
src/assets/tag.svg Normal file
View File

@ -0,0 +1 @@
<svg height="437pt" viewBox="0 0 437.004 437" width="437pt" xmlns="http://www.w3.org/2000/svg"><path d="M229 14.645A50.173 50.173 0 0 0 192.371.015L52.293 3.586C25.672 4.25 4.246 25.673 3.582 52.298L.016 192.37a50.215 50.215 0 0 0 14.625 36.633l193.367 193.36c19.539 19.495 51.168 19.495 70.707 0l143.644-143.645c19.528-19.524 19.528-51.184 0-70.711zm179.219 249.933-143.645 143.64c-11.722 11.7-30.703 11.7-42.426 0L28.785 214.86a30.131 30.131 0 0 1-8.777-21.98l3.566-140.074c.403-15.973 13.254-28.828 29.227-29.227l140.074-3.57c.254-.004.5-.008.754-.008a30.129 30.129 0 0 1 21.223 8.79l193.367 193.362c11.695 11.723 11.695 30.703 0 42.426zm0 0"/><path d="M130.719 82.574c-26.59 0-48.145 21.555-48.149 48.145 0 26.59 21.559 48.144 48.145 48.144 26.59 0 48.144-21.554 48.144-48.144-.03-26.574-21.566-48.114-48.14-48.145zm0 76.29c-15.547 0-28.145-12.602-28.149-28.145 0-15.543 12.602-28.145 28.145-28.145s28.144 12.602 28.144 28.145c-.015 15.535-12.605 28.125-28.14 28.144zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 984 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><path d="M256 0c-74.439 0-135 60.561-135 135s60.561 135 135 135 135-60.561 135-135S330.439 0 256 0zM423.966 358.195C387.006 320.667 338.009 300 286 300h-60c-52.008 0-101.006 20.667-137.966 58.195C51.255 395.539 31 444.833 31 497c0 8.284 6.716 15 15 15h420c8.284 0 15-6.716 15-15 0-52.167-20.255-101.461-57.034-138.805z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><path d="M256 0c-74.439 0-135 60.561-135 135s60.561 135 135 135 135-60.561 135-135S330.439 0 256 0zm167.966 358.195C387.006 320.667 338.009 300 286 300h-60c-52.008 0-101.006 20.667-137.966 58.195C51.255 395.539 31 444.833 31 497c0 8.284 6.716 15 15 15h420c8.284 0 15-6.716 15-15 0-52.167-20.255-101.461-57.034-138.805z"/></svg>

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 452 B

View File

@ -0,0 +1,85 @@
import { useTheme } from 'components/hooks';
import Chart, { ChartProps } from 'components/charts/Chart';
import { renderNumberLabels } from 'lib/charts';
import { useState } from 'react';
import BarChartTooltip from 'components/charts/BarChartTooltip';
export interface BarChartProps extends ChartProps {
unit: string;
stacked?: boolean;
renderXLabel?: (label: string, index: number, values: any[]) => string;
renderYLabel?: (label: string, index: number, values: any[]) => string;
XAxisType?: string;
YAxisType?: string;
}
export function BarChart(props: BarChartProps) {
const [tooltip, setTooltip] = useState(null);
const { colors } = useTheme();
const {
renderXLabel,
renderYLabel,
unit,
XAxisType = 'time',
YAxisType = 'linear',
stacked = false,
} = props;
const options = {
scales: {
x: {
type: XAxisType,
stacked: true,
time: {
unit,
},
grid: {
display: false,
},
border: {
color: colors.chart.line,
},
ticks: {
color: colors.chart.text,
autoSkip: false,
maxRotation: 0,
callback: renderXLabel,
},
},
y: {
type: YAxisType,
min: 0,
beginAtZero: true,
stacked,
grid: {
color: colors.chart.line,
},
border: {
color: colors.chart.line,
},
ticks: {
color: colors.chart.text,
callback: renderYLabel || renderNumberLabels,
},
},
},
};
const handleTooltip = ({ tooltip }: { tooltip: any }) => {
const { opacity } = tooltip;
setTooltip(opacity ? <BarChartTooltip tooltip={tooltip} unit={unit} /> : null);
};
return (
<Chart
{...props}
type="bar"
chartOptions={options}
tooltip={tooltip}
onTooltip={handleTooltip}
/>
);
}
export default BarChart;

View File

@ -0,0 +1,32 @@
import { formatDate } from 'lib/date';
import { Flexbox, StatusLight } from 'react-basics';
import { formatLongNumber } from 'lib/format';
import { useLocale } from 'components/hooks';
const formats = {
millisecond: 'T',
second: 'pp',
minute: 'p',
hour: 'h:mm aaa - PP',
day: 'PPPP',
week: 'PPPP',
month: 'LLLL yyyy',
quarter: 'qqq',
year: 'yyyy',
};
export default function BarChartTooltip({ tooltip, unit }) {
const { locale } = useLocale();
const { labelColors, dataPoints } = tooltip;
return (
<Flexbox direction="column" gap={10}>
<div>{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
</StatusLight>
</div>
</Flexbox>
);
}

View File

@ -1,7 +1,3 @@
.container {
display: grid;
}
.chart {
position: relative;
height: 400px;
@ -13,7 +9,3 @@
flex-direction: column;
gap: 10px;
}
.tooltip .value {
text-transform: lowercase;
}

View File

@ -0,0 +1,141 @@
import { useState, useRef, useEffect, ReactNode } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
import ChartJS, { LegendItem } from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import styles from './Chart.module.css';
export interface ChartProps {
type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter';
data?: object;
isLoading?: boolean;
animationDuration?: number;
updateMode?: string;
onCreate?: (chart: any) => void;
onUpdate?: (chart: any) => void;
onTooltip?: (model: any) => void;
className?: string;
chartOptions?: { [key: string]: any };
tooltip?: ReactNode;
}
export function Chart({
type,
data,
isLoading = false,
animationDuration = DEFAULT_ANIMATION_DURATION,
tooltip,
updateMode,
onCreate,
onUpdate,
onTooltip,
className,
chartOptions,
}: ChartProps) {
const canvas = useRef();
const chart = useRef(null);
const [legendItems, setLegendItems] = useState([]);
const options = {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: animationDuration,
resize: {
duration: 0,
},
active: {
duration: 0,
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
external: onTooltip,
},
},
...chartOptions,
};
const createChart = (data: any) => {
ChartJS.defaults.font.family = 'Inter';
chart.current = new ChartJS(canvas.current, {
type,
data,
options,
});
onCreate?.(chart.current);
setLegendItems(chart.current.legend.legendItems);
};
const updateChart = (data: any) => {
chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => {
dataset.data = data?.datasets[index]?.data;
});
chart.current.options = options;
// Allow config changes before update
onUpdate?.(chart.current);
chart.current.update(updateMode);
setLegendItems(chart.current.legend.legendItems);
};
useEffect(() => {
if (data) {
if (!chart.current) {
createChart(data);
} else {
updateChart(data);
}
}
}, [data]);
const handleLegendClick = (item: LegendItem) => {
if (type === 'bar') {
const { datasetIndex } = item;
const meta = chart.current.getDatasetMeta(datasetIndex);
meta.hidden =
meta.hidden === null ? !chart.current.data.datasets[datasetIndex]?.hidden : null;
} else {
const { index } = item;
const meta = chart.current.getDatasetMeta(0);
const hidden = !!meta.data[index].hidden;
meta.data[index].hidden = !hidden;
chart.current.legend.legendItems[index].hidden = !hidden;
}
chart.current.update(updateMode);
setLegendItems(chart.current.legend.legendItems);
};
return (
<>
<div className={classNames(styles.chart, className)}>
{isLoading && <Loading position="page" icon="dots" />}
<canvas ref={canvas} />
</div>
<Legend items={legendItems} onClick={handleLegendClick} />
{tooltip && (
<HoverTooltip>
<div className={styles.tooltip}>{tooltip}</div>
</HoverTooltip>
)}
</>
);
}
export default Chart;

View File

@ -0,0 +1,27 @@
import { Chart, ChartProps } from 'components/charts/Chart';
import { useState } from 'react';
import { StatusLight } from 'react-basics';
import { formatLongNumber } from 'lib/format';
export interface PieChartProps extends ChartProps {
type?: 'doughnut' | 'pie';
}
export default function PieChart(props: PieChartProps) {
const [tooltip, setTooltip] = useState(null);
const { type } = props;
const handleTooltip = ({ tooltip }) => {
const { labelColors, dataPoints } = tooltip;
setTooltip(
tooltip.opacity ? (
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints?.[0]?.raw)} {dataPoints?.[0]?.label}
</StatusLight>
) : null,
);
};
return <Chart {...props} type={type || 'pie'} tooltip={tooltip} onTooltip={handleTooltip} />;
}

View File

@ -1,5 +1,5 @@
.bar {
font-size: 11px;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
color: var(--base600);

View File

@ -1,6 +1,7 @@
import Link from 'next/link';
import { Flexbox, Icon, Icons, Text } from 'react-basics';
import styles from './Breadcrumb.module.css';
import { Fragment } from 'react';
export interface BreadcrumbProps {
data: {
@ -14,7 +15,7 @@ export function Breadcrumb({ data }: BreadcrumbProps) {
<Flexbox alignItems="center" gap={3} className={styles.bar}>
{data.map((a, i) => {
return (
<>
<Fragment key={i}>
{a.url ? (
<Link href={a.url} className={styles.link}>
<Text>{a.label}</Text>
@ -27,7 +28,7 @@ export function Breadcrumb({ data }: BreadcrumbProps) {
<Icons.ChevronDown />
</Icon>
) : null}
</>
</Fragment>
);
})}
</Flexbox>

View File

@ -1,10 +1,9 @@
import classNames from 'classnames';
import { useMessages, useNavigation } from 'components/hooks';
import { safeDecodeURIComponent } from 'next-basics';
import Link from 'next/link';
import { ReactNode } from 'react';
import { Icon, Icons } from 'react-basics';
import classNames from 'classnames';
import Link from 'next/link';
import { safeDecodeURI } from 'next-basics';
import { useNavigation } from 'components/hooks';
import { useMessages } from 'components/hooks';
import styles from './FilterLink.module.css';
export interface FilterLinkProps {
@ -40,7 +39,7 @@ export function FilterLink({
{!value && `(${label || formatMessage(labels.unknown)})`}
{value && (
<Link href={renderUrl({ [id]: value })} className={styles.label} replace>
{safeDecodeURI(label || value)}
{safeDecodeURIComponent(label || value)}
</Link>
)}
{externalUrl && (

View File

@ -2,6 +2,7 @@ export * from './queries/useApi';
export * from './queries/useConfig';
export * from './queries/useFilterQuery';
export * from './queries/useLogin';
export * from './queries/useRealtime';
export * from './queries/useReport';
export * from './queries/useReports';
export * from './queries/useShareToken';

View File

@ -0,0 +1,87 @@
import { useMemo, useRef } from 'react';
import { RealtimeData } from 'lib/types';
import { useApi } from 'components/hooks';
import { REALTIME_INTERVAL, REALTIME_RANGE } from 'lib/constants';
import { startOfMinute, subMinutes } from 'date-fns';
import { percentFilter } from 'lib/filters';
import thenby from 'thenby';
function mergeData(state = [], data = [], time: number) {
const ids = state.map(({ id }) => id);
return state
.concat(data.filter(({ id }) => !ids.includes(id)))
.filter(({ timestamp }) => timestamp >= time);
}
export function useRealtime(websiteId: string) {
const currentData = useRef({
pageviews: [],
sessions: [],
events: [],
countries: [],
visitors: [],
timestamp: 0,
});
const { get, useQuery } = useApi();
const { data, isLoading, error } = useQuery<RealtimeData>({
queryKey: ['realtime', websiteId],
queryFn: async () => {
const state = currentData.current;
const data = await get(`/realtime/${websiteId}`, { startAt: state?.timestamp || 0 });
const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
const time = date.getTime();
const { pageviews, sessions, events, timestamp } = data;
return {
pageviews: mergeData(state?.pageviews, pageviews, time),
sessions: mergeData(state?.sessions, sessions, time),
events: mergeData(state?.events, events, time),
timestamp,
};
},
enabled: !!websiteId,
refetchInterval: REALTIME_INTERVAL,
});
const realtimeData: RealtimeData = useMemo(() => {
if (!data) {
return { pageviews: [], sessions: [], events: [], countries: [], visitors: [], timestamp: 0 };
}
data.countries = percentFilter(
data.sessions
.reduce((arr, data) => {
if (!arr.find(({ id }) => id === data.id)) {
return arr.concat(data);
}
return arr;
}, [])
.reduce((arr: { x: any; y: number }[], { country }: any) => {
if (country) {
const row = arr.find(({ x }) => x === country);
if (!row) {
arr.push({ x: country, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(thenby.firstBy('y', -1)),
);
data.visitors = data.sessions.reduce((arr, val) => {
if (!arr.find(({ id }) => id === val.id)) {
return arr.concat(val);
}
return arr;
}, []);
return data;
}, [data]);
return { data: realtimeData, isLoading, error };
}
export default useRealtime;

View File

@ -4,7 +4,7 @@ import { useApi } from './useApi';
import { useTimezone } from '../useTimezone';
import { useMessages } from '../useMessages';
export function useReport(reportId: string, defaultParameters: { [key: string]: any }) {
export function useReport(reportId: string, defaultParameters: { [key: string]: any } = {}) {
const [report, setReport] = useState(null);
const [isRunning, setIsRunning] = useState(false);
const { get, post } = useApi();

View File

@ -1,19 +1,13 @@
import { useEffect } from 'react';
import useStore, { setTheme } from 'store/app';
import { getItem, setItem } from 'next-basics';
import { THEME_COLORS, THEME_CONFIG } from 'lib/constants';
import { DEFAULT_THEME, THEME_COLORS, THEME_CONFIG } from 'lib/constants';
import { colord } from 'colord';
const selector = (state: { theme: string }) => state.theme;
export function useTheme() {
const defaultTheme =
typeof window !== 'undefined'
? window?.matchMedia('(prefers-color-scheme: dark)')?.matches
? 'dark'
: 'light'
: 'light';
const theme = useStore(selector) || getItem(THEME_CONFIG) || defaultTheme;
const theme = useStore(selector) || getItem(THEME_CONFIG) || DEFAULT_THEME;
const primaryColor = colord(THEME_COLORS[theme].primary);
const colors = {

View File

@ -32,18 +32,14 @@ export function DateFilter({
const { locale } = useLocale();
const options = [
{ label: formatMessage(labels.today), value: '1day' },
{ label: formatMessage(labels.today), value: '0day' },
{
label: formatMessage(labels.lastHours, { x: 24 }),
value: '24hour',
},
{
label: formatMessage(labels.yesterday),
value: '-1day',
},
{
label: formatMessage(labels.thisWeek),
value: '1week',
value: '0week',
divider: true,
},
{
@ -52,7 +48,7 @@ export function DateFilter({
},
{
label: formatMessage(labels.thisMonth),
value: '1month',
value: '0month',
divider: true,
},
{
@ -63,7 +59,7 @@ export function DateFilter({
label: formatMessage(labels.lastDays, { x: 90 }),
value: '90day',
},
{ label: formatMessage(labels.thisYear), value: '1year' },
{ label: formatMessage(labels.thisYear), value: '0year' },
showAllTime && {
label: formatMessage(labels.allTime),
value: 'all',

View File

@ -15,6 +15,7 @@ export const labels = defineMessages({
username: { id: 'label.username', defaultMessage: 'Username' },
password: { id: 'label.password', defaultMessage: 'Password' },
role: { id: 'label.role', defaultMessage: 'Role' },
admin: { id: 'label.admin', defaultMessage: 'Admin' },
user: { id: 'label.user', defaultMessage: 'User' },
viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
manage: { id: 'label.manage', defaultMessage: 'Manage' },
@ -222,6 +223,11 @@ export const labels = defineMessages({
id: 'message.visitors-dropped-off',
defaultMessage: 'Visitors dropped off',
},
utm: { id: 'label.utm', defaultMessage: 'UTM' },
utmDescription: {
id: 'label.utm-description',
defaultMessage: 'Track your campaigns through UTM parameters.',
},
});
export const messages = defineMessages({

View File

@ -1,174 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
import Chart from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import { useLocale } from 'components/hooks';
import { useTheme } from 'components/hooks';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { renderNumberLabels } from 'lib/charts';
import styles from './BarChart.module.css';
export interface BarChartProps {
datasets?: any[];
unit?: string;
animationDuration?: number;
stacked?: boolean;
isLoading?: boolean;
renderXLabel?: (label: string, index: number, values: any[]) => string;
renderYLabel?: (label: string, index: number, values: any[]) => string;
XAxisType?: string;
YAxisType?: string;
renderTooltipPopup?: (setTooltipPopup: (data: any) => void, model: any) => void;
onCreate?: (chart: any) => void;
onUpdate?: (chart: any) => void;
className?: string;
}
export function BarChart({
datasets = [],
unit,
animationDuration = DEFAULT_ANIMATION_DURATION,
stacked = false,
isLoading = false,
renderXLabel,
renderYLabel,
XAxisType = 'time',
YAxisType = 'linear',
renderTooltipPopup,
onCreate,
onUpdate,
className,
}: BarChartProps) {
const canvas = useRef();
const chart = useRef(null);
const [tooltip, setTooltipPopup] = useState(null);
const { locale } = useLocale();
const { theme, colors } = useTheme();
const getOptions = useCallback(() => {
return {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: animationDuration,
resize: {
duration: 0,
},
active: {
duration: 0,
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
external: renderTooltipPopup ? renderTooltipPopup.bind(null, setTooltipPopup) : undefined,
},
},
scales: {
x: {
type: XAxisType,
stacked: true,
time: {
unit,
},
grid: {
display: false,
},
border: {
color: colors.chart.line,
},
ticks: {
color: colors.chart.text,
autoSkip: false,
maxRotation: 0,
callback: renderXLabel,
},
},
y: {
type: YAxisType,
min: 0,
beginAtZero: true,
stacked,
grid: {
color: colors.chart.line,
},
border: {
color: colors.chart.line,
},
ticks: {
color: colors.chart.text,
callback: renderYLabel || renderNumberLabels,
},
},
},
};
}, [
animationDuration,
renderTooltipPopup,
renderXLabel,
XAxisType,
YAxisType,
stacked,
colors,
unit,
locale,
]);
const createChart = () => {
Chart.defaults.font.family = 'Inter';
chart.current = new Chart(canvas.current, {
type: 'bar',
data: {
datasets,
},
options: getOptions() as any,
});
onCreate?.(chart.current);
};
const updateChart = () => {
setTooltipPopup(null);
chart.current.data.datasets = datasets;
chart.current.options = getOptions();
onUpdate?.(chart.current);
chart.current.update();
};
useEffect(() => {
if (datasets) {
if (!chart.current) {
createChart();
} else {
updateChart();
}
}
}, [datasets, unit, theme, animationDuration, locale]);
return (
<>
<div className={classNames(styles.chart, className)}>
{isLoading && <Loading position="page" icon="dots" />}
<canvas ref={canvas} />
</div>
<Legend chart={chart.current} />
{tooltip && (
<HoverTooltip>
<div className={styles.tooltip}>{tooltip}</div>
</HoverTooltip>
)}
</>
);
}
export default BarChart;

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { colord } from 'colord';
import BarChart from './BarChart';
import BarChart from 'components/charts/BarChart';
import { getDateArray } from 'lib/date';
import {
useLocale,
@ -10,8 +10,8 @@ import {
useNavigation,
useWebsiteEvents,
} from 'components/hooks';
import { EVENT_COLORS } from 'lib/constants';
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
import { CHART_COLORS } from 'lib/constants';
import { renderDateLabels } from 'lib/charts';
export interface EventsChartProps {
websiteId: string;
@ -26,7 +26,6 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
const {
query: { url, event },
} = useNavigation();
const { data, isLoading } = useWebsiteEvents(websiteId, {
startAt: +startDate,
endAt: +endDate,
@ -38,9 +37,8 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
offset,
});
const datasets = useMemo(() => {
const chartData = useMemo(() => {
if (!data) return [];
if (isLoading) return data;
const map = (data as any[]).reduce((obj, { x, t, y }) => {
if (!obj[x]) {
@ -56,18 +54,20 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
map[key] = getDateArray(map[key], startDate, endDate, unit);
});
return Object.keys(map).map((key, index) => {
const color = colord(EVENT_COLORS[index % EVENT_COLORS.length]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
});
}, [data, isLoading, startDate, endDate, unit]);
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
data: map[key],
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
borderWidth: 1,
};
}),
};
}, [data, startDate, endDate, unit]);
if (isLoading) {
return <Loading icon="dots" />;
@ -76,11 +76,10 @@ export function EventsChart({ websiteId, className, token }: EventsChartProps) {
return (
<BarChart
className={className}
datasets={datasets as any[]}
data={chartData}
unit={unit}
stacked={true}
renderXLabel={renderDateLabels(unit, locale)}
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
isLoading={isLoading}
/>
);

View File

@ -1,43 +1,34 @@
import { useEffect } from 'react';
import { StatusLight } from 'react-basics';
import { colord } from 'colord';
import classNames from 'classnames';
import { LegendItem } from 'chart.js/auto';
import { useLocale } from 'components/hooks';
import { useForceUpdate } from 'components/hooks';
import styles from './Legend.module.css';
export function Legend({ chart }) {
export function Legend({
items = [],
onClick,
}: {
items: any[];
onClick: (index: LegendItem) => void;
}) {
const { locale } = useLocale();
const forceUpdate = useForceUpdate();
const handleClick = (index: string | number) => {
const meta = chart.getDatasetMeta(index);
meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null;
chart.update();
forceUpdate();
};
useEffect(() => {
forceUpdate();
}, [locale, forceUpdate]);
if (!chart?.legend?.legendItems.find(({ text }) => text)) {
if (!items.find(({ text }) => text)) {
return null;
}
return (
<div className={styles.legend}>
{chart.legend.legendItems.map(({ text, fillStyle, datasetIndex, hidden }) => {
{items.map(item => {
const { text, fillStyle, hidden } = item;
const color = colord(fillStyle);
return (
<div
key={text}
className={classNames(styles.label, { [styles.hidden]: hidden })}
onClick={() => handleClick(datasetIndex)}
onClick={() => onClick(item)}
>
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
<span className={locale}>{text}</span>

View File

@ -1,7 +1,7 @@
import { useMemo } from 'react';
import BarChart, { BarChartProps } from './BarChart';
import BarChart, { BarChartProps } from 'components/charts/BarChart';
import { useLocale, useTheme, useMessages } from 'components/hooks';
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
import { renderDateLabels } from 'lib/charts';
export interface PageviewsChartProps extends BarChartProps {
data: {
@ -17,33 +17,36 @@ export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsCha
const { colors } = useTheme();
const { locale } = useLocale();
const datasets = useMemo(() => {
if (!data) return [];
const chartData = useMemo(() => {
if (!data) {
return {};
}
return [
{
label: formatMessage(labels.visitors),
data: data.sessions,
borderWidth: 1,
...colors.chart.visitors,
},
{
label: formatMessage(labels.views),
data: data.pageviews,
borderWidth: 1,
...colors.chart.views,
},
];
}, [data, colors, formatMessage, labels]);
return {
datasets: [
{
label: formatMessage(labels.visitors),
data: data.sessions,
borderWidth: 1,
...colors.chart.visitors,
},
{
label: formatMessage(labels.views),
data: data.pageviews,
borderWidth: 1,
...colors.chart.views,
},
],
};
}, [data]);
return (
<BarChart
{...props}
datasets={datasets}
data={chartData}
unit={unit}
isLoading={isLoading}
renderXLabel={renderDateLabels(unit, locale)}
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
/>
);
}

View File

@ -196,7 +196,7 @@
"label.window": "Ventana",
"label.yesterday": "Ayer",
"message.action-confirmation": "Escriba {confirmation} en el cuadro a continuación para confirmar.",
"message.active-users": "{x} {x, plural, uno {activo} otros {activos}}",
"message.active-users": "{x} {x, plural, one {activo} other {activos}}",
"message.confirm-delete": "¿Seguro que quieres eliminar {target}?",
"message.confirm-leave": "¿Seguro que quieres abandonar {target}?",
"message.confirm-remove": "¿Estás seguro de que desea eliminar {target}?",

View File

@ -4,7 +4,7 @@
"label.activity-log": "活动日志",
"label.add": "添加",
"label.add-description": "添加描述",
"label.add-member": "Add member",
"label.add-member": "添加成员",
"label.add-website": "添加网站",
"label.administrator": "管理员",
"label.after": "之后",
@ -35,7 +35,7 @@
"label.create-team": "创建团队",
"label.create-user": "创建用户",
"label.created": "已创建",
"label.created-by": "Created By",
"label.created-by": "创建者",
"label.current-password": "目前密码",
"label.custom-range": "自定义时间段",
"label.dashboard": "仪表板",
@ -45,7 +45,7 @@
"label.day": "日",
"label.default-date-range": "默认时间段",
"label.delete": "删除",
"label.delete-report": "Delete report",
"label.delete-report": "删除报告",
"label.delete-team": "删除团队",
"label.delete-user": "删除用户",
"label.delete-website": "删除网站",
@ -60,7 +60,7 @@
"label.dropoff": "丢弃",
"label.edit": "编辑",
"label.edit-dashboard": "编辑仪表板",
"label.edit-member": "Edit member",
"label.edit-member": "编辑成员",
"label.enable-share-url": "启用共享链接",
"label.event": "事件",
"label.event-data": "事件数据",
@ -95,24 +95,24 @@
"label.less-than-equals": "少于等于",
"label.login": "登录",
"label.logout": "退出",
"label.manage": "Manage",
"label.manage": "管理",
"label.max": "最大",
"label.member": "Member",
"label.member": "成员",
"label.members": "成员",
"label.min": "最小",
"label.mobile": "手机",
"label.more": "更多",
"label.my-account": "My account",
"label.my-account": "我的账户",
"label.my-websites": "我的网站",
"label.name": "名字",
"label.new-password": "新密码",
"label.none": "无",
"label.number-of-records": "{x} {x, plural, one {record} other {records}}",
"label.ok": "OK",
"label.os": "OS",
"label.os": "操作系统",
"label.overview": "概览",
"label.owner": "所有者",
"label.page-of": "总{total}中的第{current}页",
"label.page-of": "总 {total} 中的第 {current} 页",
"label.page-views": "页面浏览量",
"label.pageTitle": "标题",
"label.pages": "网页",
@ -130,7 +130,7 @@
"label.region": "州/省",
"label.regions": "州/省",
"label.remove": "移除",
"label.remove-member": "Remove member",
"label.remove-member": "移除成员",
"label.reports": "报告",
"label.required": "必填",
"label.reset": "重置",
@ -142,9 +142,9 @@
"label.save": "保存",
"label.screens": "屏幕尺寸",
"label.search": "搜索",
"label.select": "Select",
"label.select": "选择",
"label.select-date": "选择数据",
"label.select-role": "Select role",
"label.select-role": "选择角色",
"label.select-website": "选择网站",
"label.sessions": "会话",
"label.settings": "设置",
@ -157,7 +157,7 @@
"label.team-member": "团队成员",
"label.team-name": "团队名称",
"label.team-owner": "团队所有者",
"label.team-view-only": "Team view only",
"label.team-view-only": "仅团队视图",
"label.team-websites": "团队网站",
"label.teams": "团队",
"label.theme": "主题",
@ -171,8 +171,8 @@
"label.total": "总数",
"label.total-records": "总记录数",
"label.tracking-code": "跟踪代码",
"label.transfer": "Transfer",
"label.transfer-website": "Transfer website",
"label.transfer": "转移",
"label.transfer-website": "转移网站",
"label.true": "是",
"label.type": "类型",
"label.unique": "独立",
@ -195,21 +195,21 @@
"label.websites": "网站",
"label.window": "窗口",
"label.yesterday": "昨天",
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
"message.action-confirmation": "在下面的框中输入 {confirmation} 以确认。",
"message.active-users": "当前在线 {x} 人",
"message.confirm-delete": "你确定要删除 {target} 吗?",
"message.confirm-leave": "你确定要离开 {target} 吗?",
"message.confirm-remove": "Are you sure you want to remove {target}?",
"message.confirm-reset": "您确定要重置 {target} 的数据吗?",
"message.delete-team-warning": "Deleting a team will also delete all team websites.",
"message.confirm-remove": "您确定要移除 {target} ",
"message.confirm-reset": "您确定要重置 {target} 的数据吗",
"message.delete-team-warning": "删除团队也会删除所有团队的网站。",
"message.delete-website-warning": "所有相关数据将会被删除。",
"message.error": "出现错误。",
"message.event-log": "{url}上的{event}",
"message.event-log": "{url} 上的 {event}",
"message.go-to-settings": "去设置",
"message.incorrect-username-password": "用户名或密码不正确。",
"message.invalid-domain": "无效域名",
"message.min-password-length": "密码最短长度为 {n} 个字符",
"message.new-version-available": "Umami的新版本{version}已推出!",
"message.new-version-available": "Umami 的新版本 {version} 已推出!",
"message.no-data-available": "无可用数据。",
"message.no-event-data": "无可用事件。",
"message.no-match-password": "密码不一致",
@ -219,7 +219,7 @@
"message.no-users": "没有任何用户。",
"message.no-websites-configured": "你还没有设置任何网站。",
"message.page-not-found": "网页未找到。",
"message.reset-website": "如果确定重置该网站, 请在下面的输入框中输入 {confirmation} 进行二次确认。",
"message.reset-website": "如果确定重置该网站请在下面的输入框中输入 {confirmation} 进行二次确认。",
"message.reset-website-warning": "本网站的所有统计数据将被删除,但您的跟踪代码将保持不变。",
"message.saved": "保存成功。",
"message.share-url": "这是 {target} 的共享链接。",
@ -227,12 +227,12 @@
"message.team-not-found": "未找到团队。",
"message.team-websites-info": "团队中的任何人都可查看网站。",
"message.tracking-code": "跟踪代码",
"message.transfer-team-website-to-user": "Transfer this website to your account?",
"message.transfer-user-website-to-team": "Select the team to transfer this website to.",
"message.transfer-website": "Transfer website ownership to your account or another team.",
"message.triggered-event": "Triggered event",
"message.transfer-team-website-to-user": "将该网站转入您的账户?",
"message.transfer-user-website-to-team": "选择要将该网站转移到哪个团队。",
"message.transfer-website": "将网站所有权转移到您的账户或其他团队。",
"message.triggered-event": "触发事件",
"message.user-deleted": "用户已删除。",
"message.viewed-page": "Viewed page",
"message.visitor-log": "来自{country}的访客在搭载 {os} 的{device}上使用 {browser} 浏览器进行访问。",
"message.visitors-dropped-off": "Visitors dropped off"
"message.viewed-page": "已浏览页面",
"message.visitor-log": "来自 {country} 的访客在搭载 {os} 的 {device} 上使用 {browser} 浏览器进行访问。",
"message.visitors-dropped-off": "访客减少"
}

27
src/lib/charts.ts Normal file
View File

@ -0,0 +1,27 @@
import { formatDate } from 'lib/date';
import { formatLongNumber } from 'lib/format';
export function renderNumberLabels(label: string) {
return +label > 1000 ? formatLongNumber(+label) : label;
}
export function renderDateLabels(unit: string, locale: string) {
return (label: string, index: number, values: any[]) => {
const d = new Date(values[index].value);
switch (unit) {
case 'minute':
return formatDate(d, 'h:mm', locale);
case 'hour':
return formatDate(d, 'p', locale);
case 'day':
return formatDate(d, 'MMM d', locale);
case 'month':
return formatDate(d, 'MMM', locale);
case 'year':
return formatDate(d, 'YYY', locale);
default:
return label;
}
};
}

View File

@ -1,62 +0,0 @@
import { StatusLight } from 'react-basics';
import { formatDate } from 'lib/date';
import { formatLongNumber } from 'lib/format';
export function renderNumberLabels(label: string) {
return +label > 1000 ? formatLongNumber(+label) : label;
}
export function renderDateLabels(unit: string, locale: string) {
return (label: string, index: number, values: any[]) => {
const d = new Date(values[index].value);
switch (unit) {
case 'minute':
return formatDate(d, 'h:mm', locale);
case 'hour':
return formatDate(d, 'p', locale);
case 'day':
return formatDate(d, 'MMM d', locale);
case 'month':
return formatDate(d, 'MMM', locale);
case 'year':
return formatDate(d, 'YYY', locale);
default:
return label;
}
};
}
export function renderStatusTooltipPopup(unit: string, locale: string) {
return (setTooltipPopup: (data: any) => void, model: any) => {
const { opacity, labelColors, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) {
setTooltipPopup(null);
return;
}
const formats = {
millisecond: 'T',
second: 'pp',
minute: 'p',
hour: 'h:mm aaa - PP',
day: 'PPPP',
week: 'PPPP',
month: 'LLLL yyyy',
quarter: 'qqq',
year: 'yyyy',
};
setTooltipPopup(
<>
<div>{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
</StatusLight>
</div>
</>,
);
};
}

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