aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src
downloadumami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.tar.xz
umami-396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src')
-rw-r--r--src/app/(collect)/p/[slug]/route.ts68
-rw-r--r--src/app/(collect)/q/[slug]/route.ts61
-rw-r--r--src/app/(main)/App.tsx62
-rw-r--r--src/app/(main)/MobileNav.tsx71
-rw-r--r--src/app/(main)/SideNav.tsx87
-rw-r--r--src/app/(main)/TopNav.tsx26
-rw-r--r--src/app/(main)/UpdateNotice.tsx61
-rw-r--r--src/app/(main)/admin/AdminLayout.tsx33
-rw-r--r--src/app/(main)/admin/AdminNav.tsx48
-rw-r--r--src/app/(main)/admin/layout.tsx17
-rw-r--r--src/app/(main)/admin/teams/AdminTeamsDataTable.tsx19
-rw-r--r--src/app/(main)/admin/teams/AdminTeamsPage.tsx19
-rw-r--r--src/app/(main)/admin/teams/AdminTeamsTable.tsx86
-rw-r--r--src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx11
-rw-r--r--src/app/(main)/admin/teams/[teamId]/page.tsx12
-rw-r--r--src/app/(main)/admin/teams/page.tsx9
-rw-r--r--src/app/(main)/admin/users/UserAddButton.tsx32
-rw-r--r--src/app/(main)/admin/users/UserAddForm.tsx71
-rw-r--r--src/app/(main)/admin/users/UserDeleteButton.tsx35
-rw-r--r--src/app/(main)/admin/users/UserDeleteForm.tsx41
-rw-r--r--src/app/(main)/admin/users/UsersDataTable.tsx14
-rw-r--r--src/app/(main)/admin/users/UsersPage.tsx24
-rw-r--r--src/app/(main)/admin/users/UsersTable.tsx84
-rw-r--r--src/app/(main)/admin/users/[userId]/UserEditForm.tsx73
-rw-r--r--src/app/(main)/admin/users/[userId]/UserHeader.tsx9
-rw-r--r--src/app/(main)/admin/users/[userId]/UserPage.tsx19
-rw-r--r--src/app/(main)/admin/users/[userId]/UserProvider.tsx20
-rw-r--r--src/app/(main)/admin/users/[userId]/UserSettings.tsx25
-rw-r--r--src/app/(main)/admin/users/[userId]/UserWebsites.tsx15
-rw-r--r--src/app/(main)/admin/users/[userId]/page.tsx12
-rw-r--r--src/app/(main)/admin/users/page.tsx9
-rw-r--r--src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx13
-rw-r--r--src/app/(main)/admin/websites/AdminWebsitesPage.tsx19
-rw-r--r--src/app/(main)/admin/websites/AdminWebsitesTable.tsx89
-rw-r--r--src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx14
-rw-r--r--src/app/(main)/admin/websites/[websiteId]/page.tsx12
-rw-r--r--src/app/(main)/admin/websites/page.tsx9
-rw-r--r--src/app/(main)/boards/BoardAddButton.tsx32
-rw-r--r--src/app/(main)/boards/BoardAddForm.tsx60
-rw-r--r--src/app/(main)/boards/BoardsPage.tsx17
-rw-r--r--src/app/(main)/boards/[boardId]/Board.tsx10
-rw-r--r--src/app/(main)/boards/[boardId]/page.tsx12
-rw-r--r--src/app/(main)/boards/page.tsx10
-rw-r--r--src/app/(main)/console/[websiteId]/TestConsolePage.tsx207
-rw-r--r--src/app/(main)/console/[websiteId]/page.tsx22
-rw-r--r--src/app/(main)/dashboard/DashboardPage.tsx17
-rw-r--r--src/app/(main)/dashboard/page.tsx10
-rw-r--r--src/app/(main)/layout.tsx18
-rw-r--r--src/app/(main)/links/LinkAddButton.tsx19
-rw-r--r--src/app/(main)/links/LinkDeleteButton.tsx57
-rw-r--r--src/app/(main)/links/LinkEditButton.tsx16
-rw-r--r--src/app/(main)/links/LinkEditForm.tsx148
-rw-r--r--src/app/(main)/links/LinkProvider.tsx21
-rw-r--r--src/app/(main)/links/LinksDataTable.tsx14
-rw-r--r--src/app/(main)/links/LinksPage.tsx26
-rw-r--r--src/app/(main)/links/LinksTable.tsx51
-rw-r--r--src/app/(main)/links/[linkId]/LinkControls.tsx32
-rw-r--r--src/app/(main)/links/[linkId]/LinkHeader.tsx19
-rw-r--r--src/app/(main)/links/[linkId]/LinkMetricsBar.tsx70
-rw-r--r--src/app/(main)/links/[linkId]/LinkPage.tsx34
-rw-r--r--src/app/(main)/links/[linkId]/LinkPanels.tsx83
-rw-r--r--src/app/(main)/links/[linkId]/page.tsx12
-rw-r--r--src/app/(main)/links/page.tsx10
-rw-r--r--src/app/(main)/pixels/PixelAddButton.tsx19
-rw-r--r--src/app/(main)/pixels/PixelDeleteButton.tsx57
-rw-r--r--src/app/(main)/pixels/PixelEditButton.tsx21
-rw-r--r--src/app/(main)/pixels/PixelEditForm.tsx129
-rw-r--r--src/app/(main)/pixels/PixelProvider.tsx21
-rw-r--r--src/app/(main)/pixels/PixelsDataTable.tsx14
-rw-r--r--src/app/(main)/pixels/PixelsPage.tsx26
-rw-r--r--src/app/(main)/pixels/PixelsTable.tsx48
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelControls.tsx32
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelHeader.tsx19
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx70
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelPage.tsx34
-rw-r--r--src/app/(main)/pixels/[pixelId]/PixelPanels.tsx83
-rw-r--r--src/app/(main)/pixels/[pixelId]/page.tsx12
-rw-r--r--src/app/(main)/pixels/page.tsx10
-rw-r--r--src/app/(main)/settings/SettingsLayout.tsx26
-rw-r--r--src/app/(main)/settings/SettingsNav.tsx53
-rw-r--r--src/app/(main)/settings/layout.tsx17
-rw-r--r--src/app/(main)/settings/preferences/DateRangeSetting.tsx28
-rw-r--r--src/app/(main)/settings/preferences/LanguageSetting.tsx48
-rw-r--r--src/app/(main)/settings/preferences/PreferenceSettings.tsx36
-rw-r--r--src/app/(main)/settings/preferences/PreferencesPage.tsx22
-rw-r--r--src/app/(main)/settings/preferences/ThemeSetting.tsx21
-rw-r--r--src/app/(main)/settings/preferences/TimezoneSetting.tsx44
-rw-r--r--src/app/(main)/settings/preferences/page.tsx10
-rw-r--r--src/app/(main)/settings/profile/PasswordChangeButton.tsx29
-rw-r--r--src/app/(main)/settings/profile/PasswordEditForm.tsx67
-rw-r--r--src/app/(main)/settings/profile/ProfileHeader.tsx8
-rw-r--r--src/app/(main)/settings/profile/ProfilePage.tsx22
-rw-r--r--src/app/(main)/settings/profile/ProfileSettings.tsx51
-rw-r--r--src/app/(main)/settings/profile/page.tsx10
-rw-r--r--src/app/(main)/settings/teams/TeamsSettingsPage.tsx16
-rw-r--r--src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx11
-rw-r--r--src/app/(main)/settings/teams/[teamId]/page.tsx12
-rw-r--r--src/app/(main)/settings/teams/page.tsx10
-rw-r--r--src/app/(main)/settings/websites/WebsitesSettingsPage.tsx16
-rw-r--r--src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx16
-rw-r--r--src/app/(main)/settings/websites/[websiteId]/page.tsx12
-rw-r--r--src/app/(main)/settings/websites/page.tsx12
-rw-r--r--src/app/(main)/teams/TeamAddForm.tsx39
-rw-r--r--src/app/(main)/teams/TeamJoinForm.tsx40
-rw-r--r--src/app/(main)/teams/TeamLeaveButton.tsx41
-rw-r--r--src/app/(main)/teams/TeamLeaveForm.tsx48
-rw-r--r--src/app/(main)/teams/TeamProvider.tsx21
-rw-r--r--src/app/(main)/teams/TeamsAddButton.tsx33
-rw-r--r--src/app/(main)/teams/TeamsDataTable.tsx27
-rw-r--r--src/app/(main)/teams/TeamsHeader.tsx26
-rw-r--r--src/app/(main)/teams/TeamsJoinButton.tsx31
-rw-r--r--src/app/(main)/teams/TeamsPage.tsx19
-rw-r--r--src/app/(main)/teams/TeamsTable.tsx29
-rw-r--r--src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx40
-rw-r--r--src/app/(main)/teams/[teamId]/TeamEditForm.tsx89
-rw-r--r--src/app/(main)/teams/[teamId]/TeamManage.tsx32
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx46
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx62
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx60
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx19
-rw-r--r--src/app/(main)/teams/[teamId]/TeamMembersTable.tsx55
-rw-r--r--src/app/(main)/teams/[teamId]/TeamSettings.tsx49
-rw-r--r--src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx25
-rw-r--r--src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx19
-rw-r--r--src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx50
-rw-r--r--src/app/(main)/teams/page.tsx10
-rw-r--r--src/app/(main)/websites/WebsiteAddButton.tsx28
-rw-r--r--src/app/(main)/websites/WebsiteAddForm.tsx60
-rw-r--r--src/app/(main)/websites/WebsiteProvider.tsx27
-rw-r--r--src/app/(main)/websites/WebsitesDataTable.tsx47
-rw-r--r--src/app/(main)/websites/WebsitesHeader.tsx18
-rw-r--r--src/app/(main)/websites/WebsitesPage.tsx26
-rw-r--r--src/app/(main)/websites/WebsitesTable.tsx41
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx128
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx63
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx91
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx51
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx46
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx134
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx141
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx36
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx99
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx104
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx36
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css267
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx294
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx67
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx140
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx22
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx152
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx18
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx71
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx18
-rw-r--r--src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx52
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteChart.tsx61
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteControls.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx183
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx57
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx57
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx30
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx56
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx88
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteNav.tsx180
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsitePage.tsx22
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsitePanels.tsx140
-rw-r--r--src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx64
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx60
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx37
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx135
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx24
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx16
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx41
-rw-r--r--src/app/(main)/websites/[websiteId]/cohorts/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx20
-rw-r--r--src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx171
-rw-r--r--src/app/(main)/websites/[websiteId]/compare/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventProperties.tsx127
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx48
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventsPage.tsx59
-rw-r--r--src/app/(main)/websites/[websiteId]/events/EventsTable.tsx107
-rw-r--r--src/app/(main)/websites/[websiteId]/events/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/layout.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx31
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx17
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx206
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx58
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx45
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx45
-rw-r--r--src/app/(main)/websites/[websiteId]/realtime/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx60
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx37
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx86
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx24
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx16
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx38
-rw-r--r--src/app/(main)/websites/[websiteId]/segments/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx94
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx32
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx85
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx41
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx84
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx97
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx21
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx15
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx43
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx58
-rw-r--r--src/app/(main)/websites/[websiteId]/sessions/page.tsx12
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx6
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx104
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx55
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx37
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx28
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx22
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx93
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx40
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx102
-rw-r--r--src/app/(main)/websites/[websiteId]/settings/page.tsx12
-rw-r--r--src/app/(main)/websites/page.tsx10
-rw-r--r--src/app/Providers.tsx62
-rw-r--r--src/app/api/admin/teams/route.ts58
-rw-r--r--src/app/api/admin/users/route.ts46
-rw-r--r--src/app/api/admin/websites/route.ts58
-rw-r--r--src/app/api/auth/login/route.ts48
-rw-r--r--src/app/api/auth/logout/route.ts12
-rw-r--r--src/app/api/auth/sso/route.ts18
-rw-r--r--src/app/api/auth/verify/route.ts15
-rw-r--r--src/app/api/batch/route.ts58
-rw-r--r--src/app/api/config/route.ts21
-rw-r--r--src/app/api/heartbeat/route.ts3
-rw-r--r--src/app/api/links/[linkId]/route.ts77
-rw-r--r--src/app/api/links/route.ts64
-rw-r--r--src/app/api/me/password/route.ts33
-rw-r--r--src/app/api/me/route.ts12
-rw-r--r--src/app/api/me/teams/route.ts23
-rw-r--r--src/app/api/me/websites/route.ts26
-rw-r--r--src/app/api/pixels/[pixelId]/route.ts76
-rw-r--r--src/app/api/pixels/route.ts62
-rw-r--r--src/app/api/realtime/[websiteId]/route.ts36
-rw-r--r--src/app/api/reports/[reportId]/route.ts80
-rw-r--r--src/app/api/reports/attribution/route.ts26
-rw-r--r--src/app/api/reports/breakdown/route.ts26
-rw-r--r--src/app/api/reports/funnel/route.ts26
-rw-r--r--src/app/api/reports/goal/route.ts26
-rw-r--r--src/app/api/reports/journey/route.ts25
-rw-r--r--src/app/api/reports/retention/route.ts26
-rw-r--r--src/app/api/reports/revenue/route.ts26
-rw-r--r--src/app/api/reports/route.ts73
-rw-r--r--src/app/api/reports/utm/route.ts37
-rw-r--r--src/app/api/scripts/telemetry/route.ts28
-rw-r--r--src/app/api/send/route.ts284
-rw-r--r--src/app/api/share/[shareId]/route.ts19
-rw-r--r--src/app/api/teams/[teamId]/links/route.ts29
-rw-r--r--src/app/api/teams/[teamId]/pixels/route.ts29
-rw-r--r--src/app/api/teams/[teamId]/route.ts71
-rw-r--r--src/app/api/teams/[teamId]/users/[userId]/route.ts85
-rw-r--r--src/app/api/teams/[teamId]/users/route.ts83
-rw-r--r--src/app/api/teams/[teamId]/websites/route.ts29
-rw-r--r--src/app/api/teams/join/route.ts39
-rw-r--r--src/app/api/teams/route.ts55
-rw-r--r--src/app/api/users/[userId]/route.ts102
-rw-r--r--src/app/api/users/[userId]/teams/route.ts27
-rw-r--r--src/app/api/users/[userId]/websites/route.ts33
-rw-r--r--src/app/api/users/route.ts44
-rw-r--r--src/app/api/websites/[websiteId]/active/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/daterange/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/event-data/events/route.ts37
-rw-r--r--src/app/api/websites/[websiteId]/event-data/fields/route.ts35
-rw-r--r--src/app/api/websites/[websiteId]/event-data/properties/route.ts35
-rw-r--r--src/app/api/websites/[websiteId]/event-data/stats/route.ts35
-rw-r--r--src/app/api/websites/[websiteId]/event-data/values/route.ts41
-rw-r--r--src/app/api/websites/[websiteId]/events/route.ts37
-rw-r--r--src/app/api/websites/[websiteId]/events/series/route.ts37
-rw-r--r--src/app/api/websites/[websiteId]/export/route.ts64
-rw-r--r--src/app/api/websites/[websiteId]/metrics/expanded/route.ts66
-rw-r--r--src/app/api/websites/[websiteId]/metrics/route.ts66
-rw-r--r--src/app/api/websites/[websiteId]/pageviews/route.ts72
-rw-r--r--src/app/api/websites/[websiteId]/reports/route.ts46
-rw-r--r--src/app/api/websites/[websiteId]/reset/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/route.ts84
-rw-r--r--src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts92
-rw-r--r--src/app/api/websites/[websiteId]/segments/route.ts70
-rw-r--r--src/app/api/websites/[websiteId]/session-data/properties/route.ts35
-rw-r--r--src/app/api/websites/[websiteId]/session-data/values/route.ts40
-rw-r--r--src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts33
-rw-r--r--src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts25
-rw-r--r--src/app/api/websites/[websiteId]/sessions/route.ts36
-rw-r--r--src/app/api/websites/[websiteId]/sessions/stats/route.ts42
-rw-r--r--src/app/api/websites/[websiteId]/sessions/weekly/route.ts36
-rw-r--r--src/app/api/websites/[websiteId]/stats/route.ts43
-rw-r--r--src/app/api/websites/[websiteId]/transfer/route.ts50
-rw-r--r--src/app/api/websites/[websiteId]/values/route.ts50
-rw-r--r--src/app/api/websites/route.ts86
-rw-r--r--src/app/layout.tsx49
-rw-r--r--src/app/login/LoginForm.tsx70
-rw-r--r--src/app/login/LoginPage.tsx11
-rw-r--r--src/app/login/page.tsx14
-rw-r--r--src/app/logout/LogoutPage.tsx25
-rw-r--r--src/app/logout/page.tsx14
-rw-r--r--src/app/not-found.tsx13
-rw-r--r--src/app/page.tsx19
-rw-r--r--src/app/share/[...shareId]/Footer.tsx12
-rw-r--r--src/app/share/[...shareId]/Header.tsx24
-rw-r--r--src/app/share/[...shareId]/SharePage.tsx41
-rw-r--r--src/app/share/[...shareId]/page.tsx7
-rw-r--r--src/app/sso/SSOPage.tsx22
-rw-r--r--src/app/sso/page.tsx10
-rw-r--r--src/assets/add-user.svg1
-rw-r--r--src/assets/bar-chart.svg1
-rw-r--r--src/assets/bars.svg1
-rw-r--r--src/assets/bolt.svg1
-rw-r--r--src/assets/bookmark.svg1
-rw-r--r--src/assets/change.svg1
-rw-r--r--src/assets/compare.svg1
-rw-r--r--src/assets/dashboard.svg1
-rw-r--r--src/assets/download.svg1
-rw-r--r--src/assets/expand.svg1
-rw-r--r--src/assets/export.svg1
-rw-r--r--src/assets/flag.svg1
-rw-r--r--src/assets/funnel.svg1
-rw-r--r--src/assets/gear.svg1
-rw-r--r--src/assets/lightbulb.svg1
-rw-r--r--src/assets/lightning.svg1
-rw-r--r--src/assets/location.svg1
-rw-r--r--src/assets/lock.svg1
-rw-r--r--src/assets/logo-white.svg1
-rw-r--r--src/assets/logo.svg1
-rw-r--r--src/assets/magnet.svg1
-rw-r--r--src/assets/money.svg1
-rw-r--r--src/assets/network.svg1
-rw-r--r--src/assets/nodes.svg1
-rw-r--r--src/assets/overview.svg1
-rw-r--r--src/assets/path.svg1
-rw-r--r--src/assets/profile.svg1
-rw-r--r--src/assets/pushpin.svg1
-rw-r--r--src/assets/redo.svg1
-rw-r--r--src/assets/reports.svg1
-rw-r--r--src/assets/security.svg1
-rw-r--r--src/assets/speaker.svg1
-rw-r--r--src/assets/switch.svg1
-rw-r--r--src/assets/tag.svg1
-rw-r--r--src/assets/target.svg1
-rw-r--r--src/assets/visitor.svg1
-rw-r--r--src/assets/website.svg1
-rw-r--r--src/components/boards/Board.tsx9
-rw-r--r--src/components/charts/BarChart.tsx131
-rw-r--r--src/components/charts/BubbleChart.tsx31
-rw-r--r--src/components/charts/Chart.tsx130
-rw-r--r--src/components/charts/ChartTooltip.tsx23
-rw-r--r--src/components/charts/PieChart.tsx31
-rw-r--r--src/components/common/ActionForm.tsx15
-rw-r--r--src/components/common/AnimatedDiv.tsx3
-rw-r--r--src/components/common/Avatar.tsx21
-rw-r--r--src/components/common/ConfirmationForm.tsx42
-rw-r--r--src/components/common/DataGrid.tsx107
-rw-r--r--src/components/common/DateDisplay.tsx28
-rw-r--r--src/components/common/DateDistance.tsx19
-rw-r--r--src/components/common/Empty.tsx24
-rw-r--r--src/components/common/EmptyPlaceholder.tsx28
-rw-r--r--src/components/common/ErrorBoundary.tsx38
-rw-r--r--src/components/common/ErrorMessage.tsx16
-rw-r--r--src/components/common/ExternalLink.tsx23
-rw-r--r--src/components/common/Favicon.tsx22
-rw-r--r--src/components/common/FilterLink.tsx49
-rw-r--r--src/components/common/FilterRecord.tsx117
-rw-r--r--src/components/common/GridRow.tsx32
-rw-r--r--src/components/common/LinkButton.tsx41
-rw-r--r--src/components/common/LoadingPanel.tsx71
-rw-r--r--src/components/common/PageBody.tsx42
-rw-r--r--src/components/common/PageHeader.tsx58
-rw-r--r--src/components/common/Pager.tsx60
-rw-r--r--src/components/common/Panel.tsx64
-rw-r--r--src/components/common/SectionHeader.tsx28
-rw-r--r--src/components/common/SideMenu.tsx80
-rw-r--r--src/components/common/TypeConfirmationForm.tsx55
-rw-r--r--src/components/common/TypeIcon.tsx29
-rw-r--r--src/components/hooks/context/useLink.ts6
-rw-r--r--src/components/hooks/context/usePixel.ts6
-rw-r--r--src/components/hooks/context/useTeam.ts6
-rw-r--r--src/components/hooks/context/useUser.ts6
-rw-r--r--src/components/hooks/context/useWebsite.ts6
-rw-r--r--src/components/hooks/index.ts84
-rw-r--r--src/components/hooks/queries/useActiveUsersQuery.ts12
-rw-r--r--src/components/hooks/queries/useDateRangeQuery.ts23
-rw-r--r--src/components/hooks/queries/useDeleteQuery.ts12
-rw-r--r--src/components/hooks/queries/useEventDataEventsQuery.ts27
-rw-r--r--src/components/hooks/queries/useEventDataPropertiesQuery.ts27
-rw-r--r--src/components/hooks/queries/useEventDataQuery.ts27
-rw-r--r--src/components/hooks/queries/useEventDataValuesQuery.ts34
-rw-r--r--src/components/hooks/queries/useLinkQuery.ts15
-rw-r--r--src/components/hooks/queries/useLinksQuery.ts17
-rw-r--r--src/components/hooks/queries/useLoginQuery.ts23
-rw-r--r--src/components/hooks/queries/usePixelQuery.ts15
-rw-r--r--src/components/hooks/queries/usePixelsQuery.ts17
-rw-r--r--src/components/hooks/queries/useRealtimeQuery.ts17
-rw-r--r--src/components/hooks/queries/useReportQuery.ts15
-rw-r--r--src/components/hooks/queries/useReportsQuery.ts19
-rw-r--r--src/components/hooks/queries/useResultQuery.ts44
-rw-r--r--src/components/hooks/queries/useSessionActivityQuery.ts21
-rw-r--r--src/components/hooks/queries/useSessionDataPropertiesQuery.ts27
-rw-r--r--src/components/hooks/queries/useSessionDataQuery.ts12
-rw-r--r--src/components/hooks/queries/useSessionDataValuesQuery.ts32
-rw-r--r--src/components/hooks/queries/useShareTokenQuery.ts25
-rw-r--r--src/components/hooks/queries/useTeamMembersQuery.ts16
-rw-r--r--src/components/hooks/queries/useTeamQuery.ts17
-rw-r--r--src/components/hooks/queries/useTeamWebsitesQuery.ts15
-rw-r--r--src/components/hooks/queries/useTeamsQuery.ts20
-rw-r--r--src/components/hooks/queries/useUpdateQuery.ts15
-rw-r--r--src/components/hooks/queries/useUserQuery.ts17
-rw-r--r--src/components/hooks/queries/useUserTeamsQuery.ts15
-rw-r--r--src/components/hooks/queries/useUserWebsitesQuery.ts31
-rw-r--r--src/components/hooks/queries/useUsersQuery.ts17
-rw-r--r--src/components/hooks/queries/useWebsiteCohortQuery.ts21
-rw-r--r--src/components/hooks/queries/useWebsiteCohortsQuery.ts25
-rw-r--r--src/components/hooks/queries/useWebsiteEventsQuery.ts39
-rw-r--r--src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts18
-rw-r--r--src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts51
-rw-r--r--src/components/hooks/queries/useWebsiteMetricsQuery.ts47
-rw-r--r--src/components/hooks/queries/useWebsitePageviewsQuery.ts36
-rw-r--r--src/components/hooks/queries/useWebsiteQuery.ts17
-rw-r--r--src/components/hooks/queries/useWebsiteSegmentQuery.ts21
-rw-r--r--src/components/hooks/queries/useWebsiteSegmentsQuery.ts24
-rw-r--r--src/components/hooks/queries/useWebsiteSessionQuery.ts13
-rw-r--r--src/components/hooks/queries/useWebsiteSessionStatsQuery.ts17
-rw-r--r--src/components/hooks/queries/useWebsiteSessionsQuery.ts34
-rw-r--r--src/components/hooks/queries/useWebsiteStatsQuery.ts36
-rw-r--r--src/components/hooks/queries/useWebsiteValuesQuery.ts62
-rw-r--r--src/components/hooks/queries/useWebsitesQuery.ts20
-rw-r--r--src/components/hooks/queries/useWeeklyTrafficQuery.ts28
-rw-r--r--src/components/hooks/useApi.ts67
-rw-r--r--src/components/hooks/useConfig.ts33
-rw-r--r--src/components/hooks/useCountryNames.ts32
-rw-r--r--src/components/hooks/useDateParameters.ts18
-rw-r--r--src/components/hooks/useDateRange.ts37
-rw-r--r--src/components/hooks/useDocumentClick.ts13
-rw-r--r--src/components/hooks/useEscapeKey.ts19
-rw-r--r--src/components/hooks/useFields.ts23
-rw-r--r--src/components/hooks/useFilterParameters.ts70
-rw-r--r--src/components/hooks/useFilters.ts99
-rw-r--r--src/components/hooks/useForceUpdate.ts9
-rw-r--r--src/components/hooks/useFormat.ts74
-rw-r--r--src/components/hooks/useGlobalState.ts13
-rw-r--r--src/components/hooks/useLanguageNames.ts32
-rw-r--r--src/components/hooks/useLocale.ts60
-rw-r--r--src/components/hooks/useMessages.ts48
-rw-r--r--src/components/hooks/useMobile.ts9
-rw-r--r--src/components/hooks/useModified.ts13
-rw-r--r--src/components/hooks/useNavigation.ts43
-rw-r--r--src/components/hooks/usePageParameters.ts16
-rw-r--r--src/components/hooks/usePagedQuery.ts27
-rw-r--r--src/components/hooks/useRegionNames.ts22
-rw-r--r--src/components/hooks/useSlug.ts14
-rw-r--r--src/components/hooks/useSticky.ts25
-rw-r--r--src/components/hooks/useTimezone.ts95
-rw-r--r--src/components/icons.ts1
-rw-r--r--src/components/input/ActionSelect.tsx18
-rw-r--r--src/components/input/CurrencySelect.tsx34
-rw-r--r--src/components/input/DateFilter.tsx141
-rw-r--r--src/components/input/DialogButton.tsx64
-rw-r--r--src/components/input/DownloadButton.tsx42
-rw-r--r--src/components/input/ExportButton.tsx64
-rw-r--r--src/components/input/FieldFilters.tsx117
-rw-r--r--src/components/input/FilterBar.tsx155
-rw-r--r--src/components/input/FilterButtons.tsx33
-rw-r--r--src/components/input/FilterEditForm.tsx95
-rw-r--r--src/components/input/LanguageButton.tsx41
-rw-r--r--src/components/input/LookupField.tsx65
-rw-r--r--src/components/input/MenuButton.tsx32
-rw-r--r--src/components/input/MobileMenuButton.tsx17
-rw-r--r--src/components/input/MonthFilter.tsx18
-rw-r--r--src/components/input/MonthSelect.tsx47
-rw-r--r--src/components/input/NavButton.tsx188
-rw-r--r--src/components/input/PanelButton.tsx19
-rw-r--r--src/components/input/PreferencesButton.tsx32
-rw-r--r--src/components/input/ProfileButton.tsx74
-rw-r--r--src/components/input/RefreshButton.tsx32
-rw-r--r--src/components/input/ReportEditButton.tsx99
-rw-r--r--src/components/input/SegmentFilters.tsx42
-rw-r--r--src/components/input/SegmentSaveButton.tsx26
-rw-r--r--src/components/input/SettingsButton.tsx84
-rw-r--r--src/components/input/WebsiteDateFilter.tsx102
-rw-r--r--src/components/input/WebsiteFilterButton.tsx32
-rw-r--r--src/components/input/WebsiteSelect.tsx74
-rw-r--r--src/components/messages.ts518
-rw-r--r--src/components/metrics/ActiveUsers.tsx39
-rw-r--r--src/components/metrics/ChangeLabel.tsx60
-rw-r--r--src/components/metrics/DatePickerForm.tsx74
-rw-r--r--src/components/metrics/EventData.tsx22
-rw-r--r--src/components/metrics/EventsChart.tsx93
-rw-r--r--src/components/metrics/Legend.tsx39
-rw-r--r--src/components/metrics/ListTable.tsx152
-rw-r--r--src/components/metrics/MetricCard.tsx56
-rw-r--r--src/components/metrics/MetricLabel.tsx142
-rw-r--r--src/components/metrics/MetricsBar.tsx14
-rw-r--r--src/components/metrics/MetricsExpandedTable.tsx139
-rw-r--r--src/components/metrics/MetricsTable.tsx95
-rw-r--r--src/components/metrics/PageviewsChart.tsx98
-rw-r--r--src/components/metrics/RealtimeChart.tsx59
-rw-r--r--src/components/metrics/WeeklyTraffic.tsx112
-rw-r--r--src/components/metrics/WorldMap.tsx105
-rw-r--r--src/components/svg/AddUser.tsx16
-rw-r--r--src/components/svg/BarChart.tsx8
-rw-r--r--src/components/svg/Bars.tsx8
-rw-r--r--src/components/svg/Bolt.tsx8
-rw-r--r--src/components/svg/Bookmark.tsx8
-rw-r--r--src/components/svg/Calendar.tsx8
-rw-r--r--src/components/svg/Change.tsx13
-rw-r--r--src/components/svg/Clock.tsx12
-rw-r--r--src/components/svg/Compare.tsx8
-rw-r--r--src/components/svg/Dashboard.tsx21
-rw-r--r--src/components/svg/Download.tsx9
-rw-r--r--src/components/svg/Expand.tsx18
-rw-r--r--src/components/svg/Export.tsx12
-rw-r--r--src/components/svg/Flag.tsx8
-rw-r--r--src/components/svg/Funnel.tsx18
-rw-r--r--src/components/svg/Gear.tsx8
-rw-r--r--src/components/svg/Globe.tsx8
-rw-r--r--src/components/svg/Lightbulb.tsx15
-rw-r--r--src/components/svg/Lightning.tsx33
-rw-r--r--src/components/svg/Link.tsx8
-rw-r--r--src/components/svg/Location.tsx8
-rw-r--r--src/components/svg/Lock.tsx8
-rw-r--r--src/components/svg/Logo.tsx17
-rw-r--r--src/components/svg/LogoWhite.tsx26
-rw-r--r--src/components/svg/Magnet.tsx15
-rw-r--r--src/components/svg/Money.tsx15
-rw-r--r--src/components/svg/Moon.tsx8
-rw-r--r--src/components/svg/Network.tsx15
-rw-r--r--src/components/svg/Nodes.tsx12
-rw-r--r--src/components/svg/Overview.tsx8
-rw-r--r--src/components/svg/Path.tsx15
-rw-r--r--src/components/svg/Profile.tsx8
-rw-r--r--src/components/svg/Pushpin.tsx8
-rw-r--r--src/components/svg/Redo.tsx8
-rw-r--r--src/components/svg/Reports.tsx8
-rw-r--r--src/components/svg/Security.tsx16
-rw-r--r--src/components/svg/Speaker.tsx8
-rw-r--r--src/components/svg/Sun.tsx9
-rw-r--r--src/components/svg/Switch.tsx19
-rw-r--r--src/components/svg/Tag.tsx16
-rw-r--r--src/components/svg/Target.tsx21
-rw-r--r--src/components/svg/Visitor.tsx8
-rw-r--r--src/components/svg/Website.tsx13
-rw-r--r--src/components/svg/index.ts37
-rw-r--r--src/declaration.d.ts18
-rw-r--r--src/index.ts82
-rw-r--r--src/lang/ar-SA.json339
-rw-r--r--src/lang/be-BY.json339
-rw-r--r--src/lang/bg-BG.json339
-rw-r--r--src/lang/bn-BD.json339
-rw-r--r--src/lang/bs-BA.json339
-rw-r--r--src/lang/ca-ES.json339
-rw-r--r--src/lang/cs-CZ.json339
-rw-r--r--src/lang/da-DK.json339
-rw-r--r--src/lang/de-CH.json339
-rw-r--r--src/lang/de-DE.json339
-rw-r--r--src/lang/el-GR.json339
-rw-r--r--src/lang/en-GB.json339
-rw-r--r--src/lang/en-US.json339
-rw-r--r--src/lang/es-ES.json340
-rw-r--r--src/lang/fa-IR.json339
-rw-r--r--src/lang/fi-FI.json339
-rw-r--r--src/lang/fo-FO.json339
-rw-r--r--src/lang/fr-FR.json341
-rw-r--r--src/lang/ga-ES.json339
-rw-r--r--src/lang/he-IL.json339
-rw-r--r--src/lang/hi-IN.json339
-rw-r--r--src/lang/hr-HR.json339
-rw-r--r--src/lang/hu-HU.json339
-rw-r--r--src/lang/id-ID.json339
-rw-r--r--src/lang/it-IT.json339
-rw-r--r--src/lang/ja-JP.json339
-rw-r--r--src/lang/km-KH.json339
-rw-r--r--src/lang/ko-KR.json339
-rw-r--r--src/lang/lt-LT.json339
-rw-r--r--src/lang/mn-MN.json339
-rw-r--r--src/lang/ms-MY.json339
-rw-r--r--src/lang/my-MM.json339
-rw-r--r--src/lang/nb-NO.json339
-rw-r--r--src/lang/nl-NL.json339
-rw-r--r--src/lang/pl-PL.json339
-rw-r--r--src/lang/pt-BR.json339
-rw-r--r--src/lang/pt-PT.json339
-rw-r--r--src/lang/ro-RO.json339
-rw-r--r--src/lang/ru-RU.json339
-rw-r--r--src/lang/si-LK.json339
-rw-r--r--src/lang/sk-SK.json339
-rw-r--r--src/lang/sl-SI.json318
-rw-r--r--src/lang/sv-SE.json339
-rw-r--r--src/lang/ta-IN.json339
-rw-r--r--src/lang/th-TH.json339
-rw-r--r--src/lang/tr-TR.json339
-rw-r--r--src/lang/uk-UA.json339
-rw-r--r--src/lang/ur-PK.json339
-rw-r--r--src/lang/uz-UZ.json280
-rw-r--r--src/lang/vi-VN.json280
-rw-r--r--src/lang/zh-CN.json339
-rw-r--r--src/lang/zh-TW.json339
-rw-r--r--src/lib/__tests__/charts.test.ts39
-rw-r--r--src/lib/__tests__/detect.test.ts22
-rw-r--r--src/lib/__tests__/format.test.ts38
-rw-r--r--src/lib/auth.ts80
-rw-r--r--src/lib/charts.ts27
-rw-r--r--src/lib/clickhouse.ts273
-rw-r--r--src/lib/client.ts14
-rw-r--r--src/lib/colors.ts91
-rw-r--r--src/lib/constants.ts682
-rw-r--r--src/lib/crypto.ts65
-rw-r--r--src/lib/data.ts94
-rw-r--r--src/lib/date.ts375
-rw-r--r--src/lib/db.ts40
-rw-r--r--src/lib/detect.ts154
-rw-r--r--src/lib/fetch.ts58
-rw-r--r--src/lib/filters.ts31
-rw-r--r--src/lib/format.ts118
-rw-r--r--src/lib/generate.ts20
-rw-r--r--src/lib/ip.ts60
-rw-r--r--src/lib/jwt.ts36
-rw-r--r--src/lib/kafka.ts112
-rw-r--r--src/lib/lang.ts111
-rw-r--r--src/lib/load.ts40
-rw-r--r--src/lib/params.ts62
-rw-r--r--src/lib/password.ts11
-rw-r--r--src/lib/prisma.ts368
-rw-r--r--src/lib/react.ts77
-rw-r--r--src/lib/redis.ts18
-rw-r--r--src/lib/request.ts145
-rw-r--r--src/lib/response.ts58
-rw-r--r--src/lib/schema.ts232
-rw-r--r--src/lib/sql.ts0
-rw-r--r--src/lib/storage.ts25
-rw-r--r--src/lib/types.ts143
-rw-r--r--src/lib/url.ts49
-rw-r--r--src/lib/utils.ts46
-rw-r--r--src/permissions/index.ts6
-rw-r--r--src/permissions/link.ts64
-rw-r--r--src/permissions/pixel.ts64
-rw-r--r--src/permissions/report.ts27
-rw-r--r--src/permissions/team.ts68
-rw-r--r--src/permissions/user.ts29
-rw-r--r--src/permissions/website.ts128
-rw-r--r--src/queries/prisma/index.ts8
-rw-r--r--src/queries/prisma/link.ts66
-rw-r--r--src/queries/prisma/pixel.ts60
-rw-r--r--src/queries/prisma/report.ts89
-rw-r--r--src/queries/prisma/segment.ts61
-rw-r--r--src/queries/prisma/team.ts165
-rw-r--r--src/queries/prisma/teamUser.ts66
-rw-r--r--src/queries/prisma/user.ts206
-rw-r--r--src/queries/prisma/website.ts234
-rw-r--r--src/queries/sql/events/getEventData.ts63
-rw-r--r--src/queries/sql/events/getEventDataEvents.ts139
-rw-r--r--src/queries/sql/events/getEventDataFields.ts84
-rw-r--r--src/queries/sql/events/getEventDataProperties.ts88
-rw-r--r--src/queries/sql/events/getEventDataStats.ts90
-rw-r--r--src/queries/sql/events/getEventDataUsage.ts38
-rw-r--r--src/queries/sql/events/getEventDataValues.ts93
-rw-r--r--src/queries/sql/events/getEventExpandedMetrics.ts132
-rw-r--r--src/queries/sql/events/getEventMetrics.ts97
-rw-r--r--src/queries/sql/events/getEventStats.ts101
-rw-r--r--src/queries/sql/events/getEventUsage.ts38
-rw-r--r--src/queries/sql/events/getWebsiteEvents.ts119
-rw-r--r--src/queries/sql/events/saveEvent.ts249
-rw-r--r--src/queries/sql/events/saveEventData.ts79
-rw-r--r--src/queries/sql/events/saveRevenue.ts36
-rw-r--r--src/queries/sql/getActiveVisitors.ts50
-rw-r--r--src/queries/sql/getChannelExpandedMetrics.ts190
-rw-r--r--src/queries/sql/getChannelMetrics.ts142
-rw-r--r--src/queries/sql/getRealtimeActivity.ts80
-rw-r--r--src/queries/sql/getRealtimeData.ts78
-rw-r--r--src/queries/sql/getValues.ts129
-rw-r--r--src/queries/sql/getWebsiteDateRange.ts55
-rw-r--r--src/queries/sql/getWebsiteStats.ts128
-rw-r--r--src/queries/sql/getWeeklyTraffic.ts97
-rw-r--r--src/queries/sql/index.ts41
-rw-r--r--src/queries/sql/pageviews/getPageviewExpandedMetrics.ts227
-rw-r--r--src/queries/sql/pageviews/getPageviewMetrics.ts191
-rw-r--r--src/queries/sql/pageviews/getPageviewStats.ts98
-rw-r--r--src/queries/sql/reports/getAttribution.ts514
-rw-r--r--src/queries/sql/reports/getBreakdown.ts135
-rw-r--r--src/queries/sql/reports/getFunnel.ts255
-rw-r--r--src/queries/sql/reports/getGoal.ts105
-rw-r--r--src/queries/sql/reports/getJourney.ts275
-rw-r--r--src/queries/sql/reports/getRetention.ts173
-rw-r--r--src/queries/sql/reports/getRevenue.ts217
-rw-r--r--src/queries/sql/reports/getUTM.ts84
-rw-r--r--src/queries/sql/sessions/createSession.ts44
-rw-r--r--src/queries/sql/sessions/getSessionActivity.ts78
-rw-r--r--src/queries/sql/sessions/getSessionData.ts60
-rw-r--r--src/queries/sql/sessions/getSessionDataProperties.ts75
-rw-r--r--src/queries/sql/sessions/getSessionDataValues.ts85
-rw-r--r--src/queries/sql/sessions/getSessionExpandedMetrics.ts152
-rw-r--r--src/queries/sql/sessions/getSessionMetrics.ts130
-rw-r--r--src/queries/sql/sessions/getSessionStats.ts98
-rw-r--r--src/queries/sql/sessions/getWebsiteSession.ts113
-rw-r--r--src/queries/sql/sessions/getWebsiteSessionStats.ts97
-rw-r--r--src/queries/sql/sessions/getWebsiteSessions.ts156
-rw-r--r--src/queries/sql/sessions/saveSessionData.ts112
-rw-r--r--src/store/app.ts50
-rw-r--r--src/store/cache.ts9
-rw-r--r--src/store/dashboard.ts22
-rw-r--r--src/store/version.ts55
-rw-r--r--src/store/websites.ts35
-rw-r--r--src/styles/global.css43
-rw-r--r--src/styles/variables.css4
-rw-r--r--src/tracker/index.d.ts153
-rw-r--r--src/tracker/index.js240
725 files changed, 51733 insertions, 0 deletions
diff --git a/src/app/(collect)/p/[slug]/route.ts b/src/app/(collect)/p/[slug]/route.ts
new file mode 100644
index 0000000..79d6faa
--- /dev/null
+++ b/src/app/(collect)/p/[slug]/route.ts
@@ -0,0 +1,68 @@
+export const dynamic = 'force-dynamic';
+
+import { NextResponse } from 'next/server';
+import { POST } from '@/app/api/send/route';
+import type { Pixel } from '@/generated/prisma/client';
+import redis from '@/lib/redis';
+import { notFound } from '@/lib/response';
+import { findPixel } from '@/queries/prisma';
+
+const image = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw', 'base64');
+
+export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+
+ let pixel: Pixel;
+
+ if (redis.enabled) {
+ pixel = await redis.client.fetch(
+ `pixel:${slug}`,
+ async () => {
+ return findPixel({
+ where: {
+ slug,
+ },
+ });
+ },
+ 86400,
+ );
+
+ if (!pixel) {
+ return notFound();
+ }
+ } else {
+ pixel = await findPixel({
+ where: {
+ slug,
+ },
+ });
+
+ if (!pixel) {
+ return notFound();
+ }
+ }
+
+ const payload = {
+ type: 'event',
+ payload: {
+ pixel: pixel.id,
+ url: request.url,
+ referrer: request.headers.get('referer'),
+ },
+ };
+
+ const req = new Request(request.url, {
+ method: 'POST',
+ headers: request.headers,
+ body: JSON.stringify(payload),
+ });
+
+ await POST(req);
+
+ return new NextResponse(image, {
+ headers: {
+ 'Content-Type': 'image/gif',
+ 'Content-Length': image.length.toString(),
+ },
+ });
+}
diff --git a/src/app/(collect)/q/[slug]/route.ts b/src/app/(collect)/q/[slug]/route.ts
new file mode 100644
index 0000000..24089bd
--- /dev/null
+++ b/src/app/(collect)/q/[slug]/route.ts
@@ -0,0 +1,61 @@
+export const dynamic = 'force-dynamic';
+
+import { NextResponse } from 'next/server';
+import { POST } from '@/app/api/send/route';
+import type { Link } from '@/generated/prisma/client';
+import redis from '@/lib/redis';
+import { notFound } from '@/lib/response';
+import { findLink } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+
+ let link: Link;
+
+ if (redis.enabled) {
+ link = await redis.client.fetch(
+ `link:${slug}`,
+ async () => {
+ return findLink({
+ where: {
+ slug,
+ },
+ });
+ },
+ 86400,
+ );
+
+ if (!link) {
+ return notFound();
+ }
+ } else {
+ link = await findLink({
+ where: {
+ slug,
+ },
+ });
+
+ if (!link) {
+ return notFound();
+ }
+ }
+
+ const payload = {
+ type: 'event',
+ payload: {
+ link: link.id,
+ url: request.url,
+ referrer: request.headers.get('referer'),
+ },
+ };
+
+ const req = new Request(request.url, {
+ method: 'POST',
+ headers: request.headers,
+ body: JSON.stringify(payload),
+ });
+
+ await POST(req);
+
+ return NextResponse.redirect(link.url);
+}
diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx
new file mode 100644
index 0000000..eada680
--- /dev/null
+++ b/src/app/(main)/App.tsx
@@ -0,0 +1,62 @@
+'use client';
+import { Column, Grid, Loading, Row } from '@umami/react-zen';
+import Script from 'next/script';
+import { useEffect } from 'react';
+import { MobileNav } from '@/app/(main)/MobileNav';
+import { SideNav } from '@/app/(main)/SideNav';
+import { useConfig, useLoginQuery, useNavigation } from '@/components/hooks';
+import { LAST_TEAM_CONFIG } from '@/lib/constants';
+import { removeItem, setItem } from '@/lib/storage';
+import { UpdateNotice } from './UpdateNotice';
+
+export function App({ children }) {
+ const { user, isLoading, error } = useLoginQuery();
+ const config = useConfig();
+ const { pathname, teamId } = useNavigation();
+
+ useEffect(() => {
+ if (teamId) {
+ setItem(LAST_TEAM_CONFIG, teamId);
+ } else {
+ removeItem(LAST_TEAM_CONFIG);
+ }
+ }, [teamId]);
+
+ if (isLoading || !config) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (error) {
+ window.location.href = config.cloudMode
+ ? `${process.env.cloudUrl}/login`
+ : `${process.env.basePath || ''}/login`;
+ return null;
+ }
+
+ if (!user || !config) {
+ return null;
+ }
+
+ return (
+ <Grid
+ columns={{ xs: '1fr', lg: 'auto 1fr' }}
+ rows={{ xs: 'auto 1fr', lg: '1fr' }}
+ height={{ xs: 'auto', lg: '100vh' }}
+ width="100%"
+ >
+ <Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3">
+ <MobileNav />
+ </Row>
+ <Column display={{ xs: 'none', lg: 'flex' }}>
+ <SideNav />
+ </Column>
+ <Column alignItems="center" overflowY="auto" overflowX="hidden" position="relative">
+ {children}
+ </Column>
+ <UpdateNotice user={user} config={config} />
+ {process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
+ <Script src={`${process.env.basePath || ''}/telemetry.js`} />
+ )}
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/MobileNav.tsx b/src/app/(main)/MobileNav.tsx
new file mode 100644
index 0000000..aaa2584
--- /dev/null
+++ b/src/app/(main)/MobileNav.tsx
@@ -0,0 +1,71 @@
+import { Grid, IconLabel, NavMenu, NavMenuItem, Row, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { Globe, Grid2x2, LinkIcon } from '@/components/icons';
+import { MobileMenuButton } from '@/components/input/MobileMenuButton';
+import { NavButton } from '@/components/input/NavButton';
+import { Logo } from '@/components/svg';
+import { AdminNav } from './admin/AdminNav';
+import { SettingsNav } from './settings/SettingsNav';
+
+export function MobileNav() {
+ const { formatMessage, labels } = useMessages();
+ const { pathname, websiteId, renderUrl } = useNavigation();
+ const isAdmin = pathname.includes('/admin');
+ const isSettings = pathname.includes('/settings');
+
+ const links = [
+ {
+ id: 'websites',
+ label: formatMessage(labels.websites),
+ path: '/websites',
+ icon: <Globe />,
+ },
+ {
+ id: 'links',
+ label: formatMessage(labels.links),
+ path: '/links',
+ icon: <LinkIcon />,
+ },
+ {
+ id: 'pixels',
+ label: formatMessage(labels.pixels),
+ path: '/pixels',
+ icon: <Grid2x2 />,
+ },
+ ];
+
+ return (
+ <Grid columns="auto 1fr" flexGrow={1} backgroundColor="3" borderRadius>
+ <MobileMenuButton>
+ {({ close }) => {
+ return (
+ <>
+ <NavMenu padding="3" onItemClick={close} border="bottom">
+ <NavButton />
+ {links.map(link => {
+ return (
+ <Link key={link.id} href={renderUrl(link.path)}>
+ <NavMenuItem>
+ <IconLabel icon={link.icon} label={link.label} />
+ </NavMenuItem>
+ </Link>
+ );
+ })}
+ </NavMenu>
+ {websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />}
+ {isAdmin && <AdminNav onItemClick={close} />}
+ {isSettings && <SettingsNav onItemClick={close} />}
+ </>
+ );
+ }}
+ </MobileMenuButton>
+ <Row alignItems="center" justifyContent="center" flexGrow={1}>
+ <IconLabel icon={<Logo />} style={{ width: 'auto' }}>
+ <Text weight="bold">umami</Text>
+ </IconLabel>
+ </Row>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/SideNav.tsx b/src/app/(main)/SideNav.tsx
new file mode 100644
index 0000000..1ecb58d
--- /dev/null
+++ b/src/app/(main)/SideNav.tsx
@@ -0,0 +1,87 @@
+import {
+ Row,
+ Sidebar,
+ SidebarHeader,
+ SidebarItem,
+ type SidebarProps,
+ SidebarSection,
+ ThemeButton,
+} from '@umami/react-zen';
+import Link from 'next/link';
+import type { Key } from 'react';
+import { useGlobalState, useMessages, useNavigation } from '@/components/hooks';
+import { Globe, Grid2x2, LinkIcon, PanelLeft } from '@/components/icons';
+import { LanguageButton } from '@/components/input/LanguageButton';
+import { NavButton } from '@/components/input/NavButton';
+import { PanelButton } from '@/components/input/PanelButton';
+import { Logo } from '@/components/svg';
+
+export function SideNav(props: SidebarProps) {
+ const { formatMessage, labels } = useMessages();
+ const { pathname, renderUrl, websiteId, router } = useNavigation();
+ const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed');
+
+ const hasNav = !!(websiteId || pathname.startsWith('/admin') || pathname.includes('/settings'));
+
+ const links = [
+ {
+ id: 'websites',
+ label: formatMessage(labels.websites),
+ path: '/websites',
+ icon: <Globe />,
+ },
+ {
+ id: 'links',
+ label: formatMessage(labels.links),
+ path: '/links',
+ icon: <LinkIcon />,
+ },
+ {
+ id: 'pixels',
+ label: formatMessage(labels.pixels),
+ path: '/pixels',
+ icon: <Grid2x2 />,
+ },
+ ];
+
+ const handleSelect = (id: Key) => {
+ router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
+ };
+
+ return (
+ <Sidebar {...props} isCollapsed={isCollapsed || hasNav} backgroundColor>
+ <SidebarSection onClick={() => setIsCollapsed(false)}>
+ <SidebarHeader
+ label="umami"
+ icon={isCollapsed && !hasNav ? <PanelLeft /> : <Logo />}
+ style={{ maxHeight: 40 }}
+ >
+ {!isCollapsed && !hasNav && <PanelButton />}
+ </SidebarHeader>
+ </SidebarSection>
+ <SidebarSection paddingTop="0" paddingBottom="0" justifyContent="center">
+ <NavButton showText={!hasNav && !isCollapsed} onAction={handleSelect} />
+ </SidebarSection>
+ <SidebarSection flexGrow={1}>
+ {links.map(({ id, path, label, icon }) => {
+ return (
+ <Link key={id} href={renderUrl(path, false)} role="button">
+ <SidebarItem
+ label={label}
+ icon={icon}
+ isSelected={pathname.includes(path)}
+ role="button"
+ />
+ </Link>
+ );
+ })}
+ </SidebarSection>
+ <SidebarSection justifyContent="flex-start">
+ <Row wrap="wrap">
+ <LanguageButton />
+ <ThemeButton />
+ </Row>
+ </SidebarSection>
+ </Sidebar>
+ );
+}
diff --git a/src/app/(main)/TopNav.tsx b/src/app/(main)/TopNav.tsx
new file mode 100644
index 0000000..d410097
--- /dev/null
+++ b/src/app/(main)/TopNav.tsx
@@ -0,0 +1,26 @@
+import { Row, ThemeButton } from '@umami/react-zen';
+import { LanguageButton } from '@/components/input/LanguageButton';
+import { ProfileButton } from '@/components/input/ProfileButton';
+
+export function TopNav() {
+ return (
+ <Row
+ position="absolute"
+ top="0"
+ alignItems="center"
+ justifyContent="flex-end"
+ paddingY="2"
+ paddingX="3"
+ paddingRight="5"
+ width="100%"
+ style={{ position: 'sticky', top: 0 }}
+ zIndex={1}
+ >
+ <Row alignItems="center" justifyContent="flex-end" backgroundColor="2" borderRadius>
+ <ThemeButton />
+ <LanguageButton />
+ <ProfileButton />
+ </Row>
+ </Row>
+ );
+}
diff --git a/src/app/(main)/UpdateNotice.tsx b/src/app/(main)/UpdateNotice.tsx
new file mode 100644
index 0000000..ef441d0
--- /dev/null
+++ b/src/app/(main)/UpdateNotice.tsx
@@ -0,0 +1,61 @@
+import { AlertBanner, Button, Column, Row } from '@umami/react-zen';
+import { usePathname } from 'next/navigation';
+import { useCallback, useEffect, useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { REPO_URL, VERSION_CHECK } from '@/lib/constants';
+import { setItem } from '@/lib/storage';
+import { checkVersion, useVersion } from '@/store/version';
+
+export function UpdateNotice({ user, config }) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { latest, checked, hasUpdate, releaseUrl } = useVersion();
+ const pathname = usePathname();
+ const [dismissed, setDismissed] = useState(checked);
+
+ const allowUpdate =
+ process.env.NODE_ENV === 'production' &&
+ user?.isAdmin &&
+ !config?.updatesDisabled &&
+ !config?.privateMode &&
+ !pathname.includes('/share/') &&
+ !process.env.cloudMode &&
+ !dismissed;
+
+ const updateCheck = useCallback(() => {
+ setItem(VERSION_CHECK, { version: latest, time: Date.now() });
+ }, [latest]);
+
+ function handleViewClick() {
+ updateCheck();
+ setDismissed(true);
+ open(releaseUrl || REPO_URL, '_blank');
+ }
+
+ function handleDismissClick() {
+ updateCheck();
+ setDismissed(true);
+ }
+
+ useEffect(() => {
+ if (allowUpdate) {
+ checkVersion();
+ }
+ }, [allowUpdate]);
+
+ if (!allowUpdate || !hasUpdate) {
+ return null;
+ }
+
+ return (
+ <Column justifyContent="center" alignItems="center" position="fixed" top="10px" width="100%">
+ <Row width="600px">
+ <AlertBanner title={formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}>
+ <Button variant="primary" onPress={handleViewClick}>
+ {formatMessage(labels.viewDetails)}
+ </Button>
+ <Button onPress={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
+ </AlertBanner>
+ </Row>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/admin/AdminLayout.tsx b/src/app/(main)/admin/AdminLayout.tsx
new file mode 100644
index 0000000..3c8fa20
--- /dev/null
+++ b/src/app/(main)/admin/AdminLayout.tsx
@@ -0,0 +1,33 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { PageBody } from '@/components/common/PageBody';
+import { useLoginQuery } from '@/components/hooks';
+import { AdminNav } from './AdminNav';
+
+export function AdminLayout({ children }: { children: ReactNode }) {
+ const { user } = useLoginQuery();
+
+ if (!user.isAdmin || process.env.cloudMode) {
+ return null;
+ }
+
+ return (
+ <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
+ <Column
+ display={{ xs: 'none', lg: 'flex' }}
+ width="240px"
+ height="100%"
+ border="right"
+ backgroundColor
+ marginRight="2"
+ padding="3"
+ >
+ <AdminNav />
+ </Column>
+ <Column gap="6" margin="2">
+ <PageBody>{children}</PageBody>
+ </Column>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/admin/AdminNav.tsx b/src/app/(main)/admin/AdminNav.tsx
new file mode 100644
index 0000000..20c0115
--- /dev/null
+++ b/src/app/(main)/admin/AdminNav.tsx
@@ -0,0 +1,48 @@
+import { SideMenu } from '@/components/common/SideMenu';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { Globe, User, Users } from '@/components/icons';
+
+export function AdminNav({ onItemClick }: { onItemClick?: () => void }) {
+ const { formatMessage, labels } = useMessages();
+ const { pathname } = useNavigation();
+
+ const items = [
+ {
+ label: formatMessage(labels.manage),
+ items: [
+ {
+ id: 'users',
+ label: formatMessage(labels.users),
+ path: '/admin/users',
+ icon: <User />,
+ },
+ {
+ id: 'websites',
+ label: formatMessage(labels.websites),
+ path: '/admin/websites',
+ icon: <Globe />,
+ },
+ {
+ id: 'teams',
+ label: formatMessage(labels.teams),
+ path: '/admin/teams',
+ icon: <Users />,
+ },
+ ],
+ },
+ ];
+
+ const selectedKey = items
+ .flatMap(e => e.items)
+ ?.find(({ path }) => path && pathname.startsWith(path))?.id;
+
+ return (
+ <SideMenu
+ items={items}
+ title={formatMessage(labels.admin)}
+ selectedKey={selectedKey}
+ allowMinimize={false}
+ onItemClick={onItemClick}
+ />
+ );
+}
diff --git a/src/app/(main)/admin/layout.tsx b/src/app/(main)/admin/layout.tsx
new file mode 100644
index 0000000..34cdd0b
--- /dev/null
+++ b/src/app/(main)/admin/layout.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from 'next';
+import { AdminLayout } from './AdminLayout';
+
+export default function ({ children }) {
+ if (process.env.cloudMode) {
+ return null;
+ }
+
+ return <AdminLayout>{children}</AdminLayout>;
+}
+
+export const metadata: Metadata = {
+ title: {
+ template: '%s | Admin | Umami',
+ default: 'Admin | Umami',
+ },
+};
diff --git a/src/app/(main)/admin/teams/AdminTeamsDataTable.tsx b/src/app/(main)/admin/teams/AdminTeamsDataTable.tsx
new file mode 100644
index 0000000..7da8531
--- /dev/null
+++ b/src/app/(main)/admin/teams/AdminTeamsDataTable.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from 'react';
+import { DataGrid } from '@/components/common/DataGrid';
+import { useTeamsQuery } from '@/components/hooks';
+import { AdminTeamsTable } from './AdminTeamsTable';
+
+export function AdminTeamsDataTable({
+ showActions,
+}: {
+ showActions?: boolean;
+ children?: ReactNode;
+}) {
+ const queryResult = useTeamsQuery();
+
+ return (
+ <DataGrid query={queryResult} allowSearch={true}>
+ {({ data }) => <AdminTeamsTable data={data} showActions={showActions} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/admin/teams/AdminTeamsPage.tsx b/src/app/(main)/admin/teams/AdminTeamsPage.tsx
new file mode 100644
index 0000000..41e6f4a
--- /dev/null
+++ b/src/app/(main)/admin/teams/AdminTeamsPage.tsx
@@ -0,0 +1,19 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { AdminTeamsDataTable } from './AdminTeamsDataTable';
+
+export function AdminTeamsPage() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <Column gap="6" margin="2">
+ <PageHeader title={formatMessage(labels.teams)} />
+ <Panel>
+ <AdminTeamsDataTable />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/admin/teams/AdminTeamsTable.tsx b/src/app/(main)/admin/teams/AdminTeamsTable.tsx
new file mode 100644
index 0000000..9f2abd5
--- /dev/null
+++ b/src/app/(main)/admin/teams/AdminTeamsTable.tsx
@@ -0,0 +1,86 @@
+import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { useState } from 'react';
+import { DateDistance } from '@/components/common/DateDistance';
+import { useMessages } from '@/components/hooks';
+import { Edit, Trash } from '@/components/icons';
+import { MenuButton } from '@/components/input/MenuButton';
+import { TeamDeleteForm } from '../../teams/[teamId]/TeamDeleteForm';
+
+export function AdminTeamsTable({
+ data = [],
+ showActions = true,
+}: {
+ data: any[];
+ showActions?: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const [deleteTeam, setDeleteTeam] = useState(null);
+
+ return (
+ <>
+ <DataTable data={data}>
+ <DataColumn id="name" label={formatMessage(labels.name)} width="1fr">
+ {(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
+ </DataColumn>
+ <DataColumn id="websites" label={formatMessage(labels.members)} width="140px">
+ {(row: any) => row?._count?.members}
+ </DataColumn>
+ <DataColumn id="members" label={formatMessage(labels.websites)} width="140px">
+ {(row: any) => row?._count?.websites}
+ </DataColumn>
+ <DataColumn id="owner" label={formatMessage(labels.owner)}>
+ {(row: any) => {
+ const name = row?.members?.[0]?.user?.username;
+
+ return (
+ <Text title={name} truncate>
+ <Link href={`/admin/users/${row?.members?.[0]?.user?.id}`}>{name}</Link>
+ </Text>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)} width="160px">
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ {showActions && (
+ <DataColumn id="action" align="end" width="50px">
+ {(row: any) => {
+ const { id } = row;
+
+ return (
+ <MenuButton>
+ <MenuItem href={`/admin/teams/${id}`} data-test="link-button-edit">
+ <Row alignItems="center" gap>
+ <Icon>
+ <Edit />
+ </Icon>
+ <Text>{formatMessage(labels.edit)}</Text>
+ </Row>
+ </MenuItem>
+ <MenuItem
+ id="delete"
+ onAction={() => setDeleteTeam(id)}
+ data-test="link-button-delete"
+ >
+ <Row alignItems="center" gap>
+ <Icon>
+ <Trash />
+ </Icon>
+ <Text>{formatMessage(labels.delete)}</Text>
+ </Row>
+ </MenuItem>
+ </MenuButton>
+ );
+ }}
+ </DataColumn>
+ )}
+ </DataTable>
+ <Modal isOpen={!!deleteTeam}>
+ <Dialog style={{ width: 400 }}>
+ <TeamDeleteForm teamId={deleteTeam} onClose={() => setDeleteTeam(null)} />
+ </Dialog>
+ </Modal>
+ </>
+ );
+}
diff --git a/src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx b/src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx
new file mode 100644
index 0000000..2150197
--- /dev/null
+++ b/src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx
@@ -0,0 +1,11 @@
+'use client';
+import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
+import { TeamProvider } from '@/app/(main)/teams/TeamProvider';
+
+export function AdminTeamPage({ teamId }: { teamId: string }) {
+ return (
+ <TeamProvider teamId={teamId}>
+ <TeamSettings teamId={teamId} />
+ </TeamProvider>
+ );
+}
diff --git a/src/app/(main)/admin/teams/[teamId]/page.tsx b/src/app/(main)/admin/teams/[teamId]/page.tsx
new file mode 100644
index 0000000..104766a
--- /dev/null
+++ b/src/app/(main)/admin/teams/[teamId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { AdminTeamPage } from './AdminTeamPage';
+
+export default async function ({ params }: { params: Promise<{ teamId: string }> }) {
+ const { teamId } = await params;
+
+ return <AdminTeamPage teamId={teamId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Team',
+};
diff --git a/src/app/(main)/admin/teams/page.tsx b/src/app/(main)/admin/teams/page.tsx
new file mode 100644
index 0000000..987f02b
--- /dev/null
+++ b/src/app/(main)/admin/teams/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next';
+import { AdminTeamsPage } from './AdminTeamsPage';
+
+export default function () {
+ return <AdminTeamsPage />;
+}
+export const metadata: Metadata = {
+ title: 'Teams',
+};
diff --git a/src/app/(main)/admin/users/UserAddButton.tsx b/src/app/(main)/admin/users/UserAddButton.tsx
new file mode 100644
index 0000000..0525082
--- /dev/null
+++ b/src/app/(main)/admin/users/UserAddButton.tsx
@@ -0,0 +1,32 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { UserAddForm } from './UserAddForm';
+
+export function UserAddButton({ onSave }: { onSave?: () => void }) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+
+ const handleSave = () => {
+ toast(formatMessage(messages.saved));
+ touch('users');
+ onSave?.();
+ };
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary" data-test="button-create-user">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.createUser)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.createUser)} style={{ width: 400 }}>
+ {({ close }) => <UserAddForm onSave={handleSave} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/admin/users/UserAddForm.tsx b/src/app/(main)/admin/users/UserAddForm.tsx
new file mode 100644
index 0000000..6c36551
--- /dev/null
+++ b/src/app/(main)/admin/users/UserAddForm.tsx
@@ -0,0 +1,71 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ ListItem,
+ PasswordField,
+ Select,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function UserAddForm({ onSave, onClose }) {
+ const { mutateAsync, error, isPending } = useUpdateQuery(`/users`);
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ onSave(data);
+ onClose();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
+ <FormField
+ label={formatMessage(labels.username)}
+ name="username"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="new-username" data-test="input-username" />
+ </FormField>
+ <FormField
+ label={formatMessage(labels.password)}
+ name="password"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <PasswordField autoComplete="new-password" data-test="input-password" />
+ </FormField>
+ <FormField
+ label={formatMessage(labels.role)}
+ name="role"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <Select>
+ <ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
+ {formatMessage(labels.viewOnly)}
+ </ListItem>
+ <ListItem id={ROLES.user} data-test="dropdown-item-user">
+ {formatMessage(labels.user)}
+ </ListItem>
+ <ListItem id={ROLES.admin} data-test="dropdown-item-admin">
+ {formatMessage(labels.admin)}
+ </ListItem>
+ </Select>
+ </FormField>
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={false}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/admin/users/UserDeleteButton.tsx b/src/app/(main)/admin/users/UserDeleteButton.tsx
new file mode 100644
index 0000000..ee8f2c1
--- /dev/null
+++ b/src/app/(main)/admin/users/UserDeleteButton.tsx
@@ -0,0 +1,35 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useLoginQuery, useMessages } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { UserDeleteForm } from './UserDeleteForm';
+
+export function UserDeleteButton({
+ userId,
+ username,
+ onDelete,
+}: {
+ userId: string;
+ username: string;
+ onDelete?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+
+ return (
+ <DialogTrigger>
+ <Button isDisabled={userId === user?.id} data-test="button-delete">
+ <Icon size="sm">
+ <Trash />
+ </Icon>
+ <Text>{formatMessage(labels.delete)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.deleteUser)} style={{ width: 400 }}>
+ {({ close }) => (
+ <UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/admin/users/UserDeleteForm.tsx b/src/app/(main)/admin/users/UserDeleteForm.tsx
new file mode 100644
index 0000000..8f6fd50
--- /dev/null
+++ b/src/app/(main)/admin/users/UserDeleteForm.tsx
@@ -0,0 +1,41 @@
+import { AlertDialog, Row } from '@umami/react-zen';
+import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
+
+export function UserDeleteForm({
+ userId,
+ username,
+ onSave,
+ onClose,
+}: {
+ userId: string;
+ username: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { messages, labels, formatMessage } = useMessages();
+ const { mutateAsync } = useDeleteQuery(`/users/${userId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch('users');
+ touch(`users:${userId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <AlertDialog
+ title={formatMessage(labels.delete)}
+ onConfirm={handleConfirm}
+ onCancel={onClose}
+ confirmLabel={formatMessage(labels.delete)}
+ isDanger
+ >
+ <Row gap="1">{formatMessage(messages.confirmDelete, { target: username })}</Row>
+ </AlertDialog>
+ );
+}
diff --git a/src/app/(main)/admin/users/UsersDataTable.tsx b/src/app/(main)/admin/users/UsersDataTable.tsx
new file mode 100644
index 0000000..8467bd2
--- /dev/null
+++ b/src/app/(main)/admin/users/UsersDataTable.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from 'react';
+import { DataGrid } from '@/components/common/DataGrid';
+import { useUsersQuery } from '@/components/hooks';
+import { UsersTable } from './UsersTable';
+
+export function UsersDataTable({ showActions }: { showActions?: boolean; children?: ReactNode }) {
+ const queryResult = useUsersQuery();
+
+ return (
+ <DataGrid query={queryResult} allowSearch={true}>
+ {({ data }) => <UsersTable data={data} showActions={showActions} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/admin/users/UsersPage.tsx b/src/app/(main)/admin/users/UsersPage.tsx
new file mode 100644
index 0000000..7e1b0f4
--- /dev/null
+++ b/src/app/(main)/admin/users/UsersPage.tsx
@@ -0,0 +1,24 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { UserAddButton } from './UserAddButton';
+import { UsersDataTable } from './UsersDataTable';
+
+export function UsersPage() {
+ const { formatMessage, labels } = useMessages();
+
+ const handleSave = () => {};
+
+ return (
+ <Column gap="6" margin="2">
+ <PageHeader title={formatMessage(labels.users)}>
+ <UserAddButton onSave={handleSave} />
+ </PageHeader>
+ <Panel>
+ <UsersDataTable />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/admin/users/UsersTable.tsx b/src/app/(main)/admin/users/UsersTable.tsx
new file mode 100644
index 0000000..9c10f3e
--- /dev/null
+++ b/src/app/(main)/admin/users/UsersTable.tsx
@@ -0,0 +1,84 @@
+import { DataColumn, DataTable, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { useState } from 'react';
+import { DateDistance } from '@/components/common/DateDistance';
+import { useMessages } from '@/components/hooks';
+import { Edit, Trash } from '@/components/icons';
+import { MenuButton } from '@/components/input/MenuButton';
+import { ROLES } from '@/lib/constants';
+import { UserDeleteForm } from './UserDeleteForm';
+
+export function UsersTable({
+ data = [],
+ showActions = true,
+}: {
+ data: any[];
+ showActions?: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const [deleteUser, setDeleteUser] = useState(null);
+
+ return (
+ <>
+ <DataTable data={data}>
+ <DataColumn id="username" label={formatMessage(labels.username)} width="2fr">
+ {(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
+ </DataColumn>
+ <DataColumn id="role" label={formatMessage(labels.role)}>
+ {(row: any) =>
+ formatMessage(
+ labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
+ )
+ }
+ </DataColumn>
+ <DataColumn id="websites" label={formatMessage(labels.websites)}>
+ {(row: any) => row._count.websites}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ {showActions && (
+ <DataColumn id="action" align="end" width="100px">
+ {(row: any) => {
+ const { id } = row;
+
+ return (
+ <MenuButton>
+ <MenuItem href={`/admin/users/${id}`} data-test="link-button-edit">
+ <Row alignItems="center" gap>
+ <Icon>
+ <Edit />
+ </Icon>
+ <Text>{formatMessage(labels.edit)}</Text>
+ </Row>
+ </MenuItem>
+ <MenuItem
+ id="delete"
+ onAction={() => setDeleteUser(row)}
+ data-test="link-button-delete"
+ >
+ <Row alignItems="center" gap>
+ <Icon>
+ <Trash />
+ </Icon>
+ <Text>{formatMessage(labels.delete)}</Text>
+ </Row>
+ </MenuItem>
+ </MenuButton>
+ );
+ }}
+ </DataColumn>
+ )}
+ </DataTable>
+ <Modal isOpen={!!deleteUser}>
+ <UserDeleteForm
+ userId={deleteUser?.id}
+ username={deleteUser?.username}
+ onClose={() => {
+ setDeleteUser(null);
+ }}
+ />
+ </Modal>
+ </>
+ );
+}
diff --git a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx
new file mode 100644
index 0000000..28bf030
--- /dev/null
+++ b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx
@@ -0,0 +1,73 @@
+import {
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ ListItem,
+ PasswordField,
+ Select,
+ TextField,
+} from '@umami/react-zen';
+import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
+ const { formatMessage, labels, messages, getMessage } = useMessages();
+ const user = useUser();
+ const { user: login } = useLoginQuery();
+
+ const { mutateAsync, error, toast, touch } = useUpdateQuery(`/users/${userId}`);
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('users');
+ touch(`user:${user.id}`);
+ onSave?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getMessage(error?.code)} values={user}>
+ <FormField name="username" label={formatMessage(labels.username)}>
+ <TextField data-test="input-username" />
+ </FormField>
+ <FormField
+ name="password"
+ label={formatMessage(labels.password)}
+ rules={{
+ minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
+ }}
+ >
+ <PasswordField autoComplete="new-password" data-test="input-password" />
+ </FormField>
+
+ {user.id !== login.id && (
+ <FormField
+ name="role"
+ label={formatMessage(labels.role)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <Select defaultValue={user.role}>
+ <ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
+ {formatMessage(labels.viewOnly)}
+ </ListItem>
+ <ListItem id={ROLES.user} data-test="dropdown-item-user">
+ {formatMessage(labels.user)}
+ </ListItem>
+ <ListItem id={ROLES.admin} data-test="dropdown-item-admin">
+ {formatMessage(labels.admin)}
+ </ListItem>
+ </Select>
+ </FormField>
+ )}
+ <FormButtons>
+ <FormSubmitButton data-test="button-submit" variant="primary">
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/admin/users/[userId]/UserHeader.tsx b/src/app/(main)/admin/users/[userId]/UserHeader.tsx
new file mode 100644
index 0000000..1f82897
--- /dev/null
+++ b/src/app/(main)/admin/users/[userId]/UserHeader.tsx
@@ -0,0 +1,9 @@
+import { PageHeader } from '@/components/common/PageHeader';
+import { useUser } from '@/components/hooks';
+import { User } from '@/components/icons';
+
+export function UserHeader() {
+ const user = useUser();
+
+ return <PageHeader title={user?.username} icon={<User />} />;
+}
diff --git a/src/app/(main)/admin/users/[userId]/UserPage.tsx b/src/app/(main)/admin/users/[userId]/UserPage.tsx
new file mode 100644
index 0000000..5e0f8d1
--- /dev/null
+++ b/src/app/(main)/admin/users/[userId]/UserPage.tsx
@@ -0,0 +1,19 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { UserHeader } from '@/app/(main)/admin/users/[userId]/UserHeader';
+import { Panel } from '@/components/common/Panel';
+import { UserProvider } from './UserProvider';
+import { UserSettings } from './UserSettings';
+
+export function UserPage({ userId }: { userId: string }) {
+ return (
+ <UserProvider userId={userId}>
+ <Column gap="6">
+ <UserHeader />
+ <Panel>
+ <UserSettings userId={userId} />
+ </Panel>
+ </Column>
+ </UserProvider>
+ );
+}
diff --git a/src/app/(main)/admin/users/[userId]/UserProvider.tsx b/src/app/(main)/admin/users/[userId]/UserProvider.tsx
new file mode 100644
index 0000000..ea01915
--- /dev/null
+++ b/src/app/(main)/admin/users/[userId]/UserProvider.tsx
@@ -0,0 +1,20 @@
+import { Loading } from '@umami/react-zen';
+import { createContext, type ReactNode } from 'react';
+import { useUserQuery } from '@/components/hooks/queries/useUserQuery';
+import type { User } from '@/generated/prisma/client';
+
+export const UserContext = createContext<User>(null);
+
+export function UserProvider({ userId, children }: { userId: string; children: ReactNode }) {
+ const { data: user, isFetching, isLoading } = useUserQuery(userId);
+
+ if (isFetching && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (!user) {
+ return null;
+ }
+
+ return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
+}
diff --git a/src/app/(main)/admin/users/[userId]/UserSettings.tsx b/src/app/(main)/admin/users/[userId]/UserSettings.tsx
new file mode 100644
index 0000000..3f17f3e
--- /dev/null
+++ b/src/app/(main)/admin/users/[userId]/UserSettings.tsx
@@ -0,0 +1,25 @@
+import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { UserEditForm } from './UserEditForm';
+import { UserWebsites } from './UserWebsites';
+
+export function UserSettings({ userId }: { userId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <Column gap="6">
+ <Tabs>
+ <TabList>
+ <Tab id="details">{formatMessage(labels.details)}</Tab>
+ <Tab id="websites">{formatMessage(labels.websites)}</Tab>
+ </TabList>
+ <TabPanel id="details" style={{ width: 500 }}>
+ <UserEditForm userId={userId} />
+ </TabPanel>
+ <TabPanel id="websites">
+ <UserWebsites userId={userId} />
+ </TabPanel>
+ </Tabs>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/admin/users/[userId]/UserWebsites.tsx b/src/app/(main)/admin/users/[userId]/UserWebsites.tsx
new file mode 100644
index 0000000..eeb173e
--- /dev/null
+++ b/src/app/(main)/admin/users/[userId]/UserWebsites.tsx
@@ -0,0 +1,15 @@
+import { WebsitesTable } from '@/app/(main)/websites/WebsitesTable';
+import { DataGrid } from '@/components/common/DataGrid';
+import { useUserWebsitesQuery } from '@/components/hooks';
+
+export function UserWebsites({ userId }) {
+ const queryResult = useUserWebsitesQuery({ userId });
+
+ return (
+ <DataGrid query={queryResult}>
+ {({ data }) => (
+ <WebsitesTable data={data} showActions={true} allowEdit={true} allowView={true} />
+ )}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/admin/users/[userId]/page.tsx b/src/app/(main)/admin/users/[userId]/page.tsx
new file mode 100644
index 0000000..16c9f36
--- /dev/null
+++ b/src/app/(main)/admin/users/[userId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { UserPage } from './UserPage';
+
+export default async function ({ params }: { params: Promise<{ userId: string }> }) {
+ const { userId } = await params;
+
+ return <UserPage userId={userId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'User',
+};
diff --git a/src/app/(main)/admin/users/page.tsx b/src/app/(main)/admin/users/page.tsx
new file mode 100644
index 0000000..96e69eb
--- /dev/null
+++ b/src/app/(main)/admin/users/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next';
+import { UsersPage } from './UsersPage';
+
+export default function () {
+ return <UsersPage />;
+}
+export const metadata: Metadata = {
+ title: 'Users',
+};
diff --git a/src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx b/src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx
new file mode 100644
index 0000000..2105992
--- /dev/null
+++ b/src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx
@@ -0,0 +1,13 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useWebsitesQuery } from '@/components/hooks';
+import { AdminWebsitesTable } from './AdminWebsitesTable';
+
+export function AdminWebsitesDataTable() {
+ const query = useWebsitesQuery();
+
+ return (
+ <DataGrid query={query} allowSearch={true}>
+ {props => <AdminWebsitesTable {...props} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/admin/websites/AdminWebsitesPage.tsx b/src/app/(main)/admin/websites/AdminWebsitesPage.tsx
new file mode 100644
index 0000000..1c2ac92
--- /dev/null
+++ b/src/app/(main)/admin/websites/AdminWebsitesPage.tsx
@@ -0,0 +1,19 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { AdminWebsitesDataTable } from './AdminWebsitesDataTable';
+
+export function AdminWebsitesPage() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <Column gap="6" margin="2">
+ <PageHeader title={formatMessage(labels.websites)} />
+ <Panel>
+ <AdminWebsitesDataTable />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/admin/websites/AdminWebsitesTable.tsx b/src/app/(main)/admin/websites/AdminWebsitesTable.tsx
new file mode 100644
index 0000000..cfda595
--- /dev/null
+++ b/src/app/(main)/admin/websites/AdminWebsitesTable.tsx
@@ -0,0 +1,89 @@
+import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { useState } from 'react';
+import { WebsiteDeleteForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
+import { DateDistance } from '@/components/common/DateDistance';
+import { useMessages } from '@/components/hooks';
+import { Edit, Trash, Users } from '@/components/icons';
+import { MenuButton } from '@/components/input/MenuButton';
+
+export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
+ const { formatMessage, labels } = useMessages();
+ const [deleteWebsite, setDeleteWebsite] = useState(null);
+
+ return (
+ <>
+ <DataTable data={data}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {(row: any) => (
+ <Text truncate>
+ <Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
+ </Text>
+ )}
+ </DataColumn>
+ <DataColumn id="domain" label={formatMessage(labels.domain)}>
+ {(row: any) => <Text truncate>{row.domain}</Text>}
+ </DataColumn>
+ <DataColumn id="owner" label={formatMessage(labels.owner)}>
+ {(row: any) => {
+ if (row?.team) {
+ return (
+ <Row alignItems="center" gap>
+ <Icon>
+ <Users />
+ </Icon>
+ <Text truncate>
+ <Link href={`/admin/teams/${row?.team?.id}`}>{row?.team?.name}</Link>
+ </Text>
+ </Row>
+ );
+ }
+ return (
+ <Text truncate>
+ <Link href={`/admin/users/${row?.user?.id}`}>{row?.user?.username}</Link>
+ </Text>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)} width="180px">
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="50px">
+ {(row: any) => {
+ const { id } = row;
+
+ return (
+ <MenuButton>
+ <MenuItem href={`/admin/websites/${id}`} data-test="link-button-edit">
+ <Row alignItems="center" gap>
+ <Icon>
+ <Edit />
+ </Icon>
+ <Text>{formatMessage(labels.edit)}</Text>
+ </Row>
+ </MenuItem>
+ <MenuItem
+ id="delete"
+ onAction={() => setDeleteWebsite(id)}
+ data-test="link-button-delete"
+ >
+ <Row alignItems="center" gap>
+ <Icon>
+ <Trash />
+ </Icon>
+ <Text>{formatMessage(labels.delete)}</Text>
+ </Row>
+ </MenuItem>
+ </MenuButton>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ <Modal isOpen={!!deleteWebsite}>
+ <Dialog style={{ width: 400 }}>
+ <WebsiteDeleteForm websiteId={deleteWebsite} onClose={() => setDeleteWebsite(null)} />
+ </Dialog>
+ </Modal>
+ </>
+ );
+}
diff --git a/src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx b/src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx
new file mode 100644
index 0000000..5da82af
--- /dev/null
+++ b/src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx
@@ -0,0 +1,14 @@
+'use client';
+import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
+import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
+import { Panel } from '@/components/common/Panel';
+
+export function AdminWebsitePage({ websiteId }: { websiteId: string }) {
+ return (
+ <WebsiteProvider websiteId={websiteId}>
+ <Panel>
+ <WebsiteSettings websiteId={websiteId} />
+ </Panel>
+ </WebsiteProvider>
+ );
+}
diff --git a/src/app/(main)/admin/websites/[websiteId]/page.tsx b/src/app/(main)/admin/websites/[websiteId]/page.tsx
new file mode 100644
index 0000000..557adbd
--- /dev/null
+++ b/src/app/(main)/admin/websites/[websiteId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <WebsiteSettingsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Website',
+};
diff --git a/src/app/(main)/admin/websites/page.tsx b/src/app/(main)/admin/websites/page.tsx
new file mode 100644
index 0000000..d6da9f6
--- /dev/null
+++ b/src/app/(main)/admin/websites/page.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next';
+import { AdminWebsitesPage } from './AdminWebsitesPage';
+
+export default function () {
+ return <AdminWebsitesPage />;
+}
+export const metadata: Metadata = {
+ title: 'Websites',
+};
diff --git a/src/app/(main)/boards/BoardAddButton.tsx b/src/app/(main)/boards/BoardAddButton.tsx
new file mode 100644
index 0000000..f9f80f4
--- /dev/null
+++ b/src/app/(main)/boards/BoardAddButton.tsx
@@ -0,0 +1,32 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
+import { useMessages, useModified, useNavigation } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { BoardAddForm } from './BoardAddForm';
+
+export function BoardAddButton() {
+ const { formatMessage, labels, messages } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+ const { teamId } = useNavigation();
+
+ const handleSave = async () => {
+ toast(formatMessage(messages.saved));
+ touch('boards');
+ };
+
+ return (
+ <DialogTrigger>
+ <Button data-test="button-website-add" variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.addBoard)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.addBoard)} style={{ width: 400 }}>
+ {({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/boards/BoardAddForm.tsx b/src/app/(main)/boards/BoardAddForm.tsx
new file mode 100644
index 0000000..6471b21
--- /dev/null
+++ b/src/app/(main)/boards/BoardAddForm.tsx
@@ -0,0 +1,60 @@
+import { Button, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+import { DOMAIN_REGEX } from '@/lib/constants';
+
+export function BoardAddForm({
+ teamId,
+ onSave,
+ onClose,
+}: {
+ teamId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery('/websites', { teamId });
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={error?.message}>
+ <FormField
+ label={formatMessage(labels.name)}
+ data-test="input-name"
+ name="name"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+
+ <FormField
+ label={formatMessage(labels.domain)}
+ data-test="input-domain"
+ name="domain"
+ rules={{
+ required: formatMessage(labels.required),
+ pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
+ }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+ <Row justifyContent="flex-end" paddingTop="3" gap="3">
+ {onClose && (
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ )}
+ <FormSubmitButton data-test="button-submit" isDisabled={false}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </Row>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/boards/BoardsPage.tsx b/src/app/(main)/boards/BoardsPage.tsx
new file mode 100644
index 0000000..fa5eb64
--- /dev/null
+++ b/src/app/(main)/boards/BoardsPage.tsx
@@ -0,0 +1,17 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { BoardAddButton } from './BoardAddButton';
+
+export function BoardsPage() {
+ return (
+ <PageBody>
+ <Column margin="2">
+ <PageHeader title="My Boards">
+ <BoardAddButton />
+ </PageHeader>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/boards/[boardId]/Board.tsx b/src/app/(main)/boards/[boardId]/Board.tsx
new file mode 100644
index 0000000..93f24cc
--- /dev/null
+++ b/src/app/(main)/boards/[boardId]/Board.tsx
@@ -0,0 +1,10 @@
+import { Column, Heading } from '@umami/react-zen';
+
+export function Board({ boardId }: { boardId: string }) {
+ return (
+ <Column>
+ <Heading>Board title</Heading>
+ <div>{boardId}</div>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/boards/[boardId]/page.tsx b/src/app/(main)/boards/[boardId]/page.tsx
new file mode 100644
index 0000000..2cb076a
--- /dev/null
+++ b/src/app/(main)/boards/[boardId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { Board } from './Board';
+
+export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
+ const { boardId } = await params;
+
+ return <Board boardId={boardId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Board',
+};
diff --git a/src/app/(main)/boards/page.tsx b/src/app/(main)/boards/page.tsx
new file mode 100644
index 0000000..e8ca662
--- /dev/null
+++ b/src/app/(main)/boards/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { BoardsPage } from './BoardsPage';
+
+export default function () {
+ return <BoardsPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Boards',
+};
diff --git a/src/app/(main)/console/[websiteId]/TestConsolePage.tsx b/src/app/(main)/console/[websiteId]/TestConsolePage.tsx
new file mode 100644
index 0000000..56cc495
--- /dev/null
+++ b/src/app/(main)/console/[websiteId]/TestConsolePage.tsx
@@ -0,0 +1,207 @@
+'use client';
+import { Button, Column, Grid, Heading } from '@umami/react-zen';
+import Link from 'next/link';
+import Script from 'next/script';
+import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useWebsiteQuery } from '@/components/hooks';
+import { EventsChart } from '@/components/metrics/EventsChart';
+
+export function TestConsolePage({ websiteId }: { websiteId: string }) {
+ const { data } = useWebsiteQuery(websiteId);
+
+ function handleRunScript() {
+ window.umami.track(props => ({
+ ...props,
+ url: '/page-view',
+ referrer: 'https://www.google.com',
+ }));
+ window.umami.track('track-event-no-data');
+ window.umami.track('track-event-with-data', {
+ test: 'test-data',
+ boolean: true,
+ booleanError: 'true',
+ time: new Date(),
+ user: `user${Math.round(Math.random() * 10)}`,
+ number: 1,
+ number2: Math.random() * 100,
+ time2: new Date().toISOString(),
+ nested: {
+ test: 'test-data',
+ number: 1,
+ object: {
+ test: 'test-data',
+ },
+ },
+ array: [1, 2, 3],
+ });
+ }
+
+ function handleRunRevenue() {
+ window.umami.track(props => ({
+ ...props,
+ url: '/checkout-cart',
+ referrer: 'https://www.google.com',
+ }));
+ window.umami.track('checkout-cart', {
+ revenue: parseFloat((Math.random() * 1000).toFixed(2)),
+ currency: 'USD',
+ });
+ window.umami.track('affiliate-link', {
+ revenue: parseFloat((Math.random() * 1000).toFixed(2)),
+ currency: 'USD',
+ });
+ window.umami.track('promotion-link', {
+ revenue: parseFloat((Math.random() * 1000).toFixed(2)),
+ currency: 'USD',
+ });
+ window.umami.track('checkout-cart', {
+ revenue: parseFloat((Math.random() * 1000).toFixed(2)),
+ currency: 'EUR',
+ });
+ window.umami.track('promotion-link', {
+ revenue: parseFloat((Math.random() * 1000).toFixed(2)),
+ currency: 'EUR',
+ });
+ window.umami.track('affiliate-link', {
+ item1: {
+ productIdentity: 'ABC424',
+ revenue: parseFloat((Math.random() * 10000).toFixed(2)),
+ currency: 'JPY',
+ },
+ item2: {
+ productIdentity: 'ZYW684',
+ revenue: parseFloat((Math.random() * 10000).toFixed(2)),
+ currency: 'JPY',
+ },
+ });
+ }
+
+ function handleRunIdentify() {
+ window.umami.identify({
+ userId: 123,
+ name: 'brian',
+ number: Math.random() * 100,
+ test: 'test-data',
+ boolean: true,
+ booleanError: 'true',
+ time: new Date(),
+ time2: new Date().toISOString(),
+ nested: {
+ test: 'test-data',
+ number: 1,
+ object: {
+ test: 'test-data',
+ },
+ },
+ array: [1, 2, 3],
+ });
+ }
+
+ if (!data) {
+ return null;
+ }
+
+ return (
+ <PageBody>
+ <PageHeader title="Test console">
+ <Column>{data.name}</Column>
+ </PageHeader>
+ <Column gap="6" paddingY="6">
+ <Script
+ async
+ data-website-id={websiteId}
+ src={`${process.env.basePath || ''}/script.js`}
+ data-cache="true"
+ />
+ <Panel>
+ <Grid columns="1fr 1fr 1fr" gap>
+ <Column gap>
+ <Heading>Page links</Heading>
+ <div>
+ <Link href={`/console/${websiteId}?page=1`}>page one</Link>
+ </div>
+ <div>
+ <Link href={`/console/${websiteId}?page=2 `}>page two</Link>
+ </div>
+ <div>
+ <a href="https://www.google.com" data-umami-event="external-link-direct">
+ external link (direct)
+ </a>
+ </div>
+ <div>
+ <a
+ href="https://www.google.com"
+ data-umami-event="external-link-tab"
+ target="_blank"
+ rel="noreferrer"
+ >
+ external link (tab)
+ </a>
+ </div>
+ </Column>
+ <Column gap>
+ <Heading>Click events</Heading>
+ <Button id="send-event-button" data-umami-event="button-click" variant="primary">
+ Send event
+ </Button>
+ <Button
+ id="send-event-data-button"
+ data-umami-event="button-click"
+ data-umami-event-name="bob"
+ data-umami-event-id="123"
+ variant="primary"
+ >
+ Send event with data
+ </Button>
+ <Button
+ id="generate-revenue-button"
+ data-umami-event="checkout-cart"
+ data-umami-event-revenue={(Math.random() * 10000).toFixed(2).toString()}
+ data-umami-event-currency="USD"
+ variant="primary"
+ >
+ Generate revenue data
+ </Button>
+ <Button
+ id="button-with-div-button"
+ data-umami-event="button-click"
+ data-umami-event-name={'bob'}
+ data-umami-event-id="123"
+ variant="primary"
+ >
+ <div>Button with div</div>
+ </Button>
+ <div data-umami-event="div-click">DIV with attribute</div>
+ <div data-umami-event="div-click-one">
+ <div data-umami-event="div-click-two">
+ <div data-umami-event="div-click-three">Nested DIV</div>
+ </div>
+ </div>
+ </Column>
+ <Column gap>
+ <Heading>Javascript events</Heading>
+ <Button id="manual-button" variant="primary" onClick={handleRunScript}>
+ Run script
+ </Button>
+ <Button id="manual-button" variant="primary" onClick={handleRunIdentify}>
+ Run identify
+ </Button>
+ <Button id="manual-button" variant="primary" onClick={handleRunRevenue}>
+ Revenue script
+ </Button>
+ </Column>
+ </Grid>
+ </Panel>
+ <Heading>Pageviews</Heading>
+ <WebsiteChart websiteId={websiteId} />
+ <Heading>Events</Heading>
+ <Panel>
+ <EventsChart websiteId={websiteId} />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/console/[websiteId]/page.tsx b/src/app/(main)/console/[websiteId]/page.tsx
new file mode 100644
index 0000000..28b8161
--- /dev/null
+++ b/src/app/(main)/console/[websiteId]/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from 'next';
+import { TestConsolePage } from './TestConsolePage';
+
+async function getEnabled() {
+ return !!process.env.ENABLE_TEST_CONSOLE;
+}
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ const enabled = await getEnabled();
+
+ if (!enabled) {
+ return null;
+ }
+
+ return <TestConsolePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Test Console',
+};
diff --git a/src/app/(main)/dashboard/DashboardPage.tsx b/src/app/(main)/dashboard/DashboardPage.tsx
new file mode 100644
index 0000000..c2c7e75
--- /dev/null
+++ b/src/app/(main)/dashboard/DashboardPage.tsx
@@ -0,0 +1,17 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages } from '@/components/hooks';
+
+export function DashboardPage() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <PageBody>
+ <Column margin="2">
+ <PageHeader title={formatMessage(labels.dashboard)}></PageHeader>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx
new file mode 100644
index 0000000..4b79b59
--- /dev/null
+++ b/src/app/(main)/dashboard/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { DashboardPage } from './DashboardPage';
+
+export default async function () {
+ return <DashboardPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Dashboard',
+};
diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx
new file mode 100644
index 0000000..98fca4a
--- /dev/null
+++ b/src/app/(main)/layout.tsx
@@ -0,0 +1,18 @@
+import type { Metadata } from 'next';
+import { Suspense } from 'react';
+import { App } from './App';
+
+export default function ({ children }) {
+ return (
+ <Suspense>
+ <App>{children}</App>
+ </Suspense>
+ );
+}
+
+export const metadata: Metadata = {
+ title: {
+ template: '%s | Umami',
+ default: 'Umami',
+ },
+};
diff --git a/src/app/(main)/links/LinkAddButton.tsx b/src/app/(main)/links/LinkAddButton.tsx
new file mode 100644
index 0000000..4276895
--- /dev/null
+++ b/src/app/(main)/links/LinkAddButton.tsx
@@ -0,0 +1,19 @@
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { LinkEditForm } from './LinkEditForm';
+
+export function LinkAddButton({ teamId }: { teamId?: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.addLink)}
+ variant="primary"
+ width="600px"
+ >
+ {({ close }) => <LinkEditForm teamId={teamId} onClose={close} />}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/links/LinkDeleteButton.tsx b/src/app/(main)/links/LinkDeleteButton.tsx
new file mode 100644
index 0000000..78f85f8
--- /dev/null
+++ b/src/app/(main)/links/LinkDeleteButton.tsx
@@ -0,0 +1,57 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function LinkDeleteButton({
+ linkId,
+ name,
+ onSave,
+}: {
+ linkId: string;
+ websiteId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('links');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{name}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/links/LinkEditButton.tsx b/src/app/(main)/links/LinkEditButton.tsx
new file mode 100644
index 0000000..4d85879
--- /dev/null
+++ b/src/app/(main)/links/LinkEditButton.tsx
@@ -0,0 +1,16 @@
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { LinkEditForm } from './LinkEditForm';
+
+export function LinkEditButton({ linkId }: { linkId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton icon={<Edit />} title={formatMessage(labels.link)} variant="quiet" width="800px">
+ {({ close }) => {
+ return <LinkEditForm linkId={linkId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/links/LinkEditForm.tsx b/src/app/(main)/links/LinkEditForm.tsx
new file mode 100644
index 0000000..6c10c7f
--- /dev/null
+++ b/src/app/(main)/links/LinkEditForm.tsx
@@ -0,0 +1,148 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormField,
+ FormSubmitButton,
+ Icon,
+ Label,
+ Loading,
+ Row,
+ TextField,
+} from '@umami/react-zen';
+import { useEffect, useState } from 'react';
+import { useConfig, useLinkQuery, useMessages } from '@/components/hooks';
+import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
+import { RefreshCw } from '@/components/icons';
+import { LINKS_URL } from '@/lib/constants';
+import { getRandomChars } from '@/lib/generate';
+import { isValidUrl } from '@/lib/url';
+
+const generateId = () => getRandomChars(9);
+
+export function LinkEditForm({
+ linkId,
+ teamId,
+ onSave,
+ onClose,
+}: {
+ linkId?: string;
+ teamId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
+ linkId ? `/links/${linkId}` : '/links',
+ {
+ id: linkId,
+ teamId,
+ },
+ );
+ const { linksUrl } = useConfig();
+ const hostUrl = linksUrl || LINKS_URL;
+ const { data, isLoading } = useLinkQuery(linkId);
+ const [slug, setSlug] = useState(generateId());
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('links');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ const handleSlug = () => {
+ const slug = generateId();
+
+ setSlug(slug);
+
+ return slug;
+ };
+
+ const checkUrl = (url: string) => {
+ if (!isValidUrl(url)) {
+ return formatMessage(labels.invalidUrl);
+ }
+ return true;
+ };
+
+ useEffect(() => {
+ if (data) {
+ setSlug(data.slug);
+ }
+ }, [data]);
+
+ if (linkId && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
+ {({ setValue }) => {
+ return (
+ <>
+ <FormField
+ label={formatMessage(labels.name)}
+ name="name"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="off" autoFocus />
+ </FormField>
+
+ <FormField
+ label={formatMessage(labels.destinationUrl)}
+ name="url"
+ rules={{ required: formatMessage(labels.required), validate: checkUrl }}
+ >
+ <TextField placeholder="https://example.com" autoComplete="off" />
+ </FormField>
+
+ <FormField
+ name="slug"
+ rules={{
+ required: formatMessage(labels.required),
+ }}
+ style={{ display: 'none' }}
+ >
+ <input type="hidden" />
+ </FormField>
+
+ <Column>
+ <Label>{formatMessage(labels.link)}</Label>
+ <Row alignItems="center" gap>
+ <TextField
+ value={`${hostUrl}/${slug}`}
+ autoComplete="off"
+ isReadOnly
+ allowCopy
+ style={{ width: '100%' }}
+ />
+ <Button
+ variant="quiet"
+ onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
+ >
+ <Icon>
+ <RefreshCw />
+ </Icon>
+ </Button>
+ </Row>
+ </Column>
+
+ <Row justifyContent="flex-end" paddingTop="3" gap="3">
+ {onClose && (
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ )}
+ <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
+ </Row>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/links/LinkProvider.tsx b/src/app/(main)/links/LinkProvider.tsx
new file mode 100644
index 0000000..c29e13c
--- /dev/null
+++ b/src/app/(main)/links/LinkProvider.tsx
@@ -0,0 +1,21 @@
+'use client';
+import { Loading } from '@umami/react-zen';
+import { createContext, type ReactNode } from 'react';
+import { useLinkQuery } from '@/components/hooks/queries/useLinkQuery';
+import type { Link } from '@/generated/prisma/client';
+
+export const LinkContext = createContext<Link>(null);
+
+export function LinkProvider({ linkId, children }: { linkId?: string; children: ReactNode }) {
+ const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
+
+ if (isFetching && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (!link) {
+ return null;
+ }
+
+ return <LinkContext.Provider value={link}>{children}</LinkContext.Provider>;
+}
diff --git a/src/app/(main)/links/LinksDataTable.tsx b/src/app/(main)/links/LinksDataTable.tsx
new file mode 100644
index 0000000..0b3d660
--- /dev/null
+++ b/src/app/(main)/links/LinksDataTable.tsx
@@ -0,0 +1,14 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useLinksQuery, useNavigation } from '@/components/hooks';
+import { LinksTable } from './LinksTable';
+
+export function LinksDataTable() {
+ const { teamId } = useNavigation();
+ const query = useLinksQuery({ teamId });
+
+ return (
+ <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
+ {({ data }) => <LinksTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/links/LinksPage.tsx b/src/app/(main)/links/LinksPage.tsx
new file mode 100644
index 0000000..a6e4c7c
--- /dev/null
+++ b/src/app/(main)/links/LinksPage.tsx
@@ -0,0 +1,26 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { LinkAddButton } from './LinkAddButton';
+
+export function LinksPage() {
+ const { formatMessage, labels } = useMessages();
+ const { teamId } = useNavigation();
+
+ return (
+ <PageBody>
+ <Column gap="6" margin="2">
+ <PageHeader title={formatMessage(labels.links)}>
+ <LinkAddButton teamId={teamId} />
+ </PageHeader>
+ <Panel>
+ <LinksDataTable />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/links/LinksTable.tsx b/src/app/(main)/links/LinksTable.tsx
new file mode 100644
index 0000000..a3b4a86
--- /dev/null
+++ b/src/app/(main)/links/LinksTable.tsx
@@ -0,0 +1,51 @@
+import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { DateDistance } from '@/components/common/DateDistance';
+import { ExternalLink } from '@/components/common/ExternalLink';
+import { useMessages, useNavigation, useSlug } from '@/components/hooks';
+import { LinkDeleteButton } from './LinkDeleteButton';
+import { LinkEditButton } from './LinkEditButton';
+
+export function LinksTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { websiteId, renderUrl } = useNavigation();
+ const { getSlugUrl } = useSlug('link');
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {({ id, name }: any) => {
+ return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
+ }}
+ </DataColumn>
+ <DataColumn id="slug" label={formatMessage(labels.link)}>
+ {({ slug }: any) => {
+ const url = getSlugUrl(slug);
+ return (
+ <ExternalLink href={url} prefetch={false}>
+ {url}
+ </ExternalLink>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="url" label={formatMessage(labels.destinationUrl)}>
+ {({ url }: any) => {
+ return <ExternalLink href={url}>{url}</ExternalLink>;
+ }}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)} width="200px">
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="100px">
+ {({ id, name }: any) => {
+ return (
+ <Row>
+ <LinkEditButton linkId={id} />
+ <LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/LinkControls.tsx b/src/app/(main)/links/[linkId]/LinkControls.tsx
new file mode 100644
index 0000000..1d1147a
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkControls.tsx
@@ -0,0 +1,32 @@
+import { Column, Row } from '@umami/react-zen';
+import { ExportButton } from '@/components/input/ExportButton';
+import { FilterBar } from '@/components/input/FilterBar';
+import { MonthFilter } from '@/components/input/MonthFilter';
+import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
+import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
+
+export function LinkControls({
+ linkId: websiteId,
+ allowFilter = true,
+ allowDateFilter = true,
+ allowMonthFilter,
+ allowDownload = false,
+}: {
+ linkId: string;
+ allowFilter?: boolean;
+ allowDateFilter?: boolean;
+ allowMonthFilter?: boolean;
+ allowDownload?: boolean;
+}) {
+ return (
+ <Column gap>
+ <Row alignItems="center" justifyContent="space-between" gap="3">
+ {allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
+ {allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
+ {allowDownload && <ExportButton websiteId={websiteId} />}
+ {allowMonthFilter && <MonthFilter />}
+ </Row>
+ {allowFilter && <FilterBar websiteId={websiteId} />}
+ </Column>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/LinkHeader.tsx b/src/app/(main)/links/[linkId]/LinkHeader.tsx
new file mode 100644
index 0000000..a84a626
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkHeader.tsx
@@ -0,0 +1,19 @@
+import { IconLabel } from '@umami/react-zen';
+import { LinkButton } from '@/components/common/LinkButton';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useLink, useMessages, useSlug } from '@/components/hooks';
+import { ExternalLink, Link } from '@/components/icons';
+
+export function LinkHeader() {
+ const { formatMessage, labels } = useMessages();
+ const { getSlugUrl } = useSlug('link');
+ const link = useLink();
+
+ return (
+ <PageHeader title={link.name} description={link.url} icon={<Link />}>
+ <LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor>
+ <IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
+ </LinkButton>
+ </PageHeader>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/LinkMetricsBar.tsx b/src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
new file mode 100644
index 0000000..1fe8c45
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
@@ -0,0 +1,70 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
+
+export function LinkMetricsBar({
+ linkId,
+}: {
+ linkId: string;
+ showChange?: boolean;
+ compareMode?: boolean;
+}) {
+ const { isAllTime } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(linkId);
+
+ const { pageviews, visitors, visits, comparison } = data || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: formatMessage(labels.visitors),
+ change: visitors - comparison.visitors,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: formatMessage(labels.visits),
+ change: visits - comparison.visits,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: pageviews,
+ label: formatMessage(labels.views),
+ change: pageviews - comparison.pageviews,
+ formatValue: formatLongNumber,
+ },
+ ]
+ : null;
+
+ return (
+ <LoadingPanel
+ data={metrics}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={error}
+ minHeight="136px"
+ >
+ <MetricsBar>
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
+ return (
+ <MetricCard
+ key={label}
+ value={value}
+ previousValue={prev}
+ label={label}
+ change={change}
+ formatValue={formatValue}
+ reverseColors={reverseColors}
+ showChange={!isAllTime}
+ />
+ );
+ })}
+ </MetricsBar>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/LinkPage.tsx b/src/app/(main)/links/[linkId]/LinkPage.tsx
new file mode 100644
index 0000000..ddacf08
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkPage.tsx
@@ -0,0 +1,34 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
+import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader';
+import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
+import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
+import { LinkProvider } from '@/app/(main)/links/LinkProvider';
+import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
+import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
+import { PageBody } from '@/components/common/PageBody';
+import { Panel } from '@/components/common/Panel';
+
+const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
+
+export function LinkPage({ linkId }: { linkId: string }) {
+ return (
+ <LinkProvider linkId={linkId}>
+ <Grid width="100%" height="100%">
+ <Column margin="2">
+ <PageBody gap>
+ <LinkHeader />
+ <LinkControls linkId={linkId} />
+ <LinkMetricsBar linkId={linkId} showChange={true} />
+ <Panel>
+ <WebsiteChart websiteId={linkId} />
+ </Panel>
+ <LinkPanels linkId={linkId} />
+ </PageBody>
+ <ExpandedViewModal websiteId={linkId} excludedIds={excludedIds} />
+ </Column>
+ </Grid>
+ </LinkProvider>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/LinkPanels.tsx b/src/app/(main)/links/[linkId]/LinkPanels.tsx
new file mode 100644
index 0000000..f33525e
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/LinkPanels.tsx
@@ -0,0 +1,83 @@
+import { Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { GridRow } from '@/components/common/GridRow';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { WorldMap } from '@/components/metrics/WorldMap';
+
+export function LinkPanels({ linkId }: { linkId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const tableProps = {
+ websiteId: linkId,
+ limit: 10,
+ allowDownload: false,
+ showMore: true,
+ metric: formatMessage(labels.visitors),
+ };
+ const rowProps = { minHeight: 570 };
+
+ return (
+ <Grid gap="3">
+ <GridRow layout="two" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.sources)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
+ <Tab id="channel">{formatMessage(labels.channels)}</Tab>
+ </TabList>
+ <TabPanel id="referrer">
+ <MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="channel">
+ <MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.environment)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="browser">{formatMessage(labels.browsers)}</Tab>
+ <Tab id="os">{formatMessage(labels.os)}</Tab>
+ <Tab id="device">{formatMessage(labels.devices)}</Tab>
+ </TabList>
+ <TabPanel id="browser">
+ <MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="os">
+ <MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="device">
+ <MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+ <GridRow layout="two" {...rowProps}>
+ <Panel padding="0">
+ <WorldMap websiteId={linkId} />
+ </Panel>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.location)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="country">{formatMessage(labels.countries)}</Tab>
+ <Tab id="region">{formatMessage(labels.regions)}</Tab>
+ <Tab id="city">{formatMessage(labels.cities)}</Tab>
+ </TabList>
+ <TabPanel id="country">
+ <MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="region">
+ <MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="city">
+ <MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/links/[linkId]/page.tsx b/src/app/(main)/links/[linkId]/page.tsx
new file mode 100644
index 0000000..4317ada
--- /dev/null
+++ b/src/app/(main)/links/[linkId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { LinkPage } from './LinkPage';
+
+export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
+ const { linkId } = await params;
+
+ return <LinkPage linkId={linkId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Link',
+};
diff --git a/src/app/(main)/links/page.tsx b/src/app/(main)/links/page.tsx
new file mode 100644
index 0000000..24c9c18
--- /dev/null
+++ b/src/app/(main)/links/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { LinksPage } from './LinksPage';
+
+export default function () {
+ return <LinksPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Links',
+};
diff --git a/src/app/(main)/pixels/PixelAddButton.tsx b/src/app/(main)/pixels/PixelAddButton.tsx
new file mode 100644
index 0000000..1573b9e
--- /dev/null
+++ b/src/app/(main)/pixels/PixelAddButton.tsx
@@ -0,0 +1,19 @@
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { PixelEditForm } from './PixelEditForm';
+
+export function PixelAddButton({ teamId }: { teamId?: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.addPixel)}
+ variant="primary"
+ width="600px"
+ >
+ {({ close }) => <PixelEditForm teamId={teamId} onClose={close} />}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelDeleteButton.tsx b/src/app/(main)/pixels/PixelDeleteButton.tsx
new file mode 100644
index 0000000..436dba5
--- /dev/null
+++ b/src/app/(main)/pixels/PixelDeleteButton.tsx
@@ -0,0 +1,57 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function PixelDeleteButton({
+ pixelId,
+ name,
+ onSave,
+}: {
+ pixelId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error } = useDeleteQuery(`/pixels/${pixelId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('pixels');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ variant="quiet"
+ title={formatMessage(labels.confirm)}
+ width="400px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{name}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelEditButton.tsx b/src/app/(main)/pixels/PixelEditButton.tsx
new file mode 100644
index 0000000..3c5924d
--- /dev/null
+++ b/src/app/(main)/pixels/PixelEditButton.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { PixelEditForm } from './PixelEditForm';
+
+export function PixelEditButton({ pixelId }: { pixelId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Edit />}
+ title={formatMessage(labels.addPixel)}
+ variant="quiet"
+ width="600px"
+ >
+ {({ close }) => {
+ return <PixelEditForm pixelId={pixelId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelEditForm.tsx b/src/app/(main)/pixels/PixelEditForm.tsx
new file mode 100644
index 0000000..aedd3a3
--- /dev/null
+++ b/src/app/(main)/pixels/PixelEditForm.tsx
@@ -0,0 +1,129 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormField,
+ FormSubmitButton,
+ Icon,
+ Label,
+ Loading,
+ Row,
+ TextField,
+} from '@umami/react-zen';
+import { useEffect, useState } from 'react';
+import { useConfig, useMessages, usePixelQuery } from '@/components/hooks';
+import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
+import { RefreshCw } from '@/components/icons';
+import { PIXELS_URL } from '@/lib/constants';
+import { getRandomChars } from '@/lib/generate';
+
+const generateId = () => getRandomChars(9);
+
+export function PixelEditForm({
+ pixelId,
+ teamId,
+ onSave,
+ onClose,
+}: {
+ pixelId?: string;
+ teamId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
+ pixelId ? `/pixels/${pixelId}` : '/pixels',
+ {
+ id: pixelId,
+ teamId,
+ },
+ );
+ const { pixelsUrl } = useConfig();
+ const hostUrl = pixelsUrl || PIXELS_URL;
+ const { data, isLoading } = usePixelQuery(pixelId);
+ const [slug, setSlug] = useState(generateId());
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('pixels');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ const handleSlug = () => {
+ const slug = generateId();
+
+ setSlug(slug);
+
+ return slug;
+ };
+
+ useEffect(() => {
+ if (data) {
+ setSlug(data.slug);
+ }
+ }, [data]);
+
+ if (pixelId && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
+ {({ setValue }) => {
+ return (
+ <>
+ <FormField
+ label={formatMessage(labels.name)}
+ name="name"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+
+ <FormField
+ name="slug"
+ rules={{
+ required: formatMessage(labels.required),
+ }}
+ style={{ display: 'none' }}
+ >
+ <input type="hidden" />
+ </FormField>
+
+ <Column>
+ <Label>{formatMessage(labels.link)}</Label>
+ <Row alignItems="center" gap>
+ <TextField
+ value={`${hostUrl}/${slug}`}
+ autoComplete="off"
+ isReadOnly
+ allowCopy
+ style={{ width: '100%' }}
+ />
+ <Button onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}>
+ <Icon>
+ <RefreshCw />
+ </Icon>
+ </Button>
+ </Row>
+ </Column>
+
+ <Row justifyContent="flex-end" paddingTop="3" gap="3">
+ {onClose && (
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ )}
+ <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
+ </Row>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelProvider.tsx b/src/app/(main)/pixels/PixelProvider.tsx
new file mode 100644
index 0000000..9e929d8
--- /dev/null
+++ b/src/app/(main)/pixels/PixelProvider.tsx
@@ -0,0 +1,21 @@
+'use client';
+import { Loading } from '@umami/react-zen';
+import { createContext, type ReactNode } from 'react';
+import { usePixelQuery } from '@/components/hooks/queries/usePixelQuery';
+import type { Pixel } from '@/generated/prisma/client';
+
+export const PixelContext = createContext<Pixel>(null);
+
+export function PixelProvider({ pixelId, children }: { pixelId?: string; children: ReactNode }) {
+ const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId);
+
+ if (isFetching && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (!pixel) {
+ return null;
+ }
+
+ return <PixelContext.Provider value={pixel}>{children}</PixelContext.Provider>;
+}
diff --git a/src/app/(main)/pixels/PixelsDataTable.tsx b/src/app/(main)/pixels/PixelsDataTable.tsx
new file mode 100644
index 0000000..51b8c5a
--- /dev/null
+++ b/src/app/(main)/pixels/PixelsDataTable.tsx
@@ -0,0 +1,14 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useNavigation, usePixelsQuery } from '@/components/hooks';
+import { PixelsTable } from './PixelsTable';
+
+export function PixelsDataTable() {
+ const { teamId } = useNavigation();
+ const query = usePixelsQuery({ teamId });
+
+ return (
+ <DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
+ {({ data }) => <PixelsTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelsPage.tsx b/src/app/(main)/pixels/PixelsPage.tsx
new file mode 100644
index 0000000..4f6acef
--- /dev/null
+++ b/src/app/(main)/pixels/PixelsPage.tsx
@@ -0,0 +1,26 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { PixelAddButton } from './PixelAddButton';
+import { PixelsDataTable } from './PixelsDataTable';
+
+export function PixelsPage() {
+ const { formatMessage, labels } = useMessages();
+ const { teamId } = useNavigation();
+
+ return (
+ <PageBody>
+ <Column gap="6" margin="2">
+ <PageHeader title={formatMessage(labels.pixels)}>
+ <PixelAddButton teamId={teamId} />
+ </PageHeader>
+ <Panel>
+ <PixelsDataTable />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/pixels/PixelsTable.tsx b/src/app/(main)/pixels/PixelsTable.tsx
new file mode 100644
index 0000000..48a8458
--- /dev/null
+++ b/src/app/(main)/pixels/PixelsTable.tsx
@@ -0,0 +1,48 @@
+import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { DateDistance } from '@/components/common/DateDistance';
+import { ExternalLink } from '@/components/common/ExternalLink';
+import { useMessages, useNavigation, useSlug } from '@/components/hooks';
+import { PixelDeleteButton } from './PixelDeleteButton';
+import { PixelEditButton } from './PixelEditButton';
+
+export function PixelsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { renderUrl } = useNavigation();
+ const { getSlugUrl } = useSlug('pixel');
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {({ id, name }: any) => {
+ return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
+ }}
+ </DataColumn>
+ <DataColumn id="url" label="URL">
+ {({ slug }: any) => {
+ const url = getSlugUrl(slug);
+ return (
+ <ExternalLink href={url} prefetch={false}>
+ {url}
+ </ExternalLink>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="100px">
+ {(row: any) => {
+ const { id, name } = row;
+
+ return (
+ <Row>
+ <PixelEditButton pixelId={id} />
+ <PixelDeleteButton pixelId={id} name={name} />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelControls.tsx b/src/app/(main)/pixels/[pixelId]/PixelControls.tsx
new file mode 100644
index 0000000..55dcd57
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelControls.tsx
@@ -0,0 +1,32 @@
+import { Column, Row } from '@umami/react-zen';
+import { ExportButton } from '@/components/input/ExportButton';
+import { FilterBar } from '@/components/input/FilterBar';
+import { MonthFilter } from '@/components/input/MonthFilter';
+import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
+import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
+
+export function PixelControls({
+ pixelId: websiteId,
+ allowFilter = true,
+ allowDateFilter = true,
+ allowMonthFilter,
+ allowDownload = false,
+}: {
+ pixelId: string;
+ allowFilter?: boolean;
+ allowDateFilter?: boolean;
+ allowMonthFilter?: boolean;
+ allowDownload?: boolean;
+}) {
+ return (
+ <Column gap>
+ <Row alignItems="center" justifyContent="space-between" gap="3">
+ {allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
+ {allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
+ {allowDownload && <ExportButton websiteId={websiteId} />}
+ {allowMonthFilter && <MonthFilter />}
+ </Row>
+ {allowFilter && <FilterBar websiteId={websiteId} />}
+ </Column>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx b/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
new file mode 100644
index 0000000..c771687
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
@@ -0,0 +1,19 @@
+import { IconLabel } from '@umami/react-zen';
+import { LinkButton } from '@/components/common/LinkButton';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages, usePixel, useSlug } from '@/components/hooks';
+import { ExternalLink, Grid2x2 } from '@/components/icons';
+
+export function PixelHeader() {
+ const { formatMessage, labels } = useMessages();
+ const { getSlugUrl } = useSlug('pixel');
+ const pixel = usePixel();
+
+ return (
+ <PageHeader title={pixel.name} icon={<Grid2x2 />}>
+ <LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor>
+ <IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} />
+ </LinkButton>
+ </PageHeader>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx b/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
new file mode 100644
index 0000000..c9dcd35
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
@@ -0,0 +1,70 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
+
+export function PixelMetricsBar({
+ pixelId,
+}: {
+ pixelId: string;
+ showChange?: boolean;
+ compareMode?: boolean;
+}) {
+ const { isAllTime } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId);
+
+ const { pageviews, visitors, visits, comparison } = data || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: formatMessage(labels.visitors),
+ change: visitors - comparison.visitors,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: formatMessage(labels.visits),
+ change: visits - comparison.visits,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: pageviews,
+ label: formatMessage(labels.views),
+ change: pageviews - comparison.pageviews,
+ formatValue: formatLongNumber,
+ },
+ ]
+ : null;
+
+ return (
+ <LoadingPanel
+ data={metrics}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={error}
+ minHeight="136px"
+ >
+ <MetricsBar>
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
+ return (
+ <MetricCard
+ key={label}
+ value={value}
+ previousValue={prev}
+ label={label}
+ change={change}
+ formatValue={formatValue}
+ reverseColors={reverseColors}
+ showChange={!isAllTime}
+ />
+ );
+ })}
+ </MetricsBar>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelPage.tsx b/src/app/(main)/pixels/[pixelId]/PixelPage.tsx
new file mode 100644
index 0000000..7a4ae9d
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelPage.tsx
@@ -0,0 +1,34 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls';
+import { PixelHeader } from '@/app/(main)/pixels/[pixelId]/PixelHeader';
+import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
+import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels';
+import { PixelProvider } from '@/app/(main)/pixels/PixelProvider';
+import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
+import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
+import { PageBody } from '@/components/common/PageBody';
+import { Panel } from '@/components/common/Panel';
+
+const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event'];
+
+export function PixelPage({ pixelId }: { pixelId: string }) {
+ return (
+ <PixelProvider pixelId={pixelId}>
+ <Grid width="100%" height="100%">
+ <Column margin="2">
+ <PageBody gap>
+ <PixelHeader />
+ <PixelControls pixelId={pixelId} />
+ <PixelMetricsBar pixelId={pixelId} showChange={true} />
+ <Panel>
+ <WebsiteChart websiteId={pixelId} />
+ </Panel>
+ <PixelPanels pixelId={pixelId} />
+ </PageBody>
+ <ExpandedViewModal websiteId={pixelId} excludedIds={excludedIds} />
+ </Column>
+ </Grid>
+ </PixelProvider>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx b/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
new file mode 100644
index 0000000..9cc24c9
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
@@ -0,0 +1,83 @@
+import { Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { GridRow } from '@/components/common/GridRow';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { WorldMap } from '@/components/metrics/WorldMap';
+
+export function PixelPanels({ pixelId }: { pixelId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const tableProps = {
+ websiteId: pixelId,
+ limit: 10,
+ allowDownload: false,
+ showMore: true,
+ metric: formatMessage(labels.visitors),
+ };
+ const rowProps = { minHeight: 570 };
+
+ return (
+ <Grid gap="3">
+ <GridRow layout="two" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.sources)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
+ <Tab id="channel">{formatMessage(labels.channels)}</Tab>
+ </TabList>
+ <TabPanel id="referrer">
+ <MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="channel">
+ <MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.environment)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="browser">{formatMessage(labels.browsers)}</Tab>
+ <Tab id="os">{formatMessage(labels.os)}</Tab>
+ <Tab id="device">{formatMessage(labels.devices)}</Tab>
+ </TabList>
+ <TabPanel id="browser">
+ <MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="os">
+ <MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="device">
+ <MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+ <GridRow layout="two" {...rowProps}>
+ <Panel padding="0">
+ <WorldMap websiteId={pixelId} />
+ </Panel>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.location)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="country">{formatMessage(labels.countries)}</Tab>
+ <Tab id="region">{formatMessage(labels.regions)}</Tab>
+ <Tab id="city">{formatMessage(labels.cities)}</Tab>
+ </TabList>
+ <TabPanel id="country">
+ <MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="region">
+ <MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="city">
+ <MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/pixels/[pixelId]/page.tsx b/src/app/(main)/pixels/[pixelId]/page.tsx
new file mode 100644
index 0000000..d1db92f
--- /dev/null
+++ b/src/app/(main)/pixels/[pixelId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { PixelPage } from './PixelPage';
+
+export default async function ({ params }: { params: { pixelId: string } }) {
+ const { pixelId } = await params;
+
+ return <PixelPage pixelId={pixelId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Pixel',
+};
diff --git a/src/app/(main)/pixels/page.tsx b/src/app/(main)/pixels/page.tsx
new file mode 100644
index 0000000..cc240cd
--- /dev/null
+++ b/src/app/(main)/pixels/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { PixelsPage } from './PixelsPage';
+
+export default function () {
+ return <PixelsPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Pixels',
+};
diff --git a/src/app/(main)/settings/SettingsLayout.tsx b/src/app/(main)/settings/SettingsLayout.tsx
new file mode 100644
index 0000000..f658872
--- /dev/null
+++ b/src/app/(main)/settings/SettingsLayout.tsx
@@ -0,0 +1,26 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { PageBody } from '@/components/common/PageBody';
+import { SettingsNav } from './SettingsNav';
+
+export function SettingsLayout({ children }: { children: ReactNode }) {
+ return (
+ <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
+ <Column
+ display={{ xs: 'none', lg: 'flex' }}
+ width="240px"
+ height="100%"
+ border="right"
+ backgroundColor
+ marginRight="2"
+ padding="3"
+ >
+ <SettingsNav />
+ </Column>
+ <Column gap="6" margin="2">
+ <PageBody>{children}</PageBody>
+ </Column>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/settings/SettingsNav.tsx b/src/app/(main)/settings/SettingsNav.tsx
new file mode 100644
index 0000000..4b35c82
--- /dev/null
+++ b/src/app/(main)/settings/SettingsNav.tsx
@@ -0,0 +1,53 @@
+import { SideMenu } from '@/components/common/SideMenu';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { Settings2, UserCircle, Users } from '@/components/icons';
+
+export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) {
+ const { formatMessage, labels } = useMessages();
+ const { renderUrl, pathname } = useNavigation();
+
+ const items = [
+ {
+ label: formatMessage(labels.application),
+ items: [
+ {
+ id: 'preferences',
+ label: formatMessage(labels.preferences),
+ path: renderUrl('/settings/preferences'),
+ icon: <Settings2 />,
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.account),
+ items: [
+ {
+ id: 'profile',
+ label: formatMessage(labels.profile),
+ path: renderUrl('/settings/profile'),
+ icon: <UserCircle />,
+ },
+ {
+ id: 'teams',
+ label: formatMessage(labels.teams),
+ path: renderUrl('/settings/teams'),
+ icon: <Users />,
+ },
+ ],
+ },
+ ];
+
+ const selectedKey = items
+ .flatMap(e => e.items)
+ .find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id;
+
+ return (
+ <SideMenu
+ items={items}
+ title={formatMessage(labels.settings)}
+ selectedKey={selectedKey}
+ allowMinimize={false}
+ onItemClick={onItemClick}
+ />
+ );
+}
diff --git a/src/app/(main)/settings/layout.tsx b/src/app/(main)/settings/layout.tsx
new file mode 100644
index 0000000..4e773a3
--- /dev/null
+++ b/src/app/(main)/settings/layout.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from 'next';
+import { SettingsLayout } from './SettingsLayout';
+
+export default function ({ children }) {
+ if (process.env.cloudMode) {
+ return null;
+ }
+
+ return <SettingsLayout>{children}</SettingsLayout>;
+}
+
+export const metadata: Metadata = {
+ title: {
+ template: '%s | Settings | Umami',
+ default: 'Settings | Umami',
+ },
+};
diff --git a/src/app/(main)/settings/preferences/DateRangeSetting.tsx b/src/app/(main)/settings/preferences/DateRangeSetting.tsx
new file mode 100644
index 0000000..3f5e664
--- /dev/null
+++ b/src/app/(main)/settings/preferences/DateRangeSetting.tsx
@@ -0,0 +1,28 @@
+import { Button, Row } from '@umami/react-zen';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { DateFilter } from '@/components/input/DateFilter';
+import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
+import { getItem, setItem } from '@/lib/storage';
+
+export function DateRangeSetting() {
+ const { formatMessage, labels } = useMessages();
+ const [date, setDate] = useState(getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE);
+
+ const handleChange = (value: string) => {
+ setItem(DATE_RANGE_CONFIG, value);
+ setDate(value);
+ };
+
+ const handleReset = () => {
+ setItem(DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE);
+ setDate(DEFAULT_DATE_RANGE_VALUE);
+ };
+
+ return (
+ <Row gap="3">
+ <DateFilter value={date} onChange={handleChange} placement="bottom start" />
+ <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
+ </Row>
+ );
+}
diff --git a/src/app/(main)/settings/preferences/LanguageSetting.tsx b/src/app/(main)/settings/preferences/LanguageSetting.tsx
new file mode 100644
index 0000000..00a2d74
--- /dev/null
+++ b/src/app/(main)/settings/preferences/LanguageSetting.tsx
@@ -0,0 +1,48 @@
+import { Button, ListItem, Row, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { useLocale, useMessages } from '@/components/hooks';
+import { DEFAULT_LOCALE } from '@/lib/constants';
+import { languages } from '@/lib/lang';
+
+export function LanguageSetting() {
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const { locale, saveLocale } = useLocale();
+ const items = search
+ ? Object.keys(languages).filter(n => {
+ return (
+ n.toLowerCase().includes(search.toLowerCase()) ||
+ languages[n].label.toLowerCase().includes(search.toLowerCase())
+ );
+ })
+ : Object.keys(languages);
+
+ const handleReset = () => saveLocale(DEFAULT_LOCALE);
+
+ const handleOpen = (isOpen: boolean) => {
+ if (isOpen) {
+ setSearch('');
+ }
+ };
+
+ return (
+ <Row gap>
+ <Select
+ value={locale}
+ onChange={val => saveLocale(val as string)}
+ allowSearch
+ onSearch={setSearch}
+ onOpenChange={handleOpen}
+ listProps={{ style: { maxHeight: 300 } }}
+ >
+ {items.map(item => (
+ <ListItem key={item} id={item}>
+ {languages[item].label}
+ </ListItem>
+ ))}
+ {!items.length && <ListItem></ListItem>}
+ </Select>
+ <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
+ </Row>
+ );
+}
diff --git a/src/app/(main)/settings/preferences/PreferenceSettings.tsx b/src/app/(main)/settings/preferences/PreferenceSettings.tsx
new file mode 100644
index 0000000..a2890ce
--- /dev/null
+++ b/src/app/(main)/settings/preferences/PreferenceSettings.tsx
@@ -0,0 +1,36 @@
+import { Column, Label } from '@umami/react-zen';
+import { useLoginQuery, useMessages } from '@/components/hooks';
+import { DateRangeSetting } from './DateRangeSetting';
+import { LanguageSetting } from './LanguageSetting';
+import { ThemeSetting } from './ThemeSetting';
+import { TimezoneSetting } from './TimezoneSetting';
+
+export function PreferenceSettings() {
+ const { user } = useLoginQuery();
+ const { formatMessage, labels } = useMessages();
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+ <Column width="400px" gap="6">
+ <Column>
+ <Label>{formatMessage(labels.defaultDateRange)}</Label>
+ <DateRangeSetting />
+ </Column>
+ <Column>
+ <Label>{formatMessage(labels.timezone)}</Label>
+ <TimezoneSetting />
+ </Column>
+ <Column>
+ <Label>{formatMessage(labels.language)}</Label>
+ <LanguageSetting />
+ </Column>
+ <Column>
+ <Label>{formatMessage(labels.theme)}</Label>
+ <ThemeSetting />
+ </Column>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/settings/preferences/PreferencesPage.tsx b/src/app/(main)/settings/preferences/PreferencesPage.tsx
new file mode 100644
index 0000000..61e2669
--- /dev/null
+++ b/src/app/(main)/settings/preferences/PreferencesPage.tsx
@@ -0,0 +1,22 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { PreferenceSettings } from './PreferenceSettings';
+
+export function PreferencesPage() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <PageBody>
+ <Column gap="6">
+ <PageHeader title={formatMessage(labels.preferences)} />
+ <Panel>
+ <PreferenceSettings />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/settings/preferences/ThemeSetting.tsx b/src/app/(main)/settings/preferences/ThemeSetting.tsx
new file mode 100644
index 0000000..03bd6a6
--- /dev/null
+++ b/src/app/(main)/settings/preferences/ThemeSetting.tsx
@@ -0,0 +1,21 @@
+import { Button, Icon, Row, useTheme } from '@umami/react-zen';
+import { Moon, Sun } from '@/components/icons';
+
+export function ThemeSetting() {
+ const { theme, setTheme } = useTheme();
+
+ return (
+ <Row gap>
+ <Button variant={theme === 'light' ? 'primary' : undefined} onPress={() => setTheme('light')}>
+ <Icon>
+ <Sun />
+ </Icon>
+ </Button>
+ <Button variant={theme === 'dark' ? 'primary' : undefined} onPress={() => setTheme('dark')}>
+ <Icon>
+ <Moon />
+ </Icon>
+ </Button>
+ </Row>
+ );
+}
diff --git a/src/app/(main)/settings/preferences/TimezoneSetting.tsx b/src/app/(main)/settings/preferences/TimezoneSetting.tsx
new file mode 100644
index 0000000..cf20b20
--- /dev/null
+++ b/src/app/(main)/settings/preferences/TimezoneSetting.tsx
@@ -0,0 +1,44 @@
+import { Button, ListItem, Row, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { useMessages, useTimezone } from '@/components/hooks';
+import { getTimezone } from '@/lib/date';
+
+const timezones = Intl.supportedValuesOf('timeZone');
+
+export function TimezoneSetting() {
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const { timezone, saveTimezone } = useTimezone();
+ const items = search
+ ? timezones.filter(n => n.toLowerCase().includes(search.toLowerCase()))
+ : timezones;
+
+ const handleReset = () => saveTimezone(getTimezone());
+
+ const handleOpen = isOpen => {
+ if (isOpen) {
+ setSearch('');
+ }
+ };
+
+ return (
+ <Row gap>
+ <Select
+ value={timezone}
+ onChange={(value: any) => saveTimezone(value)}
+ allowSearch={true}
+ onSearch={setSearch}
+ onOpenChange={handleOpen}
+ listProps={{ style: { maxHeight: 300 } }}
+ >
+ {items.map((item: any) => (
+ <ListItem key={item} id={item}>
+ {item}
+ </ListItem>
+ ))}
+ {!items.length && <ListItem></ListItem>}
+ </Select>
+ <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
+ </Row>
+ );
+}
diff --git a/src/app/(main)/settings/preferences/page.tsx b/src/app/(main)/settings/preferences/page.tsx
new file mode 100644
index 0000000..dd16870
--- /dev/null
+++ b/src/app/(main)/settings/preferences/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { PreferencesPage } from './PreferencesPage';
+
+export default function () {
+ return <PreferencesPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Preferences',
+};
diff --git a/src/app/(main)/settings/profile/PasswordChangeButton.tsx b/src/app/(main)/settings/profile/PasswordChangeButton.tsx
new file mode 100644
index 0000000..6ce8ef8
--- /dev/null
+++ b/src/app/(main)/settings/profile/PasswordChangeButton.tsx
@@ -0,0 +1,29 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { LockKeyhole } from '@/components/icons';
+import { PasswordEditForm } from './PasswordEditForm';
+
+export function PasswordChangeButton() {
+ const { formatMessage, labels, messages } = useMessages();
+ const { toast } = useToast();
+
+ const handleSave = () => {
+ toast(formatMessage(messages.saved));
+ };
+
+ return (
+ <DialogTrigger>
+ <Button>
+ <Icon>
+ <LockKeyhole />
+ </Icon>
+ <Text>{formatMessage(labels.changePassword)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.changePassword)} style={{ width: 400 }}>
+ {({ close }) => <PasswordEditForm onSave={handleSave} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/settings/profile/PasswordEditForm.tsx b/src/app/(main)/settings/profile/PasswordEditForm.tsx
new file mode 100644
index 0000000..6f782e4
--- /dev/null
+++ b/src/app/(main)/settings/profile/PasswordEditForm.tsx
@@ -0,0 +1,67 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ PasswordField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+
+export function PasswordEditForm({ onSave, onClose }) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery('/me/password');
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ onSave();
+ onClose();
+ },
+ });
+ };
+
+ const samePassword = (value: string, values: Record<string, any>) => {
+ if (value !== values.newPassword) {
+ return formatMessage(messages.noMatchPassword);
+ }
+ return true;
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
+ <FormField
+ label={formatMessage(labels.currentPassword)}
+ name="currentPassword"
+ rules={{ required: 'Required' }}
+ >
+ <PasswordField autoComplete="current-password" />
+ </FormField>
+ <FormField
+ name="newPassword"
+ label={formatMessage(labels.newPassword)}
+ rules={{
+ required: 'Required',
+ minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
+ }}
+ >
+ <PasswordField autoComplete="new-password" />
+ </FormField>
+ <FormField
+ name="confirmPassword"
+ label={formatMessage(labels.confirmPassword)}
+ rules={{
+ required: formatMessage(labels.required),
+ minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
+ validate: samePassword,
+ }}
+ >
+ <PasswordField autoComplete="confirm-password" />
+ </FormField>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton isDisabled={isPending}>{formatMessage(labels.save)}</FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/settings/profile/ProfileHeader.tsx b/src/app/(main)/settings/profile/ProfileHeader.tsx
new file mode 100644
index 0000000..05f7996
--- /dev/null
+++ b/src/app/(main)/settings/profile/ProfileHeader.tsx
@@ -0,0 +1,8 @@
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useMessages } from '@/components/hooks';
+
+export function ProfileHeader() {
+ const { formatMessage, labels } = useMessages();
+
+ return <SectionHeader title={formatMessage(labels.profile)}></SectionHeader>;
+}
diff --git a/src/app/(main)/settings/profile/ProfilePage.tsx b/src/app/(main)/settings/profile/ProfilePage.tsx
new file mode 100644
index 0000000..f03499a
--- /dev/null
+++ b/src/app/(main)/settings/profile/ProfilePage.tsx
@@ -0,0 +1,22 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { ProfileSettings } from './ProfileSettings';
+
+export function ProfilePage() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <PageBody>
+ <Column gap="6">
+ <PageHeader title={formatMessage(labels.profile)} />
+ <Panel>
+ <ProfileSettings />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/settings/profile/ProfileSettings.tsx b/src/app/(main)/settings/profile/ProfileSettings.tsx
new file mode 100644
index 0000000..fae73a5
--- /dev/null
+++ b/src/app/(main)/settings/profile/ProfileSettings.tsx
@@ -0,0 +1,51 @@
+import { Column, Label, Row } from '@umami/react-zen';
+import { useConfig, useLoginQuery, useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+import { PasswordChangeButton } from './PasswordChangeButton';
+
+export function ProfileSettings() {
+ const { user } = useLoginQuery();
+ const { formatMessage, labels } = useMessages();
+ const { cloudMode } = useConfig();
+
+ if (!user) {
+ return null;
+ }
+
+ const { username, role } = user;
+
+ const renderRole = (value: string) => {
+ if (value === ROLES.user) {
+ return formatMessage(labels.user);
+ }
+ if (value === ROLES.admin) {
+ return formatMessage(labels.admin);
+ }
+ if (value === ROLES.viewOnly) {
+ return formatMessage(labels.viewOnly);
+ }
+
+ return formatMessage(labels.unknown);
+ };
+
+ return (
+ <Column width="400px" gap="6">
+ <Column>
+ <Label>{formatMessage(labels.username)}</Label>
+ {username}
+ </Column>
+ <Column>
+ <Label>{formatMessage(labels.role)}</Label>
+ {renderRole(role)}
+ </Column>
+ {!cloudMode && (
+ <Column>
+ <Label>{formatMessage(labels.password)}</Label>
+ <Row>
+ <PasswordChangeButton />
+ </Row>
+ </Column>
+ )}
+ </Column>
+ );
+}
diff --git a/src/app/(main)/settings/profile/page.tsx b/src/app/(main)/settings/profile/page.tsx
new file mode 100644
index 0000000..6060b91
--- /dev/null
+++ b/src/app/(main)/settings/profile/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { ProfilePage } from './ProfilePage';
+
+export default function () {
+ return <ProfilePage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Profile',
+};
diff --git a/src/app/(main)/settings/teams/TeamsSettingsPage.tsx b/src/app/(main)/settings/teams/TeamsSettingsPage.tsx
new file mode 100644
index 0000000..dc3e3bc
--- /dev/null
+++ b/src/app/(main)/settings/teams/TeamsSettingsPage.tsx
@@ -0,0 +1,16 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { TeamsDataTable } from '@/app/(main)/teams/TeamsDataTable';
+import { TeamsHeader } from '@/app/(main)/teams/TeamsHeader';
+import { Panel } from '@/components/common/Panel';
+
+export function TeamsSettingsPage() {
+ return (
+ <Column gap="6">
+ <TeamsHeader />
+ <Panel>
+ <TeamsDataTable />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx b/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx
new file mode 100644
index 0000000..9539625
--- /dev/null
+++ b/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx
@@ -0,0 +1,11 @@
+'use client';
+import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
+import { TeamProvider } from '@/app/(main)/teams/TeamProvider';
+
+export function TeamSettingsPage({ teamId }: { teamId: string }) {
+ return (
+ <TeamProvider teamId={teamId}>
+ <TeamSettings teamId={teamId} />
+ </TeamProvider>
+ );
+}
diff --git a/src/app/(main)/settings/teams/[teamId]/page.tsx b/src/app/(main)/settings/teams/[teamId]/page.tsx
new file mode 100644
index 0000000..58a380b
--- /dev/null
+++ b/src/app/(main)/settings/teams/[teamId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { TeamSettingsPage } from './TeamSettingsPage';
+
+export default async function ({ params }: { params: Promise<{ teamId: string }> }) {
+ const { teamId } = await params;
+
+ return <TeamSettingsPage teamId={teamId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Teams',
+};
diff --git a/src/app/(main)/settings/teams/page.tsx b/src/app/(main)/settings/teams/page.tsx
new file mode 100644
index 0000000..a0913f4
--- /dev/null
+++ b/src/app/(main)/settings/teams/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { TeamsSettingsPage } from './TeamsSettingsPage';
+
+export default function () {
+ return <TeamsSettingsPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Teams',
+};
diff --git a/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx b/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx
new file mode 100644
index 0000000..5009ec6
--- /dev/null
+++ b/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx
@@ -0,0 +1,16 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsitesDataTable } from '@/app/(main)/websites/WebsitesDataTable';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useMessages } from '@/components/hooks';
+
+export function WebsitesSettingsPage({ teamId }: { teamId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <Column gap>
+ <SectionHeader title={formatMessage(labels.websites)} />
+ <WebsitesDataTable teamId={teamId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx
new file mode 100644
index 0000000..53b4cd9
--- /dev/null
+++ b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx
@@ -0,0 +1,16 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
+import { WebsiteSettingsHeader } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader';
+import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
+
+export function WebsiteSettingsPage({ websiteId }: { websiteId: string }) {
+ return (
+ <WebsiteProvider websiteId={websiteId}>
+ <Column margin="2">
+ <WebsiteSettingsHeader />
+ <WebsiteSettings websiteId={websiteId} />
+ </Column>
+ </WebsiteProvider>
+ );
+}
diff --git a/src/app/(main)/settings/websites/[websiteId]/page.tsx b/src/app/(main)/settings/websites/[websiteId]/page.tsx
new file mode 100644
index 0000000..9adfc91
--- /dev/null
+++ b/src/app/(main)/settings/websites/[websiteId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { WebsiteSettingsPage } from './WebsiteSettingsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <WebsiteSettingsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Website',
+};
diff --git a/src/app/(main)/settings/websites/page.tsx b/src/app/(main)/settings/websites/page.tsx
new file mode 100644
index 0000000..19c14fd
--- /dev/null
+++ b/src/app/(main)/settings/websites/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { WebsitesSettingsPage } from './WebsitesSettingsPage';
+
+export default async function ({ params }: { params: Promise<{ teamId: string }> }) {
+ const { teamId } = await params;
+
+ return <WebsitesSettingsPage teamId={teamId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Websites',
+};
diff --git a/src/app/(main)/teams/TeamAddForm.tsx b/src/app/(main)/teams/TeamAddForm.tsx
new file mode 100644
index 0000000..c95259f
--- /dev/null
+++ b/src/app/(main)/teams/TeamAddForm.tsx
@@ -0,0 +1,39 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+
+export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) {
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
+ <FormField name="name" label={formatMessage(labels.name)}>
+ <TextField autoComplete="off" />
+ </FormField>
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" isDisabled={isPending}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/teams/TeamJoinForm.tsx b/src/app/(main)/teams/TeamJoinForm.tsx
new file mode 100644
index 0000000..6978078
--- /dev/null
+++ b/src/app/(main)/teams/TeamJoinForm.tsx
@@ -0,0 +1,40 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+
+export function TeamJoinForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) {
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+ const { mutateAsync, error, touch } = useUpdateQuery('/teams/join');
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ touch('teams:members');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
+ <FormField
+ label={formatMessage(labels.accessCode)}
+ name="accessCode"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton variant="primary">{formatMessage(labels.join)}</FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/teams/TeamLeaveButton.tsx b/src/app/(main)/teams/TeamLeaveButton.tsx
new file mode 100644
index 0000000..2cca76f
--- /dev/null
+++ b/src/app/(main)/teams/TeamLeaveButton.tsx
@@ -0,0 +1,41 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useRouter } from 'next/navigation';
+import { useLoginQuery, useMessages, useModified } from '@/components/hooks';
+import { LogOut } from '@/components/icons';
+import { TeamLeaveForm } from './TeamLeaveForm';
+
+export function TeamLeaveButton({ teamId, teamName }: { teamId: string; teamName: string }) {
+ const { formatMessage, labels } = useMessages();
+ const router = useRouter();
+ const { user } = useLoginQuery();
+ const { touch } = useModified();
+
+ const handleLeave = async () => {
+ touch('teams');
+ router.push('/settings/teams');
+ };
+
+ return (
+ <DialogTrigger>
+ <Button>
+ <Icon>
+ <LogOut />
+ </Icon>
+ <Text>{formatMessage(labels.leave)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.leaveTeam)} style={{ width: 400 }}>
+ {({ close }) => (
+ <TeamLeaveForm
+ teamId={teamId}
+ userId={user.id}
+ teamName={teamName}
+ onSave={handleLeave}
+ onClose={close}
+ />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/teams/TeamLeaveForm.tsx b/src/app/(main)/teams/TeamLeaveForm.tsx
new file mode 100644
index 0000000..b3dcaf5
--- /dev/null
+++ b/src/app/(main)/teams/TeamLeaveForm.tsx
@@ -0,0 +1,48 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
+
+export function TeamLeaveForm({
+ teamId,
+ userId,
+ teamName,
+ onSave,
+ onClose,
+}: {
+ teamId: string;
+ userId: string;
+ teamName: string;
+ onSave: () => void;
+ onClose: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage, FormattedMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useDeleteQuery(`/teams/${teamId}/users/${userId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch('teams:members');
+ onSave();
+ onClose();
+ },
+ });
+ };
+
+ return (
+ <ConfirmationForm
+ buttonLabel={formatMessage(labels.leave)}
+ message={
+ <FormattedMessage
+ {...messages.confirmLeave}
+ values={{
+ target: <b>{teamName}</b>,
+ }}
+ />
+ }
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ />
+ );
+}
diff --git a/src/app/(main)/teams/TeamProvider.tsx b/src/app/(main)/teams/TeamProvider.tsx
new file mode 100644
index 0000000..cea4161
--- /dev/null
+++ b/src/app/(main)/teams/TeamProvider.tsx
@@ -0,0 +1,21 @@
+'use client';
+import { Loading } from '@umami/react-zen';
+import { createContext, type ReactNode } from 'react';
+import { useTeamQuery } from '@/components/hooks/queries/useTeamQuery';
+import type { Team } from '@/generated/prisma/client';
+
+export const TeamContext = createContext<Team>(null);
+
+export function TeamProvider({ teamId, children }: { teamId?: string; children: ReactNode }) {
+ const { data: team, isLoading, isFetching } = useTeamQuery(teamId);
+
+ if (isFetching && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (!team) {
+ return null;
+ }
+
+ return <TeamContext.Provider value={team}>{children}</TeamContext.Provider>;
+}
diff --git a/src/app/(main)/teams/TeamsAddButton.tsx b/src/app/(main)/teams/TeamsAddButton.tsx
new file mode 100644
index 0000000..578a273
--- /dev/null
+++ b/src/app/(main)/teams/TeamsAddButton.tsx
@@ -0,0 +1,33 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { messages } from '@/components/messages';
+import { TeamAddForm } from './TeamAddForm';
+
+export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
+ const { formatMessage, labels } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+
+ const handleSave = async () => {
+ toast(formatMessage(messages.saved));
+ touch('teams');
+ onSave?.();
+ };
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.createTeam)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
+ {({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsDataTable.tsx b/src/app/(main)/teams/TeamsDataTable.tsx
new file mode 100644
index 0000000..cdce7b9
--- /dev/null
+++ b/src/app/(main)/teams/TeamsDataTable.tsx
@@ -0,0 +1,27 @@
+import Link from 'next/link';
+import { DataGrid } from '@/components/common/DataGrid';
+import { useLoginQuery, useNavigation, useUserTeamsQuery } from '@/components/hooks';
+import { TeamsTable } from './TeamsTable';
+
+export function TeamsDataTable() {
+ const { user } = useLoginQuery();
+ const query = useUserTeamsQuery(user.id);
+ const { pathname } = useNavigation();
+ const isSettings = pathname.includes('/settings');
+
+ const renderLink = (row: any) => {
+ return (
+ <Link key={row.id} href={`${isSettings ? '/settings' : ''}/teams/${row.id}`}>
+ {row.name}
+ </Link>
+ );
+ };
+
+ return (
+ <DataGrid query={query}>
+ {({ data }) => {
+ return <TeamsTable data={data} renderLink={renderLink} />;
+ }}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsHeader.tsx b/src/app/(main)/teams/TeamsHeader.tsx
new file mode 100644
index 0000000..579ba59
--- /dev/null
+++ b/src/app/(main)/teams/TeamsHeader.tsx
@@ -0,0 +1,26 @@
+import { Row } from '@umami/react-zen';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useLoginQuery, useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+import { TeamsAddButton } from './TeamsAddButton';
+import { TeamsJoinButton } from './TeamsJoinButton';
+
+export function TeamsHeader({
+ allowCreate = true,
+ allowJoin = true,
+}: {
+ allowCreate?: boolean;
+ allowJoin?: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+
+ return (
+ <PageHeader title={formatMessage(labels.teams)}>
+ <Row gap="3">
+ {allowJoin && <TeamsJoinButton />}
+ {allowCreate && user.role !== ROLES.viewOnly && <TeamsAddButton />}
+ </Row>
+ </PageHeader>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsJoinButton.tsx b/src/app/(main)/teams/TeamsJoinButton.tsx
new file mode 100644
index 0000000..017211e
--- /dev/null
+++ b/src/app/(main)/teams/TeamsJoinButton.tsx
@@ -0,0 +1,31 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { UserPlus } from '@/components/icons';
+import { TeamJoinForm } from './TeamJoinForm';
+
+export function TeamsJoinButton() {
+ const { formatMessage, labels, messages } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+
+ const handleJoin = () => {
+ toast(formatMessage(messages.saved));
+ touch('teams');
+ };
+
+ return (
+ <DialogTrigger>
+ <Button>
+ <Icon>
+ <UserPlus />
+ </Icon>
+ <Text>{formatMessage(labels.joinTeam)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.joinTeam)} style={{ width: 400 }}>
+ {({ close }) => <TeamJoinForm onSave={handleJoin} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsPage.tsx b/src/app/(main)/teams/TeamsPage.tsx
new file mode 100644
index 0000000..5b11bcf
--- /dev/null
+++ b/src/app/(main)/teams/TeamsPage.tsx
@@ -0,0 +1,19 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { TeamsDataTable } from '@/app/(main)/teams/TeamsDataTable';
+import { TeamsHeader } from '@/app/(main)/teams/TeamsHeader';
+import { PageBody } from '@/components/common/PageBody';
+import { Panel } from '@/components/common/Panel';
+
+export function TeamsPage() {
+ return (
+ <PageBody>
+ <Column gap="6">
+ <TeamsHeader />
+ <Panel>
+ <TeamsDataTable />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/teams/TeamsTable.tsx b/src/app/(main)/teams/TeamsTable.tsx
new file mode 100644
index 0000000..754f0b2
--- /dev/null
+++ b/src/app/(main)/teams/TeamsTable.tsx
@@ -0,0 +1,29 @@
+import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export interface TeamsTableProps extends DataTableProps {
+ renderLink?: (row: any) => ReactNode;
+}
+
+export function TeamsTable({ renderLink, ...props }: TeamsTableProps) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {renderLink}
+ </DataColumn>
+ <DataColumn id="owner" label={formatMessage(labels.owner)}>
+ {(row: any) => row?.members?.find(({ role }) => role === ROLES.teamOwner)?.user?.username}
+ </DataColumn>
+ <DataColumn id="members" label={formatMessage(labels.members)} align="end">
+ {(row: any) => row?._count?.members}
+ </DataColumn>
+ <DataColumn id="websites" label={formatMessage(labels.websites)} align="end">
+ {(row: any) => row?._count?.websites}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx b/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx
new file mode 100644
index 0000000..7adc9b3
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx
@@ -0,0 +1,40 @@
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+
+const CONFIRM_VALUE = 'DELETE';
+
+export function TeamDeleteForm({
+ teamId,
+ onSave,
+ onClose,
+}: {
+ teamId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { labels, formatMessage, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending, touch } = useDeleteQuery(`/teams/${teamId}`);
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch('teams');
+ touch(`teams:${teamId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <TypeConfirmationForm
+ confirmationValue={CONFIRM_VALUE}
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamEditForm.tsx b/src/app/(main)/teams/[teamId]/TeamEditForm.tsx
new file mode 100644
index 0000000..74e038f
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamEditForm.tsx
@@ -0,0 +1,89 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ IconLabel,
+ Row,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useTeam, useUpdateQuery } from '@/components/hooks';
+import { RefreshCw } from '@/components/icons';
+import { getRandomChars } from '@/lib/generate';
+
+const generateId = () => `team_${getRandomChars(16)}`;
+
+export function TeamEditForm({
+ teamId,
+ allowEdit,
+ showAccessCode,
+ onSave,
+}: {
+ teamId: string;
+ allowEdit?: boolean;
+ showAccessCode?: boolean;
+ onSave?: () => void;
+}) {
+ const team = useTeam();
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/teams/${teamId}`);
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('teams');
+ touch(`teams:${teamId}`);
+ onSave?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ ...team }}>
+ {({ setValue }) => {
+ return (
+ <>
+ <FormField name="id" label={formatMessage(labels.teamId)}>
+ <TextField isReadOnly allowCopy />
+ </FormField>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField isReadOnly={!allowEdit} />
+ </FormField>
+ {showAccessCode && (
+ <Row alignItems="flex-end" gap>
+ <FormField
+ name="accessCode"
+ label={formatMessage(labels.accessCode)}
+ style={{ flex: 1 }}
+ >
+ <TextField isReadOnly allowCopy />
+ </FormField>
+ {allowEdit && (
+ <Button
+ onPress={() => setValue('accessCode', generateId(), { shouldDirty: true })}
+ >
+ <IconLabel icon={<RefreshCw />} label={formatMessage(labels.regenerate)} />
+ </Button>
+ )}
+ </Row>
+ )}
+ {allowEdit && (
+ <FormButtons justifyContent="flex-end">
+ <FormSubmitButton variant="primary" isPending={isPending}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ )}
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamManage.tsx b/src/app/(main)/teams/[teamId]/TeamManage.tsx
new file mode 100644
index 0000000..88cbad9
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamManage.tsx
@@ -0,0 +1,32 @@
+import { Button, Dialog, DialogTrigger, Modal } from '@umami/react-zen';
+import { useRouter } from 'next/navigation';
+import { ActionForm } from '@/components/common/ActionForm';
+import { useMessages, useModified } from '@/components/hooks';
+import { TeamDeleteForm } from './TeamDeleteForm';
+
+export function TeamManage({ teamId }: { teamId: string }) {
+ const { formatMessage, labels, messages } = useMessages();
+ const router = useRouter();
+ const { touch } = useModified();
+
+ const handleLeave = async () => {
+ touch('teams');
+ router.push('/settings/teams');
+ };
+
+ return (
+ <ActionForm
+ label={formatMessage(labels.deleteTeam)}
+ description={formatMessage(messages.deleteTeamWarning)}
+ >
+ <DialogTrigger>
+ <Button variant="danger">{formatMessage(labels.delete)}</Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.deleteTeam)} style={{ width: 400 }}>
+ {({ close }) => <TeamDeleteForm teamId={teamId} onSave={handleLeave} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx b/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx
new file mode 100644
index 0000000..f75b6d1
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx
@@ -0,0 +1,46 @@
+import { useToast } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { TeamMemberEditForm } from './TeamMemberEditForm';
+
+export function TeamMemberEditButton({
+ teamId,
+ userId,
+ role,
+ onSave,
+}: {
+ teamId: string;
+ userId: string;
+ role: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+
+ const handleSave = () => {
+ touch('teams:members');
+ toast(formatMessage(messages.saved));
+ onSave?.();
+ };
+
+ return (
+ <DialogButton
+ icon={<Edit />}
+ title={formatMessage(labels.editMember)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ <TeamMemberEditForm
+ teamId={teamId}
+ userId={userId}
+ role={role}
+ onSave={handleSave}
+ onClose={close}
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx b/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx
new file mode 100644
index 0000000..4826746
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx
@@ -0,0 +1,62 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ ListItem,
+ Select,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function TeamMemberEditForm({
+ teamId,
+ userId,
+ role,
+ onSave,
+ onClose,
+}: {
+ teamId: string;
+ userId: string;
+ role: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users/${userId}`);
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ onSave();
+ onClose();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ role }}>
+ <FormField
+ name="role"
+ rules={{ required: formatMessage(labels.required) }}
+ label={formatMessage(labels.role)}
+ >
+ <Select>
+ <ListItem id={ROLES.teamManager}>{formatMessage(labels.manager)}</ListItem>
+ <ListItem id={ROLES.teamMember}>{formatMessage(labels.member)}</ListItem>
+ <ListItem id={ROLES.teamViewOnly}>{formatMessage(labels.viewOnly)}</ListItem>
+ </Select>
+ </FormField>
+
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" isDisabled={false}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx b/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx
new file mode 100644
index 0000000..4d3e8e9
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx
@@ -0,0 +1,60 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function TeamMemberRemoveButton({
+ teamId,
+ userId,
+ userName,
+ onSave,
+}: {
+ teamId: string;
+ userId: string;
+ userName: string;
+ disabled?: boolean;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error } = useDeleteQuery(`/teams/${teamId}/users/${userId}`);
+ const { touch } = useModified();
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('teams:members');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{userName}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.remove)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx b/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx
new file mode 100644
index 0000000..52c0fe3
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx
@@ -0,0 +1,19 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useTeamMembersQuery } from '@/components/hooks';
+import { TeamMembersTable } from './TeamMembersTable';
+
+export function TeamMembersDataTable({
+ teamId,
+ allowEdit = false,
+}: {
+ teamId: string;
+ allowEdit?: boolean;
+}) {
+ const queryResult = useTeamMembersQuery(teamId);
+
+ return (
+ <DataGrid query={queryResult} allowSearch>
+ {({ data }) => <TeamMembersTable data={data} teamId={teamId} allowEdit={allowEdit} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx b/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx
new file mode 100644
index 0000000..8414908
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx
@@ -0,0 +1,55 @@
+import { DataColumn, DataTable, Row } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+import { TeamMemberEditButton } from './TeamMemberEditButton';
+import { TeamMemberRemoveButton } from './TeamMemberRemoveButton';
+
+export function TeamMembersTable({
+ data = [],
+ teamId,
+ allowEdit = false,
+}: {
+ data: any[];
+ teamId: string;
+ allowEdit: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ const roles = {
+ [ROLES.teamOwner]: formatMessage(labels.teamOwner),
+ [ROLES.teamManager]: formatMessage(labels.teamManager),
+ [ROLES.teamMember]: formatMessage(labels.teamMember),
+ [ROLES.teamViewOnly]: formatMessage(labels.viewOnly),
+ };
+
+ return (
+ <DataTable data={data}>
+ <DataColumn id="username" label={formatMessage(labels.username)}>
+ {(row: any) => row?.user?.username}
+ </DataColumn>
+ <DataColumn id="role" label={formatMessage(labels.role)}>
+ {(row: any) => roles[row?.role]}
+ </DataColumn>
+ {allowEdit && (
+ <DataColumn id="action" align="end">
+ {(row: any) => {
+ if (row?.role === ROLES.teamOwner) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center" maxHeight="20px">
+ <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} />
+ <TeamMemberRemoveButton
+ teamId={teamId}
+ userId={row?.user?.id}
+ userName={row?.user?.username}
+ />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ )}
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx
new file mode 100644
index 0000000..3ddbe00
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx
@@ -0,0 +1,49 @@
+import { Column } from '@umami/react-zen';
+import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks';
+import { Users } from '@/components/icons';
+import { ROLES } from '@/lib/constants';
+import { TeamEditForm } from './TeamEditForm';
+import { TeamManage } from './TeamManage';
+import { TeamMembersDataTable } from './TeamMembersDataTable';
+
+export function TeamSettings({ teamId }: { teamId: string }) {
+ const team: any = useTeam();
+ const { user } = useLoginQuery();
+ const { pathname } = useNavigation();
+
+ const isAdmin = pathname.includes('/admin');
+
+ const isTeamOwner =
+ !!team?.members?.find(({ userId, role }) => role === ROLES.teamOwner && userId === user.id) &&
+ user.role !== ROLES.viewOnly;
+
+ const canEdit =
+ user.isAdmin ||
+ (!!team?.members?.find(
+ ({ userId, role }) =>
+ (role === ROLES.teamOwner || role === ROLES.teamManager) && userId === user.id,
+ ) &&
+ user.role !== ROLES.viewOnly);
+
+ return (
+ <Column gap="6">
+ <PageHeader title={team?.name} icon={<Users />}>
+ {!isTeamOwner && !isAdmin && <TeamLeaveButton teamId={team.id} teamName={team.name} />}
+ </PageHeader>
+ <Panel>
+ <TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
+ </Panel>
+ <Panel>
+ <TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
+ </Panel>
+ {isTeamOwner && (
+ <Panel>
+ <TeamManage teamId={teamId} />
+ </Panel>
+ )}
+ </Column>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx b/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx
new file mode 100644
index 0000000..f2b4ece
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx
@@ -0,0 +1,25 @@
+import { Icon, LoadingButton, Text } from '@umami/react-zen';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+import { X } from '@/components/icons';
+
+export function TeamWebsiteRemoveButton({ teamId, websiteId, onSave }) {
+ const { formatMessage, labels } = useMessages();
+ const { mutateAsync } = useDeleteQuery(`/teams/${teamId}/websites/${websiteId}`);
+
+ const handleRemoveTeamMember = async () => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ onSave();
+ },
+ });
+ };
+
+ return (
+ <LoadingButton variant="quiet" onClick={() => handleRemoveTeamMember()}>
+ <Icon>
+ <X />
+ </Icon>
+ <Text>{formatMessage(labels.remove)}</Text>
+ </LoadingButton>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx b/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx
new file mode 100644
index 0000000..6a2e4f4
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx
@@ -0,0 +1,19 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useTeamWebsitesQuery } from '@/components/hooks';
+import { TeamWebsitesTable } from './TeamWebsitesTable';
+
+export function TeamWebsitesDataTable({
+ teamId,
+ allowEdit = false,
+}: {
+ teamId: string;
+ allowEdit?: boolean;
+}) {
+ const queryResult = useTeamWebsitesQuery(teamId);
+
+ return (
+ <DataGrid query={queryResult} allowSearch>
+ {({ data }) => <TeamWebsitesTable data={data} teamId={teamId} allowEdit={allowEdit} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx b/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx
new file mode 100644
index 0000000..10f5654
--- /dev/null
+++ b/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx
@@ -0,0 +1,50 @@
+import { DataColumn, DataTable, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { TeamMemberEditButton } from '@/app/(main)/teams/[teamId]/TeamMemberEditButton';
+import { TeamMemberRemoveButton } from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton';
+import { useMessages } from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function TeamWebsitesTable({
+ teamId,
+ data = [],
+ allowEdit,
+}: {
+ teamId: string;
+ data: any[];
+ allowEdit: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DataTable data={data}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {(row: any) => <Link href={`/teams/${teamId}/websites/${row.id}`}>{row.name}</Link>}
+ </DataColumn>
+ <DataColumn id="domain" label={formatMessage(labels.domain)} />
+ <DataColumn id="createdBy" label={formatMessage(labels.createdBy)}>
+ {(row: any) => row?.createUser?.username}
+ </DataColumn>
+ {allowEdit && (
+ <DataColumn id="action" align="end">
+ {(row: any) => {
+ if (row?.role === ROLES.teamOwner) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center">
+ <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} />
+ <TeamMemberRemoveButton
+ teamId={teamId}
+ userId={row?.user?.id}
+ userName={row?.user?.username}
+ />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ )}
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/teams/page.tsx b/src/app/(main)/teams/page.tsx
new file mode 100644
index 0000000..7344f15
--- /dev/null
+++ b/src/app/(main)/teams/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { TeamsPage } from './TeamsPage';
+
+export default function () {
+ return <TeamsPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Teams',
+};
diff --git a/src/app/(main)/websites/WebsiteAddButton.tsx b/src/app/(main)/websites/WebsiteAddButton.tsx
new file mode 100644
index 0000000..76710ab
--- /dev/null
+++ b/src/app/(main)/websites/WebsiteAddButton.tsx
@@ -0,0 +1,28 @@
+import { useToast } from '@umami/react-zen';
+import { useMessages, useModified } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { WebsiteAddForm } from './WebsiteAddForm';
+
+export function WebsiteAddButton({ teamId, onSave }: { teamId: string; onSave?: () => void }) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { toast } = useToast();
+ const { touch } = useModified();
+
+ const handleSave = async () => {
+ toast(formatMessage(messages.saved));
+ touch('websites');
+ onSave?.();
+ };
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.addWebsite)}
+ variant="primary"
+ width="400px"
+ >
+ {({ close }) => <WebsiteAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/WebsiteAddForm.tsx b/src/app/(main)/websites/WebsiteAddForm.tsx
new file mode 100644
index 0000000..df17ad5
--- /dev/null
+++ b/src/app/(main)/websites/WebsiteAddForm.tsx
@@ -0,0 +1,60 @@
+import { Button, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+import { DOMAIN_REGEX } from '@/lib/constants';
+
+export function WebsiteAddForm({
+ teamId,
+ onSave,
+ onClose,
+}: {
+ teamId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery('/websites', { teamId });
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={error?.message}>
+ <FormField
+ label={formatMessage(labels.name)}
+ data-test="input-name"
+ name="name"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+
+ <FormField
+ label={formatMessage(labels.domain)}
+ data-test="input-domain"
+ name="domain"
+ rules={{
+ required: formatMessage(labels.required),
+ pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
+ }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+ <Row justifyContent="flex-end" paddingTop="3" gap="3">
+ {onClose && (
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ )}
+ <FormSubmitButton data-test="button-submit" isDisabled={false}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </Row>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/WebsiteProvider.tsx b/src/app/(main)/websites/WebsiteProvider.tsx
new file mode 100644
index 0000000..75e8a35
--- /dev/null
+++ b/src/app/(main)/websites/WebsiteProvider.tsx
@@ -0,0 +1,27 @@
+'use client';
+import { Loading } from '@umami/react-zen';
+import { createContext, type ReactNode } from 'react';
+import { useWebsiteQuery } from '@/components/hooks/queries/useWebsiteQuery';
+import type { Website } from '@/generated/prisma/client';
+
+export const WebsiteContext = createContext<Website>(null);
+
+export function WebsiteProvider({
+ websiteId,
+ children,
+}: {
+ websiteId: string;
+ children: ReactNode;
+}) {
+ const { data: website, isFetching, isLoading } = useWebsiteQuery(websiteId);
+
+ if (isFetching && isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ if (!website) {
+ return null;
+ }
+
+ return <WebsiteContext.Provider value={website}>{children}</WebsiteContext.Provider>;
+}
diff --git a/src/app/(main)/websites/WebsitesDataTable.tsx b/src/app/(main)/websites/WebsitesDataTable.tsx
new file mode 100644
index 0000000..3f0a6b9
--- /dev/null
+++ b/src/app/(main)/websites/WebsitesDataTable.tsx
@@ -0,0 +1,47 @@
+import Link from 'next/link';
+import { DataGrid } from '@/components/common/DataGrid';
+import { useLoginQuery, useNavigation, useUserWebsitesQuery } from '@/components/hooks';
+import { Favicon } from '@/index';
+import { Icon, Row } from '@umami/react-zen';
+import { WebsitesTable } from './WebsitesTable';
+
+export function WebsitesDataTable({
+ userId,
+ teamId,
+ allowEdit = true,
+ allowView = true,
+ showActions = true,
+}: {
+ userId?: string;
+ teamId?: string;
+ allowEdit?: boolean;
+ allowView?: boolean;
+ showActions?: boolean;
+}) {
+ const { user } = useLoginQuery();
+ const queryResult = useUserWebsitesQuery({ userId: userId || user?.id, teamId });
+ const { renderUrl } = useNavigation();
+
+ const renderLink = (row: any) => (
+ <Row alignItems="center" gap="3">
+ <Icon size="md" color="muted">
+ <Favicon domain={row.domain} />
+ </Icon>
+ <Link href={renderUrl(`/websites/${row.id}`, false)}>{row.name}</Link>
+ </Row>
+ );
+
+ return (
+ <DataGrid query={queryResult} allowSearch allowPaging>
+ {({ data }) => (
+ <WebsitesTable
+ data={data}
+ showActions={showActions}
+ allowEdit={allowEdit}
+ allowView={allowView}
+ renderLink={renderLink}
+ />
+ )}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/WebsitesHeader.tsx b/src/app/(main)/websites/WebsitesHeader.tsx
new file mode 100644
index 0000000..889b602
--- /dev/null
+++ b/src/app/(main)/websites/WebsitesHeader.tsx
@@ -0,0 +1,18 @@
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { WebsiteAddButton } from './WebsiteAddButton';
+
+export interface WebsitesHeaderProps {
+ allowCreate?: boolean;
+}
+
+export function WebsitesHeader({ allowCreate = true }: WebsitesHeaderProps) {
+ const { formatMessage, labels } = useMessages();
+ const { teamId } = useNavigation();
+
+ return (
+ <PageHeader title={formatMessage(labels.websites)}>
+ {allowCreate && <WebsiteAddButton teamId={teamId} />}
+ </PageHeader>
+ );
+}
diff --git a/src/app/(main)/websites/WebsitesPage.tsx b/src/app/(main)/websites/WebsitesPage.tsx
new file mode 100644
index 0000000..31de704
--- /dev/null
+++ b/src/app/(main)/websites/WebsitesPage.tsx
@@ -0,0 +1,26 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { PageBody } from '@/components/common/PageBody';
+import { PageHeader } from '@/components/common/PageHeader';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { WebsiteAddButton } from './WebsiteAddButton';
+import { WebsitesDataTable } from './WebsitesDataTable';
+
+export function WebsitesPage() {
+ const { teamId } = useNavigation();
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <PageBody>
+ <Column gap="6" margin="2">
+ <PageHeader title={formatMessage(labels.websites)}>
+ <WebsiteAddButton teamId={teamId} />
+ </PageHeader>
+ <Panel>
+ <WebsitesDataTable teamId={teamId} />
+ </Panel>
+ </Column>
+ </PageBody>
+ );
+}
diff --git a/src/app/(main)/websites/WebsitesTable.tsx b/src/app/(main)/websites/WebsitesTable.tsx
new file mode 100644
index 0000000..70648ed
--- /dev/null
+++ b/src/app/(main)/websites/WebsitesTable.tsx
@@ -0,0 +1,41 @@
+import { DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { LinkButton } from '@/components/common/LinkButton';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { SquarePen } from '@/components/icons';
+
+export interface WebsitesTableProps extends DataTableProps {
+ showActions?: boolean;
+ allowEdit?: boolean;
+ allowView?: boolean;
+ renderLink?: (row: any) => ReactNode;
+}
+
+export function WebsitesTable({ showActions, renderLink, ...props }: WebsitesTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { renderUrl } = useNavigation();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {renderLink}
+ </DataColumn>
+ <DataColumn id="domain" label={formatMessage(labels.domain)} />
+ {showActions && (
+ <DataColumn id="action" label=" " align="end">
+ {(row: any) => {
+ const websiteId = row.id;
+
+ return (
+ <LinkButton href={renderUrl(`/websites/${websiteId}/settings`)} variant="quiet">
+ <Icon>
+ <SquarePen />
+ </Icon>
+ </LinkButton>
+ );
+ }}
+ </DataColumn>
+ )}
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx
new file mode 100644
index 0000000..264923a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx
@@ -0,0 +1,128 @@
+import { Column, Grid } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { percentFilter } from '@/lib/filters';
+import { formatLongNumber } from '@/lib/format';
+
+export interface AttributionProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ model: string;
+ type: string;
+ step: string;
+ currency?: string;
+}
+
+export function Attribution({
+ websiteId,
+ startDate,
+ endDate,
+ model,
+ type,
+ step,
+ currency,
+}: AttributionProps) {
+ const { data, error, isLoading } = useResultQuery<any>('attribution', {
+ websiteId,
+ startDate,
+ endDate,
+ model,
+ type,
+ step,
+ });
+
+ const { formatMessage, labels } = useMessages();
+
+ const { pageviews, visitors, visits } = data?.total || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: formatMessage(labels.visitors),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: formatMessage(labels.visits),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: pageviews,
+ label: formatMessage(labels.views),
+ formatValue: formatLongNumber,
+ },
+ ]
+ : [];
+
+ function AttributionTable({ data = [], title }: { data: any; title: string }) {
+ const attributionData = percentFilter(
+ data.map(({ name, value }) => ({
+ x: name,
+ y: Number(value),
+ })),
+ );
+
+ return (
+ <ListTable
+ title={title}
+ metric={formatMessage(currency ? labels.revenue : labels.visitors)}
+ currency={currency}
+ data={attributionData.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+ }
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Column gap>
+ <MetricsBar>
+ {metrics?.map(({ label, value, formatValue }) => {
+ return (
+ <MetricCard key={label} value={value} label={label} formatValue={formatValue} />
+ );
+ })}
+ </MetricsBar>
+ <SectionHeader title={formatMessage(labels.sources)} />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ <Panel>
+ <AttributionTable data={data?.referrer} title={formatMessage(labels.referrer)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.paidAds} title={formatMessage(labels.paidAds)} />
+ </Panel>
+ </Grid>
+ <SectionHeader title="UTM" />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ <Panel>
+ <AttributionTable data={data?.utm_source} title={formatMessage(labels.sources)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_medium} title={formatMessage(labels.medium)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_cmapaign} title={formatMessage(labels.campaigns)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_content} title={formatMessage(labels.content)} />
+ </Panel>
+ <Panel>
+ <AttributionTable data={data?.utm_term} title={formatMessage(labels.terms)} />
+ </Panel>
+ </Grid>
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
new file mode 100644
index 0000000..48611c4
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
@@ -0,0 +1,63 @@
+'use client';
+import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { Attribution } from './Attribution';
+
+export function AttributionPage({ websiteId }: { websiteId: string }) {
+ const [model, setModel] = useState('first-click');
+ const [type, setType] = useState('path');
+ const [step, setStep] = useState('/');
+ const { formatMessage, labels } = useMessages();
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap="6">
+ <WebsiteControls websiteId={websiteId} />
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr 1fr' }} gap>
+ <Column>
+ <Select
+ label={formatMessage(labels.model)}
+ value={model}
+ defaultValue={model}
+ onChange={setModel}
+ >
+ <ListItem id="first-click">{formatMessage(labels.firstClick)}</ListItem>
+ <ListItem id="last-click">{formatMessage(labels.lastClick)}</ListItem>
+ </Select>
+ </Column>
+ <Column>
+ <Select
+ label={formatMessage(labels.type)}
+ value={type}
+ defaultValue={type}
+ onChange={setType}
+ >
+ <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
+ <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
+ </Select>
+ </Column>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.conversionStep)}
+ value={step}
+ defaultValue={step}
+ onSearch={setStep}
+ delay={1000}
+ />
+ </Column>
+ </Grid>
+ <Attribution
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ model={model}
+ type={type}
+ step={step}
+ />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx
new file mode 100644
index 0000000..1368d4b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { AttributionPage } from './AttributionPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <AttributionPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Attribution',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx
new file mode 100644
index 0000000..4532d97
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx
@@ -0,0 +1,91 @@
+import { Column, DataColumn, DataTable, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useFields, useFormat, useMessages, useResultQuery } from '@/components/hooks';
+import { formatShortTime } from '@/lib/format';
+
+export interface BreakdownProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ selectedFields: string[];
+}
+
+export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }: BreakdownProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { fields } = useFields();
+ const { data, error, isLoading } = useResultQuery<any>(
+ 'breakdown',
+ {
+ websiteId,
+ startDate,
+ endDate,
+ fields: selectedFields,
+ },
+ { enabled: !!selectedFields.length },
+ );
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Column overflow="auto" minHeight="0" height="100%">
+ <DataTable data={data} style={{ tableLayout: 'fixed' }}>
+ {selectedFields.map(field => {
+ return (
+ <DataColumn
+ key={field}
+ id={field}
+ label={fields.find(f => f.name === field)?.label}
+ width="minmax(120px, 1fr)"
+ >
+ {row => {
+ const value = formatValue(row[field], field);
+ return (
+ <Text truncate title={value}>
+ {value}
+ </Text>
+ );
+ }}
+ </DataColumn>
+ );
+ })}
+ <DataColumn
+ id="visitors"
+ label={formatMessage(labels.visitors)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visitors?.toLocaleString()}
+ </DataColumn>
+ <DataColumn id="visits" label={formatMessage(labels.visits)} align="end" width="120px">
+ {row => row?.visits?.toLocaleString()}
+ </DataColumn>
+ <DataColumn id="views" label={formatMessage(labels.views)} align="end" width="120px">
+ {row => row?.views?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="bounceRate"
+ label={formatMessage(labels.bounceRate)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
+ return `${Math.round(+n)}%`;
+ }}
+ </DataColumn>
+ <DataColumn
+ id="visitDuration"
+ label={formatMessage(labels.visitDuration)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = row?.totaltime / row?.visits;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+ </DataColumn>
+ </DataTable>
+ </Column>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx
new file mode 100644
index 0000000..fdead9f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx
@@ -0,0 +1,51 @@
+'use client';
+import { Column, Row } from '@umami/react-zen';
+import { useState } from 'react';
+import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { ListCheck } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { Breakdown } from './Breakdown';
+
+export function BreakdownPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+ const [fields, setFields] = useState(['path']);
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Row alignItems="center" justifyContent="flex-start">
+ <FieldsButton value={fields} onChange={setFields} />
+ </Row>
+ <Panel height="900px" overflow="auto" allowFullscreen>
+ <Breakdown
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ selectedFields={fields}
+ />
+ </Panel>
+ </Column>
+ );
+}
+
+const FieldsButton = ({ value, onChange }) => {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<ListCheck />}
+ label={formatMessage(labels.fields)}
+ width="400px"
+ minHeight="300px"
+ variant="outline"
+ >
+ {({ close }) => {
+ return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx
new file mode 100644
index 0000000..28e3368
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx
@@ -0,0 +1,46 @@
+import { Button, Column, Grid, List, ListItem } from '@umami/react-zen';
+import { useState } from 'react';
+import { useFields, useMessages } from '@/components/hooks';
+
+export function FieldSelectForm({
+ selectedFields = [],
+ onChange,
+ onClose,
+}: {
+ selectedFields?: string[];
+ onChange: (values: string[]) => void;
+ onClose?: () => void;
+}) {
+ const [selected, setSelected] = useState(selectedFields);
+ const { formatMessage, labels } = useMessages();
+ const { fields } = useFields();
+
+ const handleChange = (value: string[]) => {
+ setSelected(value);
+ };
+
+ const handleApply = () => {
+ onChange?.(selected);
+ onClose();
+ };
+
+ return (
+ <Column gap="6">
+ <List value={selected} onChange={handleChange} selectionMode="multiple">
+ {fields.map(({ name, label }) => {
+ return (
+ <ListItem key={name} id={name}>
+ {label}
+ </ListItem>
+ );
+ })}
+ </List>
+ <Grid columns="1fr 1fr" gap>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <Button onPress={handleApply} variant="primary">
+ {formatMessage(labels.apply)}
+ </Button>
+ </Grid>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx
new file mode 100644
index 0000000..841d863
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { BreakdownPage } from './BreakdownPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <BreakdownPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Insights',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
new file mode 100644
index 0000000..e336a3d
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
@@ -0,0 +1,134 @@
+import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { File, User } from '@/components/icons';
+import { ReportEditButton } from '@/components/input/ReportEditButton';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { Lightning } from '@/components/svg';
+import { formatLongNumber } from '@/lib/format';
+import { FunnelEditForm } from './FunnelEditForm';
+
+type FunnelResult = {
+ type: string;
+ value: string;
+ visitors: number;
+ previous: number;
+ dropped: number;
+ dropoff: number;
+ remaining: number;
+};
+
+export function Funnel({ id, name, type, parameters, websiteId }) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery(type, {
+ websiteId,
+ ...parameters,
+ });
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Grid gap>
+ <Grid columns="1fr auto" gap>
+ <Column gap>
+ <Row>
+ <Text size="4" weight="bold">
+ {name}
+ </Text>
+ </Row>
+ </Column>
+ <Column>
+ <ReportEditButton id={id} name={name} type={type}>
+ {({ close }) => {
+ return (
+ <Dialog
+ title={formatMessage(labels.funnel)}
+ variant="modal"
+ style={{ minHeight: 300, minWidth: 400 }}
+ >
+ <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
+ </Dialog>
+ );
+ }}
+ </ReportEditButton>
+ </Column>
+ </Grid>
+ {data?.map(
+ (
+ { type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
+ index: number,
+ ) => {
+ const isPage = type === 'path';
+ return (
+ <Grid key={index} columns="auto 1fr" gap="6">
+ <Column alignItems="center" position="relative">
+ <Row
+ borderRadius="full"
+ backgroundColor="3"
+ width="40px"
+ height="40px"
+ justifyContent="center"
+ alignItems="center"
+ style={{ zIndex: 1 }}
+ >
+ <Text weight="bold" size="3">
+ {index + 1}
+ </Text>
+ </Row>
+ {index > 0 && (
+ <Box
+ position="absolute"
+ backgroundColor="3"
+ width="2px"
+ height="120px"
+ top="-100%"
+ />
+ )}
+ </Column>
+ <Column gap>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Text color="muted">
+ {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
+ </Text>
+ <Text color="muted">{formatMessage(labels.conversionRate)}</Text>
+ </Row>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Row alignItems="center" gap>
+ <Icon>{type === 'path' ? <File /> : <Lightning />}</Icon>
+ <Text>{value}</Text>
+ </Row>
+ <Row alignItems="center" gap>
+ {index > 0 && (
+ <ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}>
+ {formatLongNumber(dropped)}
+ </ChangeLabel>
+ )}
+ <Icon>
+ <User />
+ </Icon>
+ <Text title={visitors.toString()} transform="lowercase">
+ {`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`}
+ </Text>
+ </Row>
+ </Row>
+ <Row alignItems="center" gap="6">
+ <ProgressBar
+ value={visitors || 0}
+ minValue={0}
+ maxValue={previous || 1}
+ style={{ width: '100%' }}
+ />
+ <Row minWidth="90px" justifyContent="end">
+ <Text weight="bold" size="7">
+ {Math.round(remaining * 100)}%
+ </Text>
+ </Row>
+ </Row>
+ </Column>
+ </Grid>
+ );
+ },
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx
new file mode 100644
index 0000000..29b5480
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx
@@ -0,0 +1,28 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { FunnelEditForm } from './FunnelEditForm';
+
+export function FunnelAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.funnel)}</Text>
+ </Button>
+ <Modal>
+ <Dialog
+ variant="modal"
+ title={formatMessage(labels.funnel)}
+ style={{ minHeight: 375, minWidth: 600 }}
+ >
+ {({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx
new file mode 100644
index 0000000..5d950ea
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx
@@ -0,0 +1,141 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormFieldArray,
+ FormSubmitButton,
+ Grid,
+ Icon,
+ Loading,
+ Row,
+ Text,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
+import { Plus, X } from '@/components/icons';
+import { ActionSelect } from '@/components/input/ActionSelect';
+import { LookupField } from '@/components/input/LookupField';
+
+const FUNNEL_STEPS_MAX = 8;
+
+export function FunnelEditForm({
+ id,
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ id?: string;
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useReportQuery(id);
+ const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
+
+ const handleSubmit = async ({ name, ...parameters }) => {
+ await mutateAsync(
+ { ...data, id, name, type: 'funnel', websiteId, parameters },
+ {
+ onSuccess: async () => {
+ touch('reports:funnel');
+ touch(`report:${id}`);
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ if (id && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ const defaultValues = {
+ name: data?.name || '',
+ window: data?.parameters?.window || 60,
+ steps: data?.parameters?.steps || [{ type: 'path', value: '' }],
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus />
+ </FormField>
+ <FormField
+ name="window"
+ label={formatMessage(labels.window)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField />
+ </FormField>
+ <FormFieldArray
+ name="steps"
+ label={formatMessage(labels.steps)}
+ rules={{
+ validate: value => value.length > 1 || 'At least two steps are required',
+ }}
+ >
+ {({ fields, append, remove }) => {
+ return (
+ <Grid gap>
+ {fields.map(({ id }: { id: string }, index: number) => {
+ return (
+ <Grid key={id} columns="260px 1fr auto" gap>
+ <Column>
+ <FormField
+ name={`steps.${index}.type`}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <ActionSelect />
+ </FormField>
+ </Column>
+ <Column>
+ <FormField
+ name={`steps.${index}.value`}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ {({ field, context }) => {
+ const type = context.watch(`steps.${index}.type`);
+ return <LookupField websiteId={websiteId} type={type} {...field} />;
+ }}
+ </FormField>
+ </Column>
+ <Button onPress={() => remove(index)}>
+ <Icon size="sm">
+ <X />
+ </Icon>
+ </Button>
+ </Grid>
+ );
+ })}
+ <Row>
+ <Button
+ onPress={() => append({ type: 'path', value: '' })}
+ isDisabled={fields.length >= FUNNEL_STEPS_MAX}
+ >
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.add)}</Text>
+ </Button>
+ </Row>
+ </Grid>
+ );
+ }}
+ </FormFieldArray>
+ <FormButtons>
+ <Button onPress={onClose} isDisabled={isPending}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
new file mode 100644
index 0000000..57bce52
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
@@ -0,0 +1,36 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useDateRange, useReportsQuery } from '@/components/hooks';
+import { Funnel } from './Funnel';
+import { FunnelAddButton } from './FunnelAddButton';
+
+export function FunnelsPage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'funnel' });
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <SectionHeader>
+ <FunnelAddButton websiteId={websiteId} />
+ </SectionHeader>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Grid gap>
+ {data.data?.map((report: any) => (
+ <Panel key={report.id}>
+ <Funnel {...report} startDate={startDate} endDate={endDate} />
+ </Panel>
+ ))}
+ </Grid>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx
new file mode 100644
index 0000000..2fdcf3b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { FunnelsPage } from './FunnelsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <FunnelsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Funnels',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
new file mode 100644
index 0000000..b6c4a11
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
@@ -0,0 +1,99 @@
+import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { File, User } from '@/components/icons';
+import { ReportEditButton } from '@/components/input/ReportEditButton';
+import { Lightning } from '@/components/svg';
+import { formatLongNumber } from '@/lib/format';
+import { GoalEditForm } from './GoalEditForm';
+
+export interface GoalProps {
+ id: string;
+ name: string;
+ type: string;
+ parameters: {
+ name: string;
+ type: string;
+ value: string;
+ };
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+}
+
+export type GoalData = { num: number; total: number };
+
+export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
+ websiteId,
+ startDate,
+ endDate,
+ ...parameters,
+ });
+ const isPage = parameters?.type === 'path';
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
+ {data && (
+ <Grid gap>
+ <Grid columns="1fr auto" gap>
+ <Column gap>
+ <Row>
+ <Text size="4" weight="bold">
+ {name}
+ </Text>
+ </Row>
+ </Column>
+ <Column>
+ <ReportEditButton id={id} name={name} type={type}>
+ {({ close }) => {
+ return (
+ <Dialog
+ title={formatMessage(labels.goal)}
+ variant="modal"
+ style={{ minHeight: 300, minWidth: 400 }}
+ >
+ <GoalEditForm id={id} websiteId={websiteId} onClose={close} />
+ </Dialog>
+ );
+ }}
+ </ReportEditButton>
+ </Column>
+ </Grid>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Text color="muted">
+ {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
+ </Text>
+ <Text color="muted">{formatMessage(labels.conversionRate)}</Text>
+ </Row>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Row alignItems="center" gap>
+ <Icon>{parameters.type === 'path' ? <File /> : <Lightning />}</Icon>
+ <Text>{parameters.value}</Text>
+ </Row>
+ <Row alignItems="center" gap>
+ <Icon>
+ <User />
+ </Icon>
+ <Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
+ data?.num,
+ )} / ${formatLongNumber(data?.total)}`}</Text>
+ </Row>
+ </Row>
+ <Row alignItems="center" gap="6">
+ <ProgressBar
+ value={data?.num || 0}
+ minValue={0}
+ maxValue={data?.total || 1}
+ style={{ width: '100%' }}
+ />
+ <Text weight="bold" size="7">
+ {data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
+ </Text>
+ </Row>
+ </Grid>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx
new file mode 100644
index 0000000..c85b79c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx
@@ -0,0 +1,28 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { GoalEditForm } from './GoalEditForm';
+
+export function GoalAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.goal)}</Text>
+ </Button>
+ <Modal>
+ <Dialog
+ aria-label="add goal"
+ title={formatMessage(labels.goal)}
+ style={{ minWidth: 400, minHeight: 300 }}
+ >
+ {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx
new file mode 100644
index 0000000..7f68047
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx
@@ -0,0 +1,104 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Grid,
+ Label,
+ Loading,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
+import { ActionSelect } from '@/components/input/ActionSelect';
+import { LookupField } from '@/components/input/LookupField';
+
+export function GoalEditForm({
+ id,
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ id?: string;
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useReportQuery(id);
+ const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
+
+ const handleSubmit = async (formData: Record<string, any>) => {
+ await mutateAsync(
+ { ...formData, type: 'goal', websiteId },
+ {
+ onSuccess: async () => {
+ if (id) touch(`report:${id}`);
+ touch('reports:goal');
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ if (id && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ const defaultValues = {
+ name: '',
+ parameters: { type: 'path', value: '' },
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={error?.message} defaultValues={data || defaultValues}>
+ {({ watch }) => {
+ const type = watch('parameters.type');
+
+ return (
+ <>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus />
+ </FormField>
+ <Column>
+ <Label>{formatMessage(labels.action)}</Label>
+ <Grid columns="260px 1fr" gap>
+ <Column>
+ <FormField
+ name="parameters.type"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <ActionSelect />
+ </FormField>
+ </Column>
+ <Column>
+ <FormField
+ name="parameters.value"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ {({ field }) => {
+ return <LookupField websiteId={websiteId} type={type} {...field} />;
+ }}
+ </FormField>
+ </Column>
+ </Grid>
+ </Column>
+
+ <FormButtons>
+ <Button onPress={onClose} isDisabled={isPending}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
+ </FormButtons>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
new file mode 100644
index 0000000..ff7b49f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
@@ -0,0 +1,36 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { SectionHeader } from '@/components/common/SectionHeader';
+import { useDateRange, useReportsQuery } from '@/components/hooks';
+import { Goal } from './Goal';
+import { GoalAddButton } from './GoalAddButton';
+
+export function GoalsPage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' });
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <SectionHeader>
+ <GoalAddButton websiteId={websiteId} />
+ </SectionHeader>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ {data.data.map((report: any) => (
+ <Panel key={report.id}>
+ <Goal {...report} startDate={startDate} endDate={endDate} />
+ </Panel>
+ ))}
+ </Grid>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx
new file mode 100644
index 0000000..b1ab691
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { GoalsPage } from './GoalsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <GoalsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Goals',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
new file mode 100644
index 0000000..63643f1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
@@ -0,0 +1,267 @@
+.container {
+ width: 100%;
+ height: 100%;
+ position: relative;
+
+ --journey-line-color: var(--base-color-6);
+ --journey-active-color: var(--primary-color);
+ --journey-faded-color: var(--base-color-3);
+}
+
+.view {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow: auto;
+ gap: 100px;
+ padding-right: 20px;
+}
+
+.header {
+ margin-bottom: 20px;
+}
+
+.stats {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 10px;
+ width: 100%;
+}
+
+.visitors {
+ font-weight: 600;
+ font-size: 16px;
+ text-transform: lowercase;
+}
+
+.dropoff {
+ font-weight: 600;
+ color: var(--font-color-muted);
+ background: var(--base-color-2);
+ padding: 4px 8px;
+ border-radius: 5px;
+}
+
+.num {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100%;
+ width: 50px;
+ height: 50px;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+ z-index: 1;
+ margin: 0 auto 20px;
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+}
+
+.nodes {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.wrapper {
+ padding-bottom: 10px;
+}
+
+.node {
+ position: relative;
+ cursor: pointer;
+ padding: 10px 20px;
+ background: var(--base-color-3);
+ border-radius: 5px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 300px;
+ max-width: 300px;
+ height: 60px;
+ max-height: 60px;
+}
+
+.node:hover:not(.selected) {
+ background: var(--base-color-4);
+}
+
+.node.selected {
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+}
+
+.node.active {
+ color: var(--primary-font-color);
+ background: var(--primary-color);
+}
+
+.node.selected .count {
+ color: var(--base-color-1);
+ background: var(--base-color-12);
+}
+
+.node.selected.active .count {
+ color: var(--primary-font-color);
+ background: var(--primary-color);
+}
+
+.name {
+ max-width: 200px;
+}
+
+.line {
+ position: absolute;
+ bottom: 0;
+ left: -100px;
+ width: 100px;
+ pointer-events: none;
+}
+
+.line.up {
+ bottom: 0;
+}
+
+.line.down {
+ top: 0;
+}
+
+.segment {
+ position: absolute;
+}
+
+.start {
+ left: 0;
+ width: 50px;
+ height: 30px;
+ border: 0;
+}
+
+.mid {
+ top: 60px;
+ width: 50px;
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.end {
+ width: 50px;
+ height: 30px;
+ border: 0;
+}
+
+.up .start {
+ top: 30px;
+ border-top-right-radius: 100%;
+ border-top: 3px solid var(--journey-line-color);
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.up .end {
+ width: 52px;
+ bottom: 27px;
+ right: 0;
+ border-bottom-left-radius: 100%;
+ border-bottom: 3px solid var(--journey-line-color);
+ border-left: 3px solid var(--journey-line-color);
+}
+
+.down .start {
+ bottom: 27px;
+ border-bottom-right-radius: 100%;
+ border-bottom: 3px solid var(--journey-line-color);
+ border-right: 3px solid var(--journey-line-color);
+}
+
+.down .end {
+ width: 52px;
+ top: 30px;
+ right: 0;
+ border-top-left-radius: 100%;
+ border-top: 3px solid var(--journey-line-color);
+ border-left: 3px solid var(--journey-line-color);
+}
+
+.flat .start {
+ left: 0;
+ top: 30px;
+ border-top: 3px solid var(--journey-line-color);
+}
+
+.flat .end {
+ right: 0;
+ top: 30px;
+ border-top: 3px solid var(--journey-line-color);
+}
+
+.start:before,
+.end:before {
+ content: "";
+ position: absolute;
+ border-radius: 100%;
+ border: 3px solid var(--journey-line-color);
+ background: var(--base-color-1);
+ width: 14px;
+ height: 14px;
+}
+
+.line:not(.active) .start:before,
+.line:not(.active) .end:before {
+ display: none;
+}
+
+.up .start:before {
+ left: -8px;
+ top: -8px;
+}
+
+.up .end:before {
+ right: -8px;
+ bottom: -8px;
+}
+
+.down .start:before {
+ left: -8px;
+ bottom: -8px;
+}
+
+.down .end:before {
+ right: -8px;
+ top: -8px;
+}
+
+.flat .start:before {
+ left: -8px;
+ top: -8px;
+}
+
+.flat .end:before {
+ right: -8px;
+ top: -8px;
+}
+
+.line.active .segment,
+.line.active .segment:before {
+ border-color: var(--journey-active-color);
+ z-index: 1;
+}
+
+.column.active .line:not(.active) .segment {
+ border-color: var(--journey-faded-color);
+}
+
+.column.active .line:not(.active) .segment:before {
+ display: none;
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
new file mode 100644
index 0000000..3327a42
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
@@ -0,0 +1,294 @@
+import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import classNames from 'classnames';
+import { useMemo, useState } from 'react';
+import { firstBy } from 'thenby';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
+import { File } from '@/components/icons';
+import { Lightning } from '@/components/svg';
+import { objectToArray } from '@/lib/data';
+import { formatLongNumber } from '@/lib/format';
+import styles from './Journey.module.css';
+
+const NODE_HEIGHT = 60;
+const NODE_GAP = 10;
+const LINE_WIDTH = 3;
+
+export interface JourneyProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ steps: number;
+ startStep?: string;
+ endStep?: string;
+}
+
+export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) {
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [activeNode, setActiveNode] = useState(null);
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery<any>('journey', {
+ websiteId,
+ steps,
+ startStep,
+ endStep,
+ });
+
+ useEscapeKey(() => setSelectedNode(null));
+
+ const columns = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ const selectedPaths = selectedNode?.paths ?? [];
+ const activePaths = activeNode?.paths ?? [];
+ const columns = [];
+
+ for (let columnIndex = 0; columnIndex < +steps; columnIndex++) {
+ const nodes = {};
+
+ data.forEach(({ items, count }: any, nodeIndex: any) => {
+ const name = items[columnIndex];
+
+ if (name) {
+ const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name);
+ const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name);
+
+ if (!nodes[name]) {
+ const paths = data.filter(({ items }) => items[columnIndex] === name);
+
+ nodes[name] = {
+ name,
+ count,
+ totalCount: count,
+ nodeIndex,
+ columnIndex,
+ selected,
+ active,
+ paths,
+ pathMap: paths.map(({ items, count }) => ({
+ [`${columnIndex}:${items.join(':')}`]: count,
+ })),
+ };
+ } else {
+ nodes[name].totalCount += count;
+ }
+ }
+ });
+
+ columns.push({
+ nodes: objectToArray(nodes).sort(firstBy('total', -1)),
+ });
+ }
+
+ columns.forEach((column, columnIndex) => {
+ const nodes = column.nodes.map(
+ (
+ currentNode: { totalCount: number; name: string; selected: boolean },
+ currentNodeIndex: any,
+ ) => {
+ const previousNodes = columns[columnIndex - 1]?.nodes;
+ let selectedCount = previousNodes ? 0 : currentNode.totalCount;
+ let activeCount = selectedCount;
+
+ const lines =
+ previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => {
+ const fromCount = selectedNode?.paths.reduce((sum, path) => {
+ if (
+ previousNode.name === path.items[columnIndex - 1] &&
+ currentNode.name === path.items[columnIndex]
+ ) {
+ sum += path.count;
+ }
+ return sum;
+ }, 0);
+
+ if (currentNode.selected && previousNode.selected && fromCount) {
+ arr.push([previousNodeIndex, currentNodeIndex]);
+ selectedCount += fromCount;
+
+ if (previousNode.active) {
+ activeCount += fromCount;
+ }
+ }
+
+ return arr;
+ }, []) || [];
+
+ return { ...currentNode, selectedCount, activeCount, lines };
+ },
+ );
+
+ const visitorCount = nodes.reduce(
+ (sum: number, { selected, selectedCount, active, activeCount, totalCount }) => {
+ if (!selectedNode) {
+ sum += totalCount;
+ } else if (!activeNode && selectedNode && selected) {
+ sum += selectedCount;
+ } else if (activeNode && active) {
+ sum += activeCount;
+ }
+ return sum;
+ },
+ 0,
+ );
+
+ const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0;
+ const dropOff =
+ previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0;
+
+ Object.assign(column, { nodes, visitorCount, dropOff });
+ });
+
+ return columns;
+ }, [data, selectedNode, activeNode]);
+
+ const handleClick = (name: string, columnIndex: number, paths: any[]) => {
+ if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) {
+ setSelectedNode({ name, columnIndex, paths });
+ } else {
+ setSelectedNode(null);
+ }
+ setActiveNode(null);
+ };
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error} height="100%">
+ <div className={styles.container}>
+ <div className={styles.view}>
+ {columns.map(({ visitorCount, nodes }, columnIndex) => {
+ return (
+ <div
+ key={columnIndex}
+ className={classNames(styles.column, {
+ [styles.selected]: selectedNode,
+ [styles.active]: activeNode,
+ })}
+ >
+ <div className={styles.header}>
+ <div className={styles.num}>{columnIndex + 1}</div>
+ <div className={styles.stats}>
+ <div className={styles.visitors} title={visitorCount}>
+ {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
+ </div>
+ </div>
+ </div>
+ <div className={styles.nodes}>
+ {nodes.map(
+ ({
+ name,
+ totalCount,
+ selected,
+ active,
+ paths,
+ activeCount,
+ selectedCount,
+ lines,
+ }) => {
+ const nodeCount = selected
+ ? active
+ ? activeCount
+ : selectedCount
+ : totalCount;
+
+ const remaining =
+ columnIndex > 0
+ ? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100)
+ : 0;
+
+ const dropped = 100 - remaining;
+
+ return (
+ <div
+ key={name}
+ className={styles.wrapper}
+ onMouseEnter={() =>
+ selected && setActiveNode({ name, columnIndex, paths })
+ }
+ onMouseLeave={() => selected && setActiveNode(null)}
+ >
+ <div
+ className={classNames(styles.node, {
+ [styles.selected]: selected,
+ [styles.active]: active,
+ })}
+ onClick={() => handleClick(name, columnIndex, paths)}
+ >
+ <Row alignItems="center" className={styles.name} title={name} gap>
+ <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
+ <Text truncate>{name}</Text>
+ </Row>
+ <div className={styles.count} title={nodeCount}>
+ <TooltipTrigger
+ delay={0}
+ isDisabled={columnIndex === 0 || (selectedNode && !selected)}
+ >
+ <Focusable>
+ <div>{formatLongNumber(nodeCount)}</div>
+ </Focusable>
+ <Tooltip placement="top" offset={20} showArrow>
+ <Text transform="lowercase" color="ruby">
+ {`${dropped}% ${formatMessage(labels.dropoff)}`}
+ </Text>
+ <Column>
+ <Text transform="lowercase">
+ {`${remaining}% ${formatMessage(labels.conversion)}`}
+ </Text>
+ </Column>
+ </Tooltip>
+ </TooltipTrigger>
+ </div>
+ {columnIndex < columns.length &&
+ lines.map(([fromIndex, nodeIndex], i) => {
+ const height =
+ (Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) -
+ NODE_GAP;
+ const midHeight =
+ (Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) +
+ NODE_GAP +
+ LINE_WIDTH;
+ const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name;
+
+ return (
+ <div
+ key={`${fromIndex}${nodeIndex}${i}`}
+ className={classNames(styles.line, {
+ [styles.active]:
+ active &&
+ activeNode?.paths.find(
+ (path: { items: any[] }) =>
+ path.items[columnIndex] === name &&
+ path.items[columnIndex - 1] === nodeName,
+ ),
+ [styles.up]: fromIndex < nodeIndex,
+ [styles.down]: fromIndex > nodeIndex,
+ [styles.flat]: fromIndex === nodeIndex,
+ })}
+ style={{ height }}
+ >
+ <div className={classNames(styles.segment, styles.start)} />
+ <div
+ className={classNames(styles.segment, styles.mid)}
+ style={{
+ height: midHeight,
+ }}
+ />
+ <div className={classNames(styles.segment, styles.end)} />
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+ },
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
new file mode 100644
index 0000000..14b8341
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
@@ -0,0 +1,67 @@
+'use client';
+import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { Journey } from './Journey';
+
+const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
+const DEFAULT_STEP = 3;
+
+export function JourneysPage({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+ const [steps, setSteps] = useState(DEFAULT_STEP);
+ const [startStep, setStartStep] = useState('');
+ const [endStep, setEndStep] = useState('');
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Grid columns="repeat(3, 1fr)" gap>
+ <Select
+ items={JOURNEY_STEPS}
+ label={formatMessage(labels.steps)}
+ value={steps}
+ defaultValue={steps}
+ onChange={setSteps}
+ >
+ {JOURNEY_STEPS.map(step => (
+ <ListItem key={step} id={step}>
+ {step}
+ </ListItem>
+ ))}
+ </Select>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.startStep)}
+ value={startStep}
+ onSearch={setStartStep}
+ delay={1000}
+ />
+ </Column>
+ <Column>
+ <SearchField
+ label={formatMessage(labels.endStep)}
+ value={endStep}
+ onSearch={setEndStep}
+ delay={1000}
+ />
+ </Column>
+ </Grid>
+ <Panel height="900px" allowFullscreen>
+ <Journey
+ websiteId={websiteId}
+ startDate={startDate}
+ endDate={endDate}
+ steps={steps}
+ startStep={startStep}
+ endStep={endStep}
+ />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
new file mode 100644
index 0000000..f6062a6
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { JourneysPage } from './JourneysPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <JourneysPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Journeys',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx
new file mode 100644
index 0000000..fdd8a14
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx
@@ -0,0 +1,140 @@
+import { Column, Grid, Icon, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { useLocale, useMessages, useResultQuery } from '@/components/hooks';
+import { Users } from '@/components/icons';
+import { formatDate } from '@/lib/date';
+import { formatLongNumber } from '@/lib/format';
+
+const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
+
+export interface RetentionProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ days?: number[];
+}
+
+export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { data, error, isLoading } = useResultQuery('retention', {
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ const rows =
+ data?.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => {
+ const { date, visitors, day } = row;
+ if (day === 0) {
+ return arr.concat({
+ date,
+ visitors,
+ records: days
+ .reduce((arr, day) => {
+ arr[day] = data.find(
+ (x: { date: any; day: number }) => x.date === date && x.day === day,
+ );
+ return arr;
+ }, [])
+ .filter(n => n),
+ });
+ }
+ return arr;
+ }, []) || [];
+
+ const totalDays = rows.length;
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Panel allowFullscreen height="900px">
+ <Column
+ paddingY="6"
+ paddingX={{ xs: '3', md: '6' }}
+ position="absolute"
+ top="40px"
+ left="0"
+ right="0"
+ bottom="0"
+ >
+ <Column gap="1" overflow="auto">
+ <Grid
+ columns="120px repeat(10, 100px)"
+ alignItems="center"
+ gap="1"
+ height="50px"
+ width="max-content"
+ minWidth="100%"
+ autoFlow="column"
+ >
+ <Column>
+ <Text weight="bold" align="center">
+ {formatMessage(labels.cohort)}
+ </Text>
+ </Column>
+ {days.map(n => (
+ <Column key={n}>
+ <Text weight="bold" align="center" wrap="nowrap">
+ {formatMessage(labels.day)} {n}
+ </Text>
+ </Column>
+ ))}
+ </Grid>
+ {rows.map(({ date, visitors, records }: any, rowIndex: number) => {
+ return (
+ <Grid
+ key={rowIndex}
+ columns="120px repeat(10, 100px)"
+ gap="1"
+ autoFlow="column"
+ width="max-content"
+ minWidth="100%"
+ >
+ <Column justifyContent="center" gap="1">
+ <Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
+ <Row alignItems="center" gap>
+ <Icon>
+ <Users />
+ </Icon>
+ <Text>{formatLongNumber(visitors)}</Text>
+ </Row>
+ </Column>
+ {days.map(day => {
+ if (totalDays - rowIndex < day) {
+ return null;
+ }
+ const percentage = records.filter(a => a.day === day)[0]?.percentage;
+ return (
+ <Cell key={day}>
+ {percentage ? `${Number(percentage).toFixed(2)}%` : ''}
+ </Cell>
+ );
+ })}
+ </Grid>
+ );
+ })}
+ </Column>
+ </Column>
+ </Panel>
+ )}
+ </LoadingPanel>
+ );
+}
+
+const Cell = ({ children }: { children: ReactNode }) => {
+ return (
+ <Column
+ justifyContent="center"
+ alignItems="center"
+ width="100px"
+ height="100px"
+ backgroundColor="2"
+ borderRadius
+ >
+ {children}
+ </Column>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx
new file mode 100644
index 0000000..0ec6e95
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx
@@ -0,0 +1,22 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { endOfMonth, startOfMonth } from 'date-fns';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { Retention } from './Retention';
+
+export function RetentionPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate },
+ } = useDateRange();
+
+ const monthStartDate = startOfMonth(startDate);
+ const monthEndDate = endOfMonth(startDate);
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} allowDateFilter={false} allowMonthFilter />
+ <Retention websiteId={websiteId} startDate={monthStartDate} endDate={monthEndDate} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx
new file mode 100644
index 0000000..2fbbc0a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RetentionPage } from './RetentionPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RetentionPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Retention',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
new file mode 100644
index 0000000..0e782a1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
@@ -0,0 +1,152 @@
+import { Column, Grid, Row, Text } from '@umami/react-zen';
+import classNames from 'classnames';
+import { colord } from 'colord';
+import { useCallback, useMemo, useState } from 'react';
+import { BarChart } from '@/components/charts/BarChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks';
+import { CurrencySelect } from '@/components/input/CurrencySelect';
+import { ListTable } from '@/components/metrics/ListTable';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { renderDateLabels } from '@/lib/charts';
+import { CHART_COLORS } from '@/lib/constants';
+import { generateTimeSeries } from '@/lib/date';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+export interface RevenueProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+ unit: string;
+}
+
+export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) {
+ const [currency, setCurrency] = useState('USD');
+ const { formatMessage, labels } = useMessages();
+ const { locale, dateLocale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { data, error, isLoading } = useResultQuery<any>('revenue', {
+ websiteId,
+ startDate,
+ endDate,
+ currency,
+ });
+
+ const renderCountryName = useCallback(
+ ({ label: code }) => (
+ <Row className={classNames(locale)} gap>
+ <TypeIcon type="country" value={code} />
+ <Text>{countryNames[code] || formatMessage(labels.unknown)}</Text>
+ </Row>
+ ),
+ [countryNames, locale],
+ );
+
+ const chartData: any = useMemo(() => {
+ if (!data) return [];
+
+ const map = (data.chart as any[]).reduce((obj, { x, t, y }) => {
+ if (!obj[x]) {
+ obj[x] = [];
+ }
+
+ obj[x].push({ x: t, y });
+
+ return obj;
+ }, {});
+
+ return {
+ datasets: Object.keys(map).map((key, index) => {
+ const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
+ return {
+ label: key,
+ data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ backgroundColor: color.alpha(0.6).toRgbString(),
+ borderColor: color.alpha(0.7).toRgbString(),
+ borderWidth: 1,
+ };
+ }),
+ };
+ }, [data, startDate, endDate, unit]);
+
+ const metrics = useMemo(() => {
+ if (!data) return [];
+
+ const { sum, count, unique_count } = data.total;
+
+ return [
+ {
+ value: sum,
+ label: formatMessage(labels.total),
+ formatValue: n => formatLongCurrency(n, currency),
+ },
+ {
+ value: count ? sum / count : 0,
+ label: formatMessage(labels.average),
+ formatValue: n => formatLongCurrency(n, currency),
+ },
+ {
+ value: count,
+ label: formatMessage(labels.transactions),
+ formatValue: formatLongNumber,
+ },
+ {
+ value: unique_count,
+ label: formatMessage(labels.uniqueCustomers),
+ formatValue: formatLongNumber,
+ },
+ ] as any;
+ }, [data, locale]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <Column gap>
+ <Grid columns="280px" gap>
+ <CurrencySelect value={currency} onChange={setCurrency} />
+ </Grid>
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {data && (
+ <Column gap>
+ <MetricsBar>
+ {metrics?.map(({ label, value, formatValue }) => {
+ return (
+ <MetricCard key={label} value={value} label={label} formatValue={formatValue} />
+ );
+ })}
+ </MetricsBar>
+ <Panel>
+ <BarChart
+ chartData={chartData}
+ minDate={startDate}
+ maxDate={endDate}
+ unit={unit}
+ stacked={true}
+ currency={currency}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ </Panel>
+ <Panel>
+ <ListTable
+ title={formatMessage(labels.country)}
+ metric={formatMessage(labels.revenue)}
+ data={data?.country.map(({ name, value }: { name: string; value: number }) => ({
+ label: name,
+ count: Number(value),
+ percent: (value / data?.total.sum) * 100,
+ }))}
+ currency={currency}
+ renderLabel={renderCountryName}
+ />
+ </Panel>
+ </Column>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx
new file mode 100644
index 0000000..3e429c1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx
@@ -0,0 +1,18 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { Revenue } from './Revenue';
+
+export function RevenuePage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate, unit },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <Revenue websiteId={websiteId} startDate={startDate} endDate={endDate} unit={unit} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx
new file mode 100644
index 0000000..e30d54c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx
@@ -0,0 +1,21 @@
+import { DataColumn, DataTable } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { formatLongCurrency } from '@/lib/format';
+
+export function RevenueTable({ data = [] }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DataTable data={data}>
+ <DataColumn id="currency" label={formatMessage(labels.currency)} align="end" />
+ <DataColumn id="total" label={formatMessage(labels.total)} align="end">
+ {(row: any) => formatLongCurrency(row.sum, row.currency)}
+ </DataColumn>
+ <DataColumn id="average" label={formatMessage(labels.average)} align="end">
+ {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
+ </DataColumn>
+ <DataColumn id="count" label={formatMessage(labels.transactions)} align="end" />
+ <DataColumn id="unique_count" label={formatMessage(labels.uniqueCustomers)} align="end" />
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx
new file mode 100644
index 0000000..fba10f1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RevenuePage } from './RevenuePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RevenuePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Revenue',
+};
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx
new file mode 100644
index 0000000..1399174
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx
@@ -0,0 +1,71 @@
+import { Column, Grid, Heading, Text } from '@umami/react-zen';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useResultQuery } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
+
+export interface UTMProps {
+ websiteId: string;
+ startDate: Date;
+ endDate: Date;
+}
+
+export function UTM({ websiteId, startDate, endDate }: UTMProps) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading } = useResultQuery<any>('utm', {
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="300px">
+ {data && (
+ <Column gap>
+ {UTM_PARAMS.map(param => {
+ const items = data?.[param];
+
+ const chartData = {
+ labels: items.map(({ utm }) => utm),
+ datasets: [
+ {
+ data: items.map(({ views }) => views),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+ const total = items.reduce((sum, { views }) => {
+ return +sum + +views;
+ }, 0);
+
+ return (
+ <Panel key={param}>
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap="6">
+ <Column>
+ <Heading>
+ <Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
+ </Heading>
+ <ListTable
+ metric={formatMessage(labels.views)}
+ data={items.map(({ utm, views }) => ({
+ label: utm,
+ count: views,
+ percent: (views / total) * 100,
+ }))}
+ />
+ </Column>
+ <Column>
+ <PieChart type="doughnut" chartData={chartData} />
+ </Column>
+ </Grid>
+ </Panel>
+ );
+ })}
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx
new file mode 100644
index 0000000..0d2a732
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx
@@ -0,0 +1,18 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { useDateRange } from '@/components/hooks';
+import { UTM } from './UTM';
+
+export function UTMPage({ websiteId }: { websiteId: string }) {
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange();
+
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <UTM websiteId={websiteId} startDate={startDate} endDate={endDate} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx
new file mode 100644
index 0000000..8b8fd6a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { UTMPage } from './UTMPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <UTMPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'UTM Parameters',
+};
diff --git a/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx b/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx
new file mode 100644
index 0000000..3663812
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx
@@ -0,0 +1,52 @@
+import { Dialog, Modal } from '@umami/react-zen';
+import { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView';
+import { useMobile, useNavigation } from '@/components/hooks';
+
+export function ExpandedViewModal({
+ websiteId,
+ excludedIds,
+}: {
+ websiteId: string;
+ excludedIds?: string[];
+}) {
+ const {
+ router,
+ query: { view },
+ updateParams,
+ } = useNavigation();
+ const { isMobile } = useMobile();
+
+ const handleClose = (close: () => void) => {
+ router.push(updateParams({ view: undefined }));
+ close();
+ };
+
+ const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ router.push(updateParams({ view: undefined }));
+ }
+ };
+
+ return (
+ <Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
+ <Dialog
+ style={{
+ maxWidth: 1320,
+ width: '100vw',
+ height: isMobile ? '100dvh' : 'calc(100dvh - 40px)',
+ overflow: 'hidden',
+ }}
+ >
+ {({ close }) => {
+ return (
+ <WebsiteExpandedView
+ websiteId={websiteId}
+ excludedIds={excludedIds}
+ onClose={() => handleClose(close)}
+ />
+ );
+ }}
+ </Dialog>
+ </Modal>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx
new file mode 100644
index 0000000..b2ea2a8
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx
@@ -0,0 +1,61 @@
+import { useMemo } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useTimezone } from '@/components/hooks';
+import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery';
+import { PageviewsChart } from '@/components/metrics/PageviewsChart';
+
+export function WebsiteChart({
+ websiteId,
+ compareMode,
+}: {
+ websiteId: string;
+ compareMode?: boolean;
+}) {
+ const { timezone } = useTimezone();
+ const { dateRange, dateCompare } = useDateRange({ timezone: timezone });
+ const { startDate, endDate, unit, value } = dateRange;
+ const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({
+ websiteId,
+ compare: compareMode ? dateCompare?.compare : undefined,
+ });
+ const { pageviews, sessions, compare } = (data || {}) as any;
+
+ const chartData = useMemo(() => {
+ if (data) {
+ const result = {
+ pageviews,
+ sessions,
+ };
+
+ if (compare) {
+ result.compare = {
+ pageviews: result.pageviews.map(({ x }, i) => ({
+ x,
+ y: compare.pageviews[i]?.y,
+ d: compare.pageviews[i]?.x,
+ })),
+ sessions: result.sessions.map(({ x }, i) => ({
+ x,
+ y: compare.sessions[i]?.y,
+ d: compare.sessions[i]?.x,
+ })),
+ };
+ }
+
+ return result;
+ }
+ return { pageviews: [], sessions: [] };
+ }, [data, startDate, endDate, unit]);
+
+ return (
+ <LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
+ <PageviewsChart
+ key={value}
+ data={chartData}
+ minDate={startDate}
+ maxDate={endDate}
+ unit={unit}
+ />
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
new file mode 100644
index 0000000..6223dbc
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
@@ -0,0 +1,40 @@
+import { Column, Grid, Row } from '@umami/react-zen';
+import { ExportButton } from '@/components/input/ExportButton';
+import { FilterBar } from '@/components/input/FilterBar';
+import { MonthFilter } from '@/components/input/MonthFilter';
+import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
+import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
+
+export function WebsiteControls({
+ websiteId,
+ allowFilter = true,
+ allowDateFilter = true,
+ allowMonthFilter,
+ allowDownload = false,
+ allowCompare = false,
+}: {
+ websiteId: string;
+ allowFilter?: boolean;
+ allowDateFilter?: boolean;
+ allowMonthFilter?: boolean;
+ allowDownload?: boolean;
+ allowCompare?: boolean;
+}) {
+ return (
+ <Column gap>
+ <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap>
+ <Row alignItems="center" justifyContent="flex-start">
+ {allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
+ </Row>
+ <Row alignItems="center" justifyContent={{ xs: 'flex-start', md: 'flex-end' }}>
+ {allowDateFilter && (
+ <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />
+ )}
+ {allowDownload && <ExportButton websiteId={websiteId} />}
+ {allowMonthFilter && <MonthFilter />}
+ </Row>
+ </Grid>
+ {allowFilter && <FilterBar websiteId={websiteId} />}
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx
new file mode 100644
index 0000000..29c3954
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx
@@ -0,0 +1,183 @@
+import { SideMenu } from '@/components/common/SideMenu';
+import { useMessages, useNavigation } from '@/components/hooks';
+import {
+ AppWindow,
+ Cpu,
+ Earth,
+ Globe,
+ Landmark,
+ Languages,
+ Laptop,
+ LogIn,
+ LogOut,
+ MapPin,
+ Megaphone,
+ Monitor,
+ Network,
+ Search,
+ Share2,
+ SquareSlash,
+ Tag,
+ Type,
+} from '@/components/icons';
+import { Lightning } from '@/components/svg';
+
+export function WebsiteExpandedMenu({
+ excludedIds = [],
+ onItemClick,
+}: {
+ excludedIds?: string[];
+ onItemClick?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const {
+ updateParams,
+ query: { view },
+ } = useNavigation();
+
+ const filterExcluded = (item: { id: string }) => !excludedIds.includes(item.id);
+
+ const items = [
+ {
+ label: 'URL',
+ items: [
+ {
+ id: 'path',
+ label: formatMessage(labels.path),
+ path: updateParams({ view: 'path' }),
+ icon: <SquareSlash />,
+ },
+ {
+ id: 'entry',
+ label: formatMessage(labels.entry),
+ path: updateParams({ view: 'entry' }),
+ icon: <LogIn />,
+ },
+ {
+ id: 'exit',
+ label: formatMessage(labels.exit),
+ path: updateParams({ view: 'exit' }),
+ icon: <LogOut />,
+ },
+ {
+ id: 'title',
+ label: formatMessage(labels.title),
+ path: updateParams({ view: 'title' }),
+ icon: <Type />,
+ },
+ {
+ id: 'query',
+ label: formatMessage(labels.query),
+ path: updateParams({ view: 'query' }),
+ icon: <Search />,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.sources),
+ items: [
+ {
+ id: 'referrer',
+ label: formatMessage(labels.referrer),
+ path: updateParams({ view: 'referrer' }),
+ icon: <Share2 />,
+ },
+ {
+ id: 'channel',
+ label: formatMessage(labels.channel),
+ path: updateParams({ view: 'channel' }),
+ icon: <Megaphone />,
+ },
+ {
+ id: 'domain',
+ label: formatMessage(labels.domain),
+ path: updateParams({ view: 'domain' }),
+ icon: <Globe />,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.location),
+ items: [
+ {
+ id: 'country',
+ label: formatMessage(labels.country),
+ path: updateParams({ view: 'country' }),
+ icon: <Earth />,
+ },
+ {
+ id: 'region',
+ label: formatMessage(labels.region),
+ path: updateParams({ view: 'region' }),
+ icon: <MapPin />,
+ },
+ {
+ id: 'city',
+ label: formatMessage(labels.city),
+ path: updateParams({ view: 'city' }),
+ icon: <Landmark />,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.environment),
+ items: [
+ {
+ id: 'browser',
+ label: formatMessage(labels.browser),
+ path: updateParams({ view: 'browser' }),
+ icon: <AppWindow />,
+ },
+ {
+ id: 'os',
+ label: formatMessage(labels.os),
+ path: updateParams({ view: 'os' }),
+ icon: <Cpu />,
+ },
+ {
+ id: 'device',
+ label: formatMessage(labels.device),
+ path: updateParams({ view: 'device' }),
+ icon: <Laptop />,
+ },
+ {
+ id: 'language',
+ label: formatMessage(labels.language),
+ path: updateParams({ view: 'language' }),
+ icon: <Languages />,
+ },
+ {
+ id: 'screen',
+ label: formatMessage(labels.screen),
+ path: updateParams({ view: 'screen' }),
+ icon: <Monitor />,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.other),
+ items: [
+ {
+ id: 'event',
+ label: formatMessage(labels.event),
+ path: updateParams({ view: 'event' }),
+ icon: <Lightning />,
+ },
+ {
+ id: 'hostname',
+ label: formatMessage(labels.hostname),
+ path: updateParams({ view: 'hostname' }),
+ icon: <Network />,
+ },
+ {
+ id: 'tag',
+ label: formatMessage(labels.tag),
+ path: updateParams({ view: 'tag' }),
+ icon: <Tag />,
+ },
+ ].filter(filterExcluded),
+ },
+ ];
+
+ return <SideMenu items={items} selectedKey={view} onItemClick={onItemClick} />;
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
new file mode 100644
index 0000000..2c670df
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
@@ -0,0 +1,57 @@
+import { Column, Grid, Row } from '@umami/react-zen';
+import { WebsiteExpandedMenu } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedMenu';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { MobileMenuButton } from '@/components/input/MobileMenuButton';
+import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable';
+
+export function WebsiteExpandedView({
+ websiteId,
+ excludedIds = [],
+ onClose,
+}: {
+ websiteId: string;
+ excludedIds?: string[];
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const {
+ query: { view },
+ } = useNavigation();
+
+ return (
+ <Column height="100%" overflow="hidden" gap>
+ <Row id="expanded-mobile-menu-button" display={{ xs: 'flex', md: 'none' }}>
+ <MobileMenuButton>
+ {({ close }) => {
+ return (
+ <Column padding="3">
+ <WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} />
+ </Column>
+ );
+ }}
+ </MobileMenuButton>
+ </Row>
+ <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" overflow="hidden">
+ <Column
+ id="metrics-expanded-menu"
+ display={{ xs: 'none', md: 'flex' }}
+ width="240px"
+ gap="6"
+ border="right"
+ paddingRight="3"
+ overflow="auto"
+ >
+ <WebsiteExpandedMenu excludedIds={excludedIds} />
+ </Column>
+ <Column id="metrics-expanded-table" overflow="hidden">
+ <MetricsExpandedTable
+ title={formatMessage(labels[view])}
+ type={view}
+ websiteId={websiteId}
+ onClose={onClose}
+ />
+ </Column>
+ </Grid>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
new file mode 100644
index 0000000..7dd1d77
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
@@ -0,0 +1,57 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
+import { Favicon } from '@/components/common/Favicon';
+import { LinkButton } from '@/components/common/LinkButton';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
+import { Edit, Share } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { ActiveUsers } from '@/components/metrics/ActiveUsers';
+
+export function WebsiteHeader({ showActions }: { showActions?: boolean }) {
+ const website = useWebsite();
+ const { renderUrl, pathname } = useNavigation();
+ const isSettings = pathname.endsWith('/settings');
+
+ const { formatMessage, labels } = useMessages();
+
+ if (isSettings) {
+ return null;
+ }
+
+ return (
+ <PageHeader
+ title={website.name}
+ icon={<Favicon domain={website.domain} />}
+ titleHref={renderUrl(`/websites/${website.id}`, false)}
+ >
+ <Row alignItems="center" gap="6" wrap="wrap">
+ <ActiveUsers websiteId={website.id} />
+
+ {showActions && (
+ <Row alignItems="center" gap>
+ <ShareButton websiteId={website.id} shareId={website.shareId} />
+ <LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}>
+ <Icon>
+ <Edit />
+ </Icon>
+ <Text>{formatMessage(labels.edit)}</Text>
+ </LinkButton>
+ </Row>
+ )}
+ </Row>
+ </PageHeader>
+ );
+}
+
+const ShareButton = ({ websiteId, shareId }) => {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton icon={<Share />} label={formatMessage(labels.share)} width="800px">
+ {({ close }) => {
+ return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx
new file mode 100644
index 0000000..7260a7e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx
@@ -0,0 +1,30 @@
+'use client';
+import { Column, Grid } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
+import { PageBody } from '@/components/common/PageBody';
+import { WebsiteHeader } from './WebsiteHeader';
+import { WebsiteNav } from './WebsiteNav';
+
+export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
+ return (
+ <WebsiteProvider websiteId={websiteId}>
+ <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%">
+ <Column
+ display={{ xs: 'none', lg: 'flex' }}
+ width="240px"
+ height="100%"
+ border="right"
+ backgroundColor
+ marginRight="2"
+ >
+ <WebsiteNav websiteId={websiteId} />
+ </Column>
+ <PageBody gap>
+ <WebsiteHeader showActions />
+ <Column>{children}</Column>
+ </PageBody>
+ </Grid>
+ </WebsiteProvider>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx
new file mode 100644
index 0000000..3018953
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx
@@ -0,0 +1,56 @@
+import {
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+ Text,
+} from '@umami/react-zen';
+import { Fragment } from 'react';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { Edit, More, Share } from '@/components/icons';
+
+export function WebsiteMenu({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { router, updateParams, renderUrl } = useNavigation();
+
+ const menuItems = [
+ { id: 'share', label: formatMessage(labels.share), icon: <Share /> },
+ { id: 'edit', label: formatMessage(labels.edit), icon: <Edit />, seperator: true },
+ ];
+
+ const handleAction = (id: any) => {
+ if (id === 'compare') {
+ router.push(updateParams({ compare: 'prev' }));
+ } else if (id === 'edit') {
+ router.push(renderUrl(`/websites/${websiteId}`));
+ }
+ };
+
+ return (
+ <MenuTrigger>
+ <Button variant="quiet">
+ <Icon>
+ <More />
+ </Icon>
+ </Button>
+ <Popover placement="bottom">
+ <Menu onAction={handleAction}>
+ {menuItems.map(({ id, label, icon, seperator }, index) => {
+ return (
+ <Fragment key={index}>
+ {seperator && <MenuSeparator />}
+ <MenuItem id={id}>
+ <Icon>{icon}</Icon>
+ <Text>{label}</Text>
+ </MenuItem>
+ </Fragment>
+ );
+ })}
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
new file mode 100644
index 0000000..6c91ba6
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
@@ -0,0 +1,88 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber, formatShortTime } from '@/lib/format';
+
+export function WebsiteMetricsBar({
+ websiteId,
+}: {
+ websiteId: string;
+ showChange?: boolean;
+ compareMode?: boolean;
+}) {
+ const { isAllTime } = useDateRange();
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId);
+
+ const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};
+
+ const metrics = data
+ ? [
+ {
+ value: visitors,
+ label: formatMessage(labels.visitors),
+ change: visitors - comparison.visitors,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: visits,
+ label: formatMessage(labels.visits),
+ change: visits - comparison.visits,
+ formatValue: formatLongNumber,
+ },
+ {
+ value: pageviews,
+ label: formatMessage(labels.views),
+ change: pageviews - comparison.pageviews,
+ formatValue: formatLongNumber,
+ },
+ {
+ label: formatMessage(labels.bounceRate),
+ value: (Math.min(visits, bounces) / visits) * 100,
+ prev: (Math.min(comparison.visits, comparison.bounces) / comparison.visits) * 100,
+ change:
+ (Math.min(visits, bounces) / visits) * 100 -
+ (Math.min(comparison.visits, comparison.bounces) / comparison.visits) * 100,
+ formatValue: n => `${Math.round(+n)}%`,
+ reverseColors: true,
+ },
+ {
+ label: formatMessage(labels.visitDuration),
+ value: totaltime / visits,
+ prev: comparison.totaltime / comparison.visits,
+ change: totaltime / visits - comparison.totaltime / comparison.visits,
+ formatValue: n =>
+ `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`,
+ },
+ ]
+ : null;
+
+ return (
+ <LoadingPanel
+ data={metrics}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={getErrorMessage(error)}
+ minHeight="136px"
+ >
+ <MetricsBar>
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }) => {
+ return (
+ <MetricCard
+ key={label}
+ value={value}
+ previousValue={prev}
+ label={label}
+ change={change}
+ formatValue={formatValue}
+ reverseColors={reverseColors}
+ showChange={!isAllTime}
+ />
+ );
+ })}
+ </MetricsBar>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx
new file mode 100644
index 0000000..ad05b70
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx
@@ -0,0 +1,180 @@
+import { Column, Text } from '@umami/react-zen';
+import { SideMenu } from '@/components/common/SideMenu';
+import { useMessages, useNavigation } from '@/components/hooks';
+import {
+ AlignEndHorizontal,
+ ChartPie,
+ Clock,
+ Eye,
+ Sheet,
+ Tag,
+ User,
+ UserPlus,
+} from '@/components/icons';
+import { WebsiteSelect } from '@/components/input/WebsiteSelect';
+import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
+
+export function WebsiteNav({
+ websiteId,
+ onItemClick,
+}: {
+ websiteId: string;
+ onItemClick?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { pathname, renderUrl, teamId, router } = useNavigation();
+
+ const renderPath = (path: string) =>
+ renderUrl(`/websites/${websiteId}${path}`, {
+ event: undefined,
+ compare: undefined,
+ view: undefined,
+ });
+
+ const items = [
+ {
+ label: formatMessage(labels.traffic),
+ items: [
+ {
+ id: 'overview',
+ label: formatMessage(labels.overview),
+ icon: <Eye />,
+ path: renderPath(''),
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ icon: <Lightning />,
+ path: renderPath('/events'),
+ },
+ {
+ id: 'sessions',
+ label: formatMessage(labels.sessions),
+ icon: <User />,
+ path: renderPath('/sessions'),
+ },
+ {
+ id: 'realtime',
+ label: formatMessage(labels.realtime),
+ icon: <Clock />,
+ path: renderPath('/realtime'),
+ },
+ {
+ id: 'compare',
+ label: formatMessage(labels.compare),
+ icon: <AlignEndHorizontal />,
+ path: renderPath('/compare'),
+ },
+ {
+ id: 'breakdown',
+ label: formatMessage(labels.breakdown),
+ icon: <Sheet />,
+ path: renderPath('/breakdown'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.behavior),
+ items: [
+ {
+ id: 'goals',
+ label: formatMessage(labels.goals),
+ icon: <Target />,
+ path: renderPath('/goals'),
+ },
+ {
+ id: 'funnel',
+ label: formatMessage(labels.funnels),
+ icon: <Funnel />,
+ path: renderPath('/funnels'),
+ },
+ {
+ id: 'journeys',
+ label: formatMessage(labels.journeys),
+ icon: <Path />,
+ path: renderPath('/journeys'),
+ },
+ {
+ id: 'retention',
+ label: formatMessage(labels.retention),
+ icon: <Magnet />,
+ path: renderPath('/retention'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.audience),
+ items: [
+ {
+ id: 'segments',
+ label: formatMessage(labels.segments),
+ icon: <ChartPie />,
+ path: renderPath('/segments'),
+ },
+ {
+ id: 'cohorts',
+ label: formatMessage(labels.cohorts),
+ icon: <UserPlus />,
+ path: renderPath('/cohorts'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.growth),
+ items: [
+ {
+ id: 'utm',
+ label: formatMessage(labels.utm),
+ icon: <Tag />,
+ path: renderPath('/utm'),
+ },
+ {
+ id: 'revenue',
+ label: formatMessage(labels.revenue),
+ icon: <Money />,
+ path: renderPath('/revenue'),
+ },
+ {
+ id: 'attribution',
+ label: formatMessage(labels.attribution),
+ icon: <Network />,
+ path: renderPath('/attribution'),
+ },
+ ],
+ },
+ ];
+
+ const handleChange = (value: string) => {
+ router.push(renderUrl(`/websites/${value}`));
+ };
+
+ const renderValue = (value: any) => {
+ return (
+ <Text truncate style={{ maxWidth: 160, lineHeight: 1 }}>
+ {value?.selectedItem?.name}
+ </Text>
+ );
+ };
+
+ const selectedKey = items
+ .flatMap(e => e.items)
+ .find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
+
+ return (
+ <Column padding="3" position="sticky" top="0" gap>
+ <WebsiteSelect
+ websiteId={websiteId}
+ teamId={teamId}
+ onChange={handleChange}
+ renderValue={renderValue}
+ buttonProps={{ style: { outline: 'none' } }}
+ />
+ <SideMenu
+ items={items}
+ selectedKey={selectedKey}
+ allowMinimize={false}
+ onItemClick={onItemClick}
+ />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx
new file mode 100644
index 0000000..f587e11
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx
@@ -0,0 +1,22 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
+import { Panel } from '@/components/common/Panel';
+import { WebsiteChart } from './WebsiteChart';
+import { WebsiteControls } from './WebsiteControls';
+import { WebsiteMetricsBar } from './WebsiteMetricsBar';
+import { WebsitePanels } from './WebsitePanels';
+
+export function WebsitePage({ websiteId }: { websiteId: string }) {
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} />
+ <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
+ <Panel minHeight="520px">
+ <WebsiteChart websiteId={websiteId} />
+ </Panel>
+ <WebsitePanels websiteId={websiteId} />
+ <ExpandedViewModal websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx b/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
new file mode 100644
index 0000000..a91d562
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
@@ -0,0 +1,140 @@
+import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { GridRow } from '@/components/common/GridRow';
+import { Panel } from '@/components/common/Panel';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { EventsChart } from '@/components/metrics/EventsChart';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
+import { WorldMap } from '@/components/metrics/WorldMap';
+
+export function WebsitePanels({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { pathname } = useNavigation();
+ const tableProps = {
+ websiteId,
+ limit: 10,
+ allowDownload: false,
+ showMore: true,
+ metric: formatMessage(labels.visitors),
+ };
+ const rowProps = { minHeight: '570px' };
+ const isSharePage = pathname.includes('/share/');
+
+ return (
+ <Grid gap="3">
+ <GridRow layout="two" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.pages)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="path">{formatMessage(labels.path)}</Tab>
+ <Tab id="entry">{formatMessage(labels.entry)}</Tab>
+ <Tab id="exit">{formatMessage(labels.exit)}</Tab>
+ </TabList>
+ <TabPanel id="path">
+ <MetricsTable type="path" title={formatMessage(labels.path)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="entry">
+ <MetricsTable type="entry" title={formatMessage(labels.path)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="exit">
+ <MetricsTable type="exit" title={formatMessage(labels.path)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.sources)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
+ <Tab id="channel">{formatMessage(labels.channels)}</Tab>
+ </TabList>
+ <TabPanel id="referrer">
+ <MetricsTable
+ type="referrer"
+ title={formatMessage(labels.referrer)}
+ {...tableProps}
+ />
+ </TabPanel>
+ <TabPanel id="channel">
+ <MetricsTable type="channel" title={formatMessage(labels.channel)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+
+ <GridRow layout="two" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.environment)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="browser">{formatMessage(labels.browsers)}</Tab>
+ <Tab id="os">{formatMessage(labels.os)}</Tab>
+ <Tab id="device">{formatMessage(labels.devices)}</Tab>
+ </TabList>
+ <TabPanel id="browser">
+ <MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="os">
+ <MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="device">
+ <MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+
+ <Panel>
+ <Heading size="2">{formatMessage(labels.location)}</Heading>
+ <Tabs>
+ <TabList>
+ <Tab id="country">{formatMessage(labels.countries)}</Tab>
+ <Tab id="region">{formatMessage(labels.regions)}</Tab>
+ <Tab id="city">{formatMessage(labels.cities)}</Tab>
+ </TabList>
+ <TabPanel id="country">
+ <MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="region">
+ <MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
+ </TabPanel>
+ <TabPanel id="city">
+ <MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ </GridRow>
+
+ <GridRow layout="two-one" {...rowProps}>
+ <Panel gridColumn={{ xs: 'span 1', md: 'span 2' }} paddingX="0" paddingY="0">
+ <WorldMap websiteId={websiteId} />
+ </Panel>
+
+ <Panel>
+ <Heading size="2">{formatMessage(labels.traffic)}</Heading>
+ <Row border="bottom" marginBottom="4" />
+ <WeeklyTraffic websiteId={websiteId} />
+ </Panel>
+ </GridRow>
+ {isSharePage && (
+ <GridRow layout="two-one" {...rowProps}>
+ <Panel>
+ <Heading size="2">{formatMessage(labels.events)}</Heading>
+ <Row border="bottom" marginBottom="4" />
+ <MetricsTable
+ websiteId={websiteId}
+ type="event"
+ title={formatMessage(labels.event)}
+ metric={formatMessage(labels.count)}
+ limit={15}
+ filterLink={false}
+ />
+ </Panel>
+ <Panel gridColumn={{ xs: 'span 1', md: 'span 2' }}>
+ <EventsChart websiteId={websiteId} />
+ </Panel>
+ </GridRow>
+ )}
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx b/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx
new file mode 100644
index 0000000..ac978a2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx
@@ -0,0 +1,64 @@
+import { Icon, Row, Tab, TabList, Tabs, Text } from '@umami/react-zen';
+import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
+import { ChartPie, Clock, Eye, User } from '@/components/icons';
+import { Lightning } from '@/components/svg';
+
+export function WebsiteTabs() {
+ const website = useWebsite();
+ const { pathname, renderUrl } = useNavigation();
+ const { formatMessage, labels } = useMessages();
+
+ const links = [
+ {
+ id: 'overview',
+ label: formatMessage(labels.overview),
+ icon: <Eye />,
+ path: '',
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ icon: <Lightning />,
+ path: '/events',
+ },
+ {
+ id: 'sessions',
+ label: formatMessage(labels.sessions),
+ icon: <User />,
+ path: '/sessions',
+ },
+ {
+ id: 'realtime',
+ label: formatMessage(labels.realtime),
+ icon: <Clock />,
+ path: '/realtime',
+ },
+ {
+ id: 'reports',
+ label: formatMessage(labels.reports),
+ icon: <ChartPie />,
+ path: '/reports',
+ },
+ ];
+
+ const selectedKey = links.find(({ path }) => path && pathname.includes(path))?.id || 'overview';
+
+ return (
+ <Row marginBottom="6">
+ <Tabs selectedKey={selectedKey}>
+ <TabList>
+ {links.map(({ id, label, icon, path }) => {
+ return (
+ <Tab key={id} id={id} href={renderUrl(`/websites/${website.id}${path}`)}>
+ <Row alignItems="center" gap>
+ <Icon>{icon}</Icon>
+ <Text>{label}</Text>
+ </Row>
+ </Tab>
+ );
+ })}
+ </TabList>
+ </Tabs>
+ </Row>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx
new file mode 100644
index 0000000..3f7f872
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { CohortEditForm } from './CohortEditForm';
+
+export function CohortAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.cohort)}
+ variant="primary"
+ width="800px"
+ >
+ {({ close }) => {
+ return <CohortEditForm websiteId={websiteId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx
new file mode 100644
index 0000000..94d62ff
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx
@@ -0,0 +1,60 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function CohortDeleteButton({
+ cohortId,
+ websiteId,
+ name,
+ onSave,
+}: {
+ cohortId: string;
+ websiteId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(
+ `/websites/${websiteId}/segments/${cohortId}`,
+ );
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('cohorts');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ variant="quiet"
+ title={formatMessage(labels.confirm)}
+ width="400px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{name}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx
new file mode 100644
index 0000000..0799071
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx
@@ -0,0 +1,37 @@
+import { CohortEditForm } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditForm';
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import type { Filter } from '@/lib/types';
+
+export function CohortEditButton({
+ cohortId,
+ websiteId,
+ filters,
+}: {
+ cohortId: string;
+ websiteId: string;
+ filters: Filter[];
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Edit />}
+ variant="quiet"
+ title={formatMessage(labels.cohort)}
+ width="800px"
+ >
+ {({ close }) => {
+ return (
+ <CohortEditForm
+ cohortId={cohortId}
+ websiteId={websiteId}
+ filters={filters}
+ onClose={close}
+ />
+ );
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx
new file mode 100644
index 0000000..c755035
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx
@@ -0,0 +1,135 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Grid,
+ Label,
+ Loading,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks';
+import { ActionSelect } from '@/components/input/ActionSelect';
+import { DateFilter } from '@/components/input/DateFilter';
+import { FieldFilters } from '@/components/input/FieldFilters';
+import { LookupField } from '@/components/input/LookupField';
+
+export function CohortEditForm({
+ cohortId,
+ websiteId,
+ filters = [],
+ onSave,
+ onClose,
+}: {
+ cohortId?: string;
+ websiteId: string;
+ filters?: any[];
+ showFilters?: boolean;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { data } = useWebsiteCohortQuery(websiteId, cohortId);
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
+ `/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`,
+ {
+ type: 'cohort',
+ },
+ );
+
+ const handleSubmit = async (formData: any) => {
+ await mutateAsync(formData, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('cohorts');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ if (cohortId && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ const defaultValues = {
+ parameters: { filters, dateRange: '30day', action: { type: 'path', value: '' } },
+ };
+
+ return (
+ <Form
+ error={getErrorMessage(error)}
+ onSubmit={handleSubmit}
+ defaultValues={data || defaultValues}
+ >
+ {({ watch }) => {
+ const type = watch('parameters.action.type');
+
+ return (
+ <>
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus />
+ </FormField>
+
+ <Column>
+ <Label>{formatMessage(labels.action)}</Label>
+ <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap>
+ <Column>
+ <FormField
+ name="parameters.action.type"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <ActionSelect />
+ </FormField>
+ </Column>
+ <Column>
+ <FormField
+ name="parameters.action.value"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ {({ field }) => {
+ return <LookupField websiteId={websiteId} type={type} {...field} />;
+ }}
+ </FormField>
+ </Column>
+ </Grid>
+ </Column>
+
+ <Column width="260px">
+ <Label>{formatMessage(labels.dateRange)}</Label>
+ <FormField
+ name="parameters.dateRange"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <DateFilter placement="bottom start" />
+ </FormField>
+ </Column>
+
+ <Column>
+ <Label>{formatMessage(labels.filters)}</Label>
+ <FormField name="parameters.filters">
+ <FieldFilters websiteId={websiteId} exclude={['path', 'event']} />
+ </FormField>
+ </Column>
+
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </>
+ );
+ }}
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx
new file mode 100644
index 0000000..6734384
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx
@@ -0,0 +1,24 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useWebsiteCohortsQuery } from '@/components/hooks';
+import { CohortAddButton } from './CohortAddButton';
+import { CohortsTable } from './CohortsTable';
+
+export function CohortsDataTable({ websiteId }: { websiteId?: string }) {
+ const query = useWebsiteCohortsQuery(websiteId, { type: 'cohort' });
+
+ const renderActions = () => {
+ return <CohortAddButton websiteId={websiteId} />;
+ };
+
+ return (
+ <DataGrid
+ query={query}
+ allowSearch={true}
+ autoFocus={false}
+ allowPaging={true}
+ renderActions={renderActions}
+ >
+ {({ data }) => <CohortsTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx
new file mode 100644
index 0000000..14f366e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx
@@ -0,0 +1,16 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { CohortsDataTable } from './CohortsDataTable';
+
+export function CohortsPage({ websiteId }) {
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} allowFilter={false} allowDateFilter={false} />
+ <Panel>
+ <CohortsDataTable websiteId={websiteId} />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx
new file mode 100644
index 0000000..5c7ac03
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx
@@ -0,0 +1,41 @@
+import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { CohortDeleteButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton';
+import { CohortEditButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditButton';
+import { DateDistance } from '@/components/common/DateDistance';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { filtersObjectToArray } from '@/lib/params';
+
+export function CohortsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { websiteId, renderUrl } = useNavigation();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {(row: any) => (
+ <Link href={renderUrl(`/websites/${websiteId}?cohort=${row.id}`, false)}>{row.name}</Link>
+ )}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="100px">
+ {(row: any) => {
+ const { id, name, parameters } = row;
+
+ return (
+ <Row>
+ <CohortEditButton
+ cohortId={id}
+ websiteId={websiteId}
+ filters={filtersObjectToArray(parameters)}
+ />
+ <CohortDeleteButton cohortId={id} websiteId={websiteId} name={name} />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/cohorts/page.tsx b/src/app/(main)/websites/[websiteId]/cohorts/page.tsx
new file mode 100644
index 0000000..9946f60
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/cohorts/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { CohortsPage } from './CohortsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <CohortsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Cohorts',
+};
diff --git a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
new file mode 100644
index 0000000..bca8d24
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
@@ -0,0 +1,20 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
+import { Panel } from '@/components/common/Panel';
+import { CompareTables } from './CompareTables';
+
+export function ComparePage({ websiteId }: { websiteId: string }) {
+ return (
+ <Column gap>
+ <WebsiteControls websiteId={websiteId} allowCompare={true} />
+ <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
+ <Panel minHeight="520px">
+ <WebsiteChart websiteId={websiteId} compareMode={true} />
+ </Panel>
+ <CompareTables websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
new file mode 100644
index 0000000..13c0516
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
@@ -0,0 +1,171 @@
+import { Column, Grid, Heading, ListItem, Row, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { DateDisplay } from '@/components/common/DateDisplay';
+import { Panel } from '@/components/common/Panel';
+import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { formatNumber } from '@/lib/format';
+
+export function CompareTables({ websiteId }: { websiteId: string }) {
+ const [data, setData] = useState([]);
+ const { dateRange, dateCompare } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const {
+ router,
+ updateParams,
+ query: { view = 'path' },
+ } = useNavigation();
+ const { startDate, endDate } = dateCompare;
+
+ const params = {
+ startAt: startDate.getTime(),
+ endAt: endDate.getTime(),
+ };
+
+ const renderPath = (view: string) => {
+ return updateParams({ view });
+ };
+
+ const items = [
+ {
+ id: 'path',
+ label: formatMessage(labels.path),
+ path: renderPath('path'),
+ },
+ {
+ id: 'channel',
+ label: formatMessage(labels.channels),
+ path: renderPath('channel'),
+ },
+ {
+ id: 'referrer',
+ label: formatMessage(labels.referrers),
+ path: renderPath('referrer'),
+ },
+ {
+ id: 'browser',
+ label: formatMessage(labels.browsers),
+ path: renderPath('browser'),
+ },
+ {
+ id: 'os',
+ label: formatMessage(labels.os),
+ path: renderPath('os'),
+ },
+ {
+ id: 'device',
+ label: formatMessage(labels.devices),
+ path: renderPath('device'),
+ },
+ {
+ id: 'country',
+ label: formatMessage(labels.countries),
+ path: renderPath('country'),
+ },
+ {
+ id: 'region',
+ label: formatMessage(labels.regions),
+ path: renderPath('region'),
+ },
+ {
+ id: 'city',
+ label: formatMessage(labels.cities),
+ path: renderPath('city'),
+ },
+ {
+ id: 'language',
+ label: formatMessage(labels.languages),
+ path: renderPath('language'),
+ },
+ {
+ id: 'screen',
+ label: formatMessage(labels.screens),
+ path: renderPath('screen'),
+ },
+ {
+ id: 'event',
+ label: formatMessage(labels.events),
+ path: renderPath('event'),
+ },
+ {
+ id: 'hostname',
+ label: formatMessage(labels.hostname),
+ path: renderPath('hostname'),
+ },
+ {
+ id: 'tag',
+ label: formatMessage(labels.tags),
+ path: renderPath('tag'),
+ },
+ ];
+
+ const renderChange = ({ label, count }) => {
+ const prev = data.find(d => d.x === label)?.y;
+ const value = count - prev;
+ const change = Math.abs(((count - prev) / prev) * 100);
+
+ return (
+ !Number.isNaN(change) && (
+ <Row alignItems="center" marginRight="3">
+ <ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel>
+ </Row>
+ )
+ );
+ };
+
+ const handleChange = (id: any) => {
+ router.push(renderPath(id));
+ };
+
+ return (
+ <>
+ <Row width="300px">
+ <Select
+ items={items}
+ label={formatMessage(labels.compare)}
+ value={view}
+ defaultValue={view}
+ onChange={handleChange}
+ >
+ {items.map(({ id, label }) => (
+ <ListItem key={id} id={id}>
+ {label}
+ </ListItem>
+ ))}
+ </Select>
+ </Row>
+ <Panel minHeight="300px">
+ <Grid columns={{ xs: '1fr', lg: '1fr 1fr' }} gap="6" height="100%">
+ <Column gap="6">
+ <Row alignItems="center" justifyContent="space-between">
+ <Heading size="2">{formatMessage(labels.previous)}</Heading>
+ <DateDisplay startDate={startDate} endDate={endDate} />
+ </Row>
+ <MetricsTable
+ websiteId={websiteId}
+ type={view}
+ limit={20}
+ showMore={false}
+ params={params}
+ onDataLoad={setData}
+ />
+ </Column>
+ <Column border="left" paddingLeft="6" gap="6">
+ <Row alignItems="center" justifyContent="space-between">
+ <Heading size="2"> {formatMessage(labels.current)}</Heading>
+ <DateDisplay startDate={dateRange.startDate} endDate={dateRange.endDate} />
+ </Row>
+ <MetricsTable
+ websiteId={websiteId}
+ type={view}
+ limit={20}
+ showMore={false}
+ renderChange={renderChange}
+ />
+ </Column>
+ </Grid>
+ </Panel>
+ </>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/compare/page.tsx b/src/app/(main)/websites/[websiteId]/compare/page.tsx
new file mode 100644
index 0000000..1b2899b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/compare/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { ComparePage } from './ComparePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <ComparePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Compare',
+};
diff --git a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
new file mode 100644
index 0000000..c3b1325
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
@@ -0,0 +1,127 @@
+import { Column, Grid, ListItem, Select } from '@umami/react-zen';
+import { useMemo, useState } from 'react';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useEventDataPropertiesQuery,
+ useEventDataValuesQuery,
+ useMessages,
+} from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS } from '@/lib/constants';
+
+export function EventProperties({ websiteId }: { websiteId: string }) {
+ const [propertyName, setPropertyName] = useState('');
+ const [eventName, setEventName] = useState('');
+
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId);
+
+ const events: string[] = data
+ ? data.reduce((arr: string | any[], e: { eventName: any }) => {
+ return !arr.includes(e.eventName) ? arr.concat(e.eventName) : arr;
+ }, [])
+ : [];
+ const properties: string[] = eventName
+ ? data?.filter(e => e.eventName === eventName).map(e => e.propertyName)
+ : [];
+
+ return (
+ <LoadingPanel
+ data={data}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={error}
+ minHeight="300px"
+ >
+ <Column gap="6">
+ {data && (
+ <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" marginBottom="3" gap>
+ <Select
+ label={formatMessage(labels.event)}
+ value={eventName}
+ onChange={setEventName}
+ placeholder=""
+ >
+ {events?.map(p => (
+ <ListItem key={p} id={p}>
+ {p}
+ </ListItem>
+ ))}
+ </Select>
+ <Select
+ label={formatMessage(labels.property)}
+ value={propertyName}
+ onChange={setPropertyName}
+ isDisabled={!eventName}
+ placeholder=""
+ >
+ {properties?.map(p => (
+ <ListItem key={p} id={p}>
+ {p}
+ </ListItem>
+ ))}
+ </Select>
+ </Grid>
+ )}
+ {eventName && propertyName && (
+ <EventValues websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
+ )}
+ </Column>
+ </LoadingPanel>
+ );
+}
+
+const EventValues = ({ websiteId, eventName, propertyName }) => {
+ const {
+ data: values,
+ isLoading,
+ isFetching,
+ error,
+ } = useEventDataValuesQuery(websiteId, eventName, propertyName);
+
+ const propertySum = useMemo(() => {
+ return values?.reduce((sum, { total }) => sum + total, 0) ?? 0;
+ }, [values]);
+
+ const chartData = useMemo(() => {
+ if (!propertyName || !values) return null;
+ return {
+ labels: values.map(({ value }) => value),
+ datasets: [
+ {
+ data: values.map(({ total }) => total),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+ }, [propertyName, values]);
+
+ const tableData = useMemo(() => {
+ if (!propertyName || !values || propertySum === 0) return [];
+ return values.map(({ value, total }) => ({
+ label: value,
+ count: total,
+ percent: 100 * (total / propertySum),
+ }));
+ }, [propertyName, values, propertySum]);
+
+ return (
+ <LoadingPanel
+ isLoading={isLoading}
+ isFetching={isFetching}
+ data={values}
+ error={error}
+ minHeight="300px"
+ gap="6"
+ >
+ {values && (
+ <Grid columns="1fr 1fr" gap>
+ <ListTable title={propertyName} data={tableData} />
+ <PieChart type="doughnut" chartData={chartData} />
+ </Grid>
+ )}
+ </LoadingPanel>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx
new file mode 100644
index 0000000..f686b3f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx
@@ -0,0 +1,48 @@
+import { type ReactNode, useState } from 'react';
+import { DataGrid } from '@/components/common/DataGrid';
+import { useMessages, useWebsiteEventsQuery } from '@/components/hooks';
+import { FilterButtons } from '@/components/input/FilterButtons';
+import { EventsTable } from './EventsTable';
+
+export function EventsDataTable({
+ websiteId,
+}: {
+ websiteId?: string;
+ teamId?: string;
+ children?: ReactNode;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const [view, setView] = useState('all');
+ const query = useWebsiteEventsQuery(websiteId, { view });
+
+ const buttons = [
+ {
+ id: 'all',
+ label: formatMessage(labels.all),
+ },
+ {
+ id: 'views',
+ label: formatMessage(labels.views),
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ },
+ ];
+
+ const renderActions = () => {
+ return <FilterButtons items={buttons} value={view} onChange={setView} />;
+ };
+
+ return (
+ <DataGrid
+ query={query}
+ allowSearch={true}
+ autoFocus={false}
+ allowPaging={true}
+ renderActions={renderActions}
+ >
+ {({ data }) => <EventsTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx
new file mode 100644
index 0000000..a7ed399
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx
@@ -0,0 +1,40 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages } from '@/components/hooks';
+import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
+
+export function EventsMetricsBar({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId);
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
+ {data && (
+ <MetricsBar>
+ <MetricCard
+ value={data?.visitors?.value}
+ label={formatMessage(labels.visitors)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.visits?.value}
+ label={formatMessage(labels.visits)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.pageviews?.value}
+ label={formatMessage(labels.views)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.events?.value}
+ label={formatMessage(labels.events)}
+ formatValue={formatLongNumber}
+ />
+ </MetricsBar>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
new file mode 100644
index 0000000..55ec040
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
@@ -0,0 +1,59 @@
+'use client';
+import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { type Key, useState } from 'react';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { EventsChart } from '@/components/metrics/EventsChart';
+import { MetricsTable } from '@/components/metrics/MetricsTable';
+import { getItem, setItem } from '@/lib/storage';
+import { EventProperties } from './EventProperties';
+import { EventsDataTable } from './EventsDataTable';
+
+const KEY_NAME = 'umami.events.tab';
+
+export function EventsPage({ websiteId }) {
+ const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
+ const { formatMessage, labels } = useMessages();
+
+ const handleSelect = (value: Key) => {
+ setItem(KEY_NAME, value);
+ setTab(value);
+ };
+
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} />
+ <Panel>
+ <Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
+ <TabList>
+ <Tab id="chart">{formatMessage(labels.chart)}</Tab>
+ <Tab id="activity">{formatMessage(labels.activity)}</Tab>
+ <Tab id="properties">{formatMessage(labels.properties)}</Tab>
+ </TabList>
+ <TabPanel id="activity">
+ <EventsDataTable websiteId={websiteId} />
+ </TabPanel>
+ <TabPanel id="chart">
+ <Column gap="6">
+ <Column border="bottom" paddingBottom="6">
+ <EventsChart websiteId={websiteId} />
+ </Column>
+ <MetricsTable
+ websiteId={websiteId}
+ type="event"
+ title={formatMessage(labels.event)}
+ metric={formatMessage(labels.count)}
+ />
+ </Column>
+ </TabPanel>
+ <TabPanel id="properties">
+ <EventProperties websiteId={websiteId} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <SessionModal websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
new file mode 100644
index 0000000..7fb2eb4
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
@@ -0,0 +1,107 @@
+import {
+ Button,
+ DataColumn,
+ DataTable,
+ type DataTableProps,
+ Dialog,
+ DialogTrigger,
+ Icon,
+ IconLabel,
+ Popover,
+ Row,
+ Text,
+} from '@umami/react-zen';
+import Link from 'next/link';
+import { Avatar } from '@/components/common/Avatar';
+import { DateDistance } from '@/components/common/DateDistance';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useFormat, useMessages, useNavigation } from '@/components/hooks';
+import { Eye, FileText } from '@/components/icons';
+import { EventData } from '@/components/metrics/EventData';
+import { Lightning } from '@/components/svg';
+
+export function EventsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { updateParams } = useNavigation();
+ const { formatValue } = useFormat();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
+ {(row: any) => {
+ return (
+ <Row alignItems="center" wrap="wrap" gap>
+ <Row>
+ <IconLabel
+ icon={row.eventName ? <Lightning /> : <Eye />}
+ label={formatMessage(row.eventName ? labels.triggeredEvent : labels.viewedPage)}
+ />
+ </Row>
+ <Text
+ weight="bold"
+ style={{ maxWidth: '300px' }}
+ title={row.eventName || row.urlPath}
+ truncate
+ >
+ {row.eventName || row.urlPath}
+ </Text>
+ {row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
+ </Row>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="session" label={formatMessage(labels.session)} width="80px">
+ {(row: any) => {
+ return (
+ <Link href={updateParams({ session: row.sessionId })}>
+ <Avatar seed={row.sessionId} size={32} />
+ </Link>
+ );
+ }}
+ </DataColumn>
+ <DataColumn id="location" label={formatMessage(labels.location)}>
+ {(row: any) => (
+ <TypeIcon type="country" value={row.country}>
+ {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="browser" label={formatMessage(labels.browser)} width="140px">
+ {(row: any) => (
+ <TypeIcon type="browser" value={row.browser}>
+ {formatValue(row.browser, 'browser')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="device" label={formatMessage(labels.device)} width="120px">
+ {(row: any) => (
+ <TypeIcon type="device" value={row.device}>
+ {formatValue(row.device, 'device')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="created" width="160px" align="end">
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ </DataTable>
+ );
+}
+
+const PropertiesButton = props => {
+ return (
+ <DialogTrigger>
+ <Button variant="quiet">
+ <Row alignItems="center" gap>
+ <Icon>
+ <FileText />
+ </Icon>
+ </Row>
+ </Button>
+ <Popover placement="right">
+ <Dialog>
+ <EventData {...props} />
+ </Dialog>
+ </Popover>
+ </DialogTrigger>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/events/page.tsx b/src/app/(main)/websites/[websiteId]/events/page.tsx
new file mode 100644
index 0000000..d77ba3b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/events/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { EventsPage } from './EventsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <EventsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Events',
+};
diff --git a/src/app/(main)/websites/[websiteId]/layout.tsx b/src/app/(main)/websites/[websiteId]/layout.tsx
new file mode 100644
index 0000000..67595e9
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/layout.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next';
+import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout';
+
+export default async function ({
+ children,
+ params,
+}: {
+ children: any;
+ params: Promise<{ websiteId: string }>;
+}) {
+ const { websiteId } = await params;
+
+ return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>;
+}
+
+export const metadata: Metadata = {
+ title: {
+ template: '%s | Umami',
+ default: 'Websites | Umami',
+ },
+};
diff --git a/src/app/(main)/websites/[websiteId]/page.tsx b/src/app/(main)/websites/[websiteId]/page.tsx
new file mode 100644
index 0000000..d4889c5
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { WebsitePage } from './WebsitePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <WebsitePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Websites',
+};
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
new file mode 100644
index 0000000..6e2495b
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
@@ -0,0 +1,31 @@
+import { IconLabel } from '@umami/react-zen';
+import { useCallback } from 'react';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useCountryNames, useLocale, useMessages } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+
+export function RealtimeCountries({ data }) {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+
+ const renderCountryName = useCallback(
+ ({ label: code }) => (
+ <IconLabel icon={<TypeIcon type="country" value={code} />} label={countryNames[code]} />
+ ),
+ [countryNames, locale],
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.countries)}
+ metric={formatMessage(labels.visitors)}
+ data={data.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ renderLabel={renderCountryName}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
new file mode 100644
index 0000000..2b9d881
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
@@ -0,0 +1,17 @@
+import { useMessages } from '@/components/hooks';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+
+export function RealtimeHeader({ data }: { data: any }) {
+ const { formatMessage, labels } = useMessages();
+ const { totals }: any = data || {};
+
+ return (
+ <MetricsBar>
+ <MetricCard label={formatMessage(labels.views)} value={totals.views} />
+ <MetricCard label={formatMessage(labels.visitors)} value={totals.visitors} />
+ <MetricCard label={formatMessage(labels.events)} value={totals.events} />
+ <MetricCard label={formatMessage(labels.countries)} value={totals.countries} />
+ </MetricsBar>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
new file mode 100644
index 0000000..1076361
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
@@ -0,0 +1,206 @@
+import { Column, Heading, IconLabel, Row, SearchField, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { useMemo, useState } from 'react';
+import { FixedSizeList } from 'react-window';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { useFormat } from '@/components//hooks/useFormat';
+import { Avatar } from '@/components/common/Avatar';
+import { Empty } from '@/components/common/Empty';
+import {
+ useCountryNames,
+ useLocale,
+ useMessages,
+ useMobile,
+ useNavigation,
+ useTimezone,
+ useWebsite,
+} from '@/components/hooks';
+import { Eye, User } from '@/components/icons';
+import { FilterButtons } from '@/components/input/FilterButtons';
+import { Lightning } from '@/components/svg';
+import { BROWSERS, OS_NAMES } from '@/lib/constants';
+
+const TYPE_ALL = 'all';
+const TYPE_PAGEVIEW = 'pageview';
+const TYPE_SESSION = 'session';
+const TYPE_EVENT = 'event';
+
+const icons = {
+ [TYPE_PAGEVIEW]: <Eye />,
+ [TYPE_SESSION]: <User />,
+ [TYPE_EVENT]: <Lightning />,
+};
+
+export function RealtimeLog({ data }: { data: any }) {
+ const website = useWebsite();
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels, messages, FormattedMessage } = useMessages();
+ const { formatValue } = useFormat();
+ const { locale } = useLocale();
+ const { formatTimezoneDate } = useTimezone();
+ const { countryNames } = useCountryNames(locale);
+ const [filter, setFilter] = useState(TYPE_ALL);
+ const { updateParams } = useNavigation();
+ const { isPhone } = useMobile();
+
+ const buttons = [
+ {
+ label: formatMessage(labels.all),
+ id: TYPE_ALL,
+ },
+ {
+ label: formatMessage(labels.views),
+ id: TYPE_PAGEVIEW,
+ },
+ {
+ label: formatMessage(labels.visitors),
+ id: TYPE_SESSION,
+ },
+ {
+ label: formatMessage(labels.events),
+ id: TYPE_EVENT,
+ },
+ ];
+
+ const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp');
+
+ const getIcon = ({ __type }) => icons[__type];
+
+ const getDetail = (log: {
+ __type: string;
+ eventName: string;
+ urlPath: string;
+ browser: string;
+ os: string;
+ country: string;
+ device: string;
+ }) => {
+ const { __type, eventName, urlPath, browser, os, country, device } = log;
+
+ if (__type === TYPE_EVENT) {
+ return (
+ <FormattedMessage
+ {...messages.eventLog}
+ values={{
+ event: <b key="b">{eventName || formatMessage(labels.unknown)}</b>,
+ url: (
+ <a
+ key="a"
+ href={`//${website?.domain}${urlPath}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ >
+ {urlPath}
+ </a>
+ ),
+ }}
+ />
+ );
+ }
+
+ if (__type === TYPE_PAGEVIEW) {
+ return (
+ <a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener">
+ {urlPath}
+ </a>
+ );
+ }
+
+ if (__type === TYPE_SESSION) {
+ return (
+ <FormattedMessage
+ {...messages.visitorLog}
+ values={{
+ country: <b key="country">{countryNames[country] || formatMessage(labels.unknown)}</b>,
+ browser: <b key="browser">{BROWSERS[browser]}</b>,
+ os: <b key="os">{OS_NAMES[os] || os}</b>,
+ device: <b key="device">{formatMessage(labels[device] || labels.unknown)}</b>,
+ }}
+ />
+ );
+ }
+ };
+
+ const TableRow = ({ index, style }) => {
+ const row = logs[index];
+ return (
+ <Row alignItems="center" style={style} gap>
+ <Row minWidth="30px">
+ <Link href={updateParams({ session: row.sessionId })}>
+ <Avatar seed={row.sessionId} size={32} />
+ </Link>
+ </Row>
+ <Row minWidth="100px">
+ <Text wrap="nowrap">{getTime(row)}</Text>
+ </Row>
+ <IconLabel icon={getIcon(row)}>
+ <Text style={{ maxWidth: isPhone ? '400px' : null }} truncate>
+ {getDetail(row)}
+ </Text>
+ </IconLabel>
+ </Row>
+ );
+ };
+
+ const logs = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ let logs = data.events;
+
+ if (search) {
+ logs = logs.filter(({ eventName, urlPath, browser, os, country, device }) => {
+ return [
+ eventName,
+ urlPath,
+ os,
+ formatValue(browser, 'browser'),
+ formatValue(country, 'country'),
+ formatValue(device, 'device'),
+ ]
+ .filter(n => n)
+ .map(n => n.toLowerCase())
+ .join('')
+ .includes(search.toLowerCase());
+ });
+ }
+
+ if (filter !== TYPE_ALL) {
+ return logs.filter(({ __type }) => __type === filter);
+ }
+
+ return logs;
+ }, [data, filter, formatValue, search]);
+
+ return (
+ <Column gap>
+ <Heading size="2">{formatMessage(labels.activity)}</Heading>
+ {isPhone ? (
+ <>
+ <Row>
+ <SearchField value={search} onSearch={setSearch} />
+ </Row>
+ <Row>
+ <FilterButtons items={buttons} value={filter} onChange={setFilter} />
+ </Row>
+ </>
+ ) : (
+ <Row alignItems="center" justifyContent="space-between">
+ <SearchField value={search} onSearch={setSearch} />
+ <FilterButtons items={buttons} value={filter} onChange={setFilter} />
+ </Row>
+ )}
+
+ <Column>
+ {logs?.length === 0 && <Empty />}
+ {logs?.length > 0 && (
+ <FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}>
+ {TableRow}
+ </FixedSizeList>
+ )}
+ </Column>
+ <SessionModal websiteId={website.id} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
new file mode 100644
index 0000000..6220c69
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
@@ -0,0 +1,58 @@
+'use client';
+import { Grid } from '@umami/react-zen';
+import { firstBy } from 'thenby';
+import { GridRow } from '@/components/common/GridRow';
+import { PageBody } from '@/components/common/PageBody';
+import { Panel } from '@/components/common/Panel';
+import { useMobile, useRealtimeQuery } from '@/components/hooks';
+import { RealtimeChart } from '@/components/metrics/RealtimeChart';
+import { WorldMap } from '@/components/metrics/WorldMap';
+import { percentFilter } from '@/lib/filters';
+import { RealtimeCountries } from './RealtimeCountries';
+import { RealtimeHeader } from './RealtimeHeader';
+import { RealtimeLog } from './RealtimeLog';
+import { RealtimePaths } from './RealtimePaths';
+import { RealtimeReferrers } from './RealtimeReferrers';
+
+export function RealtimePage({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useRealtimeQuery(websiteId);
+ const { isMobile } = useMobile();
+
+ if (isLoading || error) {
+ return <PageBody isLoading={isLoading} error={error} />;
+ }
+
+ const countries = percentFilter(
+ Object.keys(data.countries)
+ .map(key => ({ x: key, y: data.countries[key] }))
+ .sort(firstBy('y', -1)),
+ );
+
+ return (
+ <Grid gap="3">
+ <RealtimeHeader data={data} />
+ <Panel>
+ <RealtimeChart data={data} unit="minute" />
+ </Panel>
+ <Panel>
+ <RealtimeLog data={data} />
+ </Panel>
+ <GridRow layout="two">
+ <Panel>
+ <RealtimePaths data={data} />
+ </Panel>
+ <Panel>
+ <RealtimeReferrers data={data} />
+ </Panel>
+ </GridRow>
+ <GridRow layout="one-two">
+ <Panel>
+ <RealtimeCountries data={countries} />
+ </Panel>
+ <Panel gridColumn={isMobile ? null : 'span 2'} padding="0">
+ <WorldMap data={countries} />
+ </Panel>
+ </GridRow>
+ </Grid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
new file mode 100644
index 0000000..1f90ad8
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
@@ -0,0 +1,45 @@
+import thenby from 'thenby';
+import { useMessages, useWebsite } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { percentFilter } from '@/lib/filters';
+
+export function RealtimePaths({ data }: { data: any }) {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { urls } = data || {};
+ const limit = 15;
+
+ const renderLink = ({ label: x }) => {
+ const domain = x.startsWith('/') ? website?.domain : '';
+ return (
+ <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
+ {x}
+ </a>
+ );
+ };
+
+ const pages = percentFilter(
+ Object.keys(urls)
+ .map(key => {
+ return {
+ x: key,
+ y: urls[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.pages)}
+ metric={formatMessage(labels.views)}
+ renderLabel={renderLink}
+ data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
new file mode 100644
index 0000000..9fd4477
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
@@ -0,0 +1,45 @@
+import thenby from 'thenby';
+import { useMessages, useWebsite } from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { percentFilter } from '@/lib/filters';
+
+export function RealtimeReferrers({ data }: { data: any }) {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { referrers } = data || {};
+ const limit = 15;
+
+ const renderLink = ({ label: x }) => {
+ const domain = x.startsWith('/') ? website?.domain : '';
+ return (
+ <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
+ {x}
+ </a>
+ );
+ };
+
+ const domains = percentFilter(
+ Object.keys(referrers)
+ .map(key => {
+ return {
+ x: key,
+ y: referrers[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ <ListTable
+ title={formatMessage(labels.referrers)}
+ metric={formatMessage(labels.views)}
+ renderLabel={renderLink}
+ data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/realtime/page.tsx b/src/app/(main)/websites/[websiteId]/realtime/page.tsx
new file mode 100644
index 0000000..1552196
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/realtime/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { RealtimePage } from './RealtimePage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <RealtimePage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Real-time',
+};
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx
new file mode 100644
index 0000000..7b70fee
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { SegmentEditForm } from './SegmentEditForm';
+
+export function SegmentAddButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Plus />}
+ label={formatMessage(labels.segment)}
+ variant="primary"
+ width="800px"
+ >
+ {({ close }) => {
+ return <SegmentEditForm websiteId={websiteId} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx
new file mode 100644
index 0000000..bb52a22
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx
@@ -0,0 +1,60 @@
+import { ConfirmationForm } from '@/components/common/ConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+import { Trash } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { messages } from '@/components/messages';
+
+export function SegmentDeleteButton({
+ segmentId,
+ websiteId,
+ name,
+ onSave,
+}: {
+ segmentId: string;
+ websiteId: string;
+ name: string;
+ onSave?: () => void;
+}) {
+ const { formatMessage, labels, FormattedMessage } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(
+ `/websites/${websiteId}/segments/${segmentId}`,
+ );
+
+ const handleConfirm = async (close: () => void) => {
+ await mutateAsync(null, {
+ onSuccess: () => {
+ touch('segments');
+ onSave?.();
+ close();
+ },
+ });
+ };
+
+ return (
+ <DialogButton
+ icon={<Trash />}
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="600px"
+ >
+ {({ close }) => (
+ <ConfirmationForm
+ message={
+ <FormattedMessage
+ {...messages.confirmRemove}
+ values={{
+ target: <b>{name}</b>,
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx
new file mode 100644
index 0000000..5c56cf1
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx
@@ -0,0 +1,37 @@
+import { useMessages } from '@/components/hooks';
+import { Edit } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import type { Filter } from '@/lib/types';
+import { SegmentEditForm } from './SegmentEditForm';
+
+export function SegmentEditButton({
+ segmentId,
+ websiteId,
+ filters,
+}: {
+ segmentId: string;
+ websiteId: string;
+ filters?: Filter[];
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogButton
+ icon={<Edit />}
+ title={formatMessage(labels.segment)}
+ variant="quiet"
+ width="800px"
+ >
+ {({ close }) => {
+ return (
+ <SegmentEditForm
+ segmentId={segmentId}
+ websiteId={websiteId}
+ filters={filters}
+ onClose={close}
+ />
+ );
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx
new file mode 100644
index 0000000..c3529d9
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx
@@ -0,0 +1,86 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Label,
+ Loading,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks';
+import { FieldFilters } from '@/components/input/FieldFilters';
+import { messages } from '@/components/messages';
+
+export function SegmentEditForm({
+ segmentId,
+ websiteId,
+ filters = [],
+ showFilters = true,
+ onSave,
+ onClose,
+}: {
+ segmentId?: string;
+ websiteId: string;
+ filters?: any[];
+ showFilters?: boolean;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { data } = useWebsiteSegmentQuery(websiteId, segmentId);
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+
+ const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
+ `/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`,
+ {
+ type: 'segment',
+ },
+ );
+
+ const handleSubmit = async (formData: any) => {
+ await mutateAsync(formData, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('segments');
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ if (segmentId && !data) {
+ return <Loading placement="absolute" />;
+ }
+
+ return (
+ <Form
+ onSubmit={handleSubmit}
+ defaultValues={data || { parameters: { filters } }}
+ error={getErrorMessage(error)}
+ >
+ <FormField
+ name="name"
+ label={formatMessage(labels.name)}
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoFocus={!segmentId} />
+ </FormField>
+ {showFilters && (
+ <>
+ <Label>{formatMessage(labels.filters)}</Label>
+ <FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}>
+ <FieldFilters websiteId={websiteId} />
+ </FormField>
+ </>
+ )}
+ <FormButtons>
+ <Button isDisabled={isPending} onPress={onClose}>
+ {formatMessage(labels.cancel)}
+ </Button>
+ <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx
new file mode 100644
index 0000000..c1ba82e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx
@@ -0,0 +1,24 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useWebsiteSegmentsQuery } from '@/components/hooks';
+import { SegmentAddButton } from './SegmentAddButton';
+import { SegmentsTable } from './SegmentsTable';
+
+export function SegmentsDataTable({ websiteId }: { websiteId?: string }) {
+ const query = useWebsiteSegmentsQuery(websiteId, { type: 'segment' });
+
+ const renderActions = () => {
+ return <SegmentAddButton websiteId={websiteId} />;
+ };
+
+ return (
+ <DataGrid
+ query={query}
+ allowSearch={true}
+ autoFocus={false}
+ allowPaging={true}
+ renderActions={renderActions}
+ >
+ {({ data }) => <SegmentsTable data={data} />}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx
new file mode 100644
index 0000000..cbe7a1c
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx
@@ -0,0 +1,16 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { SegmentsDataTable } from './SegmentsDataTable';
+
+export function SegmentsPage({ websiteId }) {
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} allowFilter={false} allowDateFilter={false} />
+ <Panel>
+ <SegmentsDataTable websiteId={websiteId} />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx
new file mode 100644
index 0000000..4dbe511
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx
@@ -0,0 +1,38 @@
+import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton';
+import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton';
+import { DateDistance } from '@/components/common/DateDistance';
+import { useMessages, useNavigation } from '@/components/hooks';
+
+export function SegmentsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { websiteId, renderUrl } = useNavigation();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="name" label={formatMessage(labels.name)}>
+ {(row: any) => (
+ <Link href={renderUrl(`/websites/${websiteId}?segment=${row.id}`, false)}>
+ {row.name}
+ </Link>
+ )}
+ </DataColumn>
+ <DataColumn id="created" label={formatMessage(labels.created)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ <DataColumn id="action" align="end" width="100px">
+ {(row: any) => {
+ const { id, name } = row;
+
+ return (
+ <Row>
+ <SegmentEditButton segmentId={id} websiteId={websiteId} />
+ <SegmentDeleteButton segmentId={id} websiteId={websiteId} name={name} />
+ </Row>
+ );
+ }}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/page.tsx b/src/app/(main)/websites/[websiteId]/segments/page.tsx
new file mode 100644
index 0000000..0d3faac
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { SegmentsPage } from './SegmentsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <SegmentsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Segments',
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
new file mode 100644
index 0000000..cbb2810
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
@@ -0,0 +1,94 @@
+import {
+ Button,
+ Column,
+ Dialog,
+ DialogTrigger,
+ Heading,
+ Icon,
+ Popover,
+ Row,
+ StatusLight,
+ Text,
+} from '@umami/react-zen';
+import { isSameDay } from 'date-fns';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useMobile, useSessionActivityQuery, useTimezone } from '@/components/hooks';
+import { Eye, FileText } from '@/components/icons';
+import { EventData } from '@/components/metrics/EventData';
+import { Lightning } from '@/components/svg';
+
+export function SessionActivity({
+ websiteId,
+ sessionId,
+ startDate,
+ endDate,
+}: {
+ websiteId: string;
+ sessionId: string;
+ startDate: Date;
+ endDate: Date;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { formatTimezoneDate } = useTimezone();
+ const { data, isLoading, error } = useSessionActivityQuery(
+ websiteId,
+ sessionId,
+ startDate,
+ endDate,
+ );
+ const { isMobile } = useMobile();
+ let lastDay = null;
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Column gap>
+ {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => {
+ const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
+ lastDay = createdAt;
+
+ return (
+ <Column key={eventId} gap>
+ {showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>}
+ <Row alignItems="center" gap="6" height="40px">
+ <StatusLight color={`#${visitId?.substring(0, 6)}`}>
+ <Text wrap="nowrap">{formatTimezoneDate(createdAt, 'pp')}</Text>
+ </StatusLight>
+ <Row alignItems="center" gap="2">
+ <Icon>{eventName ? <Lightning /> : <Eye />}</Icon>
+ <Text wrap="nowrap">
+ {eventName
+ ? formatMessage(labels.triggeredEvent)
+ : formatMessage(labels.viewedPage)}
+ </Text>
+ <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
+ {eventName || urlPath}
+ </Text>
+ {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
+ </Row>
+ </Row>
+ </Column>
+ );
+ })}
+ </Column>
+ </LoadingPanel>
+ );
+}
+
+const PropertiesButton = props => {
+ return (
+ <DialogTrigger>
+ <Button variant="quiet">
+ <Row alignItems="center" gap>
+ <Icon>
+ <FileText />
+ </Icon>
+ </Row>
+ </Button>
+ <Popover placement="right">
+ <Dialog>
+ <EventData {...props} />
+ </Dialog>
+ </Popover>
+ </DialogTrigger>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx
new file mode 100644
index 0000000..7c82c17
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx
@@ -0,0 +1,32 @@
+import { Box, Column, Label, Row, Text } from '@umami/react-zen';
+import { Empty } from '@/components/common/Empty';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useSessionDataQuery } from '@/components/hooks';
+import { DATA_TYPES } from '@/lib/constants';
+
+export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) {
+ const { data, isLoading, error } = useSessionDataQuery(websiteId, sessionId);
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ {!data?.length && <Empty />}
+ <Column gap="6">
+ {data?.map(({ dataKey, dataType, stringValue }) => {
+ return (
+ <Column key={dataKey}>
+ <Label>{dataKey}</Label>
+ <Row alignItems="center" gap>
+ <Text>{stringValue}</Text>
+ <Box paddingY="1" paddingX="2" border borderRadius borderColor="5">
+ <Text color="muted" size="1">
+ {DATA_TYPES[dataType]}
+ </Text>
+ </Box>
+ </Row>
+ </Column>
+ );
+ })}
+ </Column>
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx
new file mode 100644
index 0000000..f15e6ee
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx
@@ -0,0 +1,85 @@
+import { Column, Grid, Icon, Label, Row } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { DateDistance } from '@/components/common/DateDistance';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useFormat, useLocale, useMessages, useRegionNames } from '@/components/hooks';
+import { Calendar, KeyRound, Landmark, MapPin } from '@/components/icons';
+
+export function SessionInfo({ data }) {
+ const { locale } = useLocale();
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { getRegionName } = useRegionNames(locale);
+
+ return (
+ <Grid columns="repeat(auto-fit, minmax(200px, 1fr)" gap>
+ <Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}>
+ {data?.distinctId}
+ </Info>
+
+ <Info label={formatMessage(labels.lastSeen)} icon={<Calendar />}>
+ <DateDistance date={new Date(data.lastAt)} />
+ </Info>
+
+ <Info label={formatMessage(labels.firstSeen)} icon={<Calendar />}>
+ <DateDistance date={new Date(data.firstAt)} />
+ </Info>
+
+ <Info
+ label={formatMessage(labels.country)}
+ icon={<TypeIcon type="country" value={data?.country} />}
+ >
+ {formatValue(data?.country, 'country')}
+ </Info>
+
+ <Info label={formatMessage(labels.region)} icon={<MapPin />}>
+ {getRegionName(data?.region)}
+ </Info>
+
+ <Info label={formatMessage(labels.city)} icon={<Landmark />}>
+ {data?.city}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.browser)}
+ icon={<TypeIcon type="browser" value={data?.browser} />}
+ >
+ {formatValue(data?.browser, 'browser')}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.os)}
+ icon={<TypeIcon type="os" value={data?.os?.toLowerCase()?.replaceAll(/\W/g, '-')} />}
+ >
+ {formatValue(data?.os, 'os')}
+ </Info>
+
+ <Info
+ label={formatMessage(labels.device)}
+ icon={<TypeIcon type="device" value={data?.device} />}
+ >
+ {formatValue(data?.device, 'device')}
+ </Info>
+ </Grid>
+ );
+}
+
+const Info = ({
+ label,
+ icon,
+ children,
+}: {
+ label: string;
+ icon?: ReactNode;
+ children: ReactNode;
+}) => {
+ return (
+ <Column>
+ <Label>{label}</Label>
+ <Row alignItems="center" gap>
+ {icon && <Icon>{icon}</Icon>}
+ {children || '—'}
+ </Row>
+ </Column>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx
new file mode 100644
index 0000000..d658038
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx
@@ -0,0 +1,41 @@
+import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
+import { SessionProfile } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfile';
+import { useNavigation } from '@/components/hooks';
+
+export interface SessionModalProps extends ModalProps {
+ websiteId: string;
+}
+
+export function SessionModal({ websiteId, ...props }: SessionModalProps) {
+ const {
+ router,
+ query: { session },
+ updateParams,
+ } = useNavigation();
+ const handleOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ router.push(updateParams({ session: undefined }));
+ }
+ };
+
+ return (
+ <Modal
+ placement="bottom"
+ offset="80px"
+ isOpen={!!session}
+ onOpenChange={handleOpenChange}
+ isDismissable
+ {...props}
+ >
+ <Column height="100%" maxWidth="1320px" style={{ margin: '0 auto' }}>
+ <Dialog variant="sheet">
+ {({ close }) => (
+ <Column padding="6">
+ <SessionProfile websiteId={websiteId} sessionId={session} onClose={() => close()} />
+ </Column>
+ )}
+ </Dialog>
+ </Column>
+ </Modal>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
new file mode 100644
index 0000000..6624d43
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
@@ -0,0 +1,84 @@
+import {
+ Button,
+ Column,
+ Icon,
+ Row,
+ Tab,
+ TabList,
+ TabPanel,
+ Tabs,
+ TextField,
+} from '@umami/react-zen';
+import { X } from 'lucide-react';
+import { Avatar } from '@/components/common/Avatar';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useWebsiteSessionQuery } from '@/components/hooks';
+import { SessionActivity } from './SessionActivity';
+import { SessionData } from './SessionData';
+import { SessionInfo } from './SessionInfo';
+import { SessionStats } from './SessionStats';
+
+export function SessionProfile({
+ websiteId,
+ sessionId,
+ onClose,
+}: {
+ websiteId: string;
+ sessionId: string;
+ onClose?: () => void;
+}) {
+ const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId);
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <LoadingPanel
+ data={data}
+ isLoading={isLoading}
+ error={error}
+ loadingIcon="spinner"
+ loadingPlacement="absolute"
+ >
+ {data && (
+ <Column gap>
+ {onClose && (
+ <Row justifyContent="flex-end">
+ <Button onPress={onClose} variant="quiet">
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ </Row>
+ )}
+ <Column gap="6">
+ <Row justifyContent="center" alignItems="center" gap="6">
+ <Avatar seed={data?.id} size={128} />
+ <Column width="360px">
+ <TextField label="ID" value={data?.id} allowCopy />
+ </Column>
+ </Row>
+ <SessionStats data={data} />
+ <SessionInfo data={data} />
+
+ <Tabs>
+ <TabList>
+ <Tab id="activity">{formatMessage(labels.activity)}</Tab>
+ <Tab id="properties">{formatMessage(labels.properties)}</Tab>
+ </TabList>
+ <TabPanel id="activity">
+ <SessionActivity
+ websiteId={websiteId}
+ sessionId={sessionId}
+ startDate={data?.firstAt}
+ endDate={data?.lastAt}
+ />
+ </TabPanel>
+ <TabPanel id="properties">
+ <SessionData sessionId={sessionId} websiteId={websiteId} />
+ </TabPanel>
+ </Tabs>
+ </Column>
+ </Column>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
new file mode 100644
index 0000000..1693d05
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
@@ -0,0 +1,97 @@
+import { Column, Grid, ListItem, Select } from '@umami/react-zen';
+import { useMemo, useState } from 'react';
+import { PieChart } from '@/components/charts/PieChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useMessages,
+ useSessionDataPropertiesQuery,
+ useSessionDataValuesQuery,
+} from '@/components/hooks';
+import { ListTable } from '@/components/metrics/ListTable';
+import { CHART_COLORS } from '@/lib/constants';
+
+export function SessionProperties({ websiteId }: { websiteId: string }) {
+ const [propertyName, setPropertyName] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useSessionDataPropertiesQuery(websiteId);
+
+ const properties: string[] = data?.map(e => e.propertyName);
+
+ return (
+ <LoadingPanel
+ isLoading={isLoading}
+ isFetching={isFetching}
+ data={data}
+ error={error}
+ minHeight="300px"
+ >
+ <Column gap="6">
+ {data && (
+ <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap>
+ <Select
+ label={formatMessage(labels.event)}
+ value={propertyName}
+ onChange={setPropertyName}
+ placeholder=""
+ >
+ {properties?.map(p => (
+ <ListItem key={p} id={p}>
+ {p}
+ </ListItem>
+ ))}
+ </Select>
+ </Grid>
+ )}
+ {propertyName && <SessionValues websiteId={websiteId} propertyName={propertyName} />}
+ </Column>
+ </LoadingPanel>
+ );
+}
+
+const SessionValues = ({ websiteId, propertyName }) => {
+ const { data, isLoading, isFetching, error } = useSessionDataValuesQuery(websiteId, propertyName);
+
+ const propertySum = useMemo(() => {
+ return data?.reduce((sum, { total }) => sum + total, 0) ?? 0;
+ }, [data]);
+
+ const chartData = useMemo(() => {
+ if (!propertyName || !data) return null;
+ return {
+ labels: data.map(({ value }) => value),
+ datasets: [
+ {
+ data: data.map(({ total }) => total),
+ backgroundColor: CHART_COLORS,
+ borderWidth: 0,
+ },
+ ],
+ };
+ }, [propertyName, data]);
+
+ const tableData = useMemo(() => {
+ if (!propertyName || !data || propertySum === 0) return [];
+ return data.map(({ value, total }) => ({
+ label: value,
+ count: total,
+ percent: 100 * (total / propertySum),
+ }));
+ }, [propertyName, data, propertySum]);
+
+ return (
+ <LoadingPanel
+ isLoading={isLoading}
+ isFetching={isFetching}
+ data={data}
+ error={error}
+ minHeight="300px"
+ >
+ {data && (
+ <Grid columns="1fr 1fr" gap>
+ <ListTable title={propertyName} data={tableData} />
+ <PieChart type="doughnut" chartData={chartData} />
+ </Grid>
+ )}
+ </LoadingPanel>
+ );
+};
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx
new file mode 100644
index 0000000..e25be9a
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx
@@ -0,0 +1,21 @@
+import { useMessages } from '@/components/hooks';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatShortTime } from '@/lib/format';
+
+export function SessionStats({ data }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <MetricsBar>
+ <MetricCard label={formatMessage(labels.visits)} value={data?.visits} />
+ <MetricCard label={formatMessage(labels.views)} value={data?.views} />
+ <MetricCard label={formatMessage(labels.events)} value={data?.events} />
+ <MetricCard
+ label={formatMessage(labels.visitDuration)}
+ value={data?.totaltime / data?.visits}
+ formatValue={n => `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
+ />
+ </MetricsBar>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
new file mode 100644
index 0000000..b1b9f65
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
@@ -0,0 +1,15 @@
+import { DataGrid } from '@/components/common/DataGrid';
+import { useWebsiteSessionsQuery } from '@/components/hooks';
+import { SessionsTable } from './SessionsTable';
+
+export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) {
+ const queryResult = useWebsiteSessionsQuery(websiteId);
+
+ return (
+ <DataGrid query={queryResult} allowPaging allowSearch>
+ {({ data }) => {
+ return <SessionsTable data={data} />;
+ }}
+ </DataGrid>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
new file mode 100644
index 0000000..c8317a2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
@@ -0,0 +1,40 @@
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages } from '@/components/hooks';
+import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery';
+import { MetricCard } from '@/components/metrics/MetricCard';
+import { MetricsBar } from '@/components/metrics/MetricsBar';
+import { formatLongNumber } from '@/lib/format';
+
+export function SessionsMetricsBar({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId);
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
+ {data && (
+ <MetricsBar>
+ <MetricCard
+ value={data?.visitors?.value}
+ label={formatMessage(labels.visitors)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.visits?.value}
+ label={formatMessage(labels.visits)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.pageviews?.value}
+ label={formatMessage(labels.views)}
+ formatValue={formatLongNumber}
+ />
+ <MetricCard
+ value={data?.countries?.value}
+ label={formatMessage(labels.countries)}
+ formatValue={formatLongNumber}
+ />
+ </MetricsBar>
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
new file mode 100644
index 0000000..8e9d2f2
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
@@ -0,0 +1,43 @@
+'use client';
+import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { type Key, useState } from 'react';
+import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
+import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { getItem, setItem } from '@/lib/storage';
+import { SessionProperties } from './SessionProperties';
+import { SessionsDataTable } from './SessionsDataTable';
+
+const KEY_NAME = 'umami.sessions.tab';
+
+export function SessionsPage({ websiteId }) {
+ const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity');
+ const { formatMessage, labels } = useMessages();
+
+ const handleSelect = (value: Key) => {
+ setItem(KEY_NAME, value);
+ setTab(value);
+ };
+
+ return (
+ <Column gap="3">
+ <WebsiteControls websiteId={websiteId} />
+ <Panel>
+ <Tabs selectedKey={tab} onSelectionChange={handleSelect}>
+ <TabList>
+ <Tab id="activity">{formatMessage(labels.activity)}</Tab>
+ <Tab id="properties">{formatMessage(labels.properties)}</Tab>
+ </TabList>
+ <TabPanel id="activity">
+ <SessionsDataTable websiteId={websiteId} />
+ </TabPanel>
+ <TabPanel id="properties">
+ <SessionProperties websiteId={websiteId} />
+ </TabPanel>
+ </Tabs>
+ </Panel>
+ <SessionModal websiteId={websiteId} />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
new file mode 100644
index 0000000..5d3bb37
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
@@ -0,0 +1,58 @@
+import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
+import Link from 'next/link';
+import { Avatar } from '@/components/common/Avatar';
+import { DateDistance } from '@/components/common/DateDistance';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import { useFormat, useMessages, useNavigation } from '@/components/hooks';
+
+export function SessionsTable(props: DataTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const { updateParams } = useNavigation();
+
+ return (
+ <DataTable {...props}>
+ <DataColumn id="id" label={formatMessage(labels.session)} width="100px">
+ {(row: any) => (
+ <Link href={updateParams({ session: row.id })}>
+ <Avatar seed={row.id} size={32} />
+ </Link>
+ )}
+ </DataColumn>
+ <DataColumn id="visits" label={formatMessage(labels.visits)} width="80px" />
+ <DataColumn id="views" label={formatMessage(labels.views)} width="80px" />
+ <DataColumn id="country" label={formatMessage(labels.country)}>
+ {(row: any) => (
+ <TypeIcon type="country" value={row.country}>
+ {formatValue(row.country, 'country')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="city" label={formatMessage(labels.city)} />
+ <DataColumn id="browser" label={formatMessage(labels.browser)}>
+ {(row: any) => (
+ <TypeIcon type="browser" value={row.browser}>
+ {formatValue(row.browser, 'browser')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="os" label={formatMessage(labels.os)}>
+ {(row: any) => (
+ <TypeIcon type="os" value={row.os}>
+ {formatValue(row.os, 'os')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="device" label={formatMessage(labels.device)}>
+ {(row: any) => (
+ <TypeIcon type="device" value={row.device}>
+ {formatValue(row.device, 'device')}
+ </TypeIcon>
+ )}
+ </DataColumn>
+ <DataColumn id="lastAt" label={formatMessage(labels.lastSeen)}>
+ {(row: any) => <DateDistance date={new Date(row.createdAt)} />}
+ </DataColumn>
+ </DataTable>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/page.tsx b/src/app/(main)/websites/[websiteId]/sessions/page.tsx
new file mode 100644
index 0000000..221ab71
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/sessions/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { SessionsPage } from './SessionsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <SessionsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Sessions',
+};
diff --git a/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx
new file mode 100644
index 0000000..468f250
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx
@@ -0,0 +1,6 @@
+'use client';
+import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage';
+
+export function SettingsPage({ websiteId }: { websiteId: string }) {
+ return <WebsiteSettingsPage websiteId={websiteId} />;
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx
new file mode 100644
index 0000000..21cd613
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx
@@ -0,0 +1,104 @@
+import { Button, Column, Dialog, DialogTrigger, Modal } from '@umami/react-zen';
+import { ActionForm } from '@/components/common/ActionForm';
+import {
+ useLoginQuery,
+ useMessages,
+ useModified,
+ useNavigation,
+ useUserTeamsQuery,
+} from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+import { WebsiteDeleteForm } from './WebsiteDeleteForm';
+import { WebsiteResetForm } from './WebsiteResetForm';
+import { WebsiteTransferForm } from './WebsiteTransferForm';
+
+export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
+ const { formatMessage, labels, messages } = useMessages();
+ const { user } = useLoginQuery();
+ const { touch } = useModified();
+ const { router, pathname, teamId, renderUrl } = useNavigation();
+ const { data: teams } = useUserTeamsQuery(user.id);
+ const isAdmin = pathname.startsWith('/admin');
+
+ const canTransferWebsite =
+ (
+ (!teamId &&
+ teams?.data?.filter(({ members }) =>
+ members.find(
+ ({ role, userId }) =>
+ [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
+ ),
+ )) ||
+ []
+ ).length > 0 ||
+ (teamId &&
+ !!teams?.data
+ ?.find(({ id }) => id === teamId)
+ ?.members.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id));
+
+ const handleSave = () => {
+ touch('websites');
+ onSave?.();
+ router.push(renderUrl(`/websites`));
+ };
+
+ const handleReset = async () => {
+ onSave?.();
+ };
+
+ return (
+ <Column gap="6">
+ {!isAdmin && (
+ <ActionForm
+ label={formatMessage(labels.transferWebsite)}
+ description={formatMessage(messages.transferWebsite)}
+ >
+ <DialogTrigger>
+ <Button isDisabled={!canTransferWebsite}>{formatMessage(labels.transfer)}</Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.transferWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteTransferForm websiteId={websiteId} onSave={handleSave} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+ )}
+
+ <ActionForm
+ label={formatMessage(labels.resetWebsite)}
+ description={formatMessage(messages.resetWebsiteWarning)}
+ >
+ <DialogTrigger>
+ <Button>{formatMessage(labels.reset)}</Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.resetWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+
+ <ActionForm
+ label={formatMessage(labels.deleteWebsite)}
+ description={formatMessage(messages.deleteWebsiteWarning)}
+ >
+ <DialogTrigger>
+ <Button data-test="button-delete" variant="danger">
+ {formatMessage(labels.delete)}
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.deleteWebsite)} style={{ width: 400 }}>
+ {({ close }) => (
+ <WebsiteDeleteForm websiteId={websiteId} onSave={handleSave} onClose={close} />
+ )}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ </ActionForm>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx
new file mode 100644
index 0000000..2fc0276
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx
@@ -0,0 +1,40 @@
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
+import { useDeleteQuery, useMessages } from '@/components/hooks';
+
+const CONFIRM_VALUE = 'DELETE';
+
+export function WebsiteDeleteForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/websites/${websiteId}`);
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch('websites');
+ touch(`websites:${websiteId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <TypeConfirmationForm
+ confirmationValue={CONFIRM_VALUE}
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={error}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx
new file mode 100644
index 0000000..4ae819e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx
@@ -0,0 +1,55 @@
+import { Form, FormButtons, FormField, FormSubmitButton, TextField } from '@umami/react-zen';
+import { useMessages, useUpdateQuery, useWebsite } from '@/components/hooks';
+import { DOMAIN_REGEX } from '@/lib/constants';
+
+export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
+ const website = useWebsite();
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch('websites');
+ touch(`website:${website.id}`);
+ onSave?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={website}>
+ <FormField name="id" label={formatMessage(labels.websiteId)}>
+ <TextField data-test="text-field-websiteId" value={website?.id} isReadOnly allowCopy />
+ </FormField>
+ <FormField
+ label={formatMessage(labels.name)}
+ data-test="input-name"
+ name="name"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField />
+ </FormField>
+ <FormField
+ label={formatMessage(labels.domain)}
+ data-test="input-domain"
+ name="domain"
+ rules={{
+ required: formatMessage(labels.required),
+ pattern: {
+ value: DOMAIN_REGEX,
+ message: formatMessage(messages.invalidDomain),
+ },
+ }}
+ >
+ <TextField />
+ </FormField>
+ <FormButtons>
+ <FormSubmitButton data-test="button-submit" variant="primary">
+ {formatMessage(labels.save)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx
new file mode 100644
index 0000000..d791bc9
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx
@@ -0,0 +1,37 @@
+import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+
+const CONFIRM_VALUE = 'RESET';
+
+export function WebsiteResetForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { mutateAsync, isPending, error } = useUpdateQuery(`/websites/${websiteId}/reset`);
+
+ const handleConfirm = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <TypeConfirmationForm
+ confirmationValue={CONFIRM_VALUE}
+ onConfirm={handleConfirm}
+ onClose={onClose}
+ isLoading={isPending}
+ error={error}
+ buttonLabel={formatMessage(labels.reset)}
+ />
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
new file mode 100644
index 0000000..3970cdb
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
@@ -0,0 +1,28 @@
+import { Column } from '@umami/react-zen';
+import { Panel } from '@/components/common/Panel';
+import { useWebsite } from '@/components/hooks';
+import { WebsiteData } from './WebsiteData';
+import { WebsiteEditForm } from './WebsiteEditForm';
+import { WebsiteShareForm } from './WebsiteShareForm';
+import { WebsiteTrackingCode } from './WebsiteTrackingCode';
+
+export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
+ const website = useWebsite();
+
+ return (
+ <Column gap="6">
+ <Panel>
+ <WebsiteEditForm websiteId={websiteId} />
+ </Panel>
+ <Panel>
+ <WebsiteTrackingCode websiteId={websiteId} />
+ </Panel>
+ <Panel>
+ <WebsiteShareForm websiteId={websiteId} shareId={website.shareId} />
+ </Panel>
+ <Panel>
+ <WebsiteData websiteId={websiteId} />
+ </Panel>
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
new file mode 100644
index 0000000..99977a0
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
@@ -0,0 +1,22 @@
+import { IconLabel, Row } from '@umami/react-zen';
+import Link from 'next/link';
+import { PageHeader } from '@/components/common/PageHeader';
+import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
+import { ArrowLeft, Globe } from '@/components/icons';
+
+export function WebsiteSettingsHeader() {
+ const website = useWebsite();
+ const { formatMessage, labels } = useMessages();
+ const { renderUrl } = useNavigation();
+
+ return (
+ <>
+ <Row marginTop="6">
+ <Link href={renderUrl(`/websites/${website.id}`)}>
+ <IconLabel icon={<ArrowLeft />} label={formatMessage(labels.website)} />
+ </Link>
+ </Row>
+ <PageHeader title={website?.name} description={website?.domain} icon={<Globe />} />
+ </>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
new file mode 100644
index 0000000..56c6f43
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
@@ -0,0 +1,93 @@
+import {
+ Button,
+ Column,
+ Form,
+ FormButtons,
+ FormSubmitButton,
+ IconLabel,
+ Label,
+ Row,
+ Switch,
+ TextField,
+} from '@umami/react-zen';
+import { RefreshCcw } from 'lucide-react';
+import { useState } from 'react';
+import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks';
+import { getRandomChars } from '@/lib/generate';
+
+const generateId = () => getRandomChars(16);
+
+export interface WebsiteShareFormProps {
+ websiteId: string;
+ shareId?: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}
+
+export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const [currentId, setCurrentId] = useState(shareId);
+ const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`);
+ const { cloudMode } = useConfig();
+
+ const getUrl = (shareId: string) => {
+ if (cloudMode) {
+ return `${process.env.cloudUrl}/share/${shareId}`;
+ }
+
+ return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`;
+ };
+
+ const url = getUrl(currentId);
+
+ const handleGenerate = () => {
+ setCurrentId(generateId());
+ };
+
+ const handleSwitch = () => {
+ setCurrentId(currentId ? null : generateId());
+ };
+
+ const handleSave = async () => {
+ const data = {
+ shareId: currentId,
+ };
+ await mutateAsync(data, {
+ onSuccess: async () => {
+ toast(formatMessage(messages.saved));
+ touch(`website:${websiteId}`);
+ onSave?.();
+ onClose?.();
+ },
+ });
+ };
+
+ return (
+ <Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}>
+ <Column gap>
+ <Switch isSelected={!!currentId} onChange={handleSwitch}>
+ {formatMessage(labels.enableShareUrl)}
+ </Switch>
+ {currentId && (
+ <Row alignItems="flex-end" gap>
+ <Column flexGrow={1}>
+ <Label>{formatMessage(labels.shareUrl)}</Label>
+ <TextField value={url} isReadOnly allowCopy />
+ </Column>
+ <Column>
+ <Button onPress={handleGenerate}>
+ <IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} />
+ </Button>
+ </Column>
+ </Row>
+ )}
+ <FormButtons justifyContent="flex-end">
+ <Row alignItems="center" gap>
+ {onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>}
+ <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
+ </Row>
+ </FormButtons>
+ </Column>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
new file mode 100644
index 0000000..d24f948
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
@@ -0,0 +1,40 @@
+import { Column, Label, Text, TextField } from '@umami/react-zen';
+import { useConfig, useMessages } from '@/components/hooks';
+
+const SCRIPT_NAME = 'script.js';
+
+export function WebsiteTrackingCode({
+ websiteId,
+ hostUrl,
+}: {
+ websiteId: string;
+ hostUrl?: string;
+}) {
+ const { formatMessage, messages, labels } = useMessages();
+ const config = useConfig();
+
+ const trackerScriptName =
+ config?.trackerScriptName?.split(',')?.map((n: string) => n.trim())?.[0] || SCRIPT_NAME;
+
+ const getUrl = () => {
+ if (config?.cloudMode) {
+ return `${process.env.cloudUrl}/${trackerScriptName}`;
+ }
+
+ return `${hostUrl || window?.location?.origin || ''}${
+ process.env.basePath || ''
+ }/${trackerScriptName}`;
+ };
+
+ const url = trackerScriptName?.startsWith('http') ? trackerScriptName : getUrl();
+
+ const code = `<script defer src="${url}" data-website-id="${websiteId}"></script>`;
+
+ return (
+ <Column gap>
+ <Label>{formatMessage(labels.trackingCode)}</Label>
+ <Text color="muted">{formatMessage(messages.trackingCode)}</Text>
+ <TextField value={code} isReadOnly allowCopy asTextArea resize="none" />
+ </Column>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx
new file mode 100644
index 0000000..8af4f05
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx
@@ -0,0 +1,102 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ ListItem,
+ Loading,
+ Select,
+ Text,
+} from '@umami/react-zen';
+import { type Key, useState } from 'react';
+import {
+ useLoginQuery,
+ useMessages,
+ useUpdateQuery,
+ useUserTeamsQuery,
+ useWebsite,
+} from '@/components/hooks';
+import { ROLES } from '@/lib/constants';
+
+export function WebsiteTransferForm({
+ websiteId,
+ onSave,
+ onClose,
+}: {
+ websiteId: string;
+ onSave?: () => void;
+ onClose?: () => void;
+}) {
+ const { user } = useLoginQuery();
+ const website = useWebsite();
+ const [teamId, setTeamId] = useState<string>(null);
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ const { mutateAsync, error, isPending } = useUpdateQuery(`/websites/${websiteId}/transfer`);
+ const { data: teams, isLoading } = useUserTeamsQuery(user.id);
+ const isTeamWebsite = !!website?.teamId;
+
+ const items =
+ teams?.data?.filter(({ members }) =>
+ members.some(
+ ({ role, userId }) =>
+ [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
+ ),
+ ) || [];
+
+ const handleSubmit = async () => {
+ await mutateAsync(
+ {
+ userId: website.teamId ? user.id : undefined,
+ teamId: website.userId ? teamId : undefined,
+ },
+ {
+ onSuccess: async () => {
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ const handleChange = (key: Key) => {
+ setTeamId(key as string);
+ };
+
+ if (isLoading) {
+ return <Loading icon="dots" placement="center" />;
+ }
+
+ return (
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={{ teamId }}>
+ <Text>
+ {formatMessage(
+ isTeamWebsite ? messages.transferTeamWebsiteToUser : messages.transferUserWebsiteToTeam,
+ )}
+ </Text>
+ <FormField name="teamId">
+ {!isTeamWebsite && (
+ <Select onSelectionChange={handleChange} selectedKey={teamId}>
+ {items.map(({ id, name }) => {
+ return (
+ <ListItem key={`${id}`} id={`${id}`}>
+ {name}
+ </ListItem>
+ );
+ })}
+ </Select>
+ )}
+ </FormField>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton
+ variant="primary"
+ isPending={isPending}
+ isDisabled={!isTeamWebsite && !teamId}
+ >
+ {formatMessage(labels.transfer)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/settings/page.tsx b/src/app/(main)/websites/[websiteId]/settings/page.tsx
new file mode 100644
index 0000000..a26d14f
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/settings/page.tsx
@@ -0,0 +1,12 @@
+import type { Metadata } from 'next';
+import { SettingsPage } from './SettingsPage';
+
+export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
+ const { websiteId } = await params;
+
+ return <SettingsPage websiteId={websiteId} />;
+}
+
+export const metadata: Metadata = {
+ title: 'Settings',
+};
diff --git a/src/app/(main)/websites/page.tsx b/src/app/(main)/websites/page.tsx
new file mode 100644
index 0000000..cefaf80
--- /dev/null
+++ b/src/app/(main)/websites/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { WebsitesPage } from './WebsitesPage';
+
+export default function () {
+ return <WebsitesPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Websites',
+};
diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx
new file mode 100644
index 0000000..ae1a000
--- /dev/null
+++ b/src/app/Providers.tsx
@@ -0,0 +1,62 @@
+'use client';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { RouterProvider, ZenProvider } from '@umami/react-zen';
+import { useRouter } from 'next/navigation';
+import { useEffect } from 'react';
+import { IntlProvider } from 'react-intl';
+import { ErrorBoundary } from '@/components/common/ErrorBoundary';
+import { useLocale } from '@/components/hooks';
+import 'chartjs-adapter-date-fns';
+
+const client = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ refetchOnWindowFocus: false,
+ staleTime: 1000 * 60,
+ },
+ },
+});
+
+function MessagesProvider({ children }) {
+ const { locale, messages, dir } = useLocale();
+
+ useEffect(() => {
+ document.documentElement.setAttribute('dir', dir);
+ document.documentElement.setAttribute('lang', locale);
+ }, [locale, dir]);
+
+ return (
+ <IntlProvider locale={locale} messages={messages[locale]} onError={() => null}>
+ {children}
+ </IntlProvider>
+ );
+}
+
+export function Providers({ children }) {
+ const router = useRouter();
+
+ function navigate(url: string) {
+ if (shouldUseNativeLink(url)) {
+ window.location.href = url;
+ } else {
+ router.push(url);
+ }
+ }
+
+ function shouldUseNativeLink(url: string) {
+ return url.startsWith('http');
+ }
+
+ return (
+ <ZenProvider>
+ <RouterProvider navigate={navigate}>
+ <MessagesProvider>
+ <QueryClientProvider client={client}>
+ <ErrorBoundary>{children}</ErrorBoundary>
+ </QueryClientProvider>
+ </MessagesProvider>
+ </RouterProvider>
+ </ZenProvider>
+ );
+}
diff --git a/src/app/api/admin/teams/route.ts b/src/app/api/admin/teams/route.ts
new file mode 100644
index 0000000..ceb16ab
--- /dev/null
+++ b/src/app/api/admin/teams/route.ts
@@ -0,0 +1,58 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewAllTeams } from '@/permissions';
+import { getTeams } from '@/queries/prisma/team';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewAllTeams(auth))) {
+ return unauthorized();
+ }
+
+ const teams = await getTeams(
+ {
+ include: {
+ members: {
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ },
+ _count: {
+ select: {
+ websites: {
+ where: { deletedAt: null },
+ },
+ members: {
+ where: {
+ user: { deletedAt: null },
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ query,
+ );
+
+ return json(teams);
+}
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
new file mode 100644
index 0000000..2e52261
--- /dev/null
+++ b/src/app/api/admin/users/route.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewUsers } from '@/permissions';
+import { getUsers } from '@/queries/prisma/user';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewUsers(auth))) {
+ return unauthorized();
+ }
+
+ const users = await getUsers(
+ {
+ include: {
+ _count: {
+ select: {
+ websites: {
+ where: { deletedAt: null },
+ },
+ },
+ },
+ },
+ omit: {
+ password: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ query,
+ );
+
+ return json(users);
+}
diff --git a/src/app/api/admin/websites/route.ts b/src/app/api/admin/websites/route.ts
new file mode 100644
index 0000000..09b2ef9
--- /dev/null
+++ b/src/app/api/admin/websites/route.ts
@@ -0,0 +1,58 @@
+import { z } from 'zod';
+import { ROLES } from '@/lib/constants';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewAllWebsites } from '@/permissions';
+import { getWebsites } from '@/queries/prisma/website';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewAllWebsites(auth))) {
+ return unauthorized();
+ }
+
+ const websites = await getWebsites(
+ {
+ include: {
+ user: {
+ where: {
+ deletedAt: null,
+ },
+ select: {
+ username: true,
+ id: true,
+ },
+ },
+ team: {
+ where: {
+ deletedAt: null,
+ },
+ include: {
+ members: {
+ where: {
+ role: ROLES.teamOwner,
+ },
+ },
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ query,
+ );
+
+ return json(websites);
+}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..17ca2f7
--- /dev/null
+++ b/src/app/api/auth/login/route.ts
@@ -0,0 +1,48 @@
+import { z } from 'zod';
+import { saveAuth } from '@/lib/auth';
+import { ROLES } from '@/lib/constants';
+import { secret } from '@/lib/crypto';
+import { createSecureToken } from '@/lib/jwt';
+import { checkPassword } from '@/lib/password';
+import redis from '@/lib/redis';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { getAllUserTeams, getUserByUsername } from '@/queries/prisma';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ username: z.string(),
+ password: z.string(),
+ });
+
+ const { body, error } = await parseRequest(request, schema, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ const { username, password } = body;
+
+ const user = await getUserByUsername(username, { includePassword: true });
+
+ if (!user || !checkPassword(password, user.password)) {
+ return unauthorized({ code: 'incorrect-username-password' });
+ }
+
+ const { id, role, createdAt } = user;
+
+ let token: string;
+
+ if (redis.enabled) {
+ token = await saveAuth({ userId: id, role });
+ } else {
+ token = createSecureToken({ userId: user.id, role }, secret());
+ }
+
+ const teams = await getAllUserTeams(id);
+
+ return json({
+ token,
+ user: { id, username, role, createdAt, isAdmin: role === ROLES.admin, teams },
+ });
+}
diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..7bf0a81
--- /dev/null
+++ b/src/app/api/auth/logout/route.ts
@@ -0,0 +1,12 @@
+import redis from '@/lib/redis';
+import { ok } from '@/lib/response';
+
+export async function POST(request: Request) {
+ if (redis.enabled) {
+ const token = request.headers.get('authorization')?.split(' ')?.[1];
+
+ await redis.client.del(token);
+ }
+
+ return ok();
+}
diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts
new file mode 100644
index 0000000..bba3dde
--- /dev/null
+++ b/src/app/api/auth/sso/route.ts
@@ -0,0 +1,18 @@
+import { saveAuth } from '@/lib/auth';
+import redis from '@/lib/redis';
+import { parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+
+export async function POST(request: Request) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ if (redis.enabled) {
+ const token = await saveAuth({ userId: auth.user.id }, 86400);
+
+ return json({ user: auth.user, token });
+ }
+}
diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts
new file mode 100644
index 0000000..b308b7b
--- /dev/null
+++ b/src/app/api/auth/verify/route.ts
@@ -0,0 +1,15 @@
+import { parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+import { getAllUserTeams } from '@/queries/prisma';
+
+export async function POST(request: Request) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const teams = await getAllUserTeams(auth.user.id);
+
+ return json({ ...auth.user, teams });
+}
diff --git a/src/app/api/batch/route.ts b/src/app/api/batch/route.ts
new file mode 100644
index 0000000..46e8b3c
--- /dev/null
+++ b/src/app/api/batch/route.ts
@@ -0,0 +1,58 @@
+import { z } from 'zod';
+import * as send from '@/app/api/send/route';
+import { parseRequest } from '@/lib/request';
+import { json, serverError } from '@/lib/response';
+import { anyObjectParam } from '@/lib/schema';
+
+const schema = z.array(anyObjectParam);
+
+export async function POST(request: Request) {
+ try {
+ const { body, error } = await parseRequest(request, schema, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ const errors = [];
+
+ let index = 0;
+ let cache = null;
+ for (const data of body) {
+ // Recreate a fresh Request since `new Request(request)` will have the following error:
+ // > Cannot read private member #state from an object whose class did not declare it
+
+ // Copy headers we received, ensure JSON content type, and avoid conflicting content-length
+ const headers = new Headers(request.headers);
+ headers.set('content-type', 'application/json');
+ headers.delete('content-length');
+
+ const newRequest = new Request(request.url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(data),
+ });
+
+ const response = await send.POST(newRequest);
+ const responseJson = await response.json();
+
+ if (!response.ok) {
+ errors.push({ index, response: responseJson });
+ } else {
+ cache ??= responseJson.cache;
+ }
+
+ index++;
+ }
+
+ return json({
+ size: body.length,
+ processed: body.length - errors.length,
+ errors: errors.length,
+ details: errors,
+ cache,
+ });
+ } catch (e) {
+ return serverError(e);
+ }
+}
diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts
new file mode 100644
index 0000000..4e40caa
--- /dev/null
+++ b/src/app/api/config/route.ts
@@ -0,0 +1,21 @@
+import { parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+
+export async function GET(request: Request) {
+ const { error } = await parseRequest(request, null, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ return json({
+ cloudMode: !!process.env.CLOUD_MODE,
+ faviconUrl: process.env.FAVICON_URL,
+ linksUrl: process.env.LINKS_URL,
+ pixelsUrl: process.env.PIXELS_URL,
+ privateMode: !!process.env.PRIVATE_MODE,
+ telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
+ trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
+ updatesDisabled: !!process.env.DISABLE_UPDATES,
+ });
+}
diff --git a/src/app/api/heartbeat/route.ts b/src/app/api/heartbeat/route.ts
new file mode 100644
index 0000000..9146308
--- /dev/null
+++ b/src/app/api/heartbeat/route.ts
@@ -0,0 +1,3 @@
+export async function GET() {
+ return Response.json({ ok: true });
+}
diff --git a/src/app/api/links/[linkId]/route.ts b/src/app/api/links/[linkId]/route.ts
new file mode 100644
index 0000000..92f572c
--- /dev/null
+++ b/src/app/api/links/[linkId]/route.ts
@@ -0,0 +1,77 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
+import { canDeleteLink, canUpdateLink, canViewLink } from '@/permissions';
+import { deleteLink, getLink, updateLink } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { linkId } = await params;
+
+ if (!(await canViewLink(auth, linkId))) {
+ return unauthorized();
+ }
+
+ const website = await getLink(linkId);
+
+ return json(website);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ linkId: string }> }) {
+ const schema = z.object({
+ name: z.string().optional(),
+ url: z.string().optional(),
+ slug: z.string().min(8).optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { linkId } = await params;
+ const { name, url, slug } = body;
+
+ if (!(await canUpdateLink(auth, linkId))) {
+ return unauthorized();
+ }
+
+ try {
+ const result = await updateLink(linkId, { name, url, slug });
+
+ return Response.json(result);
+ } catch (e: any) {
+ if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) {
+ return badRequest({ message: 'That slug is already taken.' });
+ }
+
+ return serverError(e);
+ }
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ linkId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { linkId } = await params;
+
+ if (!(await canDeleteLink(auth, linkId))) {
+ return unauthorized();
+ }
+
+ await deleteLink(linkId);
+
+ return ok();
+}
diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts
new file mode 100644
index 0000000..a639888
--- /dev/null
+++ b/src/app/api/links/route.ts
@@ -0,0 +1,64 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
+import { createLink, getUserLinks } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const links = await getUserLinks(auth.user.id, filters);
+
+ return json(links);
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().max(100),
+ url: z.string().max(500),
+ slug: z.string().max(100),
+ teamId: z.string().nullable().optional(),
+ id: z.uuid().nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { id, name, url, slug, teamId } = body;
+
+ if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
+ return unauthorized();
+ }
+
+ const data: any = {
+ id: id ?? uuid(),
+ name,
+ url,
+ slug,
+ teamId,
+ };
+
+ if (!teamId) {
+ data.userId = auth.user.id;
+ }
+
+ const result = await createLink(data);
+
+ return json(result);
+}
diff --git a/src/app/api/me/password/route.ts b/src/app/api/me/password/route.ts
new file mode 100644
index 0000000..24c7370
--- /dev/null
+++ b/src/app/api/me/password/route.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+import { checkPassword, hashPassword } from '@/lib/password';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json } from '@/lib/response';
+import { getUser, updateUser } from '@/queries/prisma/user';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ currentPassword: z.string(),
+ newPassword: z.string().min(8),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const userId = auth.user.id;
+ const { currentPassword, newPassword } = body;
+
+ const user = await getUser(userId, { includePassword: true });
+
+ if (!checkPassword(currentPassword, user.password)) {
+ return badRequest({ message: 'Current password is incorrect' });
+ }
+
+ const password = hashPassword(newPassword);
+
+ const updated = await updateUser(userId, { password });
+
+ return json(updated);
+}
diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts
new file mode 100644
index 0000000..59a3255
--- /dev/null
+++ b/src/app/api/me/route.ts
@@ -0,0 +1,12 @@
+import { parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+
+export async function GET(request: Request) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ return json(auth);
+}
diff --git a/src/app/api/me/teams/route.ts b/src/app/api/me/teams/route.ts
new file mode 100644
index 0000000..555bf30
--- /dev/null
+++ b/src/app/api/me/teams/route.ts
@@ -0,0 +1,23 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { getUserTeams } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const teams = await getUserTeams(auth.user.id, filters);
+
+ return json(teams);
+}
diff --git a/src/app/api/me/websites/route.ts b/src/app/api/me/websites/route.ts
new file mode 100644
index 0000000..9ec39c7
--- /dev/null
+++ b/src/app/api/me/websites/route.ts
@@ -0,0 +1,26 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ includeTeams: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ if (query.includeTeams) {
+ return json(await getAllUserWebsitesIncludingTeamOwner(auth.user.id, filters));
+ }
+
+ return json(await getUserWebsites(auth.user.id, filters));
+}
diff --git a/src/app/api/pixels/[pixelId]/route.ts b/src/app/api/pixels/[pixelId]/route.ts
new file mode 100644
index 0000000..ecaf1fd
--- /dev/null
+++ b/src/app/api/pixels/[pixelId]/route.ts
@@ -0,0 +1,76 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
+import { canDeletePixel, canUpdatePixel, canViewPixel } from '@/permissions';
+import { deletePixel, getPixel, updatePixel } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ pixelId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { pixelId } = await params;
+
+ if (!(await canViewPixel(auth, pixelId))) {
+ return unauthorized();
+ }
+
+ const pixel = await getPixel(pixelId);
+
+ return json(pixel);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ pixelId: string }> }) {
+ const schema = z.object({
+ name: z.string().optional(),
+ slug: z.string().min(8).optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { pixelId } = await params;
+ const { name, slug } = body;
+
+ if (!(await canUpdatePixel(auth, pixelId))) {
+ return unauthorized();
+ }
+
+ try {
+ const pixel = await updatePixel(pixelId, { name, slug });
+
+ return Response.json(pixel);
+ } catch (e: any) {
+ if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) {
+ return badRequest({ message: 'That slug is already taken.' });
+ }
+
+ return serverError(e);
+ }
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ pixelId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { pixelId } = await params;
+
+ if (!(await canDeletePixel(auth, pixelId))) {
+ return unauthorized();
+ }
+
+ await deletePixel(pixelId);
+
+ return ok();
+}
diff --git a/src/app/api/pixels/route.ts b/src/app/api/pixels/route.ts
new file mode 100644
index 0000000..8baae4f
--- /dev/null
+++ b/src/app/api/pixels/route.ts
@@ -0,0 +1,62 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
+import { createPixel, getUserPixels } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const links = await getUserPixels(auth.user.id, filters);
+
+ return json(links);
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().max(100),
+ slug: z.string().max(100),
+ teamId: z.string().nullable().optional(),
+ id: z.uuid().nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { id, name, slug, teamId } = body;
+
+ if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
+ return unauthorized();
+ }
+
+ const data: any = {
+ id: id ?? uuid(),
+ name,
+ slug,
+ teamId,
+ };
+
+ if (!teamId) {
+ data.userId = auth.user.id;
+ }
+
+ const result = await createPixel(data);
+
+ return json(result);
+}
diff --git a/src/app/api/realtime/[websiteId]/route.ts b/src/app/api/realtime/[websiteId]/route.ts
new file mode 100644
index 0000000..32b7a16
--- /dev/null
+++ b/src/app/api/realtime/[websiteId]/route.ts
@@ -0,0 +1,36 @@
+import { startOfMinute, subMinutes } from 'date-fns';
+import { REALTIME_RANGE } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getRealtimeData } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, query, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(
+ {
+ ...query,
+ startAt: subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(),
+ endAt: Date.now(),
+ },
+ websiteId,
+ );
+
+ const data = await getRealtimeData(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/[reportId]/route.ts b/src/app/api/reports/[reportId]/route.ts
new file mode 100644
index 0000000..1f22c62
--- /dev/null
+++ b/src/app/api/reports/[reportId]/route.ts
@@ -0,0 +1,80 @@
+import { parseRequest } from '@/lib/request';
+import { json, notFound, ok, unauthorized } from '@/lib/response';
+import { reportSchema } from '@/lib/schema';
+import { canDeleteWebsite, canUpdateWebsite, canViewReport } from '@/permissions';
+import { deleteReport, getReport, updateReport } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ reportId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { reportId } = await params;
+
+ const report = await getReport(reportId);
+
+ if (!(await canViewReport(auth, report))) {
+ return unauthorized();
+ }
+
+ return json(report);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ reportId: string }> },
+) {
+ const { auth, body, error } = await parseRequest(request, reportSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { reportId } = await params;
+ const { websiteId, type, name, description, parameters } = body;
+
+ const report = await getReport(reportId);
+
+ if (!report) {
+ return notFound();
+ }
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const result = await updateReport(reportId, {
+ websiteId,
+ userId: auth.user.id,
+ type,
+ name,
+ description,
+ parameters,
+ } as any);
+
+ return json(result);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ reportId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { reportId } = await params;
+ const report = await getReport(reportId);
+
+ if (!(await canDeleteWebsite(auth, report.websiteId))) {
+ return unauthorized();
+ }
+
+ await deleteReport(reportId);
+
+ return ok();
+}
diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts
new file mode 100644
index 0000000..bd7d86d
--- /dev/null
+++ b/src/app/api/reports/attribution/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { type AttributionParameters, getAttribution } from '@/queries/sql/reports/getAttribution';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getAttribution(websiteId, parameters as AttributionParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/breakdown/route.ts b/src/app/api/reports/breakdown/route.ts
new file mode 100644
index 0000000..3c59314
--- /dev/null
+++ b/src/app/api/reports/breakdown/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { type BreakdownParameters, getBreakdown } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/funnel/route.ts b/src/app/api/reports/funnel/route.ts
new file mode 100644
index 0000000..c13f6f1
--- /dev/null
+++ b/src/app/api/reports/funnel/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { type FunnelParameters, getFunnel } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getFunnel(websiteId, parameters as FunnelParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/goal/route.ts b/src/app/api/reports/goal/route.ts
new file mode 100644
index 0000000..3bd0415
--- /dev/null
+++ b/src/app/api/reports/goal/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { type GoalParameters, getGoal } from '@/queries/sql/reports/getGoal';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getGoal(websiteId, parameters as GoalParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts
new file mode 100644
index 0000000..29e8531
--- /dev/null
+++ b/src/app/api/reports/journey/route.ts
@@ -0,0 +1,25 @@
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getJourney } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, parameters, filters } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const queryFilters = await getQueryFilters(filters, websiteId);
+
+ const data = await getJourney(websiteId, parameters, queryFilters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/retention/route.ts b/src/app/api/reports/retention/route.ts
new file mode 100644
index 0000000..d1a7d69
--- /dev/null
+++ b/src/app/api/reports/retention/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getRetention, type RetentionParameters } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(body.filters, websiteId);
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+
+ const data = await getRetention(websiteId, parameters as RetentionParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/revenue/route.ts b/src/app/api/reports/revenue/route.ts
new file mode 100644
index 0000000..6a55661
--- /dev/null
+++ b/src/app/api/reports/revenue/route.ts
@@ -0,0 +1,26 @@
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+ const filters = await getQueryFilters(body.filters, websiteId);
+
+ const data = await getRevenue(websiteId, parameters as RevenuParameters, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts
new file mode 100644
index 0000000..b0a4135
--- /dev/null
+++ b/src/app/api/reports/route.ts
@@ -0,0 +1,73 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, reportSchema, reportTypeParam } from '@/lib/schema';
+import { canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { createReport, getReports } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ websiteId: z.uuid(),
+ type: reportTypeParam.optional(),
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { page, search, pageSize, websiteId, type } = query;
+ const filters = {
+ page,
+ pageSize,
+ search,
+ };
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getReports(
+ {
+ where: {
+ websiteId,
+ type,
+ website: {
+ deletedAt: null,
+ },
+ },
+ },
+ filters,
+ );
+
+ return json(data);
+}
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, type, name, description, parameters } = body;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const result = await createReport({
+ id: uuid(),
+ userId: auth.user.id,
+ websiteId,
+ type,
+ name,
+ description: description || '',
+ parameters,
+ });
+
+ return json(result);
+}
diff --git a/src/app/api/reports/utm/route.ts b/src/app/api/reports/utm/route.ts
new file mode 100644
index 0000000..577fdab
--- /dev/null
+++ b/src/app/api/reports/utm/route.ts
@@ -0,0 +1,37 @@
+import { UTM_PARAMS } from '@/lib/constants';
+import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { reportResultSchema } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getUTM, type UTMParameters } from '@/queries/sql';
+
+export async function POST(request: Request) {
+ const { auth, body, error } = await parseRequest(request, reportResultSchema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(body.filters, websiteId);
+ const parameters = await setWebsiteDate(websiteId, body.parameters);
+
+ const data = {
+ utm_source: [],
+ utm_medium: [],
+ utm_campaign: [],
+ utm_term: [],
+ utm_content: [],
+ };
+
+ for (const key of UTM_PARAMS) {
+ data[key] = await getUTM(websiteId, { column: key, ...parameters } as UTMParameters, filters);
+ }
+
+ return json(data);
+}
diff --git a/src/app/api/scripts/telemetry/route.ts b/src/app/api/scripts/telemetry/route.ts
new file mode 100644
index 0000000..b19e99f
--- /dev/null
+++ b/src/app/api/scripts/telemetry/route.ts
@@ -0,0 +1,28 @@
+import { CURRENT_VERSION, TELEMETRY_PIXEL } from '@/lib/constants';
+
+export async function GET() {
+ if (
+ process.env.NODE_ENV !== 'production' ||
+ process.env.DISABLE_TELEMETRY ||
+ process.env.PRIVATE_MODE
+ ) {
+ return new Response('/* telemetry disabled */', {
+ headers: {
+ 'content-type': 'text/javascript',
+ },
+ });
+ }
+
+ const script = `
+ (()=>{const i=document.createElement('img');
+ i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}');
+ i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;');
+ document.body.appendChild(i);})();
+ `;
+
+ return new Response(script.replace(/\s\s+/g, ''), {
+ headers: {
+ 'content-type': 'text/javascript',
+ },
+ });
+}
diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts
new file mode 100644
index 0000000..a0becc2
--- /dev/null
+++ b/src/app/api/send/route.ts
@@ -0,0 +1,284 @@
+import { startOfHour, startOfMonth } from 'date-fns';
+import { isbot } from 'isbot';
+import { serializeError } from 'serialize-error';
+import { z } from 'zod';
+import clickhouse from '@/lib/clickhouse';
+import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants';
+import { hash, secret, uuid } from '@/lib/crypto';
+import { getClientInfo, hasBlockedIp } from '@/lib/detect';
+import { createToken, parseToken } from '@/lib/jwt';
+import { fetchWebsite } from '@/lib/load';
+import { parseRequest } from '@/lib/request';
+import { badRequest, forbidden, json, serverError } from '@/lib/response';
+import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
+import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
+import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
+
+interface Cache {
+ websiteId: string;
+ sessionId: string;
+ visitId: string;
+ iat: number;
+}
+
+const schema = z.object({
+ type: z.enum(['event', 'identify']),
+ payload: z
+ .object({
+ website: z.uuid().optional(),
+ link: z.uuid().optional(),
+ pixel: z.uuid().optional(),
+ data: anyObjectParam.optional(),
+ hostname: z.string().max(100).optional(),
+ language: z.string().max(35).optional(),
+ referrer: urlOrPathParam.optional(),
+ screen: z.string().max(11).optional(),
+ title: z.string().optional(),
+ url: urlOrPathParam.optional(),
+ name: z.string().max(50).optional(),
+ tag: z.string().max(50).optional(),
+ ip: z.string().optional(),
+ userAgent: z.string().optional(),
+ timestamp: z.coerce.number().int().optional(),
+ id: z.string().optional(),
+ browser: z.string().optional(),
+ os: z.string().optional(),
+ device: z.string().optional(),
+ })
+ .refine(
+ data => {
+ const keys = [data.website, data.link, data.pixel];
+ const count = keys.filter(Boolean).length;
+ return count === 1;
+ },
+ {
+ message: 'Exactly one of website, link, or pixel must be provided',
+ path: ['website'],
+ },
+ ),
+});
+
+export async function POST(request: Request) {
+ try {
+ const { body, error } = await parseRequest(request, schema, { skipAuth: true });
+
+ if (error) {
+ return error();
+ }
+
+ const { type, payload } = body;
+
+ const {
+ website: websiteId,
+ pixel: pixelId,
+ link: linkId,
+ hostname,
+ screen,
+ language,
+ url,
+ referrer,
+ name,
+ data,
+ title,
+ tag,
+ timestamp,
+ id,
+ } = payload;
+
+ const sourceId = websiteId || pixelId || linkId;
+
+ // Cache check
+ let cache: Cache | null = null;
+
+ if (websiteId) {
+ const cacheHeader = request.headers.get('x-umami-cache');
+
+ if (cacheHeader) {
+ const result = await parseToken(cacheHeader, secret());
+
+ if (result) {
+ cache = result;
+ }
+ }
+
+ // Find website
+ if (!cache?.websiteId) {
+ const website = await fetchWebsite(websiteId);
+
+ if (!website) {
+ return badRequest({ message: 'Website not found.' });
+ }
+ }
+ }
+
+ // Client info
+ const { ip, userAgent, device, browser, os, country, region, city } = await getClientInfo(
+ request,
+ payload,
+ );
+
+ // Bot check
+ if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
+ return json({ beep: 'boop' });
+ }
+
+ // IP block
+ if (hasBlockedIp(ip)) {
+ return forbidden();
+ }
+
+ const createdAt = timestamp ? new Date(timestamp * 1000) : new Date();
+ const now = Math.floor(Date.now() / 1000);
+
+ const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
+ const visitSalt = hash(startOfHour(createdAt).toUTCString());
+
+ const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt);
+
+ // Create a session if not found
+ if (!clickhouse.enabled && !cache?.sessionId) {
+ await createSession({
+ id: sessionId,
+ websiteId: sourceId,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ distinctId: id,
+ createdAt,
+ });
+ }
+
+ // Visit info
+ let visitId = cache?.visitId || uuid(sessionId, visitSalt);
+ let iat = cache?.iat || now;
+
+ // Expire visit after 30 minutes
+ if (!timestamp && now - iat > 1800) {
+ visitId = uuid(sessionId, visitSalt);
+ iat = now;
+ }
+
+ if (type === COLLECTION_TYPE.event) {
+ const base = hostname ? `https://${hostname}` : 'https://localhost';
+ const currentUrl = new URL(url, base);
+
+ let urlPath =
+ currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash;
+ const urlQuery = currentUrl.search.substring(1);
+ const urlDomain = currentUrl.hostname.replace(/^www./, '');
+
+ let referrerPath: string;
+ let referrerQuery: string;
+ let referrerDomain: string;
+
+ // UTM Params
+ const utmSource = currentUrl.searchParams.get('utm_source');
+ const utmMedium = currentUrl.searchParams.get('utm_medium');
+ const utmCampaign = currentUrl.searchParams.get('utm_campaign');
+ const utmContent = currentUrl.searchParams.get('utm_content');
+ const utmTerm = currentUrl.searchParams.get('utm_term');
+
+ // Click IDs
+ const gclid = currentUrl.searchParams.get('gclid');
+ const fbclid = currentUrl.searchParams.get('fbclid');
+ const msclkid = currentUrl.searchParams.get('msclkid');
+ const ttclid = currentUrl.searchParams.get('ttclid');
+ const lifatid = currentUrl.searchParams.get('li_fat_id');
+ const twclid = currentUrl.searchParams.get('twclid');
+
+ if (process.env.REMOVE_TRAILING_SLASH) {
+ urlPath = urlPath.replace(/\/(?=(#.*)?$)/, '');
+ }
+
+ if (referrer) {
+ const referrerUrl = new URL(referrer, base);
+
+ referrerPath = referrerUrl.pathname;
+ referrerQuery = referrerUrl.search.substring(1);
+ referrerDomain = referrerUrl.hostname.replace(/^www\./, '');
+ }
+
+ const eventType = linkId
+ ? EVENT_TYPE.linkEvent
+ : pixelId
+ ? EVENT_TYPE.pixelEvent
+ : name
+ ? EVENT_TYPE.customEvent
+ : EVENT_TYPE.pageView;
+
+ await saveEvent({
+ websiteId: sourceId,
+ sessionId,
+ visitId,
+ eventType,
+ createdAt,
+
+ // Page
+ pageTitle: safeDecodeURIComponent(title),
+ hostname: hostname || urlDomain,
+ urlPath: safeDecodeURI(urlPath),
+ urlQuery,
+ referrerPath: safeDecodeURI(referrerPath),
+ referrerQuery,
+ referrerDomain,
+
+ // Session
+ distinctId: id,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+
+ // Events
+ eventName: name,
+ eventData: data,
+ tag,
+
+ // UTM
+ utmSource,
+ utmMedium,
+ utmCampaign,
+ utmContent,
+ utmTerm,
+
+ // Click IDs
+ gclid,
+ fbclid,
+ msclkid,
+ ttclid,
+ lifatid,
+ twclid,
+ });
+ } else if (type === COLLECTION_TYPE.identify) {
+ if (data) {
+ await saveSessionData({
+ websiteId,
+ sessionId,
+ sessionData: data,
+ distinctId: id,
+ createdAt,
+ });
+ }
+ }
+
+ const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
+
+ return json({ cache: token, sessionId, visitId });
+ } catch (e) {
+ const error = serializeError(e);
+
+ // eslint-disable-next-line no-console
+ console.log(error);
+
+ return serverError({ errorObject: error });
+ }
+}
diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts
new file mode 100644
index 0000000..bef87c4
--- /dev/null
+++ b/src/app/api/share/[shareId]/route.ts
@@ -0,0 +1,19 @@
+import { secret } from '@/lib/crypto';
+import { createToken } from '@/lib/jwt';
+import { json, notFound } from '@/lib/response';
+import { getSharedWebsite } from '@/queries/prisma';
+
+export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) {
+ const { shareId } = await params;
+
+ const website = await getSharedWebsite(shareId);
+
+ if (!website) {
+ return notFound();
+ }
+
+ const data = { websiteId: website.id };
+ const token = createToken(data, secret());
+
+ return json({ ...data, token });
+}
diff --git a/src/app/api/teams/[teamId]/links/route.ts b/src/app/api/teams/[teamId]/links/route.ts
new file mode 100644
index 0000000..41e139b
--- /dev/null
+++ b/src/app/api/teams/[teamId]/links/route.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewTeam } from '@/permissions';
+import { getTeamLinks } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+ const { teamId } = await params;
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const links = await getTeamLinks(teamId, filters);
+
+ return json(links);
+}
diff --git a/src/app/api/teams/[teamId]/pixels/route.ts b/src/app/api/teams/[teamId]/pixels/route.ts
new file mode 100644
index 0000000..daac204
--- /dev/null
+++ b/src/app/api/teams/[teamId]/pixels/route.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewTeam } from '@/permissions';
+import { getTeamPixels } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+ const { teamId } = await params;
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const websites = await getTeamPixels(teamId, filters);
+
+ return json(websites);
+}
diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts
new file mode 100644
index 0000000..c334b2a
--- /dev/null
+++ b/src/app/api/teams/[teamId]/route.ts
@@ -0,0 +1,71 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, notFound, ok, unauthorized } from '@/lib/response';
+import { canDeleteTeam, canUpdateTeam, canViewTeam } from '@/permissions';
+import { deleteTeam, getTeam, updateTeam } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const team = await getTeam(teamId, { includeMembers: true });
+
+ if (!team) {
+ return notFound({ message: 'Team not found.' });
+ }
+
+ return json(team);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ name: z.string().max(50).optional(),
+ accessCode: z.string().max(50).optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const team = await updateTeam(teamId, body);
+
+ return json(team);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ teamId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canDeleteTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ await deleteTeam(teamId);
+
+ return ok();
+}
diff --git a/src/app/api/teams/[teamId]/users/[userId]/route.ts b/src/app/api/teams/[teamId]/users/[userId]/route.ts
new file mode 100644
index 0000000..d09af9d
--- /dev/null
+++ b/src/app/api/teams/[teamId]/users/[userId]/route.ts
@@ -0,0 +1,85 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, unauthorized } from '@/lib/response';
+import { teamRoleParam } from '@/lib/schema';
+import { canDeleteTeamUser, canUpdateTeam } from '@/permissions';
+import { deleteTeamUser, getTeamUser, updateTeamUser } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ teamId: string; userId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId, userId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ return json(teamUser);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ teamId: string; userId: string }> },
+) {
+ const schema = z.object({
+ role: teamRoleParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId, userId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (!teamUser) {
+ return badRequest({ message: 'The User does not exists on this team.' });
+ }
+
+ const user = await updateTeamUser(teamUser.id, body);
+
+ return json(user);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ teamId: string; userId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId, userId } = await params;
+
+ if (!(await canDeleteTeamUser(auth, teamId, userId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (!teamUser) {
+ return badRequest({ message: 'The User does not exists on this team.' });
+ }
+
+ await deleteTeamUser(teamId, userId);
+
+ return ok();
+}
diff --git a/src/app/api/teams/[teamId]/users/route.ts b/src/app/api/teams/[teamId]/users/route.ts
new file mode 100644
index 0000000..c129763
--- /dev/null
+++ b/src/app/api/teams/[teamId]/users/route.ts
@@ -0,0 +1,83 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams, teamRoleParam } from '@/lib/schema';
+import { canUpdateTeam, canViewTeam } from '@/permissions';
+import { createTeamUser, getTeamUser, getTeamUsers } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be a member of this team.' });
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const users = await getTeamUsers(
+ {
+ where: {
+ teamId,
+ user: {
+ deletedAt: null,
+ },
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'asc',
+ },
+ },
+ filters,
+ );
+
+ return json(users);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ userId: z.uuid(),
+ role: teamRoleParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { teamId } = await params;
+
+ if (!(await canUpdateTeam(auth, teamId))) {
+ return unauthorized({ message: 'You must be the owner/manager of this team.' });
+ }
+
+ const { userId, role } = body;
+
+ const teamUser = await getTeamUser(teamId, userId);
+
+ if (teamUser) {
+ return badRequest({ message: 'User is already a member of the Team.' });
+ }
+
+ const users = await createTeamUser(userId, teamId, role);
+
+ return json(users);
+}
diff --git a/src/app/api/teams/[teamId]/websites/route.ts b/src/app/api/teams/[teamId]/websites/route.ts
new file mode 100644
index 0000000..05c6d80
--- /dev/null
+++ b/src/app/api/teams/[teamId]/websites/route.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canViewTeam } from '@/permissions';
+import { getTeamWebsites } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ });
+ const { teamId } = await params;
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canViewTeam(auth, teamId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const websites = await getTeamWebsites(teamId, filters);
+
+ return json(websites);
+}
diff --git a/src/app/api/teams/join/route.ts b/src/app/api/teams/join/route.ts
new file mode 100644
index 0000000..3ce0913
--- /dev/null
+++ b/src/app/api/teams/join/route.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import { ROLES } from '@/lib/constants';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, notFound } from '@/lib/response';
+import { createTeamUser, findTeam, getTeamUser } from '@/queries/prisma';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ accessCode: z.string().max(50),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { accessCode } = body;
+
+ const team = await findTeam({
+ where: {
+ accessCode,
+ },
+ });
+
+ if (!team) {
+ return notFound({ message: 'Team not found.', code: 'team-not-found' });
+ }
+
+ const teamUser = await getTeamUser(team.id, auth.user.id);
+
+ if (teamUser) {
+ return badRequest({ message: 'User is already a team member.' });
+ }
+
+ const user = await createTeamUser(auth.user.id, team.id, ROLES.teamMember);
+
+ return json(user);
+}
diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts
new file mode 100644
index 0000000..53ef592
--- /dev/null
+++ b/src/app/api/teams/route.ts
@@ -0,0 +1,55 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { getRandomChars } from '@/lib/generate';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { canCreateTeam } from '@/permissions';
+import { createTeam, getUserTeams } from '@/queries/prisma';
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const teams = await getUserTeams(auth.user.id, filters);
+
+ return json(teams);
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().max(50),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canCreateTeam(auth))) {
+ return unauthorized();
+ }
+
+ const { name } = body;
+
+ const team = await createTeam(
+ {
+ id: uuid(),
+ name,
+ accessCode: `team_${getRandomChars(16)}`,
+ },
+ auth.user.id,
+ );
+
+ return json(team);
+}
diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts
new file mode 100644
index 0000000..aade8aa
--- /dev/null
+++ b/src/app/api/users/[userId]/route.ts
@@ -0,0 +1,102 @@
+import { z } from 'zod';
+import { hashPassword } from '@/lib/password';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, unauthorized } from '@/lib/response';
+import { userRoleParam } from '@/lib/schema';
+import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions';
+import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (!(await canViewUser(auth, userId))) {
+ return unauthorized();
+ }
+
+ const user = await getUser(userId);
+
+ return json(user);
+}
+
+export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const schema = z.object({
+ username: z.string().max(255).optional(),
+ password: z.string().max(255).optional(),
+ role: userRoleParam.optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (!(await canUpdateUser(auth, userId))) {
+ return unauthorized();
+ }
+
+ const { username, password, role } = body;
+
+ const user = await getUser(userId);
+
+ const data: any = {};
+
+ if (password) {
+ data.password = hashPassword(password);
+ }
+
+ // Only admin can change these fields
+ if (role && auth.user.isAdmin) {
+ data.role = role;
+ }
+
+ if (username && auth.user.isAdmin) {
+ data.username = username;
+ }
+
+ // Check when username changes
+ if (data.username && user.username !== data.username) {
+ const user = await getUserByUsername(username);
+
+ if (user) {
+ return badRequest({ message: 'User already exists' });
+ }
+ }
+
+ const updated = await updateUser(userId, data);
+
+ return json(updated);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ userId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (!(await canDeleteUser(auth))) {
+ return unauthorized();
+ }
+
+ if (userId === auth.user.id) {
+ return badRequest({ message: 'You cannot delete yourself.' });
+ }
+
+ await deleteUser(userId);
+
+ return ok();
+}
diff --git a/src/app/api/users/[userId]/teams/route.ts b/src/app/api/users/[userId]/teams/route.ts
new file mode 100644
index 0000000..7a834a3
--- /dev/null
+++ b/src/app/api/users/[userId]/teams/route.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams } from '@/lib/schema';
+import { getUserTeams } from '@/queries/prisma';
+
+export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (auth.user.id !== userId && !auth.user.isAdmin) {
+ return unauthorized();
+ }
+
+ const teams = await getUserTeams(userId, query);
+
+ return json(teams);
+}
diff --git a/src/app/api/users/[userId]/websites/route.ts b/src/app/api/users/[userId]/websites/route.ts
new file mode 100644
index 0000000..1107d8e
--- /dev/null
+++ b/src/app/api/users/[userId]/websites/route.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
+
+export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ includeTeams: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { userId } = await params;
+
+ if (!auth.user.isAdmin && auth.user.id !== userId) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ if (query.includeTeams) {
+ return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters));
+ }
+
+ return json(await getUserWebsites(userId, filters));
+}
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
new file mode 100644
index 0000000..dbb114c
--- /dev/null
+++ b/src/app/api/users/route.ts
@@ -0,0 +1,44 @@
+import { z } from 'zod';
+import { ROLES } from '@/lib/constants';
+import { uuid } from '@/lib/crypto';
+import { hashPassword } from '@/lib/password';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { canCreateUser } from '@/permissions';
+import { createUser, getUserByUsername } from '@/queries/prisma';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ id: z.uuid().optional(),
+ username: z.string().max(255),
+ password: z.string(),
+ role: z.string().regex(/admin|user|view-only/i),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ if (!(await canCreateUser(auth))) {
+ return unauthorized();
+ }
+
+ const { id, username, password, role } = body;
+
+ const existingUser = await getUserByUsername(username, { showDeleted: true });
+
+ if (existingUser) {
+ return badRequest({ message: 'User already exists' });
+ }
+
+ const user = await createUser({
+ id: id || uuid(),
+ username,
+ password: hashPassword(password),
+ role: role ?? ROLES.user,
+ });
+
+ return json(user);
+}
diff --git a/src/app/api/websites/[websiteId]/active/route.ts b/src/app/api/websites/[websiteId]/active/route.ts
new file mode 100644
index 0000000..233b97e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/active/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getActiveVisitors } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const visitors = await getActiveVisitors(websiteId);
+
+ return json(visitors);
+}
diff --git a/src/app/api/websites/[websiteId]/daterange/route.ts b/src/app/api/websites/[websiteId]/daterange/route.ts
new file mode 100644
index 0000000..14a241f
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/daterange/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteDateRange } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const dateRange = await getWebsiteDateRange(websiteId);
+
+ return json(dateRange);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts
new file mode 100644
index 0000000..54afab2
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getEventData } from '@/queries/sql/events/getEventData';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; eventId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, eventId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getEventData(websiteId, eventId);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/events/route.ts b/src/app/api/websites/[websiteId]/event-data/events/route.ts
new file mode 100644
index 0000000..eb6ee6e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/events/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataEvents } from '@/queries/sql/events/getEventDataEvents';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ event: z.string().optional(),
+ ...filterParams,
+ });
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataEvents(websiteId, {
+ ...filters,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/fields/route.ts b/src/app/api/websites/[websiteId]/event-data/fields/route.ts
new file mode 100644
index 0000000..bce6a97
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/fields/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataFields } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataFields(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/properties/route.ts b/src/app/api/websites/[websiteId]/event-data/properties/route.ts
new file mode 100644
index 0000000..52d15cf
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/properties/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataProperties } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataProperties(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/stats/route.ts b/src/app/api/websites/[websiteId]/event-data/stats/route.ts
new file mode 100644
index 0000000..042e989
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/stats/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataStats(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data/values/route.ts b/src/app/api/websites/[websiteId]/event-data/values/route.ts
new file mode 100644
index 0000000..12e8f2d
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data/values/route.ts
@@ -0,0 +1,41 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataValues } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ event: z.string(),
+ propertyName: z.string(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { propertyName } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventDataValues(websiteId, {
+ ...filters,
+ propertyName,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/events/route.ts b/src/app/api/websites/[websiteId]/events/route.ts
new file mode 100644
index 0000000..74ec3ec
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/events/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, pagingParams, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteEvents } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().optional(),
+ endAt: z.coerce.number().optional(),
+ ...filterParams,
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getWebsiteEvents(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/events/series/route.ts b/src/app/api/websites/[websiteId]/events/series/route.ts
new file mode 100644
index 0000000..977e9c8
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/events/series/route.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ unit: unitParam.optional(),
+ timezone: timezoneParam,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getEventStats(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/export/route.ts b/src/app/api/websites/[websiteId]/export/route.ts
new file mode 100644
index 0000000..eec81c6
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/export/route.ts
@@ -0,0 +1,64 @@
+import JSZip from 'jszip';
+import Papa from 'papaparse';
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { dateRangeParams, pagingParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...dateRangeParams,
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([
+ getEventMetrics(websiteId, { type: 'event' }, filters),
+ getPageviewMetrics(websiteId, { type: 'path' }, filters),
+ getPageviewMetrics(websiteId, { type: 'referrer' }, filters),
+ getSessionMetrics(websiteId, { type: 'browser' }, filters),
+ getSessionMetrics(websiteId, { type: 'os' }, filters),
+ getSessionMetrics(websiteId, { type: 'device' }, filters),
+ getSessionMetrics(websiteId, { type: 'country' }, filters),
+ ]);
+
+ const zip = new JSZip();
+
+ const parse = (data: any) => {
+ return Papa.unparse(data, {
+ header: true,
+ skipEmptyLines: true,
+ });
+ };
+
+ zip.file('events.csv', parse(events));
+ zip.file('pages.csv', parse(pages));
+ zip.file('referrers.csv', parse(referrers));
+ zip.file('browsers.csv', parse(browsers));
+ zip.file('os.csv', parse(os));
+ zip.file('devices.csv', parse(devices));
+ zip.file('countries.csv', parse(countries));
+
+ const content = await zip.generateAsync({ type: 'nodebuffer' });
+ const base64 = content.toString('base64');
+
+ return json({ zip: base64 });
+}
diff --git a/src/app/api/websites/[websiteId]/metrics/expanded/route.ts b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts
new file mode 100644
index 0000000..d52c177
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts
@@ -0,0 +1,66 @@
+import { z } from 'zod';
+import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import {
+ getChannelExpandedMetrics,
+ getEventExpandedMetrics,
+ getPageviewExpandedMetrics,
+ getSessionExpandedMetrics,
+} from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: z.string(),
+ limit: z.coerce.number().optional(),
+ offset: z.coerce.number().optional(),
+ ...dateRangeParams,
+ ...searchParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { type, limit, offset, search } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ if (search) {
+ filters[type] = `c.${search}`;
+ }
+
+ if (SESSION_COLUMNS.includes(type)) {
+ const data = await getSessionExpandedMetrics(websiteId, { type, limit, offset }, filters);
+
+ return json(data);
+ }
+
+ if (EVENT_COLUMNS.includes(type)) {
+ if (type === 'event') {
+ filters.eventType = EVENT_TYPE.customEvent;
+ return json(await getEventExpandedMetrics(websiteId, { type, limit, offset }, filters));
+ } else {
+ return json(await getPageviewExpandedMetrics(websiteId, { type, limit, offset }, filters));
+ }
+ }
+
+ if (type === 'channel') {
+ return json(await getChannelExpandedMetrics(websiteId, filters));
+ }
+
+ return badRequest();
+}
diff --git a/src/app/api/websites/[websiteId]/metrics/route.ts b/src/app/api/websites/[websiteId]/metrics/route.ts
new file mode 100644
index 0000000..12784ad
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/metrics/route.ts
@@ -0,0 +1,66 @@
+import { z } from 'zod';
+import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import {
+ getChannelMetrics,
+ getEventMetrics,
+ getPageviewMetrics,
+ getSessionMetrics,
+} from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: z.string(),
+ limit: z.coerce.number().optional(),
+ offset: z.coerce.number().optional(),
+ ...dateRangeParams,
+ ...searchParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { type, limit, offset, search } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ if (search) {
+ filters[type] = `c.${search}`;
+ }
+
+ if (SESSION_COLUMNS.includes(type)) {
+ const data = await getSessionMetrics(websiteId, { type, limit, offset }, filters);
+
+ return json(data);
+ }
+
+ if (EVENT_COLUMNS.includes(type)) {
+ if (type === 'event') {
+ filters.eventType = EVENT_TYPE.customEvent;
+ return json(await getEventMetrics(websiteId, { type, limit, offset }, filters));
+ } else {
+ return json(await getPageviewMetrics(websiteId, { type, limit, offset }, filters));
+ }
+ }
+
+ if (type === 'channel') {
+ return json(await getChannelMetrics(websiteId, filters));
+ }
+
+ return badRequest();
+}
diff --git a/src/app/api/websites/[websiteId]/pageviews/route.ts b/src/app/api/websites/[websiteId]/pageviews/route.ts
new file mode 100644
index 0000000..af59bce
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/pageviews/route.ts
@@ -0,0 +1,72 @@
+import { z } from 'zod';
+import { getCompareDate } from '@/lib/date';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getPageviewStats, getSessionStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...dateRangeParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const [pageviews, sessions] = await Promise.all([
+ getPageviewStats(websiteId, filters),
+ getSessionStats(websiteId, filters),
+ ]);
+
+ if (filters.compare) {
+ const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate(
+ filters.compare,
+ filters.startDate,
+ filters.endDate,
+ );
+
+ const [comparePageviews, compareSessions] = await Promise.all([
+ getPageviewStats(websiteId, {
+ ...filters,
+ startDate: compareStartDate,
+ endDate: compareEndDate,
+ }),
+ getSessionStats(websiteId, {
+ ...filters,
+ startDate: compareStartDate,
+ endDate: compareEndDate,
+ }),
+ ]);
+
+ return json({
+ pageviews,
+ sessions,
+ startDate: filters.startDate,
+ endDate: filters.endDate,
+ compare: {
+ pageviews: comparePageviews,
+ sessions: compareSessions,
+ startDate: compareStartDate,
+ endDate: compareEndDate,
+ },
+ });
+ }
+
+ return json({ pageviews, sessions });
+}
diff --git a/src/app/api/websites/[websiteId]/reports/route.ts b/src/app/api/websites/[websiteId]/reports/route.ts
new file mode 100644
index 0000000..93e7ab4
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/reports/route.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, pagingParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getReports } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+ filters: { type: string },
+) {
+ const schema = z.object({
+ ...filterParams,
+ ...pagingParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { page, pageSize, search } = query;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getReports(
+ {
+ where: {
+ websiteId,
+ type: filters.type,
+ },
+ },
+ {
+ page,
+ pageSize,
+ search,
+ },
+ );
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/reset/route.ts b/src/app/api/websites/[websiteId]/reset/route.ts
new file mode 100644
index 0000000..e0be5a5
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/reset/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { ok, unauthorized } from '@/lib/response';
+import { canUpdateWebsite } from '@/permissions';
+import { resetWebsite } from '@/queries/prisma';
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ await resetWebsite(websiteId);
+
+ return ok();
+}
diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts
new file mode 100644
index 0000000..b4c0e7e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/route.ts
@@ -0,0 +1,84 @@
+import { z } from 'zod';
+import { SHARE_ID_REGEX } from '@/lib/constants';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
+import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const website = await getWebsite(websiteId);
+
+ return json(website);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ name: z.string().optional(),
+ domain: z.string().optional(),
+ shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { name, domain, shareId } = body;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ try {
+ const website = await updateWebsite(websiteId, { name, domain, shareId });
+
+ return Response.json(website);
+ } catch (e: any) {
+ if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) {
+ return badRequest({ message: 'That share ID is already taken.' });
+ }
+
+ return serverError(e);
+ }
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canDeleteWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ await deleteWebsite(websiteId);
+
+ return ok();
+}
diff --git a/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts
new file mode 100644
index 0000000..b51f783
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts
@@ -0,0 +1,92 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { json, notFound, ok, unauthorized } from '@/lib/response';
+import { anyObjectParam, segmentTypeParam } from '@/lib/schema';
+import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { deleteSegment, getSegment, updateSegment } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; segmentId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, segmentId } = await params;
+
+ const segment = await getSegment(segmentId);
+
+ if (websiteId && !(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ return json(segment);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; segmentId: string }> },
+) {
+ const schema = z.object({
+ type: segmentTypeParam,
+ name: z.string().max(200),
+ parameters: anyObjectParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, segmentId } = await params;
+ const { type, name, parameters } = body;
+
+ const segment = await getSegment(segmentId);
+
+ if (!segment) {
+ return notFound();
+ }
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const result = await updateSegment(segmentId, {
+ type,
+ name,
+ parameters,
+ } as any);
+
+ return json(result);
+}
+
+export async function DELETE(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; segmentId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, segmentId } = await params;
+
+ const segment = await getSegment(segmentId);
+
+ if (!segment) {
+ return notFound();
+ }
+
+ if (!(await canDeleteWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ await deleteSegment(segmentId);
+
+ return ok();
+}
diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts
new file mode 100644
index 0000000..4592765
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/segments/route.ts
@@ -0,0 +1,70 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema';
+import { canUpdateWebsite, canViewWebsite } from '@/permissions';
+import { createSegment, getWebsiteSegments } from '@/queries/prisma';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: segmentTypeParam,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { type } = query;
+
+ if (websiteId && !(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query);
+
+ const segments = await getWebsiteSegments(websiteId, type, filters);
+
+ return json(segments);
+}
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: segmentTypeParam,
+ name: z.string().max(200),
+ parameters: anyObjectParam,
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { type, name, parameters } = body;
+
+ if (!(await canUpdateWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const result = await createSegment({
+ id: uuid(),
+ websiteId,
+ type,
+ name,
+ parameters,
+ } as any);
+
+ return json(result);
+}
diff --git a/src/app/api/websites/[websiteId]/session-data/properties/route.ts b/src/app/api/websites/[websiteId]/session-data/properties/route.ts
new file mode 100644
index 0000000..2d8db15
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/session-data/properties/route.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getSessionDataProperties } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getSessionDataProperties(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/session-data/values/route.ts b/src/app/api/websites/[websiteId]/session-data/values/route.ts
new file mode 100644
index 0000000..7d06870
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/session-data/values/route.ts
@@ -0,0 +1,40 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getSessionDataValues } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ propertyName: z.string().optional(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { propertyName } = query;
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getSessionDataValues(websiteId, {
+ ...filters,
+ propertyName,
+ });
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts
new file mode 100644
index 0000000..41b766d
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts
@@ -0,0 +1,33 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getSessionActivity } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; sessionId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, sessionId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getSessionActivity(websiteId, sessionId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts
new file mode 100644
index 0000000..6b5c241
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getSessionData } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; sessionId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, sessionId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getSessionData(websiteId, sessionId);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts
new file mode 100644
index 0000000..1091663
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts
@@ -0,0 +1,25 @@
+import { parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteSession } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string; sessionId: string }> },
+) {
+ const { auth, error } = await parseRequest(request);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId, sessionId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getWebsiteSession(websiteId, sessionId);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/route.ts b/src/app/api/websites/[websiteId]/sessions/route.ts
new file mode 100644
index 0000000..ed4757a
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams, pagingParams, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteSessions } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...dateRangeParams,
+ ...filterParams,
+ ...pagingParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getWebsiteSessions(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/stats/route.ts b/src/app/api/websites/[websiteId]/sessions/stats/route.ts
new file mode 100644
index 0000000..459830e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/stats/route.ts
@@ -0,0 +1,42 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteSessionStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const metrics = await getWebsiteSessionStats(websiteId, filters);
+
+ const data = Object.keys(metrics[0]).reduce((obj, key) => {
+ obj[key] = {
+ value: Number(metrics[0][key]) || 0,
+ };
+ return obj;
+ }, {});
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/sessions/weekly/route.ts b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts
new file mode 100644
index 0000000..b9ccf3e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts
@@ -0,0 +1,36 @@
+import { z } from 'zod';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams, timezoneParam } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWeeklyTraffic } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ startAt: z.coerce.number().int(),
+ endAt: z.coerce.number().int(),
+ timezone: timezoneParam,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getWeeklyTraffic(websiteId, filters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/stats/route.ts b/src/app/api/websites/[websiteId]/stats/route.ts
new file mode 100644
index 0000000..07c8b96
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/stats/route.ts
@@ -0,0 +1,43 @@
+import { z } from 'zod';
+import { getCompareDate } from '@/lib/date';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { dateRangeParams, filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteStats } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ ...dateRangeParams,
+ ...filterParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const filters = await getQueryFilters(query, websiteId);
+
+ const data = await getWebsiteStats(websiteId, filters);
+
+ const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate);
+
+ const comparison = await getWebsiteStats(websiteId, {
+ ...filters,
+ startDate,
+ endDate,
+ });
+
+ return json({ ...data, comparison });
+}
diff --git a/src/app/api/websites/[websiteId]/transfer/route.ts b/src/app/api/websites/[websiteId]/transfer/route.ts
new file mode 100644
index 0000000..df2fed2
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/transfer/route.ts
@@ -0,0 +1,50 @@
+import { z } from 'zod';
+import { parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { canTransferWebsiteToTeam, canTransferWebsiteToUser } from '@/permissions';
+import { updateWebsite } from '@/queries/prisma';
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ userId: z.uuid().optional(),
+ teamId: z.uuid().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+ const { userId, teamId } = body;
+
+ if (userId) {
+ if (!(await canTransferWebsiteToUser(auth, websiteId, userId))) {
+ return unauthorized();
+ }
+
+ const website = await updateWebsite(websiteId, {
+ userId,
+ teamId: null,
+ });
+
+ return json(website);
+ } else if (teamId) {
+ if (!(await canTransferWebsiteToTeam(auth, websiteId, teamId))) {
+ return unauthorized();
+ }
+
+ const website = await updateWebsite(websiteId, {
+ userId: null,
+ teamId,
+ });
+
+ return json(website);
+ }
+
+ return badRequest();
+}
diff --git a/src/app/api/websites/[websiteId]/values/route.ts b/src/app/api/websites/[websiteId]/values/route.ts
new file mode 100644
index 0000000..172325e
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/values/route.ts
@@ -0,0 +1,50 @@
+import { z } from 'zod';
+import { EVENT_COLUMNS, FILTER_COLUMNS, SEGMENT_TYPES, SESSION_COLUMNS } from '@/lib/constants';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { badRequest, json, unauthorized } from '@/lib/response';
+import { dateRangeParams, fieldsParam, searchParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getWebsiteSegments } from '@/queries/prisma';
+import { getValues } from '@/queries/sql';
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ websiteId: string }> },
+) {
+ const schema = z.object({
+ type: fieldsParam,
+ ...dateRangeParams,
+ ...searchParams,
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { websiteId } = await params;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const { type } = query;
+
+ if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !SEGMENT_TYPES[type]) {
+ return badRequest();
+ }
+
+ let values: any[];
+
+ if (SEGMENT_TYPES[type]) {
+ values = (await getWebsiteSegments(websiteId, type))?.data?.map(segment => ({
+ value: segment.name,
+ }));
+ } else {
+ const filters = await getQueryFilters(query, websiteId);
+ values = await getValues(websiteId, FILTER_COLUMNS[type], filters);
+ }
+
+ return json(values.filter(n => n).sort());
+}
diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts
new file mode 100644
index 0000000..e2b26c1
--- /dev/null
+++ b/src/app/api/websites/route.ts
@@ -0,0 +1,86 @@
+import { z } from 'zod';
+import { uuid } from '@/lib/crypto';
+import redis from '@/lib/redis';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { pagingParams, searchParams } from '@/lib/schema';
+import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
+import { createWebsite, getWebsiteCount } from '@/queries/prisma';
+import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website';
+
+const CLOUD_WEBSITE_LIMIT = 3;
+
+export async function GET(request: Request) {
+ const schema = z.object({
+ ...pagingParams,
+ ...searchParams,
+ includeTeams: z.string().optional(),
+ });
+
+ const { auth, query, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const userId = auth.user.id;
+
+ const filters = await getQueryFilters(query);
+
+ if (query.includeTeams) {
+ return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters));
+ }
+
+ return json(await getUserWebsites(userId, filters));
+}
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ name: z.string().max(100),
+ domain: z.string().max(500),
+ shareId: z.string().max(50).nullable().optional(),
+ teamId: z.uuid().nullable().optional(),
+ id: z.uuid().nullable().optional(),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const { id, name, domain, shareId, teamId } = body;
+
+ if (process.env.CLOUD_MODE && !teamId) {
+ const account = await redis.client.get(`account:${auth.user.id}`);
+
+ if (!account?.hasSubscription) {
+ const count = await getWebsiteCount(auth.user.id);
+
+ if (count >= CLOUD_WEBSITE_LIMIT) {
+ return unauthorized({ message: 'Website limit reached.' });
+ }
+ }
+ }
+
+ if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
+ return unauthorized();
+ }
+
+ const data: any = {
+ id: id ?? uuid(),
+ createdBy: auth.user.id,
+ name,
+ domain,
+ shareId,
+ teamId,
+ };
+
+ if (!teamId) {
+ data.userId = auth.user.id;
+ }
+
+ const website = await createWebsite(data);
+
+ return json(website);
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..afcbfc6
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,49 @@
+import type { Metadata } from 'next';
+import { Suspense } from 'react';
+import { Providers } from './Providers';
+import '@fontsource/inter/300.css';
+import '@fontsource/inter/400.css';
+import '@fontsource/inter/500.css';
+import '@fontsource/inter/700.css';
+import '@umami/react-zen/styles.css';
+import '@/styles/global.css';
+import '@/styles/variables.css';
+
+export default function ({ children }) {
+ if (process.env.DISABLE_UI) {
+ return (
+ <html>
+ <body></body>
+ </html>
+ );
+ }
+
+ return (
+ <html lang="en">
+ <head>
+ <link rel="icon" href="/favicon.ico" />
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+ <link rel="manifest" href="/site.webmanifest" />
+ <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
+ <meta name="msapplication-TileColor" content="#da532c" />
+ <meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
+ <meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />
+ <meta name="robots" content="noindex,nofollow" />
+ </head>
+ <body>
+ <Suspense>
+ <Providers>{children}</Providers>
+ </Suspense>
+ </body>
+ </html>
+ );
+}
+
+export const metadata: Metadata = {
+ title: {
+ template: '%s | Umami',
+ default: 'Umami',
+ },
+};
diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx
new file mode 100644
index 0000000..26d78dd
--- /dev/null
+++ b/src/app/login/LoginForm.tsx
@@ -0,0 +1,70 @@
+import {
+ Column,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ Heading,
+ Icon,
+ PasswordField,
+ TextField,
+} from '@umami/react-zen';
+import { useRouter } from 'next/navigation';
+import { useMessages, useUpdateQuery } from '@/components/hooks';
+import { Logo } from '@/components/svg';
+import { setClientAuthToken } from '@/lib/client';
+import { setUser } from '@/store/app';
+
+export function LoginForm() {
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+ const router = useRouter();
+ const { mutateAsync, error } = useUpdateQuery('/auth/login');
+
+ const handleSubmit = async (data: any) => {
+ await mutateAsync(data, {
+ onSuccess: async ({ token, user }) => {
+ setClientAuthToken(token);
+ setUser(user);
+ router.push('/');
+ },
+ });
+ };
+
+ return (
+ <Column justifyContent="center" alignItems="center" gap="6">
+ <Icon size="lg">
+ <Logo />
+ </Icon>
+ <Heading>umami</Heading>
+ <Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
+ <FormField
+ label={formatMessage(labels.username)}
+ data-test="input-username"
+ name="username"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <TextField autoComplete="username" />
+ </FormField>
+
+ <FormField
+ label={formatMessage(labels.password)}
+ data-test="input-password"
+ name="password"
+ rules={{ required: formatMessage(labels.required) }}
+ >
+ <PasswordField autoComplete="current-password" />
+ </FormField>
+ <FormButtons>
+ <FormSubmitButton
+ data-test="button-submit"
+ variant="primary"
+ style={{ flex: 1 }}
+ isDisabled={false}
+ >
+ {formatMessage(labels.login)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ </Column>
+ );
+}
diff --git a/src/app/login/LoginPage.tsx b/src/app/login/LoginPage.tsx
new file mode 100644
index 0000000..6f485e3
--- /dev/null
+++ b/src/app/login/LoginPage.tsx
@@ -0,0 +1,11 @@
+'use client';
+import { Column } from '@umami/react-zen';
+import { LoginForm } from './LoginForm';
+
+export function LoginPage() {
+ return (
+ <Column alignItems="center" height="100vh" backgroundColor="2" paddingTop="12">
+ <LoginForm />
+ </Column>
+ );
+}
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
new file mode 100644
index 0000000..ea27735
--- /dev/null
+++ b/src/app/login/page.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from 'next';
+import { LoginPage } from './LoginPage';
+
+export default async function () {
+ if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) {
+ return null;
+ }
+
+ return <LoginPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Login',
+};
diff --git a/src/app/logout/LogoutPage.tsx b/src/app/logout/LogoutPage.tsx
new file mode 100644
index 0000000..33e1615
--- /dev/null
+++ b/src/app/logout/LogoutPage.tsx
@@ -0,0 +1,25 @@
+'use client';
+import { useRouter } from 'next/navigation';
+import { useEffect } from 'react';
+import { useApi } from '@/components/hooks';
+import { removeClientAuthToken } from '@/lib/client';
+import { setUser } from '@/store/app';
+
+export function LogoutPage() {
+ const router = useRouter();
+ const { post } = useApi();
+
+ useEffect(() => {
+ async function logout() {
+ await post('/auth/logout');
+
+ window.location.href = `${process.env.basePath || ''}/login`;
+ }
+
+ removeClientAuthToken();
+ setUser(null);
+ logout();
+ }, [router, post]);
+
+ return null;
+}
diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx
new file mode 100644
index 0000000..2095278
--- /dev/null
+++ b/src/app/logout/page.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from 'next';
+import { LogoutPage } from './LogoutPage';
+
+export default function () {
+ if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) {
+ return null;
+ }
+
+ return <LogoutPage />;
+}
+
+export const metadata: Metadata = {
+ title: 'Logout',
+};
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..b376151
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,13 @@
+'use client';
+import { Flexbox } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+
+export default function () {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <Flexbox alignItems="center" justifyContent="center" flexGrow="1" minHeight="600px">
+ <h1>{formatMessage(labels.pageNotFound)}</h1>
+ </Flexbox>
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..6f0033d
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,19 @@
+'use client';
+import { redirect } from 'next/navigation';
+import { useEffect } from 'react';
+import { LAST_TEAM_CONFIG } from '@/lib/constants';
+import { getItem } from '@/lib/storage';
+
+export default function RootPage() {
+ useEffect(() => {
+ const lastTeam = getItem(LAST_TEAM_CONFIG);
+
+ if (lastTeam) {
+ redirect(`/teams/${lastTeam}/websites`);
+ } else {
+ redirect(`/websites`);
+ }
+ }, []);
+
+ return null;
+}
diff --git a/src/app/share/[...shareId]/Footer.tsx b/src/app/share/[...shareId]/Footer.tsx
new file mode 100644
index 0000000..f294862
--- /dev/null
+++ b/src/app/share/[...shareId]/Footer.tsx
@@ -0,0 +1,12 @@
+import { Row, Text } from '@umami/react-zen';
+import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants';
+
+export function Footer() {
+ return (
+ <Row as="footer" paddingY="6" justifyContent="flex-end">
+ <a href={HOMEPAGE_URL} target="_blank">
+ <Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`}
+ </a>
+ </Row>
+ );
+}
diff --git a/src/app/share/[...shareId]/Header.tsx b/src/app/share/[...shareId]/Header.tsx
new file mode 100644
index 0000000..d7b7dcb
--- /dev/null
+++ b/src/app/share/[...shareId]/Header.tsx
@@ -0,0 +1,24 @@
+import { Icon, Row, Text, ThemeButton } from '@umami/react-zen';
+import { LanguageButton } from '@/components/input/LanguageButton';
+import { PreferencesButton } from '@/components/input/PreferencesButton';
+import { Logo } from '@/components/svg';
+
+export function Header() {
+ return (
+ <Row as="header" justifyContent="space-between" alignItems="center" paddingY="3">
+ <a href="https://umami.is" target="_blank" rel="noopener">
+ <Row alignItems="center" gap>
+ <Icon>
+ <Logo />
+ </Icon>
+ <Text weight="bold">umami</Text>
+ </Row>
+ </a>
+ <Row alignItems="center" gap>
+ <ThemeButton />
+ <LanguageButton />
+ <PreferencesButton />
+ </Row>
+ </Row>
+ );
+}
diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx
new file mode 100644
index 0000000..7ed0667
--- /dev/null
+++ b/src/app/share/[...shareId]/SharePage.tsx
@@ -0,0 +1,41 @@
+'use client';
+import { Column, useTheme } from '@umami/react-zen';
+import { useEffect } from 'react';
+import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
+import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
+import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
+import { PageBody } from '@/components/common/PageBody';
+import { useShareTokenQuery } from '@/components/hooks';
+import { Footer } from './Footer';
+import { Header } from './Header';
+
+export function SharePage({ shareId }) {
+ const { shareToken, isLoading } = useShareTokenQuery(shareId);
+ const { setTheme } = useTheme();
+
+ useEffect(() => {
+ const url = new URL(window?.location?.href);
+ const theme = url.searchParams.get('theme');
+
+ if (theme === 'light' || theme === 'dark') {
+ setTheme(theme);
+ }
+ }, []);
+
+ if (isLoading || !shareToken) {
+ return null;
+ }
+
+ return (
+ <Column backgroundColor="2">
+ <PageBody gap>
+ <Header />
+ <WebsiteProvider websiteId={shareToken.websiteId}>
+ <WebsiteHeader showActions={false} />
+ <WebsitePage websiteId={shareToken.websiteId} />
+ </WebsiteProvider>
+ <Footer />
+ </PageBody>
+ </Column>
+ );
+}
diff --git a/src/app/share/[...shareId]/page.tsx b/src/app/share/[...shareId]/page.tsx
new file mode 100644
index 0000000..b9900eb
--- /dev/null
+++ b/src/app/share/[...shareId]/page.tsx
@@ -0,0 +1,7 @@
+import { SharePage } from './SharePage';
+
+export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) {
+ const { shareId } = await params;
+
+ return <SharePage shareId={shareId[0]} />;
+}
diff --git a/src/app/sso/SSOPage.tsx b/src/app/sso/SSOPage.tsx
new file mode 100644
index 0000000..3cc9509
--- /dev/null
+++ b/src/app/sso/SSOPage.tsx
@@ -0,0 +1,22 @@
+'use client';
+import { Loading } from '@umami/react-zen';
+import { useRouter, useSearchParams } from 'next/navigation';
+import { useEffect } from 'react';
+import { setClientAuthToken } from '@/lib/client';
+
+export function SSOPage() {
+ const router = useRouter();
+ const search = useSearchParams();
+ const url = search.get('url');
+ const token = search.get('token');
+
+ useEffect(() => {
+ if (url && token) {
+ setClientAuthToken(token);
+
+ router.push(url);
+ }
+ }, [router, url, token]);
+
+ return <Loading placement="absolute" />;
+}
diff --git a/src/app/sso/page.tsx b/src/app/sso/page.tsx
new file mode 100644
index 0000000..f6290d4
--- /dev/null
+++ b/src/app/sso/page.tsx
@@ -0,0 +1,10 @@
+import { Suspense } from 'react';
+import { SSOPage } from './SSOPage';
+
+export default function () {
+ return (
+ <Suspense>
+ <SSOPage />
+ </Suspense>
+ );
+}
diff --git a/src/assets/add-user.svg b/src/assets/add-user.svg
new file mode 100644
index 0000000..c6b4f48
--- /dev/null
+++ b/src/assets/add-user.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" data-name="Layer 2" viewBox="0 0 30 30"><path d="M15 14a5.5 5.5 0 1 1 5.5-5.5A5.51 5.51 0 0 1 15 14zm0-9a3.5 3.5 0 1 0 3.5 3.5A3.5 3.5 0 0 0 15 5zM7.5 24.5a1 1 0 0 1-1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1-1.2 1.6A6.44 6.44 0 0 0 15 17a6.51 6.51 0 0 0-6.5 6.5 1 1 0 0 1-1 1zM23 27a1 1 0 0 1-1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1-1 1z"/><path d="M26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2z"/></svg> \ No newline at end of file
diff --git a/src/assets/bar-chart.svg b/src/assets/bar-chart.svg
new file mode 100644
index 0000000..ae8b870
--- /dev/null
+++ b/src/assets/bar-chart.svg
@@ -0,0 +1 @@
+<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M7 13v9a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1zm7-12h-4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm8 5h-4a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1z"/></svg> \ No newline at end of file
diff --git a/src/assets/bars.svg b/src/assets/bars.svg
new file mode 100644
index 0000000..ba383fa
--- /dev/null
+++ b/src/assets/bars.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M424 392H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24Zm0-320H24C10.8 72 0 82.8 0 96s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24Zm0 160H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24Z"/></svg> \ No newline at end of file
diff --git a/src/assets/bolt.svg b/src/assets/bolt.svg
new file mode 100644
index 0000000..4654a1e
--- /dev/null
+++ b/src/assets/bolt.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"/></svg> \ No newline at end of file
diff --git a/src/assets/bookmark.svg b/src/assets/bookmark.svg
new file mode 100644
index 0000000..5abc5ed
--- /dev/null
+++ b/src/assets/bookmark.svg
@@ -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> \ No newline at end of file
diff --git a/src/assets/change.svg b/src/assets/change.svg
new file mode 100644
index 0000000..bf907e6
--- /dev/null
+++ b/src/assets/change.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512.013 512.013" style="enable-background:new 0 0 512.013 512.013" xml:space="preserve"><path d="m372.653 244.726 22.56 22.56 112-112c6.204-6.241 6.204-16.319 0-22.56l-112-112-22.56 22.72 84.8 84.64H.013v32h457.44l-84.8 84.64zm139.36 107.36H54.573l84.8-84.64-22.72-22.72-112 112c-6.204 6.241-6.204 16.319 0 22.56l112 112 22.56-22.56-84.64-84.64h457.44v-32z"/></svg> \ No newline at end of file
diff --git a/src/assets/compare.svg b/src/assets/compare.svg
new file mode 100644
index 0000000..e037c24
--- /dev/null
+++ b/src/assets/compare.svg
@@ -0,0 +1 @@
+<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M6 22a1 1 0 0 1-.71-.29l-4-4a1 1 0 0 1 0-1.42l4-4a1 1 0 0 1 1.42 1.42L4.41 16H22a1 1 0 0 1 0 2H4.41l2.3 2.29a1 1 0 0 1 0 1.42A1 1 0 0 1 6 22zm12-10a1 1 0 0 1-.71-.29 1 1 0 0 1 0-1.42L19.59 8H2a1 1 0 0 1 0-2h17.59l-2.3-2.29a1 1 0 0 1 1.42-1.42l4 4a1 1 0 0 1 0 1.42l-4 4A1 1 0 0 1 18 12z"/></svg> \ No newline at end of file
diff --git a/src/assets/dashboard.svg b/src/assets/dashboard.svg
new file mode 100644
index 0000000..398f2f2
--- /dev/null
+++ b/src/assets/dashboard.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layout-dashboard"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>
diff --git a/src/assets/download.svg b/src/assets/download.svg
new file mode 100644
index 0000000..b2482c9
--- /dev/null
+++ b/src/assets/download.svg
@@ -0,0 +1 @@
+<svg id="Layer_1" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m97.4999924 82.6562576.0000076-11.298912c0-1.957756-1.5870743-3.544838-3.544838-3.544838h-4.785324c-1.9577637 0-3.544838 1.5870743-3.544838 3.5448303l-.0000076 11.2989121c0 1.639595-1.329155 2.96875-2.96875 2.96875l-65.3124924-.0000229c-1.639596 0-2.96875-1.329155-2.968749-2.96875l.0000038-11.298912c0-1.957756-1.5870762-3.544838-3.544836-3.544838h-4.7853256c-1.9577594 0-3.5448372 1.5870743-3.544838 3.544838l-.0000036 11.298912c-.0000026 8.1979752 6.6457672 14.84375 14.8437443 14.84375l65.3124965.0000229c8.1979751 0 14.84375-6.6457672 14.84375-14.8437424z"/><path d="m29.6809349 44.1050034-3.3884087 3.3884048c-1.3843441 1.384346-1.384346 3.6288109-.0000019 5.0131569l19.5066929 19.5067101c2.3174515 2.3200302 6.0768623 2.3221207 8.3968925.0046768.0015564-.0015564.0031128-.0031204.0046692-.0046768l19.5067177-19.5066948c1.384346-1.3843422 1.384346-3.6288109 0-5.0131569l-3.3884125-3.3884048c-1.3843384-1.384346-3.6288071-1.384346-5.0131531-.0000038l-9.3684235 9.3684196.0000153-47.4285965c0-1.9577589-1.5870781-3.544837-3.5448341-3.5448377l-4.7853279-.0000014c-1.9577599-.0000007-3.544838 1.5870759-3.544838 3.5448353l-.0000153 47.4285965-9.3684158-9.3684235c-1.3843459-1.384346-3.6288127-1.384346-5.0131568-.0000038z"/></svg>
diff --git a/src/assets/expand.svg b/src/assets/expand.svg
new file mode 100644
index 0000000..43b9036
--- /dev/null
+++ b/src/assets/expand.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 48 48"><path d="M7.5 40.018v-10.5c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5v11a4.5 4.5 0 0 0 4.5 4.5h12a2.5 2.5 0 0 0 0-5zm33 0H29a2.5 2.5 0 0 0 0 5h12a4.5 4.5 0 0 0 4.5-4.5v-11c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5zm-33-33H19a2.5 2.5 0 0 0 0-5H7a4.5 4.5 0 0 0-4.5 4.5v11a2.5 2.5 0 0 0 5 0zm33 0v10.5a2.5 2.5 0 0 0 5 0v-11a4.5 4.5 0 0 0-4.5-4.5H29a2.5 2.5 0 0 0 0 5z"/></svg> \ No newline at end of file
diff --git a/src/assets/export.svg b/src/assets/export.svg
new file mode 100644
index 0000000..d7585b1
--- /dev/null
+++ b/src/assets/export.svg
@@ -0,0 +1 @@
+<svg id="Layer_1" enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><switch><g><path d="m8.7 7.7 2.3-2.3v9.6c0 .6.4 1 1 1s1-.4 1-1v-9.6l2.3 2.3c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0zm12.3 6.3c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1h-14c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1z"/></g></switch></svg>
diff --git a/src/assets/flag.svg b/src/assets/flag.svg
new file mode 100644
index 0000000..c375058
--- /dev/null
+++ b/src/assets/flag.svg
@@ -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> \ No newline at end of file
diff --git a/src/assets/funnel.svg b/src/assets/funnel.svg
new file mode 100644
index 0000000..c97b2fd
--- /dev/null
+++ b/src/assets/funnel.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32"><path d="M29 11H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h26a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zM4 9h24V5H4z"/><path d="M25 17H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zM8 15h16v-4H8z"/><path d="M22 23H10a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zm-11-2h10v-4H11z"/><path d="M19 29h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zm-5-2h4v-4h-4z"/></svg>
diff --git a/src/assets/gear.svg b/src/assets/gear.svg
new file mode 100644
index 0000000..47805d4
--- /dev/null
+++ b/src/assets/gear.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M504.265 315.978c0-8.652-4.607-16.844-12.359-21.392l-32.908-18.971a199.182 199.182 0 0 0 0-39.23l32.908-18.971c7.752-4.548 12.359-12.74 12.359-21.392 0-21.267-49.318-128.176-84.519-128.176-4.244 0-8.51 1.093-12.367 3.357l-32.78 18.969a195.058 195.058 0 0 0-34.068-19.744v-37.94c0-11.226-7.484-21.035-18.326-23.875C300.654 2.871 278.425 0 256.181 0a257.698 257.698 0 0 0-66.121 8.613c-10.842 2.84-18.326 12.649-18.326 23.875v37.94a195.058 195.058 0 0 0-34.068 19.744l-32.78-18.969a24.36 24.36 0 0 0-12.367-3.357h-.007C60.048 67.846 8 169.591 8 196.022c0 8.652 4.607 16.844 12.359 21.392l32.908 18.971a199.182 199.182 0 0 0 0 39.23l-32.908 18.971C12.607 299.134 8 307.326 8 315.978c0 21.267 49.318 128.176 84.519 128.176 4.244 0 8.51-1.093 12.367-3.357l32.78-18.969a195.058 195.058 0 0 0 34.068 19.744v37.94c0 11.226 7.484 21.035 18.326 23.875 21.551 5.742 43.78 8.613 66.024 8.613 22.246 0 44.506-2.871 66.121-8.613 10.842-2.84 18.326-12.649 18.326-23.875v-37.94a195.058 195.058 0 0 0 34.068-19.744l32.78 18.969a24.36 24.36 0 0 0 12.367 3.357c32.463 0 84.519-101.731 84.519-128.176Zm-88.904 73.981c-23.8-13.773-11.26-6.515-43.656-25.264-42.056 30.395-32.33 24.731-79.174 45.887v50.238a210.138 210.138 0 0 1-36.438 3.18 208.924 208.924 0 0 1-36.359-3.176v-50.242c-46.955-21.206-37.182-15.538-79.174-45.887l-43.636 25.254a207.379 207.379 0 0 1-36.407-63.109c21.126-12.177 11.844-6.826 43.571-25.117-2.539-25.64-3.811-35.644-3.811-45.683 0-10.022 1.268-20.08 3.811-45.763-31.89-18.385-22.517-12.982-43.584-25.125a207.107 207.107 0 0 1 36.4-63.111c23.8 13.773 11.26 6.515 43.656 25.264 42.056-30.395 32.33-24.731 79.174-45.887V51.18A210.146 210.146 0 0 1 256.172 48c15.425 0 27.954 1.694 36.359 3.176v50.242c46.955 21.206 37.182 15.538 79.174 45.887l43.638-25.254a207.414 207.414 0 0 1 36.405 63.109c-21.126 12.177-11.844 6.826-43.571 25.117 2.539 25.64 3.811 35.644 3.811 45.683 0 10.022-1.268 20.08-3.811 45.763 31.89 18.385 22.517 12.982 43.584 25.125a207.107 207.107 0 0 1-36.4 63.111ZM256.133 160c-52.875 0-96 43.125-96 96s43.125 96 96 96 96-43.125 96-96-43.125-96-96-96Zm0 144c-26.467 0-48-21.533-48-48s21.533-48 48-48 48 21.533 48 48-21.534 48-48 48Z"/></svg> \ No newline at end of file
diff --git a/src/assets/lightbulb.svg b/src/assets/lightbulb.svg
new file mode 100644
index 0000000..46572b0
--- /dev/null
+++ b/src/assets/lightbulb.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 512 512"><path d="M223.718 124.76c-48.027 11.198-86.688 49.285-98.494 97.031-11.843 47.899 1.711 96.722 36.259 130.601C173.703 364.377 181 383.586 181 403.777V407c0 13.296 5.801 25.26 15 33.505V467c0 24.813 20.187 45 45 45h30c24.813 0 45-20.187 45-45v-26.495c9.199-8.245 15-20.208 15-33.505v-3.282c0-19.884 7.687-39.458 20.563-52.361C376.994 325.87 391 292.005 391 256c0-86.079-79.769-151.638-167.282-131.24zM286 467c0 8.271-6.729 15-15 15h-30c-8.271 0-15-6.729-15-15v-15h60v15zm44.326-136.834C311.689 348.843 301 375.651 301 403.718V407c0 8.271-6.729 15-15 15h-60c-8.271 0-15-6.729-15-15v-3.223c0-28.499-10.393-55.035-28.513-72.804-26.89-26.37-37.409-64.493-28.141-101.981 9.125-36.907 39.029-66.353 76.184-75.015C299.202 137.964 361 189.228 361 256c0 28.004-10.894 54.343-30.674 74.166zM139.327 118.114 96.9 75.688c-5.857-5.858-15.355-5.858-21.213 0-5.858 5.858-5.858 15.355 0 21.213l42.427 42.426c5.857 5.858 15.356 5.858 21.213 0 5.858-5.858 5.858-15.355 0-21.213zM76 241H15c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15zm421 0h-61c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15zM436.313 75.688c-5.856-5.858-15.354-5.858-21.213 0l-42.427 42.426c-5.858 5.857-5.858 15.355 0 21.213 5.857 5.858 15.355 5.858 21.213 0l42.427-42.426c5.858-5.857 5.858-15.355 0-21.213zM256 0c-8.284 0-15 6.716-15 15v61c0 8.284 6.716 15 15 15s15-6.716 15-15V15c0-8.284-6.716-15-15-15z"/><path d="M256 181c-6.166 0-12.447.739-18.658 2.194-25.865 6.037-47.518 27.328-53.879 52.979-1.994 8.041 2.907 16.175 10.947 18.17 8.042 1.994 16.176-2.909 18.17-10.948 3.661-14.758 16.647-27.5 31.593-30.989 3.982-.933 7.962-1.406 11.827-1.406 8.284 0 15-6.716 15-15s-6.716-15-15-15z"/></svg>
diff --git a/src/assets/lightning.svg b/src/assets/lightning.svg
new file mode 100644
index 0000000..14cb95d
--- /dev/null
+++ b/src/assets/lightning.svg
@@ -0,0 +1 @@
+<svg xml:space="preserve" viewBox="0 0 682.667 682.667" xmlns="http://www.w3.org/2000/svg"><defs><clipPath clipPathUnits="userSpaceOnUse" id="a"><path d="M0 512h512V0H0Z"/></clipPath></defs><g clip-path="url(#a)" transform="matrix(1.33333 0 0 -1.33333 0 682.667)"><path d="M0 0h137.962L69.319-155.807h140.419L.242-482l55.349 222.794h-155.853z" style="fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" transform="translate(201.262 496.994)"/></g></svg> \ No newline at end of file
diff --git a/src/assets/location.svg b/src/assets/location.svg
new file mode 100644
index 0000000..f7f085e
--- /dev/null
+++ b/src/assets/location.svg
@@ -0,0 +1 @@
+<svg height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M32 0A24.032 24.032 0 0 0 8 24c0 17.23 22.36 38.81 23.31 39.72a.99.99 0 0 0 1.38 0C33.64 62.81 56 41.23 56 24A24.032 24.032 0 0 0 32 0zm0 35a11 11 0 1 1 11-11 11.007 11.007 0 0 1-11 11z"/></svg> \ No newline at end of file
diff --git a/src/assets/lock.svg b/src/assets/lock.svg
new file mode 100644
index 0000000..27fcc5e
--- /dev/null
+++ b/src/assets/lock.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722z"/></svg> \ No newline at end of file
diff --git a/src/assets/logo-white.svg b/src/assets/logo-white.svg
new file mode 100644
index 0000000..20c41fb
--- /dev/null
+++ b/src/assets/logo-white.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 428 389.11"><circle cx="214.15" cy="181" r="171" fill="none" stroke="#fff" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z" fill="#fff"/></svg> \ No newline at end of file
diff --git a/src/assets/logo.svg b/src/assets/logo.svg
new file mode 100644
index 0000000..c7f4517
--- /dev/null
+++ b/src/assets/logo.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 428 389.11"><circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z"/></svg>
diff --git a/src/assets/magnet.svg b/src/assets/magnet.svg
new file mode 100644
index 0000000..79e1627
--- /dev/null
+++ b/src/assets/magnet.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" height="512" viewBox="0 0 508.467 508.467" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M426.815 239.006c-11.722-11.724-30.702-11.729-42.427-.001L267.67 355.723c-53.811 53.809-142.478 19.197-140.68-54.511.547-22.415 9.826-43.738 26.129-60.041l116.717-116.717c11.724-11.722 11.728-30.702 0-42.427l-46.668-46.669c-11.725-11.725-30.702-11.726-42.427 0L60.629 155.47C21.579 194.52.047 246.44 0 301.665c-.093 110.827 88.182 206.288 206.244 206.394 56.778 0 109.204-21.924 148.29-61.01l118.948-118.948c11.724-11.722 11.728-30.702 0-42.427zM201.954 56.572l46.669 46.669-58.455 58.456-46.669-46.669zm131.367 369.264c-69.043 69.043-182.868 70.02-251.708.933-68.763-69.009-68.66-181.196.229-250.086l40.443-40.443 46.669 46.669-37.049 37.049c-45.115 45.112-46.916 116.85-3.395 160.371 43.279 43.279 115.221 41.756 160.372-3.394l37.049-37.049 46.669 46.669zm60.494-60.493-46.669-46.669 58.456-58.456 46.669 46.669zM379.357 95.099c15.199 3.839 30.418 19.07 34.336 34.192 2.089 8.058 10.303 12.828 18.283 10.758 8.02-2.078 12.836-10.264 10.758-18.283-6.651-25.662-30.176-49.223-56.03-55.753-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.837 16.189 10.87 18.217zm128.627 7.025C495.968 55.749 452.769 12.62 406.239.868c-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.838 16.188 10.87 18.217 35.882 9.063 70.769 43.871 80.051 79.695 2.088 8.058 10.304 12.828 18.283 10.758 8.02-2.078 12.836-10.263 10.758-18.283z"/></svg>
diff --git a/src/assets/money.svg b/src/assets/money.svg
new file mode 100644
index 0000000..2f364d8
--- /dev/null
+++ b/src/assets/money.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" 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="M347 302c8.271 0 15 6.639 15 14.8h30c0-19.468-12.541-36.067-30-42.231V242h-30v32.58c-17.459 6.192-30 22.865-30 42.42 0 24.813 20.187 45 45 45 8.271 0 15 6.729 15 15s-6.729 15-15 15-15-6.729-15-15h-30c0 19.555 12.541 36.228 30 42.42v32.38h30v-32.38c17.459-6.192 30-22.865 30-42.42 0-24.813-20.187-45-45-45-8.271 0-15-6.729-15-15s6.729-15 15-15z"/><path d="M347 182c-5.057 0-10.058.242-15 .689V90c0-26.011-18.548-49.61-52.226-66.449C249.4 8.364 209.35 0 167 0 124.564 0 84.193 8.347 53.323 23.502 18.938 40.385 0 64 0 90v272c0 26 18.938 49.616 53.323 66.498C84.193 443.653 124.564 452 167 452c17.009 0 33.647-1.358 49.615-4.004C246.826 486.909 294.035 512 347 512c90.981 0 165-74.019 165-165s-74.019-165-165-165zM66.545 50.432C92.992 37.447 129.606 30 167 30c79.558 0 135 31.621 135 60s-55.442 60-135 60c-37.394 0-74.008-7.447-100.455-20.432C43.32 118.166 30 103.744 30 90s13.32-28.166 36.545-39.568zM30 142.265c6.724 5.137 14.512 9.907 23.323 14.233C84.193 171.653 124.564 180 167 180c42.35 0 82.4-8.364 112.774-23.551 8.359-4.18 15.783-8.776 22.226-13.722v45.51c-29.896 8.485-56.359 25.209-76.778 47.548C206.946 239.908 187.386 242 167 242c-37.394 0-74.008-7.447-100.455-20.432C43.32 210.166 30 195.744 30 182v-39.735zm0 92c6.724 5.137 14.512 9.907 23.323 14.233C84.193 263.653 124.564 272 167 272c11.581 0 22.942-.621 34.021-1.839a163.743 163.743 0 0 0-18.293 61.395c-5.211.286-10.465.444-15.728.444-37.394 0-74.008-7.447-100.455-20.432C43.32 300.166 30 285.744 30 272v-37.735zM167 422c-37.394 0-74.008-7.447-100.455-20.432C43.32 390.166 30 375.744 30 362v-37.736c6.724 5.137 14.512 9.907 23.323 14.233C84.193 353.653 124.564 362 167 362c5.23 0 10.459-.132 15.654-.388a163.726 163.726 0 0 0 16.486 58.557A280.559 280.559 0 0 1 167 422zm180 60c-74.439 0-135-60.561-135-135s60.561-135 135-135 135 60.561 135 135-60.561 135-135 135z"/></svg>
diff --git a/src/assets/network.svg b/src/assets/network.svg
new file mode 100644
index 0000000..93c941c
--- /dev/null
+++ b/src/assets/network.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg"><g id="_x30_6_network"><path d="m28 19c-.809 0-1.54.325-2.08.847l-6.011-3.01c.058-.271.091-.55.091-.837s-.033-.566-.091-.837l6.011-3.01c.54.522 1.271.847 2.08.847 1.654 0 3-1.346 3-3s-1.346-3-3-3-3 1.346-3 3c0 .123.022.24.036.359l-6.036 3.023c-.521-.597-1.21-1.035-2-1.24v-5.326c1.162-.415 2-1.514 2-2.816 0-1.654-1.346-3-3-3s-3 1.346-3 3c0 1.302.838 2.401 2 2.815v5.327c-.79.205-1.478.643-2 1.24l-6.037-3.022c.015-.12.037-.237.037-.36 0-1.654-1.346-3-3-3s-3 1.346-3 3 1.346 3 3 3c.809 0 1.54-.325 2.08-.847l6.011 3.01c-.058.271-.091.55-.091.837s.033.566.091.837l-6.011 3.01c-.54-.522-1.271-.847-2.08-.847-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3c0-.123-.022-.24-.036-.359l6.036-3.023c.521.597 1.21 1.035 2 1.24v5.326c-1.162.415-2 1.514-2 2.816 0 1.654 1.346 3 3 3s3-1.346 3-3c0-1.302-.838-2.401-2-2.816v-5.326c.79-.205 1.478-.643 2-1.24l6.037 3.022c-.015.12-.037.237-.037.36 0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3zm0-10c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1zm-24 2c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm0 12c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm12-20c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1zm0 26c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm0-11c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2zm12 5c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1z"/></g></svg>
diff --git a/src/assets/nodes.svg b/src/assets/nodes.svg
new file mode 100644
index 0000000..b3e22a7
--- /dev/null
+++ b/src/assets/nodes.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M19 9.874A4.002 4.002 0 0 0 18 2a4.002 4.002 0 0 0-3.874 3H9.874A4.002 4.002 0 0 0 2 6a4.002 4.002 0 0 0 3 3.874v4.252A4.002 4.002 0 0 0 6 22a4.002 4.002 0 0 0 3.874-3h4.252A4.002 4.002 0 0 0 22 18a4.002 4.002 0 0 0-3-3.874zM6 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm3.874 3A4.007 4.007 0 0 1 7 9.874v4.252A4.007 4.007 0 0 1 9.874 17h4.252A4.007 4.007 0 0 1 17 14.126V9.874A4.007 4.007 0 0 1 14.126 7zM18 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM8 18a2 2 0 1 0-4 0 2 2 0 0 0 4 0z" clip-rule="evenodd"/></svg> \ No newline at end of file
diff --git a/src/assets/overview.svg b/src/assets/overview.svg
new file mode 100644
index 0000000..ec44b4e
--- /dev/null
+++ b/src/assets/overview.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M452 36H60C26.916 36 0 62.916 0 96v240c0 33.084 26.916 60 60 60h176v40H132v40h248v-40H276v-40h176c33.084 0 60-26.916 60-60V96c0-33.084-26.916-60-60-60zm20 300c0 11.028-8.972 20-20 20H60c-11.028 0-20-8.972-20-20V96c0-11.028 8.972-20 20-20h392c11.028 0 20 8.972 20 20v240z"/></svg> \ No newline at end of file
diff --git a/src/assets/path.svg b/src/assets/path.svg
new file mode 100644
index 0000000..e99207d
--- /dev/null
+++ b/src/assets/path.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64"><path d="m56.4 47.6-6-6c-.8-.8-2-.8-2.8 0s-.8 2 0 2.8l2.6 2.6H18.5c-3.6 0-6.5-2.9-6.5-6.5s2.9-6.5 6.5-6.5h27C51.3 34 56 29.3 56 23.5S51.3 13 45.5 13H22.7c-.9-3.4-4-6-7.7-6-4.4 0-8 3.6-8 8s3.6 8 8 8c3.7 0 6.8-2.6 7.7-6h22.8c3.6 0 6.5 2.9 6.5 6.5S49.1 30 45.5 30h-27C12.7 30 8 34.7 8 40.5S12.7 51 18.5 51h31.7l-2.6 2.6c-.8.8-.8 2 0 2.8.4.4.9.6 1.4.6s1-.2 1.4-.6l6-6c.8-.8.8-2 0-2.8M15 19c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4"/></svg>
diff --git a/src/assets/profile.svg b/src/assets/profile.svg
new file mode 100644
index 0000000..6a1af5a
--- /dev/null
+++ b/src/assets/profile.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02zM111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703zM256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934zm0 0"/></svg> \ No newline at end of file
diff --git a/src/assets/pushpin.svg b/src/assets/pushpin.svg
new file mode 100644
index 0000000..6926221
--- /dev/null
+++ b/src/assets/pushpin.svg
@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024" fill="currentColor" height="1em" width="1em"><path d="M878.3 392.1 631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 0 0-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8z"/></svg> \ No newline at end of file
diff --git a/src/assets/redo.svg b/src/assets/redo.svg
new file mode 100644
index 0000000..4544eb1
--- /dev/null
+++ b/src/assets/redo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.568C418.075 51.834 341.788 7.73 255.207 8.001 118.82 8.428 7.787 120.009 8 256.396 8.214 393.181 119.165 504 256 504c63.926 0 122.202-24.187 166.178-63.908 5.113-4.618 5.354-12.561.482-17.433l-19.738-19.738c-4.498-4.498-11.753-4.785-16.501-.552C351.787 433.246 306.105 452 256 452c-108.322 0-196-87.662-196-196 0-108.322 87.662-196 196-196 79.545 0 147.941 47.282 178.675 115.302l-126.389-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12z"/></svg> \ No newline at end of file
diff --git a/src/assets/reports.svg b/src/assets/reports.svg
new file mode 100644
index 0000000..66dfc32
--- /dev/null
+++ b/src/assets/reports.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M61.17 18.91A32 32 0 1 0 46.4 60.54l.15-.06.16-.1a31.93 31.93 0 0 0 14.47-41.44s-.01-.02-.01-.03zm-4.53-.16L34 28.91V4.1a28 28 0 0 1 22.64 14.65zM4 32A28 28 0 0 1 30 4.1V32a1.74 1.74 0 0 0 0 .39.17.17 0 0 0 0 .07 1.49 1.49 0 0 0 .15.4l12.76 24.9A28 28 0 0 1 4 32zm42.47 23.94L34.74 33l23.54-10.6a28 28 0 0 1-11.81 33.54z"/></svg> \ No newline at end of file
diff --git a/src/assets/security.svg b/src/assets/security.svg
new file mode 100644
index 0000000..dd20891
--- /dev/null
+++ b/src/assets/security.svg
@@ -0,0 +1 @@
+<svg id="Layer_1" height="512" viewBox="0 0 36 36" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m18 34a1.07 1.07 0 0 1 -.48-.11l-4.87-2.43a13.79 13.79 0 0 1 -7.65-12.41v-12.14a1.07 1.07 0 0 1 1.05-1.07h3.47a7.45 7.45 0 0 0 4-1.19l3.87-2.48a1.07 1.07 0 0 1 1.15 0l3.87 2.48a7.45 7.45 0 0 0 4 1.19h3.47a1.07 1.07 0 0 1 1.12 1.07v12.14a13.79 13.79 0 0 1 -7.67 12.4l-4.87 2.43a1.07 1.07 0 0 1 -.46.12zm-10.88-26v11.05a11.67 11.67 0 0 0 6.49 10.49l4.39 2.2 4.39-2.2a11.67 11.67 0 0 0 6.49-10.49v-11.05h-2.4a9.57 9.57 0 0 1 -5.19-1.53l-3.29-2.14-3.29 2.12a9.57 9.57 0 0 1 -5.19 1.55z"/><path d="m18 18.8a4.8 4.8 0 1 1 4.8-4.8 4.81 4.81 0 0 1 -4.8 4.8zm0-7.47a2.67 2.67 0 1 0 2.67 2.67 2.67 2.67 0 0 0 -2.67-2.66z"/><path d="m24.4 24.67h-2.13a2.14 2.14 0 0 0 -2.13-2.13h-4.28a2.13 2.13 0 0 0 -2.13 2.13h-2.13a4.26 4.26 0 0 1 4.26-4.26h4.27a4.27 4.27 0 0 1 4.27 4.26z"/></svg> \ No newline at end of file
diff --git a/src/assets/speaker.svg b/src/assets/speaker.svg
new file mode 100644
index 0000000..f243a49
--- /dev/null
+++ b/src/assets/speaker.svg
@@ -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> \ No newline at end of file
diff --git a/src/assets/switch.svg b/src/assets/switch.svg
new file mode 100644
index 0000000..86166cc
--- /dev/null
+++ b/src/assets/switch.svg
@@ -0,0 +1 @@
+<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" height="200px" width="200px" xmlns="http://www.w3.org/2000/svg"><path d="M16 3l4 4l-4 4"></path><path d="M10 7l10 0"></path><path d="M8 13l-4 4l4 4"></path><path d="M4 17l9 0"></path></svg>
diff --git a/src/assets/tag.svg b/src/assets/tag.svg
new file mode 100644
index 0000000..d123869
--- /dev/null
+++ b/src/assets/tag.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" 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>
diff --git a/src/assets/target.svg b/src/assets/target.svg
new file mode 100644
index 0000000..ae9fef2
--- /dev/null
+++ b/src/assets/target.svg
@@ -0,0 +1 @@
+<svg fill="currentColor" clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M19.393 10.825a.75.75 0 0 1 1.458-.352c.181.75.277 1.533.277 2.338 0 5.485-4.453 9.939-9.939 9.939-5.485 0-9.939-4.454-9.939-9.939 0-5.486 4.454-9.939 9.939-9.939.805 0 1.588.096 2.338.277a.75.75 0 1 1-.352 1.458A8.442 8.442 0 0 0 2.75 12.811a8.442 8.442 0 0 0 8.439 8.439 8.442 8.442 0 0 0 8.204-10.425z"/><path d="M14.764 12.811a.75.75 0 0 1 1.5 0c0 2.8-2.274 5.074-5.075 5.074a5.077 5.077 0 0 1-5.074-5.074 5.077 5.077 0 0 1 5.074-5.075.75.75 0 0 1 0 1.5 3.575 3.575 0 1 0 3.575 3.575zm7.766-7.223-3.057 3.058a.75.75 0 0 1-.531.22h-3.058a.75.75 0 0 1-.75-.75V5.058a.75.75 0 0 1 .22-.531l3.058-3.057a.75.75 0 0 1 1.242.293L20.3 3.7l1.937.646a.75.75 0 0 1 .293 1.242zm-1.918-.202-1.142-.381a.753.753 0 0 1-.475-.475l-.381-1.142-1.98 1.98v1.998h1.998z"/><path d="M15.354 7.585a.75.75 0 1 1 1.061 1.061l-4.587 4.586a.749.749 0 1 1-1.06-1.06z"/></svg>
diff --git a/src/assets/visitor.svg b/src/assets/visitor.svg
new file mode 100644
index 0000000..829eb8e
--- /dev/null
+++ b/src/assets/visitor.svg
@@ -0,0 +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 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> \ No newline at end of file
diff --git a/src/assets/website.svg b/src/assets/website.svg
new file mode 100644
index 0000000..6096a65
--- /dev/null
+++ b/src/assets/website.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 511.999 511.999" viewBox="0 0 511.999 511.999"><path d="M437.019 74.981C388.667 26.628 324.38 0 256 0 187.62 0 123.332 26.628 74.981 74.98 26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98 68.381 0 132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018zM96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230.423 230.423 0 0 1 15.806-17.528zm-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4zm-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437zm35.622 46.146a229.917 229.917 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679zm144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709v102.545zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06v76.141zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249v102.669zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157zm-34.934-44.964a230.122 230.122 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888zm-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006v-74.606zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505h-97.315zm-.002-208.845h.001c22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674V32.139zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249zm144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230.268 230.268 0 0 1-16.884 18.873zm34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993z"/></svg> \ No newline at end of file
diff --git a/src/components/boards/Board.tsx b/src/components/boards/Board.tsx
new file mode 100644
index 0000000..70f0fa0
--- /dev/null
+++ b/src/components/boards/Board.tsx
@@ -0,0 +1,9 @@
+import { Column } from '@umami/react-zen';
+
+export interface BoardProps {
+ children?: React.ReactNode;
+}
+
+export function Board({ children }: BoardProps) {
+ return <Column>{children}</Column>;
+}
diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx
new file mode 100644
index 0000000..7bfc72d
--- /dev/null
+++ b/src/components/charts/BarChart.tsx
@@ -0,0 +1,131 @@
+import { useTheme } from '@umami/react-zen';
+import { useMemo, useState } from 'react';
+import { Chart, type ChartProps } from '@/components/charts/Chart';
+import { ChartTooltip } from '@/components/charts/ChartTooltip';
+import { useLocale } from '@/components/hooks';
+import { renderNumberLabels } from '@/lib/charts';
+import { getThemeColors } from '@/lib/colors';
+import { DATE_FORMATS, formatDate } from '@/lib/date';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+const dateFormats = {
+ millisecond: 'T',
+ second: 'pp',
+ minute: 'p',
+ hour: 'p - PP',
+ day: 'PPPP',
+ week: 'PPPP',
+ month: 'LLLL yyyy',
+ quarter: 'qqq',
+ year: 'yyyy',
+};
+
+export interface BarChartProps extends ChartProps {
+ unit?: string;
+ stacked?: boolean;
+ currency?: string;
+ renderXLabel?: (label: string, index: number, values: any[]) => string;
+ renderYLabel?: (label: string, index: number, values: any[]) => string;
+ XAxisType?: string;
+ YAxisType?: string;
+ minDate?: Date;
+ maxDate?: Date;
+}
+
+export function BarChart({
+ chartData,
+ renderXLabel,
+ renderYLabel,
+ unit,
+ XAxisType = 'timeseries',
+ YAxisType = 'linear',
+ stacked = false,
+ minDate,
+ maxDate,
+ currency,
+ ...props
+}: BarChartProps) {
+ const [tooltip, setTooltip] = useState(null);
+ const { theme } = useTheme();
+ const { locale } = useLocale();
+ const { colors } = useMemo(() => getThemeColors(theme), [theme]);
+
+ const chartOptions: any = useMemo(() => {
+ return {
+ __id: Date.now(),
+ scales: {
+ x: {
+ type: XAxisType,
+ stacked: true,
+ min: formatDate(minDate, DATE_FORMATS[unit], locale),
+ max: formatDate(maxDate, DATE_FORMATS[unit], locale),
+ offset: 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: !!stacked,
+ grid: {
+ color: colors.chart.line,
+ },
+ border: {
+ color: colors.chart.line,
+ },
+ ticks: {
+ color: colors.chart.text,
+ callback: renderYLabel || renderNumberLabels,
+ },
+ },
+ },
+ };
+ }, [chartData, colors, unit, stacked, renderXLabel, renderYLabel]);
+
+ const handleTooltip = ({ tooltip }: { tooltip: any }) => {
+ const { opacity, labelColors, dataPoints } = tooltip;
+
+ setTooltip(
+ opacity
+ ? {
+ title: formatDate(
+ new Date(dataPoints[0].raw?.d || dataPoints[0].raw?.x || dataPoints[0].raw),
+ dateFormats[unit],
+ locale,
+ ),
+ color: labelColors?.[0]?.backgroundColor,
+ value: currency
+ ? formatLongCurrency(dataPoints[0].raw.y, currency)
+ : `${formatLongNumber(dataPoints[0].raw.y)} ${dataPoints[0].dataset.label}`,
+ }
+ : null,
+ );
+ };
+
+ return (
+ <>
+ <Chart
+ {...props}
+ type="bar"
+ chartData={chartData}
+ chartOptions={chartOptions}
+ onTooltip={handleTooltip}
+ />
+ {tooltip && <ChartTooltip {...tooltip} />}
+ </>
+ );
+}
diff --git a/src/components/charts/BubbleChart.tsx b/src/components/charts/BubbleChart.tsx
new file mode 100644
index 0000000..bf487ac
--- /dev/null
+++ b/src/components/charts/BubbleChart.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react';
+import { Chart, type ChartProps } from '@/components/charts/Chart';
+import { ChartTooltip } from '@/components/charts/ChartTooltip';
+
+export interface BubbleChartProps extends ChartProps {
+ type?: 'bubble';
+}
+
+export function BubbleChart({ type = 'bubble', ...props }: BubbleChartProps) {
+ const [tooltip, setTooltip] = useState(null);
+
+ const handleTooltip = ({ tooltip }) => {
+ const { opacity, labelColors, title, dataPoints } = tooltip;
+
+ setTooltip(
+ opacity
+ ? {
+ color: labelColors?.[0]?.backgroundColor,
+ value: `${title}: ${dataPoints[0].raw}`,
+ }
+ : null,
+ );
+ };
+
+ return (
+ <>
+ <Chart {...props} type={type} onTooltip={handleTooltip} />
+ {tooltip && <ChartTooltip {...tooltip} />}
+ </>
+ );
+}
diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx
new file mode 100644
index 0000000..b6ae9d7
--- /dev/null
+++ b/src/components/charts/Chart.tsx
@@ -0,0 +1,130 @@
+import { Box, type BoxProps, Column } from '@umami/react-zen';
+import ChartJS, {
+ type ChartData,
+ type ChartOptions,
+ type LegendItem,
+ type UpdateMode,
+} from 'chart.js/auto';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Legend } from '@/components/metrics/Legend';
+import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
+
+ChartJS.defaults.font.family = 'Inter';
+
+export interface ChartProps extends BoxProps {
+ type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter';
+ chartData?: ChartData & { focusLabel?: string };
+ chartOptions?: ChartOptions;
+ updateMode?: UpdateMode;
+ animationDuration?: number;
+ onTooltip?: (model: any) => void;
+}
+
+export function Chart({
+ type,
+ chartData,
+ animationDuration = DEFAULT_ANIMATION_DURATION,
+ updateMode,
+ onTooltip,
+ chartOptions,
+ ...props
+}: ChartProps) {
+ const canvas = useRef(null);
+ const chart = useRef(null);
+ const [legendItems, setLegendItems] = useState([]);
+
+ const options = useMemo(() => {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: {
+ duration: animationDuration,
+ resize: {
+ duration: 0,
+ },
+ active: {
+ duration: 0,
+ },
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ intersect: true,
+ external: onTooltip,
+ },
+ },
+ ...chartOptions,
+ };
+ }, [chartOptions]);
+
+ 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);
+ };
+
+ // Create chart
+ useEffect(() => {
+ if (canvas.current) {
+ chart.current = new ChartJS(canvas.current, {
+ type,
+ data: chartData,
+ options,
+ });
+
+ setLegendItems(chart.current.legend.legendItems);
+ }
+
+ return () => {
+ chart.current?.destroy();
+ };
+ }, []);
+
+ // Update chart
+ useEffect(() => {
+ if (chart.current && chartData) {
+ // Replace labels and datasets *in-place*
+ chart.current.data.labels = chartData.labels;
+ chart.current.data.datasets = chartData.datasets;
+
+ if (chartData.focusLabel !== null) {
+ chart.current.data.datasets.forEach((ds: { hidden: boolean; label: any }) => {
+ ds.hidden = chartData.focusLabel ? ds.label !== chartData.focusLabel : false;
+ });
+ }
+
+ chart.current.options = options;
+
+ chart.current.update(updateMode);
+
+ setLegendItems(chart.current.legend.legendItems);
+ }
+ }, [chartData, options, updateMode]);
+
+ return (
+ <Column gap="6">
+ <Box {...props}>
+ <canvas ref={canvas} />
+ </Box>
+ <Legend items={legendItems} onClick={handleLegendClick} />
+ </Column>
+ );
+}
diff --git a/src/components/charts/ChartTooltip.tsx b/src/components/charts/ChartTooltip.tsx
new file mode 100644
index 0000000..95ba2a2
--- /dev/null
+++ b/src/components/charts/ChartTooltip.tsx
@@ -0,0 +1,23 @@
+import { Column, FloatingTooltip, Row, StatusLight } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export function ChartTooltip({
+ title,
+ color,
+ value,
+}: {
+ title?: string;
+ color?: string;
+ value?: ReactNode;
+}) {
+ return (
+ <FloatingTooltip>
+ <Column gap="3" fontSize="1">
+ {title && <Row alignItems="center">{title}</Row>}
+ <Row alignItems="center">
+ <StatusLight color={color}>{value}</StatusLight>
+ </Row>
+ </Column>
+ </FloatingTooltip>
+ );
+}
diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx
new file mode 100644
index 0000000..2470fe7
--- /dev/null
+++ b/src/components/charts/PieChart.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react';
+import { Chart, type ChartProps } from '@/components/charts/Chart';
+import { ChartTooltip } from '@/components/charts/ChartTooltip';
+
+export interface PieChartProps extends ChartProps {
+ type?: 'doughnut' | 'pie';
+}
+
+export function PieChart({ type = 'pie', ...props }: PieChartProps) {
+ const [tooltip, setTooltip] = useState(null);
+
+ const handleTooltip = ({ tooltip }) => {
+ const { opacity, labelColors, title, dataPoints } = tooltip;
+
+ setTooltip(
+ opacity
+ ? {
+ color: labelColors?.[0]?.backgroundColor,
+ value: `${title}: ${dataPoints[0].raw}`,
+ }
+ : null,
+ );
+ };
+
+ return (
+ <>
+ <Chart {...props} type={type} onTooltip={handleTooltip} />
+ {tooltip && <ChartTooltip {...tooltip} />}
+ </>
+ );
+}
diff --git a/src/components/common/ActionForm.tsx b/src/components/common/ActionForm.tsx
new file mode 100644
index 0000000..c6f44e8
--- /dev/null
+++ b/src/components/common/ActionForm.tsx
@@ -0,0 +1,15 @@
+import { Column, Row, Text } from '@umami/react-zen';
+
+export function ActionForm({ label, description, children }) {
+ return (
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Column gap="2">
+ <Text weight="bold">{label}</Text>
+ <Text color="muted">{description}</Text>
+ </Column>
+ <Row alignItems="center" gap>
+ {children}
+ </Row>
+ </Row>
+ );
+}
diff --git a/src/components/common/AnimatedDiv.tsx b/src/components/common/AnimatedDiv.tsx
new file mode 100644
index 0000000..f994897
--- /dev/null
+++ b/src/components/common/AnimatedDiv.tsx
@@ -0,0 +1,3 @@
+import { type AnimatedComponent, animated } from '@react-spring/web';
+
+export const AnimatedDiv: AnimatedComponent<any> = animated.div;
diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx
new file mode 100644
index 0000000..9b198b3
--- /dev/null
+++ b/src/components/common/Avatar.tsx
@@ -0,0 +1,21 @@
+import { lorelei } from '@dicebear/collection';
+import { createAvatar } from '@dicebear/core';
+import { useMemo } from 'react';
+import { getColor, getPastel } from '@/lib/colors';
+
+const lib = lorelei;
+
+export function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) {
+ const backgroundColor = getPastel(getColor(seed), 4);
+
+ const avatar = useMemo(() => {
+ return createAvatar(lib, {
+ ...props,
+ seed,
+ size,
+ backgroundColor: [backgroundColor],
+ }).toDataUri();
+ }, []);
+
+ return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%', width: size }} />;
+}
diff --git a/src/components/common/ConfirmationForm.tsx b/src/components/common/ConfirmationForm.tsx
new file mode 100644
index 0000000..b909ef5
--- /dev/null
+++ b/src/components/common/ConfirmationForm.tsx
@@ -0,0 +1,42 @@
+import { Box, Button, Form, FormButtons, FormSubmitButton } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { useMessages } from '@/components/hooks';
+
+export interface ConfirmationFormProps {
+ message: ReactNode;
+ buttonLabel?: ReactNode;
+ buttonVariant?: 'primary' | 'quiet' | 'danger';
+ isLoading?: boolean;
+ error?: string | Error;
+ onConfirm?: () => void;
+ onClose?: () => void;
+}
+
+export function ConfirmationForm({
+ message,
+ buttonLabel,
+ buttonVariant,
+ isLoading,
+ error,
+ onConfirm,
+ onClose,
+}: ConfirmationFormProps) {
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+
+ return (
+ <Form onSubmit={onConfirm} error={getErrorMessage(error)}>
+ <Box marginY="4">{message}</Box>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton
+ data-test="button-confirm"
+ isLoading={isLoading}
+ variant={buttonVariant}
+ isDisabled={false}
+ >
+ {buttonLabel || formatMessage(labels.ok)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx
new file mode 100644
index 0000000..7e07b8d
--- /dev/null
+++ b/src/components/common/DataGrid.tsx
@@ -0,0 +1,107 @@
+import type { UseQueryResult } from '@tanstack/react-query';
+import { Column, Row, SearchField } from '@umami/react-zen';
+import {
+ cloneElement,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+ useCallback,
+ useState,
+} from 'react';
+import { Empty } from '@/components/common/Empty';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Pager } from '@/components/common/Pager';
+import { useMessages, useMobile, useNavigation } from '@/components/hooks';
+import type { PageResult } from '@/lib/types';
+
+const DEFAULT_SEARCH_DELAY = 600;
+
+export interface DataGridProps {
+ query: UseQueryResult<PageResult<any>, any>;
+ searchDelay?: number;
+ allowSearch?: boolean;
+ allowPaging?: boolean;
+ autoFocus?: boolean;
+ renderActions?: () => ReactNode;
+ renderEmpty?: () => ReactNode;
+ children: ReactNode | ((data: any) => ReactNode);
+}
+
+export function DataGrid({
+ query,
+ searchDelay = 600,
+ allowSearch,
+ allowPaging = true,
+ autoFocus,
+ renderActions,
+ renderEmpty = () => <Empty />,
+ children,
+}: DataGridProps) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading, isFetching } = query;
+ const { router, updateParams, query: queryParams } = useNavigation();
+ const [search, setSearch] = useState(queryParams?.search || data?.search || '');
+ const showPager = allowPaging && data && data.count > data.pageSize;
+ const { isMobile } = useMobile();
+ const displayMode = isMobile ? 'cards' : undefined;
+
+ const handleSearch = (value: string) => {
+ if (value !== search) {
+ setSearch(value);
+ router.push(updateParams({ search: value, page: 1 }));
+ }
+ };
+
+ const handlePageChange = useCallback(
+ (page: number) => {
+ router.push(updateParams({ search, page }));
+ },
+ [search],
+ );
+
+ const child = data ? (typeof children === 'function' ? children(data) : children) : null;
+
+ return (
+ <Column gap="4" minHeight="300px">
+ {allowSearch && (
+ <Row alignItems="center" justifyContent="space-between" wrap="wrap" gap>
+ <SearchField
+ value={search}
+ onSearch={handleSearch}
+ delay={searchDelay || DEFAULT_SEARCH_DELAY}
+ autoFocus={autoFocus}
+ placeholder={formatMessage(labels.search)}
+ />
+ {renderActions?.()}
+ </Row>
+ )}
+ <LoadingPanel
+ data={data}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={error}
+ renderEmpty={renderEmpty}
+ >
+ {data && (
+ <>
+ <Column>
+ {isValidElement(child)
+ ? cloneElement(child as ReactElement<any>, { displayMode })
+ : child}
+ </Column>
+ {showPager && (
+ <Row marginTop="6">
+ <Pager
+ page={data.page}
+ pageSize={data.pageSize}
+ count={data.count}
+ onPageChange={handlePageChange}
+ />
+ </Row>
+ )}
+ </>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/components/common/DateDisplay.tsx b/src/components/common/DateDisplay.tsx
new file mode 100644
index 0000000..0bece8a
--- /dev/null
+++ b/src/components/common/DateDisplay.tsx
@@ -0,0 +1,28 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import { differenceInDays, isSameDay } from 'date-fns';
+import { useLocale } from '@/components/hooks';
+import { Calendar } from '@/components/icons';
+import { formatDate } from '@/lib/date';
+
+export function DateDisplay({ startDate, endDate }) {
+ const { locale } = useLocale();
+ const isSingleDate = differenceInDays(endDate, startDate) === 0;
+
+ return (
+ <Row gap="3" alignItems="center" wrap="nowrap">
+ <Icon>
+ <Calendar />
+ </Icon>
+ <Text wrap="nowrap">
+ {isSingleDate ? (
+ formatDate(startDate, 'PP', locale)
+ ) : (
+ <>
+ {formatDate(startDate, 'PP', locale)}
+ {!isSameDay(startDate, endDate) && ` — ${formatDate(endDate, 'PP', locale)}`}
+ </>
+ )}
+ </Text>
+ </Row>
+ );
+}
diff --git a/src/components/common/DateDistance.tsx b/src/components/common/DateDistance.tsx
new file mode 100644
index 0000000..e8bd278
--- /dev/null
+++ b/src/components/common/DateDistance.tsx
@@ -0,0 +1,19 @@
+import { Text } from '@umami/react-zen';
+import { formatDistanceToNow } from 'date-fns';
+import { useLocale, useTimezone } from '@/components/hooks';
+import { isInvalidDate } from '@/lib/date';
+
+export function DateDistance({ date }: { date: Date }) {
+ const { formatTimezoneDate } = useTimezone();
+ const { dateLocale } = useLocale();
+
+ if (isInvalidDate(date)) {
+ return null;
+ }
+
+ return (
+ <Text title={formatTimezoneDate(date?.toISOString(), 'PPPpp')}>
+ {formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })}
+ </Text>
+ );
+}
diff --git a/src/components/common/Empty.tsx b/src/components/common/Empty.tsx
new file mode 100644
index 0000000..8bd8d82
--- /dev/null
+++ b/src/components/common/Empty.tsx
@@ -0,0 +1,24 @@
+import { Row } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+
+export interface EmptyProps {
+ message?: string;
+}
+
+export function Empty({ message }: EmptyProps) {
+ const { formatMessage, messages } = useMessages();
+
+ return (
+ <Row
+ color="muted"
+ alignItems="center"
+ justifyContent="center"
+ width="100%"
+ height="100%"
+ minHeight="70px"
+ flexGrow={1}
+ >
+ {message || formatMessage(messages.noDataAvailable)}
+ </Row>
+ );
+}
diff --git a/src/components/common/EmptyPlaceholder.tsx b/src/components/common/EmptyPlaceholder.tsx
new file mode 100644
index 0000000..64492e0
--- /dev/null
+++ b/src/components/common/EmptyPlaceholder.tsx
@@ -0,0 +1,28 @@
+import { Column, Icon, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export interface EmptyPlaceholderProps {
+ title?: string;
+ description?: string;
+ icon?: ReactNode;
+ children?: ReactNode;
+}
+
+export function EmptyPlaceholder({ title, description, icon, children }: EmptyPlaceholderProps) {
+ return (
+ <Column alignItems="center" justifyContent="center" gap="5" height="100%" width="100%">
+ {icon && (
+ <Icon color="10" size="xl">
+ {icon}
+ </Icon>
+ )}
+ {title && (
+ <Text weight="bold" size="4">
+ {title}
+ </Text>
+ )}
+ {description && <Text color="muted">{description}</Text>}
+ {children}
+ </Column>
+ );
+}
diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx
new file mode 100644
index 0000000..4c0c82e
--- /dev/null
+++ b/src/components/common/ErrorBoundary.tsx
@@ -0,0 +1,38 @@
+import { Button, Column } from '@umami/react-zen';
+import type { ErrorInfo, ReactNode } from 'react';
+import { ErrorBoundary as Boundary } from 'react-error-boundary';
+import { useMessages } from '@/components/hooks';
+
+const logError = (error: Error, info: ErrorInfo) => {
+ // eslint-disable-next-line no-console
+ console.error(error, info.componentStack);
+};
+
+export function ErrorBoundary({ children }: { children: ReactNode }) {
+ const { formatMessage, messages } = useMessages();
+
+ const fallbackRender = ({ error, resetErrorBoundary }) => {
+ return (
+ <Column
+ role="alert"
+ gap
+ width="100%"
+ height="100%"
+ position="absolute"
+ justifyContent="center"
+ alignItems="center"
+ >
+ <h1>{formatMessage(messages.error)}</h1>
+ <h3>{error.message}</h3>
+ <pre>{error.stack}</pre>
+ <Button onClick={resetErrorBoundary}>OK</Button>
+ </Column>
+ );
+ };
+
+ return (
+ <Boundary fallbackRender={fallbackRender} onError={logError}>
+ {children}
+ </Boundary>
+ );
+}
diff --git a/src/components/common/ErrorMessage.tsx b/src/components/common/ErrorMessage.tsx
new file mode 100644
index 0000000..3c30151
--- /dev/null
+++ b/src/components/common/ErrorMessage.tsx
@@ -0,0 +1,16 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { AlertTriangle } from '@/components/icons';
+
+export function ErrorMessage() {
+ const { formatMessage, messages } = useMessages();
+
+ return (
+ <Row alignItems="center" justifyContent="center" gap>
+ <Icon>
+ <AlertTriangle />
+ </Icon>
+ <Text>{formatMessage(messages.error)}</Text>
+ </Row>
+ );
+}
diff --git a/src/components/common/ExternalLink.tsx b/src/components/common/ExternalLink.tsx
new file mode 100644
index 0000000..dec0d16
--- /dev/null
+++ b/src/components/common/ExternalLink.tsx
@@ -0,0 +1,23 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import Link, { type LinkProps } from 'next/link';
+import type { ReactNode } from 'react';
+import { ExternalLink as LinkIcon } from '@/components/icons';
+
+export function ExternalLink({
+ href,
+ children,
+ ...props
+}: LinkProps & { href: string; children: ReactNode }) {
+ return (
+ <Row alignItems="center" overflow="hidden" gap>
+ <Text title={href} truncate>
+ <Link {...props} href={href} target="_blank">
+ {children}
+ </Link>
+ </Text>
+ <Icon size="sm" strokeColor="muted">
+ <LinkIcon />
+ </Icon>
+ </Row>
+ );
+}
diff --git a/src/components/common/Favicon.tsx b/src/components/common/Favicon.tsx
new file mode 100644
index 0000000..a6b5e52
--- /dev/null
+++ b/src/components/common/Favicon.tsx
@@ -0,0 +1,22 @@
+import { useConfig } from '@/components/hooks';
+import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants';
+
+function getHostName(url: string) {
+ const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
+ return match && match.length > 1 ? match[1] : null;
+}
+
+export function Favicon({ domain, ...props }) {
+ const config = useConfig();
+
+ if (config?.privateMode) {
+ return null;
+ }
+
+ const url = config?.faviconUrl || FAVICON_URL;
+ const hostName = domain ? getHostName(domain) : null;
+ const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName;
+ const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null;
+
+ return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null;
+}
diff --git a/src/components/common/FilterLink.tsx b/src/components/common/FilterLink.tsx
new file mode 100644
index 0000000..d719a37
--- /dev/null
+++ b/src/components/common/FilterLink.tsx
@@ -0,0 +1,49 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { type HTMLAttributes, type ReactNode, useState } from 'react';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { ExternalLink } from '@/components/icons';
+
+export interface FilterLinkProps extends HTMLAttributes<HTMLDivElement> {
+ type: string;
+ value: string;
+ label?: string;
+ icon?: ReactNode;
+ externalUrl?: string;
+}
+
+export function FilterLink({ type, value, label, externalUrl, icon }: FilterLinkProps) {
+ const [showLink, setShowLink] = useState(false);
+ const { formatMessage, labels } = useMessages();
+ const { updateParams, query } = useNavigation();
+ const active = query[type] !== undefined;
+ const selected = query[type] === value;
+
+ return (
+ <Row
+ alignItems="center"
+ gap
+ fontWeight={active && selected ? 'bold' : undefined}
+ color={active && !selected ? 'muted' : undefined}
+ onMouseOver={() => setShowLink(true)}
+ onMouseOut={() => setShowLink(false)}
+ >
+ {icon}
+ {!value && `(${label || formatMessage(labels.unknown)})`}
+ {value && (
+ <Text title={label || value} truncate>
+ <Link href={updateParams({ [type]: `eq.${value}` })} replace>
+ {label || value}
+ </Link>
+ </Text>
+ )}
+ {externalUrl && showLink && (
+ <a href={externalUrl} target="_blank" rel="noreferrer noopener">
+ <Icon color="muted">
+ <ExternalLink />
+ </Icon>
+ </a>
+ )}
+ </Row>
+ );
+}
diff --git a/src/components/common/FilterRecord.tsx b/src/components/common/FilterRecord.tsx
new file mode 100644
index 0000000..0400264
--- /dev/null
+++ b/src/components/common/FilterRecord.tsx
@@ -0,0 +1,117 @@
+import { Button, Column, Grid, Icon, Label, ListItem, Select, TextField } from '@umami/react-zen';
+import { useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks';
+import { X } from '@/components/icons';
+import { isSearchOperator } from '@/lib/params';
+
+export interface FilterRecordProps {
+ websiteId: string;
+ type: string;
+ startDate: Date;
+ endDate: Date;
+ name: string;
+ operator: string;
+ value: string;
+ onSelect?: (name: string, value: any) => void;
+ onRemove?: (name: string) => void;
+ onChange?: (name: string, value: string) => void;
+}
+
+export function FilterRecord({
+ websiteId,
+ type,
+ startDate,
+ endDate,
+ name,
+ operator,
+ value,
+ onSelect,
+ onRemove,
+ onChange,
+}: FilterRecordProps) {
+ const { fields, operators } = useFilters();
+ const [selected, setSelected] = useState(value);
+ const [search, setSearch] = useState('');
+ const { formatValue } = useFormat();
+ const { data, isLoading } = useWebsiteValuesQuery({
+ websiteId,
+ type,
+ search,
+ startDate,
+ endDate,
+ });
+ const isSearch = isSearchOperator(operator);
+ const items = data?.filter(({ value }) => value) || [];
+
+ const handleSearch = (value: string) => {
+ setSearch(value);
+ };
+
+ const handleSelectOperator = (value: any) => {
+ onSelect?.(name, value);
+ };
+
+ const handleSelectValue = (value: string) => {
+ setSelected(value);
+ onChange?.(name, value);
+ };
+
+ const renderValue = () => {
+ return formatValue(selected, type);
+ };
+
+ return (
+ <Column>
+ <Label>{fields.find(f => f.name === name)?.label}</Label>
+ <Grid columns="1fr auto" gap>
+ <Grid columns={{ xs: '1fr', md: '200px 1fr' }} gap>
+ <Select
+ items={operators.filter(({ type }) => type === 'string')}
+ value={operator}
+ onChange={handleSelectOperator}
+ >
+ {({ name, label }: any) => {
+ return (
+ <ListItem key={name} id={name}>
+ {label}
+ </ListItem>
+ );
+ }}
+ </Select>
+ {isSearch && (
+ <TextField value={selected} defaultValue={selected} onChange={handleSelectValue} />
+ )}
+ {!isSearch && (
+ <Select
+ items={items}
+ value={selected}
+ onChange={handleSelectValue}
+ searchValue={search}
+ renderValue={renderValue}
+ onSearch={handleSearch}
+ isLoading={isLoading}
+ listProps={{ renderEmptyState: () => <Empty /> }}
+ allowSearch
+ >
+ {items?.map(({ value }) => {
+ return (
+ <ListItem key={value} id={value}>
+ {formatValue(value, type)}
+ </ListItem>
+ );
+ })}
+ </Select>
+ )}
+ </Grid>
+ <Column justifyContent="flex-start">
+ <Button onPress={() => onRemove?.(name)}>
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ </Column>
+ </Grid>
+ </Column>
+ );
+}
diff --git a/src/components/common/GridRow.tsx b/src/components/common/GridRow.tsx
new file mode 100644
index 0000000..72f1db6
--- /dev/null
+++ b/src/components/common/GridRow.tsx
@@ -0,0 +1,32 @@
+import { Grid } from '@umami/react-zen';
+
+const LAYOUTS = {
+ one: { columns: '1fr' },
+ two: {
+ columns: {
+ xs: '1fr',
+ md: 'repeat(auto-fill, minmax(560px, 1fr))',
+ },
+ },
+ three: {
+ columns: {
+ xs: '1fr',
+ md: 'repeat(auto-fill, minmax(360px, 1fr))',
+ },
+ },
+ 'one-two': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } },
+ 'two-one': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } },
+};
+
+export function GridRow(props: {
+ layout?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare';
+ className?: string;
+ children?: any;
+}) {
+ const { layout = 'two', children, ...otherProps } = props;
+ return (
+ <Grid gap="3" {...LAYOUTS[layout]} {...otherProps}>
+ {children}
+ </Grid>
+ );
+}
diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx
new file mode 100644
index 0000000..35292ba
--- /dev/null
+++ b/src/components/common/LinkButton.tsx
@@ -0,0 +1,41 @@
+import { Button, type ButtonProps } from '@umami/react-zen';
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+import { useLocale } from '@/components/hooks';
+
+export interface LinkButtonProps extends ButtonProps {
+ href: string;
+ target?: string;
+ scroll?: boolean;
+ variant?: any;
+ prefetch?: boolean;
+ asAnchor?: boolean;
+ children?: ReactNode;
+}
+
+export function LinkButton({
+ href,
+ variant,
+ scroll = true,
+ target,
+ prefetch,
+ children,
+ asAnchor,
+ ...props
+}: LinkButtonProps) {
+ const { dir } = useLocale();
+
+ return (
+ <Button {...props} variant={variant} asChild>
+ {asAnchor ? (
+ <a href={href} target={target}>
+ {children}
+ </a>
+ ) : (
+ <Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}>
+ {children}
+ </Link>
+ )}
+ </Button>
+ );
+}
diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx
new file mode 100644
index 0000000..fb37e14
--- /dev/null
+++ b/src/components/common/LoadingPanel.tsx
@@ -0,0 +1,71 @@
+import { Column, type ColumnProps, Loading } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { ErrorMessage } from '@/components/common/ErrorMessage';
+
+export interface LoadingPanelProps extends ColumnProps {
+ data?: any;
+ error?: unknown;
+ isEmpty?: boolean;
+ isLoading?: boolean;
+ isFetching?: boolean;
+ loadingIcon?: 'dots' | 'spinner';
+ loadingPlacement?: 'center' | 'absolute' | 'inline';
+ renderEmpty?: () => ReactNode;
+ children: ReactNode;
+}
+
+export function LoadingPanel({
+ data,
+ error,
+ isEmpty,
+ isLoading,
+ isFetching,
+ loadingIcon = 'dots',
+ loadingPlacement = 'absolute',
+ renderEmpty = () => <Empty />,
+ children,
+ ...props
+}: LoadingPanelProps): ReactNode {
+ const empty = isEmpty ?? checkEmpty(data);
+
+ // Show loading spinner only if no data exists
+ if (isLoading || isFetching) {
+ return (
+ <Column position="relative" height="100%" width="100%" {...props}>
+ <Loading icon={loadingIcon} placement={loadingPlacement} />
+ </Column>
+ );
+ }
+
+ // Show error
+ if (error) {
+ return <ErrorMessage />;
+ }
+
+ // Show empty state (once loaded)
+ if (!error && !isLoading && !isFetching && empty) {
+ return renderEmpty();
+ }
+
+ // Show main content when data exists
+ if (!isLoading && !isFetching && !error && !empty) {
+ return children;
+ }
+
+ return null;
+}
+
+function checkEmpty(data: any) {
+ if (!data) return false;
+
+ if (Array.isArray(data)) {
+ return data.length <= 0;
+ }
+
+ if (typeof data === 'object') {
+ return Object.keys(data).length <= 0;
+ }
+
+ return !!data;
+}
diff --git a/src/components/common/PageBody.tsx b/src/components/common/PageBody.tsx
new file mode 100644
index 0000000..f07e589
--- /dev/null
+++ b/src/components/common/PageBody.tsx
@@ -0,0 +1,42 @@
+'use client';
+import { AlertBanner, Column, type ColumnProps, Loading } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { useMessages } from '@/components/hooks';
+
+const DEFAULT_WIDTH = '1320px';
+
+export function PageBody({
+ maxWidth = DEFAULT_WIDTH,
+ error,
+ isLoading,
+ children,
+ ...props
+}: {
+ maxWidth?: string;
+ error?: unknown;
+ isLoading?: boolean;
+ children?: ReactNode;
+} & ColumnProps) {
+ const { formatMessage, messages } = useMessages();
+
+ if (error) {
+ return <AlertBanner title={formatMessage(messages.error)} variant="error" />;
+ }
+
+ if (isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ return (
+ <Column
+ {...props}
+ width="100%"
+ paddingBottom="6"
+ maxWidth={maxWidth}
+ paddingX={{ xs: '3', md: '6' }}
+ style={{ margin: '0 auto' }}
+ >
+ {children}
+ </Column>
+ );
+}
diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx
new file mode 100644
index 0000000..9216788
--- /dev/null
+++ b/src/components/common/PageHeader.tsx
@@ -0,0 +1,58 @@
+import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { LinkButton } from './LinkButton';
+
+export function PageHeader({
+ title,
+ description,
+ label,
+ icon,
+ showBorder = true,
+ titleHref,
+ children,
+}: {
+ title: string;
+ description?: string;
+ label?: ReactNode;
+ icon?: ReactNode;
+ showBorder?: boolean;
+ titleHref?: string;
+ allowEdit?: boolean;
+ className?: string;
+ children?: ReactNode;
+}) {
+ return (
+ <Grid
+ columns={{ xs: '1fr', md: '1fr 1fr' }}
+ paddingY="6"
+ marginBottom="6"
+ border={showBorder ? 'bottom' : undefined}
+ >
+ <Column gap="2">
+ {label}
+ <Row alignItems="center" gap="3">
+ {icon && (
+ <Icon size="md" color="muted">
+ {icon}
+ </Icon>
+ )}
+ {title && titleHref ? (
+ <LinkButton href={titleHref} variant="quiet">
+ <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
+ </LinkButton>
+ ) : (
+ title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
+ )}
+ </Row>
+ {description && (
+ <Text color="muted" truncate style={{ maxWidth: 600 }} title={description}>
+ {description}
+ </Text>
+ )}
+ </Column>
+ <Row justifyContent="flex-end" alignItems="center">
+ {children}
+ </Row>
+ </Grid>
+ );
+}
diff --git a/src/components/common/Pager.tsx b/src/components/common/Pager.tsx
new file mode 100644
index 0000000..c65e2f6
--- /dev/null
+++ b/src/components/common/Pager.tsx
@@ -0,0 +1,60 @@
+import { Button, Icon, Row, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { ChevronRight } from '@/components/icons';
+
+export interface PagerProps {
+ page: string | number;
+ pageSize: string | number;
+ count: string | number;
+ onPageChange: (nextPage: number) => void;
+ className?: string;
+}
+
+export function Pager({ page, pageSize, count, onPageChange }: PagerProps) {
+ const { formatMessage, labels } = useMessages();
+ const maxPage = pageSize && count ? Math.ceil(+count / +pageSize) : 0;
+ const lastPage = page === maxPage;
+ const firstPage = page === 1;
+
+ if (count === 0 || !maxPage) {
+ return null;
+ }
+
+ const handlePageChange = (value: number) => {
+ const nextPage = +page + +value;
+
+ if (nextPage > 0 && nextPage <= maxPage) {
+ onPageChange(nextPage);
+ }
+ };
+
+ if (maxPage === 1) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1}>
+ <Text>{formatMessage(labels.numberOfRecords, { x: count.toLocaleString() })}</Text>
+ <Row alignItems="center" justifyContent="flex-end" gap="3">
+ <Text>
+ {formatMessage(labels.pageOf, {
+ current: page.toLocaleString(),
+ total: maxPage.toLocaleString(),
+ })}
+ </Text>
+ <Row gap="1">
+ <Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
+ <Icon size="sm" rotate={180}>
+ <ChevronRight />
+ </Icon>
+ </Button>
+ <Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
+ <Icon size="sm">
+ <ChevronRight />
+ </Icon>
+ </Button>
+ </Row>
+ </Row>
+ </Row>
+ );
+}
diff --git a/src/components/common/Panel.tsx b/src/components/common/Panel.tsx
new file mode 100644
index 0000000..bb66746
--- /dev/null
+++ b/src/components/common/Panel.tsx
@@ -0,0 +1,64 @@
+import {
+ Button,
+ Column,
+ type ColumnProps,
+ Heading,
+ Icon,
+ Row,
+ Tooltip,
+ TooltipTrigger,
+} from '@umami/react-zen';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { Maximize, X } from '@/components/icons';
+
+export interface PanelProps extends ColumnProps {
+ title?: string;
+ allowFullscreen?: boolean;
+}
+
+const fullscreenStyles = {
+ position: 'fixed',
+ width: '100%',
+ height: '100%',
+ top: 0,
+ left: 0,
+ border: 'none',
+ zIndex: 9999,
+} as any;
+
+export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) {
+ const { formatMessage, labels } = useMessages();
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ const handleFullscreen = () => {
+ setIsFullscreen(!isFullscreen);
+ };
+
+ return (
+ <Column
+ paddingY="6"
+ paddingX={{ xs: '3', md: '6' }}
+ border
+ borderRadius="3"
+ backgroundColor
+ position="relative"
+ gap
+ {...props}
+ style={{ ...style, ...(isFullscreen ? fullscreenStyles : {}) }}
+ >
+ {title && <Heading>{title}</Heading>}
+ {allowFullscreen && (
+ <Row justifyContent="flex-end" alignItems="center">
+ <TooltipTrigger delay={0} isDisabled={isFullscreen}>
+ <Button size="sm" variant="quiet" onPress={handleFullscreen}>
+ <Icon>{isFullscreen ? <X /> : <Maximize />}</Icon>
+ </Button>
+ <Tooltip>{formatMessage(labels.maximize)}</Tooltip>
+ </TooltipTrigger>
+ </Row>
+ )}
+ {children}
+ </Column>
+ );
+}
diff --git a/src/components/common/SectionHeader.tsx b/src/components/common/SectionHeader.tsx
new file mode 100644
index 0000000..5b911ef
--- /dev/null
+++ b/src/components/common/SectionHeader.tsx
@@ -0,0 +1,28 @@
+import { Heading, Icon, Row, type RowProps, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export function SectionHeader({
+ title,
+ description,
+ icon,
+ children,
+ ...props
+}: {
+ title?: string;
+ description?: string;
+ icon?: ReactNode;
+ allowEdit?: boolean;
+ className?: string;
+ children?: ReactNode;
+} & RowProps) {
+ return (
+ <Row {...props} justifyContent="space-between" alignItems="center" height="60px">
+ <Row gap="3" alignItems="center">
+ {icon && <Icon size="md">{icon}</Icon>}
+ {title && <Heading size="3">{title}</Heading>}
+ {description && <Text color="muted">{description}</Text>}
+ </Row>
+ <Row justifyContent="flex-end">{children}</Row>
+ </Row>
+ );
+}
diff --git a/src/components/common/SideMenu.tsx b/src/components/common/SideMenu.tsx
new file mode 100644
index 0000000..92ff798
--- /dev/null
+++ b/src/components/common/SideMenu.tsx
@@ -0,0 +1,80 @@
+import {
+ Column,
+ Heading,
+ IconLabel,
+ NavMenu,
+ NavMenuGroup,
+ NavMenuItem,
+ type NavMenuProps,
+ Row,
+} from '@umami/react-zen';
+import Link from 'next/link';
+
+interface SideMenuData {
+ id: string;
+ label: string;
+ icon?: any;
+ path: string;
+}
+
+interface SideMenuItems {
+ label?: string;
+ items: SideMenuData[];
+}
+
+export interface SideMenuProps extends NavMenuProps {
+ items: SideMenuItems[];
+ title?: string;
+ selectedKey?: string;
+ allowMinimize?: boolean;
+}
+
+export function SideMenu({
+ items = [],
+ title,
+ selectedKey,
+ allowMinimize,
+ ...props
+}: SideMenuProps) {
+ const renderItems = (items: SideMenuData[]) => {
+ return items?.map(({ id, label, icon, path }) => {
+ const isSelected = selectedKey === id;
+
+ return (
+ <Link key={id} href={path}>
+ <NavMenuItem isSelected={isSelected}>
+ <IconLabel icon={icon}>{label}</IconLabel>
+ </NavMenuItem>
+ </Link>
+ );
+ });
+ };
+
+ return (
+ <Column gap overflowY="auto" justifyContent="space-between" position="sticky" top="20px">
+ {title && (
+ <Row padding>
+ <Heading size="1">{title}</Heading>
+ </Row>
+ )}
+ <NavMenu gap="6" {...props}>
+ {items?.map(({ label, items }, index) => {
+ if (label) {
+ return (
+ <NavMenuGroup
+ title={label}
+ key={`${label}${index}`}
+ gap="1"
+ allowMinimize={allowMinimize}
+ marginBottom="3"
+ >
+ {renderItems(items)}
+ </NavMenuGroup>
+ );
+ }
+ return null;
+ })}
+ </NavMenu>
+ </Column>
+ );
+}
diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx
new file mode 100644
index 0000000..1121fa7
--- /dev/null
+++ b/src/components/common/TypeConfirmationForm.tsx
@@ -0,0 +1,55 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+
+export function TypeConfirmationForm({
+ confirmationValue,
+ buttonLabel,
+ buttonVariant,
+ isLoading,
+ error,
+ onConfirm,
+ onClose,
+}: {
+ confirmationValue: string;
+ buttonLabel?: string;
+ buttonVariant?: 'primary' | 'outline' | 'quiet' | 'danger' | 'zero';
+ isLoading?: boolean;
+ error?: string | Error;
+ onConfirm?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ if (!confirmationValue) {
+ return null;
+ }
+
+ return (
+ <Form onSubmit={onConfirm} error={getErrorMessage(error)}>
+ <p>
+ {formatMessage(messages.actionConfirmation, {
+ confirmation: confirmationValue,
+ })}
+ </p>
+ <FormField
+ label={formatMessage(labels.confirm)}
+ name="confirm"
+ rules={{ validate: value => value === confirmationValue }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton isLoading={isLoading} variant={buttonVariant}>
+ {buttonLabel || formatMessage(labels.ok)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/components/common/TypeIcon.tsx b/src/components/common/TypeIcon.tsx
new file mode 100644
index 0000000..8894b3a
--- /dev/null
+++ b/src/components/common/TypeIcon.tsx
@@ -0,0 +1,29 @@
+import { Row } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export function TypeIcon({
+ type,
+ value,
+ children,
+}: {
+ type: 'browser' | 'country' | 'device' | 'os';
+ value: string;
+ children?: ReactNode;
+}) {
+ return (
+ <Row gap="3" alignItems="center">
+ <img
+ src={`${process.env.basePath || ''}/images/${type}/${
+ value?.replaceAll(' ', '-').toLowerCase() || 'unknown'
+ }.png`}
+ onError={e => {
+ e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`;
+ }}
+ alt={value}
+ width={type === 'country' ? undefined : 16}
+ height={type === 'country' ? undefined : 16}
+ />
+ {children}
+ </Row>
+ );
+}
diff --git a/src/components/hooks/context/useLink.ts b/src/components/hooks/context/useLink.ts
new file mode 100644
index 0000000..8766bbb
--- /dev/null
+++ b/src/components/hooks/context/useLink.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { LinkContext } from '@/app/(main)/links/LinkProvider';
+
+export function useLink() {
+ return useContext(LinkContext);
+}
diff --git a/src/components/hooks/context/usePixel.ts b/src/components/hooks/context/usePixel.ts
new file mode 100644
index 0000000..69cad6f
--- /dev/null
+++ b/src/components/hooks/context/usePixel.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { PixelContext } from '@/app/(main)/pixels/PixelProvider';
+
+export function usePixel() {
+ return useContext(PixelContext);
+}
diff --git a/src/components/hooks/context/useTeam.ts b/src/components/hooks/context/useTeam.ts
new file mode 100644
index 0000000..95ff4be
--- /dev/null
+++ b/src/components/hooks/context/useTeam.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { TeamContext } from '@/app/(main)/teams/TeamProvider';
+
+export function useTeam() {
+ return useContext(TeamContext);
+}
diff --git a/src/components/hooks/context/useUser.ts b/src/components/hooks/context/useUser.ts
new file mode 100644
index 0000000..fa97ea9
--- /dev/null
+++ b/src/components/hooks/context/useUser.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider';
+
+export function useUser() {
+ return useContext(UserContext);
+}
diff --git a/src/components/hooks/context/useWebsite.ts b/src/components/hooks/context/useWebsite.ts
new file mode 100644
index 0000000..3d4be27
--- /dev/null
+++ b/src/components/hooks/context/useWebsite.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { WebsiteContext } from '@/app/(main)/websites/WebsiteProvider';
+
+export function useWebsite() {
+ return useContext(WebsiteContext);
+}
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
new file mode 100644
index 0000000..e8e5c13
--- /dev/null
+++ b/src/components/hooks/index.ts
@@ -0,0 +1,84 @@
+'use client';
+
+// Context hooks
+export * from './context/useLink';
+export * from './context/usePixel';
+export * from './context/useTeam';
+export * from './context/useUser';
+export * from './context/useWebsite';
+
+// Query hooks
+export * from './queries/useActiveUsersQuery';
+export * from './queries/useDateRangeQuery';
+export * from './queries/useDeleteQuery';
+export * from './queries/useEventDataEventsQuery';
+export * from './queries/useEventDataPropertiesQuery';
+export * from './queries/useEventDataQuery';
+export * from './queries/useEventDataValuesQuery';
+export * from './queries/useLinkQuery';
+export * from './queries/useLinksQuery';
+export * from './queries/useLoginQuery';
+export * from './queries/usePixelQuery';
+export * from './queries/usePixelsQuery';
+export * from './queries/useRealtimeQuery';
+export * from './queries/useReportQuery';
+export * from './queries/useReportsQuery';
+export * from './queries/useResultQuery';
+export * from './queries/useSessionActivityQuery';
+export * from './queries/useSessionDataPropertiesQuery';
+export * from './queries/useSessionDataQuery';
+export * from './queries/useSessionDataValuesQuery';
+export * from './queries/useShareTokenQuery';
+export * from './queries/useTeamMembersQuery';
+export * from './queries/useTeamQuery';
+export * from './queries/useTeamsQuery';
+export * from './queries/useTeamWebsitesQuery';
+export * from './queries/useUpdateQuery';
+export * from './queries/useUserQuery';
+export * from './queries/useUsersQuery';
+export * from './queries/useUserTeamsQuery';
+export * from './queries/useUserWebsitesQuery';
+export * from './queries/useWebsiteCohortQuery';
+export * from './queries/useWebsiteCohortsQuery';
+export * from './queries/useWebsiteEventsQuery';
+export * from './queries/useWebsiteEventsSeriesQuery';
+export * from './queries/useWebsiteExpandedMetricsQuery';
+export * from './queries/useWebsiteMetricsQuery';
+export * from './queries/useWebsitePageviewsQuery';
+export * from './queries/useWebsiteQuery';
+export * from './queries/useWebsiteSegmentQuery';
+export * from './queries/useWebsiteSegmentsQuery';
+export * from './queries/useWebsiteSessionQuery';
+export * from './queries/useWebsiteSessionStatsQuery';
+export * from './queries/useWebsiteSessionsQuery';
+export * from './queries/useWebsiteStatsQuery';
+export * from './queries/useWebsitesQuery';
+export * from './queries/useWebsiteValuesQuery';
+export * from './queries/useWeeklyTrafficQuery';
+
+// Regular hooks
+export * from './useApi';
+export * from './useConfig';
+export * from './useCountryNames';
+export * from './useDateParameters';
+export * from './useDateRange';
+export * from './useDocumentClick';
+export * from './useEscapeKey';
+export * from './useFields';
+export * from './useFilterParameters';
+export * from './useFilters';
+export * from './useForceUpdate';
+export * from './useFormat';
+export * from './useGlobalState';
+export * from './useLanguageNames';
+export * from './useLocale';
+export * from './useMessages';
+export * from './useMobile';
+export * from './useModified';
+export * from './useNavigation';
+export * from './usePagedQuery';
+export * from './usePageParameters';
+export * from './useRegionNames';
+export * from './useSlug';
+export * from './useSticky';
+export * from './useTimezone';
diff --git a/src/components/hooks/queries/useActiveUsersQuery.ts b/src/components/hooks/queries/useActiveUsersQuery.ts
new file mode 100644
index 0000000..42867c1
--- /dev/null
+++ b/src/components/hooks/queries/useActiveUsersQuery.ts
@@ -0,0 +1,12 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+
+export function useActyiveUsersQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ return useQuery<any>({
+ queryKey: ['websites:active', websiteId],
+ queryFn: () => get(`/websites/${websiteId}/active`),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useDateRangeQuery.ts b/src/components/hooks/queries/useDateRangeQuery.ts
new file mode 100644
index 0000000..84b7eec
--- /dev/null
+++ b/src/components/hooks/queries/useDateRangeQuery.ts
@@ -0,0 +1,23 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+
+type DateRange = {
+ startDate?: string;
+ endDate?: string;
+};
+
+export function useDateRangeQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+
+ const { data } = useQuery<DateRange>({
+ queryKey: ['date-range', websiteId],
+ queryFn: () => get(`/websites/${websiteId}/daterange`),
+ enabled: !!websiteId,
+ ...options,
+ });
+
+ return {
+ startDate: data?.startDate ? new Date(data.startDate) : null,
+ endDate: data?.endDate ? new Date(data.endDate) : null,
+ };
+}
diff --git a/src/components/hooks/queries/useDeleteQuery.ts b/src/components/hooks/queries/useDeleteQuery.ts
new file mode 100644
index 0000000..556231a
--- /dev/null
+++ b/src/components/hooks/queries/useDeleteQuery.ts
@@ -0,0 +1,12 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useDeleteQuery(path: string, params?: Record<string, any>) {
+ const { del, useMutation } = useApi();
+ const query = useMutation({
+ mutationFn: () => del(path, params),
+ });
+ const { touch } = useModified();
+
+ return { ...query, touch };
+}
diff --git a/src/components/hooks/queries/useEventDataEventsQuery.ts b/src/components/hooks/queries/useEventDataEventsQuery.ts
new file mode 100644
index 0000000..1401989
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataEventsQuery.ts
@@ -0,0 +1,27 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useEventDataEventsQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: [
+ 'websites:event-data:events',
+ { websiteId, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/event-data/events`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useEventDataPropertiesQuery.ts b/src/components/hooks/queries/useEventDataPropertiesQuery.ts
new file mode 100644
index 0000000..dfa6e92
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataPropertiesQuery.ts
@@ -0,0 +1,27 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useEventDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<any>({
+ queryKey: [
+ 'websites:event-data:properties',
+ { websiteId, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/event-data/properties`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useEventDataQuery.ts b/src/components/hooks/queries/useEventDataQuery.ts
new file mode 100644
index 0000000..2ccbd63
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataQuery.ts
@@ -0,0 +1,27 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useEventDataQuery(websiteId: string, eventId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const params = useFilterParameters();
+
+ return useQuery({
+ queryKey: [
+ 'websites:event-data',
+ { websiteId, eventId, startAt, endAt, unit, timezone, ...params },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/event-data/${eventId}`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...params,
+ }),
+ enabled: !!(websiteId && eventId),
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useEventDataValuesQuery.ts b/src/components/hooks/queries/useEventDataValuesQuery.ts
new file mode 100644
index 0000000..6529e14
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataValuesQuery.ts
@@ -0,0 +1,34 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useEventDataValuesQuery(
+ websiteId: string,
+ event: string,
+ propertyName: string,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<any>({
+ queryKey: [
+ 'websites:event-data:values',
+ { websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/event-data/values`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ event,
+ propertyName,
+ }),
+ enabled: !!(websiteId && propertyName),
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useLinkQuery.ts b/src/components/hooks/queries/useLinkQuery.ts
new file mode 100644
index 0000000..2a5d4a9
--- /dev/null
+++ b/src/components/hooks/queries/useLinkQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useLinkQuery(linkId: string) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`link:${linkId}`);
+
+ return useQuery({
+ queryKey: ['link', { linkId, modified }],
+ queryFn: () => {
+ return get(`/links/${linkId}`);
+ },
+ enabled: !!linkId,
+ });
+}
diff --git a/src/components/hooks/queries/useLinksQuery.ts b/src/components/hooks/queries/useLinksQuery.ts
new file mode 100644
index 0000000..ebf945f
--- /dev/null
+++ b/src/components/hooks/queries/useLinksQuery.ts
@@ -0,0 +1,17 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useLinksQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) {
+ const { modified } = useModified('links');
+ const { get } = useApi();
+
+ return usePagedQuery({
+ queryKey: ['links', { teamId, modified }],
+ queryFn: pageParams => {
+ return get(teamId ? `/teams/${teamId}/links` : '/links', pageParams);
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useLoginQuery.ts b/src/components/hooks/queries/useLoginQuery.ts
new file mode 100644
index 0000000..a64b784
--- /dev/null
+++ b/src/components/hooks/queries/useLoginQuery.ts
@@ -0,0 +1,23 @@
+import { setUser, useApp } from '@/store/app';
+import { useApi } from '../useApi';
+
+const selector = (state: { user: any }) => state.user;
+
+export function useLoginQuery() {
+ const { post, useQuery } = useApi();
+ const user = useApp(selector);
+
+ const query = useQuery({
+ queryKey: ['login'],
+ queryFn: async () => {
+ const data = await post('/auth/verify');
+
+ setUser(data);
+
+ return data;
+ },
+ enabled: !user,
+ });
+
+ return { user, setUser, ...query };
+}
diff --git a/src/components/hooks/queries/usePixelQuery.ts b/src/components/hooks/queries/usePixelQuery.ts
new file mode 100644
index 0000000..7fd83c2
--- /dev/null
+++ b/src/components/hooks/queries/usePixelQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function usePixelQuery(pixelId: string) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`pixel:${pixelId}`);
+
+ return useQuery({
+ queryKey: ['pixel', { pixelId, modified }],
+ queryFn: () => {
+ return get(`/pixels/${pixelId}`);
+ },
+ enabled: !!pixelId,
+ });
+}
diff --git a/src/components/hooks/queries/usePixelsQuery.ts b/src/components/hooks/queries/usePixelsQuery.ts
new file mode 100644
index 0000000..c431179
--- /dev/null
+++ b/src/components/hooks/queries/usePixelsQuery.ts
@@ -0,0 +1,17 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function usePixelsQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) {
+ const { modified } = useModified('pixels');
+ const { get } = useApi();
+
+ return usePagedQuery({
+ queryKey: ['pixels', { teamId, modified }],
+ queryFn: pageParams => {
+ return get(teamId ? `/teams/${teamId}/pixels` : '/pixels', pageParams);
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useRealtimeQuery.ts b/src/components/hooks/queries/useRealtimeQuery.ts
new file mode 100644
index 0000000..1a5bd1c
--- /dev/null
+++ b/src/components/hooks/queries/useRealtimeQuery.ts
@@ -0,0 +1,17 @@
+import { REALTIME_INTERVAL } from '@/lib/constants';
+import type { RealtimeData } from '@/lib/types';
+import { useApi } from '../useApi';
+
+export function useRealtimeQuery(websiteId: string) {
+ const { get, useQuery } = useApi();
+ const { data, isLoading, error } = useQuery<RealtimeData>({
+ queryKey: ['realtime', { websiteId }],
+ queryFn: async () => {
+ return get(`/realtime/${websiteId}`);
+ },
+ enabled: !!websiteId,
+ refetchInterval: REALTIME_INTERVAL,
+ });
+
+ return { data, isLoading, error };
+}
diff --git a/src/components/hooks/queries/useReportQuery.ts b/src/components/hooks/queries/useReportQuery.ts
new file mode 100644
index 0000000..6973e2d
--- /dev/null
+++ b/src/components/hooks/queries/useReportQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useReportQuery(reportId: string) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`report:${reportId}`);
+
+ return useQuery({
+ queryKey: ['report', { reportId, modified }],
+ queryFn: () => {
+ return get(`/reports/${reportId}`);
+ },
+ enabled: !!reportId,
+ });
+}
diff --git a/src/components/hooks/queries/useReportsQuery.ts b/src/components/hooks/queries/useReportsQuery.ts
new file mode 100644
index 0000000..ba1bdd4
--- /dev/null
+++ b/src/components/hooks/queries/useReportsQuery.ts
@@ -0,0 +1,19 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useReportsQuery(
+ { websiteId, type }: { websiteId: string; type?: string },
+ options?: ReactQueryOptions,
+) {
+ const { modified } = useModified(`reports:${type}`);
+ const { get } = useApi();
+
+ return usePagedQuery({
+ queryKey: ['reports', { websiteId, type, modified }],
+ queryFn: async () => get('/reports', { websiteId, type }),
+ enabled: !!websiteId && !!type,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useResultQuery.ts b/src/components/hooks/queries/useResultQuery.ts
new file mode 100644
index 0000000..c6fce12
--- /dev/null
+++ b/src/components/hooks/queries/useResultQuery.ts
@@ -0,0 +1,44 @@
+import { useDateParameters } from '@/components/hooks/useDateParameters';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useResultQuery<T = any>(
+ type: string,
+ params?: Record<string, any>,
+ options?: ReactQueryOptions<T>,
+) {
+ const { websiteId, ...parameters } = params;
+ const { post, useQuery } = useApi();
+ const { startDate, endDate, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<T>({
+ queryKey: [
+ 'reports',
+ {
+ type,
+ websiteId,
+ startDate,
+ endDate,
+ timezone,
+ ...params,
+ ...filters,
+ },
+ ],
+ queryFn: () =>
+ post(`/reports/${type}`, {
+ websiteId,
+ type,
+ filters,
+ parameters: {
+ startDate,
+ endDate,
+ timezone,
+ ...parameters,
+ },
+ }),
+ enabled: !!type,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useSessionActivityQuery.ts b/src/components/hooks/queries/useSessionActivityQuery.ts
new file mode 100644
index 0000000..d8d34ac
--- /dev/null
+++ b/src/components/hooks/queries/useSessionActivityQuery.ts
@@ -0,0 +1,21 @@
+import { useApi } from '../useApi';
+
+export function useSessionActivityQuery(
+ websiteId: string,
+ sessionId: string,
+ startDate: Date,
+ endDate: Date,
+) {
+ const { get, useQuery } = useApi();
+
+ return useQuery({
+ queryKey: ['session:activity', { websiteId, sessionId, startDate, endDate }],
+ queryFn: () => {
+ return get(`/websites/${websiteId}/sessions/${sessionId}/activity`, {
+ startAt: +new Date(startDate),
+ endAt: +new Date(endDate),
+ });
+ },
+ enabled: Boolean(websiteId && sessionId && startDate && endDate),
+ });
+}
diff --git a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts
new file mode 100644
index 0000000..ac651bb
--- /dev/null
+++ b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts
@@ -0,0 +1,27 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useSessionDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<any>({
+ queryKey: [
+ 'websites:session-data:properties',
+ { websiteId, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/session-data/properties`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useSessionDataQuery.ts b/src/components/hooks/queries/useSessionDataQuery.ts
new file mode 100644
index 0000000..62b5398
--- /dev/null
+++ b/src/components/hooks/queries/useSessionDataQuery.ts
@@ -0,0 +1,12 @@
+import { useApi } from '../useApi';
+
+export function useSessionDataQuery(websiteId: string, sessionId: string) {
+ const { get, useQuery } = useApi();
+
+ return useQuery({
+ queryKey: ['session:data', { websiteId, sessionId }],
+ queryFn: () => {
+ return get(`/websites/${websiteId}/sessions/${sessionId}/properties`, { websiteId });
+ },
+ });
+}
diff --git a/src/components/hooks/queries/useSessionDataValuesQuery.ts b/src/components/hooks/queries/useSessionDataValuesQuery.ts
new file mode 100644
index 0000000..d5e180b
--- /dev/null
+++ b/src/components/hooks/queries/useSessionDataValuesQuery.ts
@@ -0,0 +1,32 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useSessionDataValuesQuery(
+ websiteId: string,
+ propertyName: string,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<any>({
+ queryKey: [
+ 'websites:session-data:values',
+ { websiteId, propertyName, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/session-data/values`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ propertyName,
+ }),
+ enabled: !!(websiteId && propertyName),
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useShareTokenQuery.ts b/src/components/hooks/queries/useShareTokenQuery.ts
new file mode 100644
index 0000000..dbad3dc
--- /dev/null
+++ b/src/components/hooks/queries/useShareTokenQuery.ts
@@ -0,0 +1,25 @@
+import { setShareToken, useApp } from '@/store/app';
+import { useApi } from '../useApi';
+
+const selector = (state: { shareToken: string }) => state.shareToken;
+
+export function useShareTokenQuery(shareId: string): {
+ shareToken: any;
+ isLoading?: boolean;
+ error?: Error;
+} {
+ const shareToken = useApp(selector);
+ const { get, useQuery } = useApi();
+ const { isLoading, error } = useQuery({
+ queryKey: ['share', shareId],
+ queryFn: async () => {
+ const data = await get(`/share/${shareId}`);
+
+ setShareToken(data);
+
+ return data;
+ },
+ });
+
+ return { shareToken, isLoading, error };
+}
diff --git a/src/components/hooks/queries/useTeamMembersQuery.ts b/src/components/hooks/queries/useTeamMembersQuery.ts
new file mode 100644
index 0000000..6f6f815
--- /dev/null
+++ b/src/components/hooks/queries/useTeamMembersQuery.ts
@@ -0,0 +1,16 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useTeamMembersQuery(teamId: string) {
+ const { get } = useApi();
+ const { modified } = useModified(`teams:members`);
+
+ return usePagedQuery({
+ queryKey: ['teams:members', { teamId, modified }],
+ queryFn: (params: any) => {
+ return get(`/teams/${teamId}/users`, params);
+ },
+ enabled: !!teamId,
+ });
+}
diff --git a/src/components/hooks/queries/useTeamQuery.ts b/src/components/hooks/queries/useTeamQuery.ts
new file mode 100644
index 0000000..c076a6a
--- /dev/null
+++ b/src/components/hooks/queries/useTeamQuery.ts
@@ -0,0 +1,17 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useTeamQuery(teamId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`teams:${teamId}`);
+
+ return useQuery({
+ queryKey: ['teams', { teamId, modified }],
+ queryFn: () => get(`/teams/${teamId}`),
+ enabled: !!teamId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useTeamWebsitesQuery.ts b/src/components/hooks/queries/useTeamWebsitesQuery.ts
new file mode 100644
index 0000000..ffe601b
--- /dev/null
+++ b/src/components/hooks/queries/useTeamWebsitesQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useTeamWebsitesQuery(teamId: string) {
+ const { get } = useApi();
+ const { modified } = useModified(`websites`);
+
+ return usePagedQuery({
+ queryKey: ['teams:websites', { teamId, modified }],
+ queryFn: (params: any) => {
+ return get(`/teams/${teamId}/websites`, params);
+ },
+ });
+}
diff --git a/src/components/hooks/queries/useTeamsQuery.ts b/src/components/hooks/queries/useTeamsQuery.ts
new file mode 100644
index 0000000..f1a09f4
--- /dev/null
+++ b/src/components/hooks/queries/useTeamsQuery.ts
@@ -0,0 +1,20 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useTeamsQuery(params?: Record<string, any>, options?: ReactQueryOptions) {
+ const { get } = useApi();
+ const { modified } = useModified(`teams`);
+
+ return usePagedQuery({
+ queryKey: ['teams:admin', { modified, ...params }],
+ queryFn: pageParams => {
+ return get(`/admin/teams`, {
+ ...pageParams,
+ ...params,
+ });
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useUpdateQuery.ts b/src/components/hooks/queries/useUpdateQuery.ts
new file mode 100644
index 0000000..85a9442
--- /dev/null
+++ b/src/components/hooks/queries/useUpdateQuery.ts
@@ -0,0 +1,15 @@
+import { useToast } from '@umami/react-zen';
+import type { ApiError } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useUpdateQuery(path: string, params?: Record<string, any>) {
+ const { post, useMutation } = useApi();
+ const query = useMutation<any, ApiError, Record<string, any>>({
+ mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }),
+ });
+ const { touch } = useModified();
+ const { toast } = useToast();
+
+ return { ...query, touch, toast };
+}
diff --git a/src/components/hooks/queries/useUserQuery.ts b/src/components/hooks/queries/useUserQuery.ts
new file mode 100644
index 0000000..07e23f0
--- /dev/null
+++ b/src/components/hooks/queries/useUserQuery.ts
@@ -0,0 +1,17 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useUserQuery(userId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`user:${userId}`);
+
+ return useQuery({
+ queryKey: ['users', { userId, modified }],
+ queryFn: () => get(`/users/${userId}`),
+ enabled: !!userId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useUserTeamsQuery.ts b/src/components/hooks/queries/useUserTeamsQuery.ts
new file mode 100644
index 0000000..82f6549
--- /dev/null
+++ b/src/components/hooks/queries/useUserTeamsQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useUserTeamsQuery(userId: string) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`teams`);
+
+ return useQuery({
+ queryKey: ['teams', { userId, modified }],
+ queryFn: () => {
+ return get(`/users/${userId}/teams`);
+ },
+ enabled: !!userId,
+ });
+}
diff --git a/src/components/hooks/queries/useUserWebsitesQuery.ts b/src/components/hooks/queries/useUserWebsitesQuery.ts
new file mode 100644
index 0000000..f98eaff
--- /dev/null
+++ b/src/components/hooks/queries/useUserWebsitesQuery.ts
@@ -0,0 +1,31 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useUserWebsitesQuery(
+ { userId, teamId }: { userId?: string; teamId?: string },
+ params?: Record<string, any>,
+ options?: ReactQueryOptions,
+) {
+ const { get } = useApi();
+ const { modified } = useModified(`websites`);
+
+ return usePagedQuery({
+ queryKey: ['websites', { userId, teamId, modified, ...params }],
+ queryFn: pageParams => {
+ return get(
+ teamId
+ ? `/teams/${teamId}/websites`
+ : userId
+ ? `/users/${userId}/websites`
+ : '/me/websites',
+ {
+ ...pageParams,
+ ...params,
+ },
+ );
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useUsersQuery.ts b/src/components/hooks/queries/useUsersQuery.ts
new file mode 100644
index 0000000..d87900b
--- /dev/null
+++ b/src/components/hooks/queries/useUsersQuery.ts
@@ -0,0 +1,17 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useUsersQuery() {
+ const { get } = useApi();
+ const { modified } = useModified(`users`);
+
+ return usePagedQuery({
+ queryKey: ['users:admin', { modified }],
+ queryFn: (pageParams: any) => {
+ return get('/admin/users', {
+ ...pageParams,
+ });
+ },
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteCohortQuery.ts b/src/components/hooks/queries/useWebsiteCohortQuery.ts
new file mode 100644
index 0000000..975766e
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteCohortQuery.ts
@@ -0,0 +1,21 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteCohortQuery(
+ websiteId: string,
+ cohortId: string,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`cohorts`);
+
+ return useQuery({
+ queryKey: ['website:cohorts', { websiteId, cohortId, modified }],
+ queryFn: () => get(`/websites/${websiteId}/segments/${cohortId}`),
+ enabled: !!(websiteId && cohortId),
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteCohortsQuery.ts b/src/components/hooks/queries/useWebsiteCohortsQuery.ts
new file mode 100644
index 0000000..e0cbf4c
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteCohortsQuery.ts
@@ -0,0 +1,25 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteCohortsQuery(
+ websiteId: string,
+ params?: Record<string, string>,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`cohorts`);
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['website:cohorts', { websiteId, modified, ...filters, ...params }],
+ queryFn: pageParams => {
+ return get(`/websites/${websiteId}/segments`, { ...pageParams, ...filters, ...params });
+ },
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteEventsQuery.ts b/src/components/hooks/queries/useWebsiteEventsQuery.ts
new file mode 100644
index 0000000..fc4dad5
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteEventsQuery.ts
@@ -0,0 +1,39 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+import { usePagedQuery } from '../usePagedQuery';
+
+const EVENT_TYPES = {
+ views: 1,
+ events: 2,
+};
+
+export function useWebsiteEventsQuery(
+ websiteId: string,
+ params?: Record<string, any>,
+ options?: ReactQueryOptions,
+) {
+ const { get } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return usePagedQuery({
+ queryKey: [
+ 'websites:events',
+ { websiteId, startAt, endAt, unit, timezone, ...filters, ...params },
+ ],
+ queryFn: pageParams =>
+ get(`/websites/${websiteId}/events`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...pageParams,
+ eventType: EVENT_TYPES[params.view],
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts
new file mode 100644
index 0000000..6c1d112
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts
@@ -0,0 +1,18 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useWebsiteEventsSeriesQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['websites:events:series', { websiteId, startAt, endAt, unit, timezone, ...filters }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/events/series`, { startAt, endAt, unit, timezone, ...filters }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
new file mode 100644
index 0000000..b2e9019
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
@@ -0,0 +1,51 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export type WebsiteExpandedMetricsData = {
+ name: string;
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}[];
+
+export function useWebsiteExpandedMetricsQuery(
+ websiteId: string,
+ params: { type: string; limit?: number; search?: string },
+ options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<WebsiteExpandedMetricsData>({
+ queryKey: [
+ 'websites:metrics:expanded',
+ {
+ websiteId,
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...params,
+ },
+ ],
+ queryFn: async () =>
+ get(`/websites/${websiteId}/metrics/expanded`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...params,
+ }),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteMetricsQuery.ts b/src/components/hooks/queries/useWebsiteMetricsQuery.ts
new file mode 100644
index 0000000..67c5e4d
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteMetricsQuery.ts
@@ -0,0 +1,47 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export type WebsiteMetricsData = {
+ x: string;
+ y: number;
+}[];
+
+export function useWebsiteMetricsQuery(
+ websiteId: string,
+ params: { type: string; limit?: number; search?: string },
+ options?: ReactQueryOptions<WebsiteMetricsData>,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<WebsiteMetricsData>({
+ queryKey: [
+ 'websites:metrics',
+ {
+ websiteId,
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...params,
+ },
+ ],
+ queryFn: async () =>
+ get(`/websites/${websiteId}/metrics`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...params,
+ }),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsitePageviewsQuery.ts b/src/components/hooks/queries/useWebsitePageviewsQuery.ts
new file mode 100644
index 0000000..b35c820
--- /dev/null
+++ b/src/components/hooks/queries/useWebsitePageviewsQuery.ts
@@ -0,0 +1,36 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export interface WebsitePageviewsData {
+ pageviews: { x: string; y: number }[];
+ sessions: { x: string; y: number }[];
+}
+
+export function useWebsitePageviewsQuery(
+ { websiteId, compare }: { websiteId: string; compare?: string },
+ options?: ReactQueryOptions<WebsitePageviewsData>,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const queryParams = useFilterParameters();
+
+ return useQuery<WebsitePageviewsData>({
+ queryKey: [
+ 'websites:pageviews',
+ { websiteId, compare, startAt, endAt, unit, timezone, ...queryParams },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/pageviews`, {
+ compare,
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...queryParams,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteQuery.ts b/src/components/hooks/queries/useWebsiteQuery.ts
new file mode 100644
index 0000000..b9a5533
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteQuery.ts
@@ -0,0 +1,17 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`website:${websiteId}`);
+
+ return useQuery({
+ queryKey: ['website', { websiteId, modified }],
+ queryFn: () => get(`/websites/${websiteId}`),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSegmentQuery.ts b/src/components/hooks/queries/useWebsiteSegmentQuery.ts
new file mode 100644
index 0000000..1923fbd
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSegmentQuery.ts
@@ -0,0 +1,21 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteSegmentQuery(
+ websiteId: string,
+ segmentId: string,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`segments`);
+
+ return useQuery({
+ queryKey: ['website:segments', { websiteId, segmentId, modified }],
+ queryFn: () => get(`/websites/${websiteId}/segments/${segmentId}`),
+ enabled: !!(websiteId && segmentId),
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSegmentsQuery.ts b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts
new file mode 100644
index 0000000..8d3af96
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts
@@ -0,0 +1,24 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteSegmentsQuery(
+ websiteId: string,
+ params?: Record<string, string>,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`segments`);
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['website:segments', { websiteId, modified, ...filters, ...params }],
+ queryFn: pageParams =>
+ get(`/websites/${websiteId}/segments`, { ...pageParams, ...filters, ...params }),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSessionQuery.ts b/src/components/hooks/queries/useWebsiteSessionQuery.ts
new file mode 100644
index 0000000..21e9491
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSessionQuery.ts
@@ -0,0 +1,13 @@
+import { useApi } from '../useApi';
+
+export function useWebsiteSessionQuery(websiteId: string, sessionId: string) {
+ const { get, useQuery } = useApi();
+
+ return useQuery({
+ queryKey: ['session', { websiteId, sessionId }],
+ queryFn: () => {
+ return get(`/websites/${websiteId}/sessions/${sessionId}`);
+ },
+ enabled: Boolean(websiteId && sessionId),
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts
new file mode 100644
index 0000000..bac9fc9
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts
@@ -0,0 +1,17 @@
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useWebsiteSessionStatsQuery(websiteId: string, options?: Record<string, string>) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['sessions:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/sessions/stats`, { startAt, endAt, unit, timezone, ...filters }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSessionsQuery.ts b/src/components/hooks/queries/useWebsiteSessionsQuery.ts
new file mode 100644
index 0000000..31906be
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSessionsQuery.ts
@@ -0,0 +1,34 @@
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useWebsiteSessionsQuery(
+ websiteId: string,
+ params?: Record<string, string | number>,
+) {
+ const { get } = useApi();
+ const { modified } = useModified(`sessions`);
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return usePagedQuery({
+ queryKey: [
+ 'sessions',
+ { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters },
+ ],
+ queryFn: pageParams => {
+ return get(`/websites/${websiteId}/sessions`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...pageParams,
+ ...params,
+ pageSize: 20,
+ });
+ },
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteStatsQuery.ts b/src/components/hooks/queries/useWebsiteStatsQuery.ts
new file mode 100644
index 0000000..e9a0c48
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteStatsQuery.ts
@@ -0,0 +1,36 @@
+import type { UseQueryOptions } from '@tanstack/react-query';
+import { useDateParameters } from '@/components/hooks/useDateParameters';
+import { useApi } from '../useApi';
+import { useFilterParameters } from '../useFilterParameters';
+
+export interface WebsiteStatsData {
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+ comparison: {
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+ };
+}
+
+export function useWebsiteStatsQuery(
+ websiteId: string,
+ options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<WebsiteStatsData>({
+ queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteValuesQuery.ts b/src/components/hooks/queries/useWebsiteValuesQuery.ts
new file mode 100644
index 0000000..1e09736
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteValuesQuery.ts
@@ -0,0 +1,62 @@
+import { useCountryNames } from '@/components/hooks/useCountryNames';
+import { useRegionNames } from '@/components/hooks/useRegionNames';
+import { useApi } from '../useApi';
+import { useLocale } from '../useLocale';
+
+export function useWebsiteValuesQuery({
+ websiteId,
+ type,
+ startDate,
+ endDate,
+ search,
+}: {
+ websiteId: string;
+ type: string;
+ startDate: Date;
+ endDate: Date;
+ search?: string;
+}) {
+ const { get, useQuery } = useApi();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { regionNames } = useRegionNames(locale);
+
+ const names = {
+ country: countryNames,
+ region: regionNames,
+ };
+
+ const getSearch = (type: string, value: string) => {
+ if (value) {
+ const values = names[type];
+
+ if (values) {
+ return (
+ Object.keys(values)
+ .reduce((arr: string[], key: string) => {
+ if (values[key].toLowerCase().includes(value.toLowerCase())) {
+ return arr.concat(key);
+ }
+ return arr;
+ }, [])
+ .slice(0, 5)
+ .join(',') || value
+ );
+ }
+
+ return value;
+ }
+ };
+
+ return useQuery({
+ queryKey: ['websites:values', { websiteId, type, startDate, endDate, search }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/values`, {
+ type,
+ startAt: +startDate,
+ endAt: +endDate,
+ search: getSearch(type, search),
+ }),
+ enabled: !!(websiteId && type && startDate && endDate),
+ });
+}
diff --git a/src/components/hooks/queries/useWebsitesQuery.ts b/src/components/hooks/queries/useWebsitesQuery.ts
new file mode 100644
index 0000000..a7b6618
--- /dev/null
+++ b/src/components/hooks/queries/useWebsitesQuery.ts
@@ -0,0 +1,20 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useWebsitesQuery(params?: Record<string, any>, options?: ReactQueryOptions) {
+ const { get } = useApi();
+ const { modified } = useModified(`websites`);
+
+ return usePagedQuery({
+ queryKey: ['websites:admin', { modified, ...params }],
+ queryFn: pageParams => {
+ return get(`/admin/websites`, {
+ ...pageParams,
+ ...params,
+ });
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWeeklyTrafficQuery.ts b/src/components/hooks/queries/useWeeklyTrafficQuery.ts
new file mode 100644
index 0000000..a76ebb3
--- /dev/null
+++ b/src/components/hooks/queries/useWeeklyTrafficQuery.ts
@@ -0,0 +1,28 @@
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useModified } from '../useModified';
+
+export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string, string | number>) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`sessions`);
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: [
+ 'sessions',
+ { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters },
+ ],
+ queryFn: () => {
+ return get(`/websites/${websiteId}/sessions/weekly`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...params,
+ ...filters,
+ });
+ },
+ });
+}
diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts
new file mode 100644
index 0000000..35cabd5
--- /dev/null
+++ b/src/components/hooks/useApi.ts
@@ -0,0 +1,67 @@
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { useCallback } from 'react';
+import { getClientAuthToken } from '@/lib/client';
+import { SHARE_TOKEN_HEADER } from '@/lib/constants';
+import { type FetchResponse, httpDelete, httpGet, httpPost, httpPut } from '@/lib/fetch';
+import { useApp } from '@/store/app';
+
+const selector = (state: { shareToken: { token?: string } }) => state.shareToken;
+
+async function handleResponse(res: FetchResponse): Promise<any> {
+ if (!res.ok) {
+ const { message, code, status } = res?.data?.error || {};
+
+ return Promise.reject(Object.assign(new Error(message), { code, status }));
+ }
+ return Promise.resolve(res.data);
+}
+
+export function useApi() {
+ const shareToken = useApp(selector);
+
+ const defaultHeaders = {
+ authorization: `Bearer ${getClientAuthToken()}`,
+ [SHARE_TOKEN_HEADER]: shareToken?.token,
+ };
+ const basePath = process.env.basePath;
+
+ const getUrl = (url: string) => {
+ return url.startsWith('http') ? url : `${basePath || ''}/api${url}`;
+ };
+
+ const getHeaders = (headers: any = {}) => {
+ return { ...defaultHeaders, ...headers };
+ };
+
+ return {
+ get: useCallback(
+ async (url: string, params: object = {}, headers: object = {}) => {
+ return httpGet(getUrl(url), params, getHeaders(headers)).then(handleResponse);
+ },
+ [httpGet],
+ ),
+
+ post: useCallback(
+ async (url: string, params: object = {}, headers: object = {}) => {
+ return httpPost(getUrl(url), params, getHeaders(headers)).then(handleResponse);
+ },
+ [httpPost],
+ ),
+
+ put: useCallback(
+ async (url: string, params: object = {}, headers: object = {}) => {
+ return httpPut(getUrl(url), params, getHeaders(headers)).then(handleResponse);
+ },
+ [httpPut],
+ ),
+
+ del: useCallback(
+ async (url: string, params: object = {}, headers: object = {}) => {
+ return httpDelete(getUrl(url), params, getHeaders(headers)).then(handleResponse);
+ },
+ [httpDelete],
+ ),
+ useQuery,
+ useMutation,
+ };
+}
diff --git a/src/components/hooks/useConfig.ts b/src/components/hooks/useConfig.ts
new file mode 100644
index 0000000..c1cdcaf
--- /dev/null
+++ b/src/components/hooks/useConfig.ts
@@ -0,0 +1,33 @@
+import { useEffect } from 'react';
+import { useApi } from '@/components/hooks/useApi';
+import { setConfig, useApp } from '@/store/app';
+
+export type Config = {
+ cloudMode: boolean;
+ faviconUrl?: string;
+ linksUrl?: string;
+ pixelsUrl?: string;
+ privateMode: boolean;
+ telemetryDisabled: boolean;
+ trackerScriptName?: string;
+ updatesDisabled: boolean;
+};
+
+export function useConfig(): Config {
+ const { config } = useApp();
+ const { get } = useApi();
+
+ async function loadConfig() {
+ const data = await get(`/config`);
+
+ setConfig(data);
+ }
+
+ useEffect(() => {
+ if (!config) {
+ loadConfig();
+ }
+ }, []);
+
+ return config;
+}
diff --git a/src/components/hooks/useCountryNames.ts b/src/components/hooks/useCountryNames.ts
new file mode 100644
index 0000000..1ec9fc1
--- /dev/null
+++ b/src/components/hooks/useCountryNames.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from 'react';
+import { httpGet } from '@/lib/fetch';
+import enUS from '../../../public/intl/country/en-US.json';
+
+const countryNames = {
+ 'en-US': enUS,
+};
+
+export function useCountryNames(locale: string) {
+ const [list, setList] = useState(countryNames[locale] || enUS);
+
+ async function loadData(locale: string) {
+ const { data } = await httpGet(`${process.env.basePath || ''}/intl/country/${locale}.json`);
+
+ if (data) {
+ countryNames[locale] = data;
+ setList(countryNames[locale]);
+ } else {
+ setList(enUS);
+ }
+ }
+
+ useEffect(() => {
+ if (!countryNames[locale]) {
+ loadData(locale);
+ } else {
+ setList(countryNames[locale]);
+ }
+ }, [locale]);
+
+ return { countryNames: list };
+}
diff --git a/src/components/hooks/useDateParameters.ts b/src/components/hooks/useDateParameters.ts
new file mode 100644
index 0000000..d84b423
--- /dev/null
+++ b/src/components/hooks/useDateParameters.ts
@@ -0,0 +1,18 @@
+import { useDateRange } from './useDateRange';
+import { useTimezone } from './useTimezone';
+
+export function useDateParameters() {
+ const {
+ dateRange: { startDate, endDate, unit },
+ } = useDateRange();
+ const { timezone, localToUtc, canonicalizeTimezone } = useTimezone();
+
+ return {
+ startAt: +localToUtc(startDate),
+ endAt: +localToUtc(endDate),
+ startDate: localToUtc(startDate).toISOString(),
+ endDate: localToUtc(endDate).toISOString(),
+ unit,
+ timezone: canonicalizeTimezone(timezone),
+ };
+}
diff --git a/src/components/hooks/useDateRange.ts b/src/components/hooks/useDateRange.ts
new file mode 100644
index 0000000..755f36e
--- /dev/null
+++ b/src/components/hooks/useDateRange.ts
@@ -0,0 +1,37 @@
+import { useMemo } from 'react';
+import { useLocale } from '@/components/hooks/useLocale';
+import { useNavigation } from '@/components/hooks/useNavigation';
+import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
+import { getCompareDate, getOffsetDateRange, parseDateRange } from '@/lib/date';
+import { getItem } from '@/lib/storage';
+
+export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
+ const {
+ query: { date = '', offset = 0, compare = 'prev' },
+ } = useNavigation();
+ const { locale } = useLocale();
+
+ const dateRange = useMemo(() => {
+ const dateRangeObject = parseDateRange(
+ date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
+ locale,
+ options.timezone,
+ );
+
+ return !options.ignoreOffset && offset
+ ? getOffsetDateRange(dateRangeObject, +offset)
+ : dateRangeObject;
+ }, [date, offset, options]);
+
+ const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
+
+ return {
+ date,
+ offset,
+ compare,
+ isAllTime: date.endsWith(`:all`),
+ isCustomRange: date.startsWith('range:'),
+ dateRange,
+ dateCompare,
+ };
+}
diff --git a/src/components/hooks/useDocumentClick.ts b/src/components/hooks/useDocumentClick.ts
new file mode 100644
index 0000000..611f628
--- /dev/null
+++ b/src/components/hooks/useDocumentClick.ts
@@ -0,0 +1,13 @@
+import { useEffect } from 'react';
+
+export function useDocumentClick(handler: (event: MouseEvent) => any) {
+ useEffect(() => {
+ document.addEventListener('click', handler);
+
+ return () => {
+ document.removeEventListener('click', handler);
+ };
+ }, [handler]);
+
+ return null;
+}
diff --git a/src/components/hooks/useEscapeKey.ts b/src/components/hooks/useEscapeKey.ts
new file mode 100644
index 0000000..cc1d308
--- /dev/null
+++ b/src/components/hooks/useEscapeKey.ts
@@ -0,0 +1,19 @@
+import { type KeyboardEvent, useCallback, useEffect } from 'react';
+
+export function useEscapeKey(handler: (event: KeyboardEvent) => void) {
+ const escFunction = useCallback((event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ handler(event);
+ }
+ }, []);
+
+ useEffect(() => {
+ document.addEventListener('keydown', escFunction as any, false);
+
+ return () => {
+ document.removeEventListener('keydown', escFunction as any, false);
+ };
+ }, [escFunction]);
+
+ return null;
+}
diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts
new file mode 100644
index 0000000..22a1dcf
--- /dev/null
+++ b/src/components/hooks/useFields.ts
@@ -0,0 +1,23 @@
+import { useMessages } from './useMessages';
+
+export function useFields() {
+ const { formatMessage, labels } = useMessages();
+
+ const fields = [
+ { name: 'path', type: 'string', label: formatMessage(labels.path) },
+ { name: 'query', type: 'string', label: formatMessage(labels.query) },
+ { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
+ { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
+ { name: 'browser', type: 'string', label: formatMessage(labels.browser) },
+ { name: 'os', type: 'string', label: formatMessage(labels.os) },
+ { name: 'device', type: 'string', label: formatMessage(labels.device) },
+ { name: 'country', type: 'string', label: formatMessage(labels.country) },
+ { name: 'region', type: 'string', label: formatMessage(labels.region) },
+ { name: 'city', type: 'string', label: formatMessage(labels.city) },
+ { name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
+ { name: 'tag', type: 'string', label: formatMessage(labels.tag) },
+ { name: 'event', type: 'string', label: formatMessage(labels.event) },
+ ];
+
+ return { fields };
+}
diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts
new file mode 100644
index 0000000..5403212
--- /dev/null
+++ b/src/components/hooks/useFilterParameters.ts
@@ -0,0 +1,70 @@
+import { useMemo } from 'react';
+import { useNavigation } from './useNavigation';
+
+export function useFilterParameters() {
+ const {
+ query: {
+ path,
+ referrer,
+ title,
+ query,
+ host,
+ os,
+ browser,
+ device,
+ country,
+ region,
+ city,
+ event,
+ tag,
+ hostname,
+ page,
+ pageSize,
+ search,
+ segment,
+ cohort,
+ },
+ } = useNavigation();
+
+ return useMemo(() => {
+ return {
+ path,
+ referrer,
+ title,
+ query,
+ host,
+ os,
+ browser,
+ device,
+ country,
+ region,
+ city,
+ event,
+ tag,
+ hostname,
+ search,
+ segment,
+ cohort,
+ };
+ }, [
+ path,
+ referrer,
+ title,
+ query,
+ host,
+ os,
+ browser,
+ device,
+ country,
+ region,
+ city,
+ event,
+ tag,
+ hostname,
+ page,
+ pageSize,
+ search,
+ segment,
+ cohort,
+ ]);
+}
diff --git a/src/components/hooks/useFilters.ts b/src/components/hooks/useFilters.ts
new file mode 100644
index 0000000..850e2af
--- /dev/null
+++ b/src/components/hooks/useFilters.ts
@@ -0,0 +1,99 @@
+import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
+import { safeDecodeURIComponent } from '@/lib/url';
+import { useFields } from './useFields';
+import { useMessages } from './useMessages';
+import { useNavigation } from './useNavigation';
+
+export function useFilters() {
+ const { formatMessage, labels } = useMessages();
+ const { query } = useNavigation();
+ const { fields } = useFields();
+
+ const operators = [
+ { name: 'eq', type: 'string', label: formatMessage(labels.is) },
+ { name: 'neq', type: 'string', label: formatMessage(labels.isNot) },
+ { name: 'c', type: 'string', label: formatMessage(labels.contains) },
+ { name: 'dnc', type: 'string', label: formatMessage(labels.doesNotContain) },
+ { name: 'i', type: 'array', label: formatMessage(labels.includes) },
+ { name: 'dni', type: 'array', label: formatMessage(labels.doesNotInclude) },
+ { name: 't', type: 'boolean', label: formatMessage(labels.isTrue) },
+ { name: 'f', type: 'boolean', label: formatMessage(labels.isFalse) },
+ { name: 'eq', type: 'number', label: formatMessage(labels.is) },
+ { name: 'neq', type: 'number', label: formatMessage(labels.isNot) },
+ { name: 'gt', type: 'number', label: formatMessage(labels.greaterThan) },
+ { name: 'lt', type: 'number', label: formatMessage(labels.lessThan) },
+ { name: 'gte', type: 'number', label: formatMessage(labels.greaterThanEquals) },
+ { name: 'lte', type: 'number', label: formatMessage(labels.lessThanEquals) },
+ { name: 'bf', type: 'date', label: formatMessage(labels.before) },
+ { name: 'af', type: 'date', label: formatMessage(labels.after) },
+ { name: 'eq', type: 'uuid', label: formatMessage(labels.is) },
+ ];
+
+ const operatorLabels = {
+ [OPERATORS.equals]: formatMessage(labels.is),
+ [OPERATORS.notEquals]: formatMessage(labels.isNot),
+ [OPERATORS.set]: formatMessage(labels.isSet),
+ [OPERATORS.notSet]: formatMessage(labels.isNotSet),
+ [OPERATORS.contains]: formatMessage(labels.contains),
+ [OPERATORS.doesNotContain]: formatMessage(labels.doesNotContain),
+ [OPERATORS.true]: formatMessage(labels.true),
+ [OPERATORS.false]: formatMessage(labels.false),
+ [OPERATORS.greaterThan]: formatMessage(labels.greaterThan),
+ [OPERATORS.lessThan]: formatMessage(labels.lessThan),
+ [OPERATORS.greaterThanEquals]: formatMessage(labels.greaterThanEquals),
+ [OPERATORS.lessThanEquals]: formatMessage(labels.lessThanEquals),
+ [OPERATORS.before]: formatMessage(labels.before),
+ [OPERATORS.after]: formatMessage(labels.after),
+ };
+
+ const typeFilters = {
+ string: [OPERATORS.equals, OPERATORS.notEquals, OPERATORS.contains, OPERATORS.doesNotContain],
+ array: [OPERATORS.contains, OPERATORS.doesNotContain],
+ boolean: [OPERATORS.true, OPERATORS.false],
+ number: [
+ OPERATORS.equals,
+ OPERATORS.notEquals,
+ OPERATORS.greaterThan,
+ OPERATORS.lessThan,
+ OPERATORS.greaterThanEquals,
+ OPERATORS.lessThanEquals,
+ ],
+ date: [OPERATORS.before, OPERATORS.after],
+ uuid: [OPERATORS.equals],
+ };
+
+ const filters = Object.keys(query).reduce((arr, key) => {
+ if (FILTER_COLUMNS[key]) {
+ let operator = 'eq';
+ let value = safeDecodeURIComponent(query[key]);
+ const label = fields.find(({ name }) => name === key)?.label;
+
+ const match = value.match(/^([a-z]+)\.(.*)/);
+
+ if (match) {
+ operator = match[1];
+ value = match[2];
+ }
+
+ return arr.concat({
+ name: key,
+ operator,
+ value,
+ label,
+ });
+ }
+ return arr;
+ }, []);
+
+ const getFilters = (type: string) => {
+ return (
+ typeFilters[type]?.map((key: string | number) => ({
+ type,
+ value: key,
+ label: operatorLabels[key],
+ })) ?? []
+ );
+ };
+
+ return { fields, operators, filters, operatorLabels, typeFilters, getFilters };
+}
diff --git a/src/components/hooks/useForceUpdate.ts b/src/components/hooks/useForceUpdate.ts
new file mode 100644
index 0000000..550cc5c
--- /dev/null
+++ b/src/components/hooks/useForceUpdate.ts
@@ -0,0 +1,9 @@
+import { useCallback, useState } from 'react';
+
+export function useForceUpdate() {
+ const [, update] = useState(Object.create(null));
+
+ return useCallback(() => {
+ update(Object.create(null));
+ }, [update]);
+}
diff --git a/src/components/hooks/useFormat.ts b/src/components/hooks/useFormat.ts
new file mode 100644
index 0000000..896fa07
--- /dev/null
+++ b/src/components/hooks/useFormat.ts
@@ -0,0 +1,74 @@
+import { BROWSERS, OS_NAMES } from '@/lib/constants';
+import regions from '../../../public/iso-3166-2.json';
+import { useCountryNames } from './useCountryNames';
+import { useLanguageNames } from './useLanguageNames';
+import { useLocale } from './useLocale';
+import { useMessages } from './useMessages';
+
+export function useFormat() {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { languageNames } = useLanguageNames(locale);
+
+ const formatOS = (value: string): string => {
+ return OS_NAMES[value] || value;
+ };
+
+ const formatBrowser = (value: string): string => {
+ return BROWSERS[value] || value;
+ };
+
+ const formatDevice = (value: string): string => {
+ return formatMessage(labels[value] || labels.unknown);
+ };
+
+ const formatCountry = (value: string): string => {
+ return countryNames[value] || value;
+ };
+
+ const formatRegion = (value?: string): string => {
+ const [country] = value?.split('-') || [];
+ return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value;
+ };
+
+ const formatCity = (value: string, country?: string): string => {
+ return countryNames[country] ? `${value}, ${countryNames[country]}` : value;
+ };
+
+ const formatLanguage = (value: string): string => {
+ return languageNames[value?.split('-')[0]] || value;
+ };
+
+ const formatValue = (value: string, type: string, data?: Record<string, any>): string => {
+ switch (type) {
+ case 'os':
+ return formatOS(value);
+ case 'browser':
+ return formatBrowser(value);
+ case 'device':
+ return formatDevice(value);
+ case 'country':
+ return formatCountry(value);
+ case 'region':
+ return formatRegion(value);
+ case 'city':
+ return formatCity(value, data?.country);
+ case 'language':
+ return formatLanguage(value);
+ default:
+ return typeof value === 'string' ? value : undefined;
+ }
+ };
+
+ return {
+ formatOS,
+ formatBrowser,
+ formatDevice,
+ formatCountry,
+ formatRegion,
+ formatCity,
+ formatLanguage,
+ formatValue,
+ };
+}
diff --git a/src/components/hooks/useGlobalState.ts b/src/components/hooks/useGlobalState.ts
new file mode 100644
index 0000000..6f21226
--- /dev/null
+++ b/src/components/hooks/useGlobalState.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand';
+
+const store = create(() => ({}));
+
+const useGlobalState = (key: string, value?: any) => {
+ if (value !== undefined && store.getState()[key] === undefined) {
+ store.setState({ [key]: value });
+ }
+
+ return [store(state => state[key]), (value: any) => store.setState({ [key]: value })];
+};
+
+export { useGlobalState };
diff --git a/src/components/hooks/useLanguageNames.ts b/src/components/hooks/useLanguageNames.ts
new file mode 100644
index 0000000..0cc03d7
--- /dev/null
+++ b/src/components/hooks/useLanguageNames.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from 'react';
+import { httpGet } from '@/lib/fetch';
+import enUS from '../../../public/intl/language/en-US.json';
+
+const languageNames = {
+ 'en-US': enUS,
+};
+
+export function useLanguageNames(locale) {
+ const [list, setList] = useState(languageNames[locale] || enUS);
+
+ async function loadData(locale) {
+ const { data } = await httpGet(`${process.env.basePath || ''}/intl/language/${locale}.json`);
+
+ if (data) {
+ languageNames[locale] = data;
+ setList(languageNames[locale]);
+ } else {
+ setList(enUS);
+ }
+ }
+
+ useEffect(() => {
+ if (!languageNames[locale]) {
+ loadData(locale);
+ } else {
+ setList(languageNames[locale]);
+ }
+ }, [locale]);
+
+ return { languageNames: list };
+}
diff --git a/src/components/hooks/useLocale.ts b/src/components/hooks/useLocale.ts
new file mode 100644
index 0000000..3eb669e
--- /dev/null
+++ b/src/components/hooks/useLocale.ts
@@ -0,0 +1,60 @@
+import { useEffect } from 'react';
+import { LOCALE_CONFIG } from '@/lib/constants';
+import { httpGet } from '@/lib/fetch';
+import { getDateLocale, getTextDirection } from '@/lib/lang';
+import { setItem } from '@/lib/storage';
+import { setLocale, useApp } from '@/store/app';
+import enUS from '../../../public/intl/country/en-US.json';
+import { useForceUpdate } from './useForceUpdate';
+
+const messages = {
+ 'en-US': enUS,
+};
+
+const selector = (state: { locale: string }) => state.locale;
+
+export function useLocale() {
+ const locale = useApp(selector);
+ const forceUpdate = useForceUpdate();
+ const dir = getTextDirection(locale);
+ const dateLocale = getDateLocale(locale);
+
+ async function loadMessages(locale: string) {
+ const { data } = await httpGet(`${process.env.basePath || ''}/intl/messages/${locale}.json`);
+
+ messages[locale] = data;
+ }
+
+ async function saveLocale(value: string) {
+ if (!messages[value]) {
+ await loadMessages(value);
+ }
+
+ setItem(LOCALE_CONFIG, value);
+
+ document.getElementById('__next')?.setAttribute('dir', getTextDirection(value));
+
+ if (locale !== value) {
+ setLocale(value);
+ } else {
+ forceUpdate();
+ }
+ }
+
+ useEffect(() => {
+ if (!messages[locale]) {
+ saveLocale(locale);
+ }
+ }, [locale]);
+
+ useEffect(() => {
+ const url = new URL(window?.location?.href);
+ const locale = url.searchParams.get('locale');
+
+ if (locale) {
+ saveLocale(locale);
+ }
+ }, []);
+
+ return { locale, saveLocale, messages, dir, dateLocale };
+}
diff --git a/src/components/hooks/useMessages.ts b/src/components/hooks/useMessages.ts
new file mode 100644
index 0000000..d5bc242
--- /dev/null
+++ b/src/components/hooks/useMessages.ts
@@ -0,0 +1,48 @@
+import { FormattedMessage, type MessageDescriptor, useIntl } from 'react-intl';
+import { labels, messages } from '@/components/messages';
+import type { ApiError } from '@/lib/types';
+
+type FormatMessage = (
+ descriptor: MessageDescriptor,
+ values?: Record<string, string | number | boolean | null | undefined>,
+ opts?: any,
+) => string | null;
+
+interface UseMessages {
+ formatMessage: FormatMessage;
+ messages: typeof messages;
+ labels: typeof labels;
+ getMessage: (id: string) => string;
+ getErrorMessage: (error: ApiError) => string | undefined;
+ FormattedMessage: typeof FormattedMessage;
+}
+
+export function useMessages(): UseMessages {
+ const intl = useIntl();
+
+ const getMessage = (id: string) => {
+ const message = Object.values(messages).find(value => value.id === `message.${id}`);
+
+ return message ? formatMessage(message) : id;
+ };
+
+ const getErrorMessage = (error: ApiError) => {
+ if (!error) {
+ return undefined;
+ }
+
+ const code = error?.code;
+
+ return code ? getMessage(code) : error?.message || 'Unknown error';
+ };
+
+ const formatMessage = (
+ descriptor: MessageDescriptor,
+ values?: Record<string, string | number | boolean | null | undefined>,
+ opts?: any,
+ ) => {
+ return descriptor ? intl.formatMessage(descriptor, values, opts) : null;
+ };
+
+ return { formatMessage, messages, labels, getMessage, getErrorMessage, FormattedMessage };
+}
diff --git a/src/components/hooks/useMobile.ts b/src/components/hooks/useMobile.ts
new file mode 100644
index 0000000..6b40f3d
--- /dev/null
+++ b/src/components/hooks/useMobile.ts
@@ -0,0 +1,9 @@
+import { useBreakpoint } from '@umami/react-zen';
+
+export function useMobile() {
+ const breakpoint = useBreakpoint();
+ const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
+ const isPhone = ['xs', 'sm'].includes(breakpoint);
+
+ return { breakpoint, isMobile, isPhone };
+}
diff --git a/src/components/hooks/useModified.ts b/src/components/hooks/useModified.ts
new file mode 100644
index 0000000..ea88888
--- /dev/null
+++ b/src/components/hooks/useModified.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand';
+
+const store = create(() => ({}));
+
+export function touch(key: string) {
+ store.setState({ [key]: Date.now() });
+}
+
+export function useModified(key?: string) {
+ const modified = store(state => state?.[key]);
+
+ return { modified, touch };
+}
diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts
new file mode 100644
index 0000000..0a18ac7
--- /dev/null
+++ b/src/components/hooks/useNavigation.ts
@@ -0,0 +1,43 @@
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import { buildPath } from '@/lib/url';
+
+export function useNavigation() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [, teamId] = pathname.match(/\/teams\/([a-f0-9-]+)/) || [];
+ const [, websiteId] = pathname.match(/\/websites\/([a-f0-9-]+)/) || [];
+ const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams));
+
+ const updateParams = (params?: Record<string, string | number>) => {
+ return buildPath(pathname, { ...queryParams, ...params });
+ };
+
+ const replaceParams = (params?: Record<string, string | number>) => {
+ return buildPath(pathname, params);
+ };
+
+ const renderUrl = (path: string, params?: Record<string, string | number> | false) => {
+ return buildPath(
+ teamId ? `/teams/${teamId}${path}` : path,
+ params === false ? {} : { ...queryParams, ...params },
+ );
+ };
+
+ useEffect(() => {
+ setQueryParams(Object.fromEntries(searchParams));
+ }, [searchParams.toString()]);
+
+ return {
+ router,
+ pathname,
+ searchParams,
+ query: queryParams,
+ teamId,
+ websiteId,
+ updateParams,
+ replaceParams,
+ renderUrl,
+ };
+}
diff --git a/src/components/hooks/usePageParameters.ts b/src/components/hooks/usePageParameters.ts
new file mode 100644
index 0000000..42cf391
--- /dev/null
+++ b/src/components/hooks/usePageParameters.ts
@@ -0,0 +1,16 @@
+import { useMemo } from 'react';
+import { useNavigation } from './useNavigation';
+
+export function usePageParameters() {
+ const {
+ query: { page, pageSize, search },
+ } = useNavigation();
+
+ return useMemo(() => {
+ return {
+ page,
+ pageSize,
+ search,
+ };
+ }, [page, pageSize, search]);
+}
diff --git a/src/components/hooks/usePagedQuery.ts b/src/components/hooks/usePagedQuery.ts
new file mode 100644
index 0000000..c818de6
--- /dev/null
+++ b/src/components/hooks/usePagedQuery.ts
@@ -0,0 +1,27 @@
+import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
+import type { PageResult } from '@/lib/types';
+import { useApi } from './useApi';
+import { useNavigation } from './useNavigation';
+
+export function usePagedQuery<TData = any, TError = Error>({
+ queryKey,
+ queryFn,
+ ...options
+}: Omit<
+ UseQueryOptions<PageResult<TData>, TError, PageResult<TData>, readonly unknown[]>,
+ 'queryFn' | 'queryKey'
+> & {
+ queryKey: readonly unknown[];
+ queryFn: (params?: object) => Promise<PageResult<TData>> | PageResult<TData>;
+}): UseQueryResult<PageResult<TData>, TError> {
+ const {
+ query: { page, search },
+ } = useNavigation();
+ const { useQuery } = useApi();
+
+ return useQuery<PageResult<TData>, TError>({
+ queryKey: [...queryKey, page, search] as const,
+ queryFn: () => queryFn({ page, search }),
+ ...options,
+ });
+}
diff --git a/src/components/hooks/useRegionNames.ts b/src/components/hooks/useRegionNames.ts
new file mode 100644
index 0000000..57dcc41
--- /dev/null
+++ b/src/components/hooks/useRegionNames.ts
@@ -0,0 +1,22 @@
+import regions from '../../../public/iso-3166-2.json';
+import { useCountryNames } from './useCountryNames';
+
+export function useRegionNames(locale: string) {
+ const { countryNames } = useCountryNames(locale);
+
+ const getRegionName = (regionCode: string, countryCode?: string) => {
+ if (!countryCode) {
+ return regions[regionCode];
+ }
+
+ if (!regionCode) {
+ return null;
+ }
+
+ const region = regionCode?.includes('-') ? regionCode : `${countryCode}-${regionCode}`;
+
+ return regions[region] ? `${regions[region]}, ${countryNames[countryCode]}` : region;
+ };
+
+ return { regionNames: regions, getRegionName };
+}
diff --git a/src/components/hooks/useSlug.ts b/src/components/hooks/useSlug.ts
new file mode 100644
index 0000000..f795dfe
--- /dev/null
+++ b/src/components/hooks/useSlug.ts
@@ -0,0 +1,14 @@
+import { useConfig } from '@/components/hooks/useConfig';
+import { LINKS_URL, PIXELS_URL } from '@/lib/constants';
+
+export function useSlug(type: 'link' | 'pixel') {
+ const { linksUrl, pixelsUrl } = useConfig();
+
+ const hostUrl = type === 'link' ? linksUrl || LINKS_URL : pixelsUrl || PIXELS_URL;
+
+ const getSlugUrl = (slug: string) => {
+ return `${hostUrl}/${slug}`;
+ };
+
+ return { getSlugUrl, hostUrl };
+}
diff --git a/src/components/hooks/useSticky.ts b/src/components/hooks/useSticky.ts
new file mode 100644
index 0000000..ef9fb36
--- /dev/null
+++ b/src/components/hooks/useSticky.ts
@@ -0,0 +1,25 @@
+import { useEffect, useRef, useState } from 'react';
+
+export function useSticky({ enabled = true, threshold = 1 }) {
+ const [isSticky, setIsSticky] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ let observer: IntersectionObserver | undefined;
+ // eslint-disable-next-line no-undef
+ const handler: IntersectionObserverCallback = ([entry]) =>
+ setIsSticky(entry.intersectionRatio < threshold);
+
+ if (enabled && ref.current) {
+ observer = new IntersectionObserver(handler, { threshold: [threshold] });
+ observer.observe(ref.current);
+ }
+ return () => {
+ if (observer) {
+ observer.disconnect();
+ }
+ };
+ }, [ref, enabled, threshold]);
+
+ return { ref, isSticky };
+}
diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts
new file mode 100644
index 0000000..ef25539
--- /dev/null
+++ b/src/components/hooks/useTimezone.ts
@@ -0,0 +1,95 @@
+import { formatInTimeZone, utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
+import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants';
+import { getTimezone } from '@/lib/date';
+import { setItem } from '@/lib/storage';
+import { setTimezone, useApp } from '@/store/app';
+import { useLocale } from './useLocale';
+
+const selector = (state: { timezone: string }) => state.timezone;
+
+export function useTimezone() {
+ const timezone = useApp(selector);
+ const localTimeZone = getTimezone();
+ const { dateLocale } = useLocale();
+
+ const saveTimezone = (value: string) => {
+ setItem(TIMEZONE_CONFIG, value);
+ setTimezone(value);
+ };
+
+ const formatTimezoneDate = (date: string, pattern: string) => {
+ return formatInTimeZone(
+ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})?Z$/.test(date)
+ ? date
+ : `${date.split(' ').join('T')}Z`,
+ timezone,
+ pattern,
+ { locale: dateLocale },
+ );
+ };
+
+ const formatSeriesTimezone = (data: any, column: string, timezone: string) => {
+ return data.map(item => {
+ const date = new Date(item[column]);
+
+ const format = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ hour12: false,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+
+ const parts = format.formatToParts(date);
+ const get = type => parts.find(p => p.type === type)?.value;
+
+ const year = get('year');
+ const month = get('month');
+ const day = get('day');
+ const hour = get('hour');
+ const minute = get('minute');
+ const second = get('second');
+
+ return {
+ ...item,
+ [column]: `${year}-${month}-${day} ${hour}:${minute}:${second}`,
+ };
+ });
+ };
+
+ const toUtc = (date: Date | string | number) => {
+ return zonedTimeToUtc(date, timezone);
+ };
+
+ const fromUtc = (date: Date | string | number) => {
+ return utcToZonedTime(date, timezone);
+ };
+
+ const localToUtc = (date: Date | string | number) => {
+ return zonedTimeToUtc(date, localTimeZone);
+ };
+
+ const localFromUtc = (date: Date | string | number) => {
+ return utcToZonedTime(date, localTimeZone);
+ };
+
+ const canonicalizeTimezone = (timezone: string): string => {
+ return TIMEZONE_LEGACY[timezone] ?? timezone;
+ };
+
+ return {
+ timezone,
+ localTimeZone,
+ toUtc,
+ fromUtc,
+ localToUtc,
+ localFromUtc,
+ saveTimezone,
+ formatTimezoneDate,
+ formatSeriesTimezone,
+ canonicalizeTimezone,
+ };
+}
diff --git a/src/components/icons.ts b/src/components/icons.ts
new file mode 100644
index 0000000..fe433d5
--- /dev/null
+++ b/src/components/icons.ts
@@ -0,0 +1 @@
+export * from 'lucide-react';
diff --git a/src/components/input/ActionSelect.tsx b/src/components/input/ActionSelect.tsx
new file mode 100644
index 0000000..616ee34
--- /dev/null
+++ b/src/components/input/ActionSelect.tsx
@@ -0,0 +1,18 @@
+import { ListItem, Select } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+
+export interface ActionSelectProps {
+ value?: string;
+ onChange?: (value: string) => void;
+}
+
+export function ActionSelect({ value = 'path', onChange }: ActionSelectProps) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <Select value={value} onChange={onChange}>
+ <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
+ <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
+ </Select>
+ );
+}
diff --git a/src/components/input/CurrencySelect.tsx b/src/components/input/CurrencySelect.tsx
new file mode 100644
index 0000000..2b6045b
--- /dev/null
+++ b/src/components/input/CurrencySelect.tsx
@@ -0,0 +1,34 @@
+import { ListItem, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { CURRENCIES } from '@/lib/constants';
+
+export function CurrencySelect({ value, onChange }) {
+ const { formatMessage, labels } = useMessages();
+ const [search, setSearch] = useState('');
+
+ return (
+ <Select
+ items={CURRENCIES}
+ label={formatMessage(labels.currency)}
+ value={value}
+ defaultValue={value}
+ onChange={onChange}
+ listProps={{ style: { maxHeight: 300 } }}
+ onSearch={setSearch}
+ allowSearch
+ >
+ {CURRENCIES.map(({ id, name }) => {
+ if (search && !`${id}${name}`.toLowerCase().includes(search)) {
+ return null;
+ }
+
+ return (
+ <ListItem key={id} id={id}>
+ {id} &mdash; {name}
+ </ListItem>
+ );
+ }).filter(n => n)}
+ </Select>
+ );
+}
diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx
new file mode 100644
index 0000000..2e17529
--- /dev/null
+++ b/src/components/input/DateFilter.tsx
@@ -0,0 +1,141 @@
+import { Dialog, ListItem, ListSeparator, Modal, Select, type SelectProps } from '@umami/react-zen';
+import { endOfYear } from 'date-fns';
+import { Fragment, type Key, useState } from 'react';
+import { DateDisplay } from '@/components/common/DateDisplay';
+import { useMessages, useMobile } from '@/components/hooks';
+import { DatePickerForm } from '@/components/metrics/DatePickerForm';
+import { parseDateRange } from '@/lib/date';
+
+export interface DateFilterProps extends SelectProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ showAllTime?: boolean;
+ renderDate?: boolean;
+ placement?: any;
+}
+
+export function DateFilter({
+ value,
+ onChange,
+ showAllTime,
+ renderDate,
+ placement = 'bottom',
+ ...props
+}: DateFilterProps) {
+ const { formatMessage, labels } = useMessages();
+ const [showPicker, setShowPicker] = useState(false);
+ const { startDate, endDate } = parseDateRange(value) || {};
+ const { isMobile } = useMobile();
+
+ const options = [
+ { label: formatMessage(labels.today), value: '0day' },
+ {
+ label: formatMessage(labels.lastHours, { x: '24' }),
+ value: '24hour',
+ },
+ {
+ label: formatMessage(labels.thisWeek),
+ value: '0week',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '7' }),
+ value: '7day',
+ },
+ {
+ label: formatMessage(labels.thisMonth),
+ value: '0month',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '30' }),
+ value: '30day',
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '90' }),
+ value: '90day',
+ },
+ { label: formatMessage(labels.thisYear), value: '0year' },
+ {
+ label: formatMessage(labels.lastMonths, { x: '6' }),
+ value: '6month',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastMonths, { x: '12' }),
+ value: '12month',
+ },
+ showAllTime && {
+ label: formatMessage(labels.allTime),
+ value: 'all',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.customRange),
+ value: 'custom',
+ divider: true,
+ },
+ ]
+ .filter(n => n)
+ .map((a, id) => ({ ...a, id }));
+
+ const handleChange = (value: Key) => {
+ if (value === 'custom') {
+ setShowPicker(true);
+ return;
+ }
+ onChange(value.toString());
+ };
+
+ const handlePickerChange = (value: string) => {
+ setShowPicker(false);
+ onChange(value.toString());
+ };
+
+ const renderValue = ({ defaultChildren }) => {
+ return value?.startsWith('range') || renderDate ? (
+ <DateDisplay startDate={startDate} endDate={endDate} />
+ ) : (
+ defaultChildren
+ );
+ };
+
+ const selectedValue = value.endsWith(':all') ? 'all' : value;
+
+ return (
+ <>
+ <Select
+ {...props}
+ value={selectedValue}
+ placeholder={formatMessage(labels.selectDate)}
+ onChange={handleChange}
+ renderValue={renderValue}
+ popoverProps={{ placement }}
+ isFullscreen={isMobile}
+ >
+ {options.map(({ label, value, divider }: any) => {
+ return (
+ <Fragment key={label}>
+ {divider && <ListSeparator />}
+ <ListItem id={value}>{label}</ListItem>
+ </Fragment>
+ );
+ })}
+ </Select>
+ {showPicker && (
+ <Modal isOpen={true}>
+ <Dialog>
+ <DatePickerForm
+ startDate={startDate}
+ endDate={endDate}
+ minDate={new Date(2000, 0, 1)}
+ maxDate={endOfYear(new Date())}
+ onChange={handlePickerChange}
+ onClose={() => setShowPicker(false)}
+ />
+ </Dialog>
+ </Modal>
+ )}
+ </>
+ );
+}
diff --git a/src/components/input/DialogButton.tsx b/src/components/input/DialogButton.tsx
new file mode 100644
index 0000000..7527226
--- /dev/null
+++ b/src/components/input/DialogButton.tsx
@@ -0,0 +1,64 @@
+import {
+ Button,
+ type ButtonProps,
+ Dialog,
+ type DialogProps,
+ DialogTrigger,
+ IconLabel,
+ Modal,
+} from '@umami/react-zen';
+import type { CSSProperties, ReactNode } from 'react';
+import { useMobile } from '@/components/hooks';
+
+export interface DialogButtonProps extends Omit<ButtonProps, 'children'> {
+ icon?: ReactNode;
+ label?: ReactNode;
+ title?: ReactNode;
+ width?: string;
+ height?: string;
+ minWidth?: string;
+ minHeight?: string;
+ children?: DialogProps['children'];
+}
+
+export function DialogButton({
+ icon,
+ label,
+ title,
+ width,
+ height,
+ minWidth,
+ minHeight,
+ children,
+ ...props
+}: DialogButtonProps) {
+ const { isMobile } = useMobile();
+ const style: CSSProperties = {
+ width,
+ height,
+ minWidth,
+ minHeight,
+ maxHeight: 'calc(100dvh - 40px)',
+ padding: '32px',
+ };
+
+ if (isMobile) {
+ style.width = '100%';
+ style.height = '100%';
+ style.maxHeight = '100%';
+ style.overflowY = 'auto';
+ }
+
+ return (
+ <DialogTrigger>
+ <Button {...props}>
+ <IconLabel icon={icon} label={label} />
+ </Button>
+ <Modal placement={isMobile ? 'fullscreen' : 'center'}>
+ <Dialog variant={isMobile ? 'sheet' : undefined} title={title || label} style={style}>
+ {children}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/DownloadButton.tsx b/src/components/input/DownloadButton.tsx
new file mode 100644
index 0000000..5df3305
--- /dev/null
+++ b/src/components/input/DownloadButton.tsx
@@ -0,0 +1,42 @@
+import { Button, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import Papa from 'papaparse';
+import { useMessages } from '@/components/hooks';
+import { Download } from '@/components/icons';
+
+export function DownloadButton({
+ filename = 'data',
+ data,
+}: {
+ filename?: string;
+ data?: any;
+ onClick?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ const handleClick = async () => {
+ downloadCsv(`${filename}.csv`, Papa.unparse(data));
+ };
+
+ return (
+ <TooltipTrigger delay={0}>
+ <Button variant="quiet" onClick={handleClick} isDisabled={!data || data.length === 0}>
+ <Icon>
+ <Download />
+ </Icon>
+ </Button>
+ <Tooltip>{formatMessage(labels.download)}</Tooltip>
+ </TooltipTrigger>
+ );
+}
+
+function downloadCsv(filename: string, data: any) {
+ const blob = new Blob([data], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+
+ URL.revokeObjectURL(url);
+}
diff --git a/src/components/input/ExportButton.tsx b/src/components/input/ExportButton.tsx
new file mode 100644
index 0000000..7b65a57
--- /dev/null
+++ b/src/components/input/ExportButton.tsx
@@ -0,0 +1,64 @@
+import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { useSearchParams } from 'next/navigation';
+import { useState } from 'react';
+import { useApi, useMessages } from '@/components/hooks';
+import { useDateParameters } from '@/components/hooks/useDateParameters';
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import { Download } from '@/components/icons';
+
+export function ExportButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const [isLoading, setIsLoading] = useState(false);
+ const date = useDateParameters();
+ const filters = useFilterParameters();
+ const searchParams = useSearchParams();
+ const { get } = useApi();
+
+ const handleClick = async () => {
+ setIsLoading(true);
+
+ const { zip } = await get(`/websites/${websiteId}/export`, {
+ ...date,
+ ...filters,
+ ...searchParams,
+ format: 'json',
+ });
+
+ await loadZip(zip);
+
+ setIsLoading(false);
+ };
+
+ return (
+ <TooltipTrigger delay={0}>
+ <LoadingButton
+ variant="quiet"
+ showText={!isLoading}
+ isLoading={isLoading}
+ onClick={handleClick}
+ >
+ <Icon>
+ <Download />
+ </Icon>
+ </LoadingButton>
+ <Tooltip>{formatMessage(labels.download)}</Tooltip>
+ </TooltipTrigger>
+ );
+}
+
+async function loadZip(zip: string) {
+ const binary = atob(zip);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+
+ const blob = new Blob([bytes], { type: 'application/zip' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'download.zip';
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx
new file mode 100644
index 0000000..2174068
--- /dev/null
+++ b/src/components/input/FieldFilters.tsx
@@ -0,0 +1,117 @@
+import {
+ Button,
+ Column,
+ Grid,
+ Icon,
+ List,
+ ListItem,
+ Menu,
+ MenuItem,
+ MenuTrigger,
+ Popover,
+ Row,
+} from '@umami/react-zen';
+import { endOfDay, subMonths } from 'date-fns';
+import type { Key } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { FilterRecord } from '@/components/common/FilterRecord';
+import { useFields, useMessages, useMobile } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+
+export interface FieldFiltersProps {
+ websiteId: string;
+ value?: { name: string; operator: string; value: string }[];
+ exclude?: string[];
+ onChange?: (data: any) => void;
+}
+
+export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) {
+ const { formatMessage, messages } = useMessages();
+ const { fields } = useFields();
+ const startDate = subMonths(endOfDay(new Date()), 6);
+ const endDate = endOfDay(new Date());
+ const { isMobile } = useMobile();
+
+ const updateFilter = (name: string, props: Record<string, any>) => {
+ onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
+ };
+
+ const handleAdd = (name: Key) => {
+ onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' }));
+ };
+
+ const handleChange = (name: string, value: Key) => {
+ updateFilter(name, { value });
+ };
+
+ const handleSelect = (name: string, operator: Key) => {
+ updateFilter(name, { operator });
+ };
+
+ const handleRemove = (name: string) => {
+ onChange(value.filter(filter => filter.name !== name));
+ };
+
+ return (
+ <Grid columns={{ xs: '1fr', md: '180px 1fr' }} overflow="hidden" gapY="6">
+ <Row display={{ xs: 'flex', md: 'none' }}>
+ <MenuTrigger>
+ <Button>
+ <Icon>
+ <Plus />
+ </Icon>
+ </Button>
+ <Popover placement={isMobile ? 'left' : 'bottom start'} shouldFlip>
+ <Menu
+ onAction={handleAdd}
+ style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
+ >
+ {fields
+ .filter(({ name }) => !exclude.includes(name))
+ .map(field => {
+ const isDisabled = !!value.find(({ name }) => name === field.name);
+ return (
+ <MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
+ {field.label}
+ </MenuItem>
+ );
+ })}
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ </Row>
+ <Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6">
+ <List onAction={handleAdd}>
+ {fields
+ .filter(({ name }) => !exclude.includes(name))
+ .map(field => {
+ const isDisabled = !!value.find(({ name }) => name === field.name);
+ return (
+ <ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
+ {field.label}
+ </ListItem>
+ );
+ })}
+ </List>
+ </Column>
+ <Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
+ {value.map(filter => {
+ return (
+ <FilterRecord
+ key={filter.name}
+ websiteId={websiteId}
+ type={filter.name}
+ startDate={startDate}
+ endDate={endDate}
+ {...filter}
+ onSelect={handleSelect}
+ onRemove={handleRemove}
+ onChange={handleChange}
+ />
+ );
+ })}
+ {!value.length && <Empty message={formatMessage(messages.nothingSelected)} />}
+ </Column>
+ </Grid>
+ );
+}
diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx
new file mode 100644
index 0000000..5a52e56
--- /dev/null
+++ b/src/components/input/FilterBar.tsx
@@ -0,0 +1,155 @@
+import {
+ Button,
+ Dialog,
+ DialogTrigger,
+ Icon,
+ Modal,
+ Row,
+ Text,
+ Tooltip,
+ TooltipTrigger,
+} from '@umami/react-zen';
+import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
+import {
+ useFilters,
+ useFormat,
+ useMessages,
+ useNavigation,
+ useWebsiteSegmentQuery,
+} from '@/components/hooks';
+import { Bookmark, X } from '@/components/icons';
+import { isSearchOperator } from '@/lib/params';
+
+export function FilterBar({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const {
+ router,
+ pathname,
+ updateParams,
+ replaceParams,
+ query: { segment, cohort },
+ } = useNavigation();
+ const { filters, operatorLabels } = useFilters();
+ const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment || cohort);
+ const canSaveSegment = filters.length > 0 && !segment && !cohort && !pathname.includes('/share');
+
+ const handleCloseFilter = (param: string) => {
+ router.push(updateParams({ [param]: undefined }));
+ };
+
+ const handleResetFilter = () => {
+ router.push(replaceParams());
+ };
+
+ const handleSegmentRemove = (type: string) => {
+ router.push(updateParams({ [type]: undefined }));
+ };
+
+ if (!filters.length && !segment && !cohort) {
+ return null;
+ }
+
+ return (
+ <Row gap alignItems="center" justifyContent="space-between" padding="2" backgroundColor="3">
+ <Row alignItems="center" gap="2" wrap="wrap">
+ {segment && !isLoading && (
+ <FilterItem
+ name="segment"
+ label={formatMessage(labels.segment)}
+ value={data?.name || segment}
+ operator={operatorLabels.eq}
+ onRemove={() => handleSegmentRemove('segment')}
+ />
+ )}
+ {cohort && !isLoading && (
+ <FilterItem
+ name="cohort"
+ label={formatMessage(labels.cohort)}
+ value={data?.name || cohort}
+ operator={operatorLabels.eq}
+ onRemove={() => handleSegmentRemove('cohort')}
+ />
+ )}
+ {filters.map(filter => {
+ const { name, label, operator, value } = filter;
+ const paramValue = isSearchOperator(operator) ? value : formatValue(value, name);
+
+ return (
+ <FilterItem
+ key={name}
+ name={name}
+ label={label}
+ operator={operatorLabels[operator]}
+ value={paramValue}
+ onRemove={(name: string) => handleCloseFilter(name)}
+ />
+ );
+ })}
+ </Row>
+ <Row alignItems="center">
+ <DialogTrigger>
+ {canSaveSegment && (
+ <TooltipTrigger delay={0}>
+ <Button variant="zero">
+ <Icon>
+ <Bookmark />
+ </Icon>
+ </Button>
+ <Tooltip>
+ <Text>{formatMessage(labels.saveSegment)}</Text>
+ </Tooltip>
+ </TooltipTrigger>
+ )}
+ <Modal>
+ <Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
+ {({ close }) => {
+ return <SegmentEditForm websiteId={websiteId} onClose={close} filters={filters} />;
+ }}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ <TooltipTrigger delay={0}>
+ <Button variant="zero" onPress={handleResetFilter}>
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ <Tooltip>
+ <Text>{formatMessage(labels.clearAll)}</Text>
+ </Tooltip>
+ </TooltipTrigger>
+ </Row>
+ </Row>
+ );
+}
+
+const FilterItem = ({ name, label, operator, value, onRemove }) => {
+ return (
+ <Row
+ border
+ padding="2"
+ color
+ backgroundColor
+ borderRadius
+ alignItems="center"
+ justifyContent="space-between"
+ theme="dark"
+ >
+ <Row alignItems="center" gap="4">
+ <Row alignItems="center" gap="2">
+ <Text color="12" weight="bold">
+ {label}
+ </Text>
+ <Text color="11">{operator}</Text>
+ <Text color="12" weight="bold">
+ {value}
+ </Text>
+ </Row>
+ <Icon onClick={() => onRemove(name)} size="xs" style={{ cursor: 'pointer' }}>
+ <X />
+ </Icon>
+ </Row>
+ </Row>
+ );
+};
diff --git a/src/components/input/FilterButtons.tsx b/src/components/input/FilterButtons.tsx
new file mode 100644
index 0000000..ff37fb1
--- /dev/null
+++ b/src/components/input/FilterButtons.tsx
@@ -0,0 +1,33 @@
+import { Box, ToggleGroup, ToggleGroupItem } from '@umami/react-zen';
+import { useState } from 'react';
+
+export interface FilterButtonsProps {
+ items: { id: string; label: string }[];
+ value: string;
+ onChange?: (value: string) => void;
+}
+
+export function FilterButtons({ items, value, onChange }: FilterButtonsProps) {
+ const [selected, setSelected] = useState(value);
+
+ const handleChange = (value: string) => {
+ setSelected(value);
+ onChange?.(value);
+ };
+
+ return (
+ <Box>
+ <ToggleGroup
+ value={[selected]}
+ onChange={e => handleChange(e[0])}
+ disallowEmptySelection={true}
+ >
+ {items.map(({ id, label }) => (
+ <ToggleGroupItem key={id} id={id}>
+ {label}
+ </ToggleGroupItem>
+ ))}
+ </ToggleGroup>
+ </Box>
+ );
+}
diff --git a/src/components/input/FilterEditForm.tsx b/src/components/input/FilterEditForm.tsx
new file mode 100644
index 0000000..44f4384
--- /dev/null
+++ b/src/components/input/FilterEditForm.tsx
@@ -0,0 +1,95 @@
+import { Button, Column, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { useState } from 'react';
+import { useFilters, useMessages, useMobile, useNavigation } from '@/components/hooks';
+import { FieldFilters } from '@/components/input/FieldFilters';
+import { SegmentFilters } from '@/components/input/SegmentFilters';
+
+export interface FilterEditFormProps {
+ websiteId?: string;
+ onChange?: (params: { filters: any[]; segment?: string; cohort?: string }) => void;
+ onClose?: () => void;
+}
+
+export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) {
+ const {
+ query: { segment, cohort },
+ pathname,
+ } = useNavigation();
+ const { filters } = useFilters();
+ const { formatMessage, labels } = useMessages();
+ const [currentFilters, setCurrentFilters] = useState(filters);
+ const [currentSegment, setCurrentSegment] = useState(segment);
+ const [currentCohort, setCurrentCohort] = useState(cohort);
+ const { isMobile } = useMobile();
+ const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
+
+ const handleReset = () => {
+ setCurrentFilters([]);
+ setCurrentSegment(undefined);
+ setCurrentCohort(undefined);
+ };
+
+ const handleSave = () => {
+ onChange?.({
+ filters: currentFilters.filter(f => f.value),
+ segment: currentSegment,
+ cohort: currentCohort,
+ });
+ onClose?.();
+ };
+
+ const handleSegmentChange = (id: string, type: string) => {
+ setCurrentSegment(type === 'segment' ? id : undefined);
+ setCurrentCohort(type === 'cohort' ? id : undefined);
+ };
+
+ return (
+ <Column width={isMobile ? 'auto' : '800px'} gap="6">
+ <Column minHeight="500px">
+ <Tabs>
+ <TabList>
+ <Tab id="fields">{formatMessage(labels.fields)}</Tab>
+ {!excludeFilters && (
+ <>
+ <Tab id="segments">{formatMessage(labels.segments)}</Tab>
+ <Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab>
+ </>
+ )}
+ </TabList>
+ <TabPanel id="fields">
+ <FieldFilters
+ websiteId={websiteId}
+ value={currentFilters}
+ onChange={setCurrentFilters}
+ exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
+ />
+ </TabPanel>
+ <TabPanel id="segments">
+ <SegmentFilters
+ websiteId={websiteId}
+ segmentId={currentSegment}
+ onChange={handleSegmentChange}
+ />
+ </TabPanel>
+ <TabPanel id="cohorts">
+ <SegmentFilters
+ type="cohort"
+ websiteId={websiteId}
+ segmentId={currentCohort}
+ onChange={handleSegmentChange}
+ />
+ </TabPanel>
+ </Tabs>
+ </Column>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
+ <Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <Button variant="primary" onPress={handleSave}>
+ {formatMessage(labels.apply)}
+ </Button>
+ </Row>
+ </Row>
+ </Column>
+ );
+}
diff --git a/src/components/input/LanguageButton.tsx b/src/components/input/LanguageButton.tsx
new file mode 100644
index 0000000..ac43dcb
--- /dev/null
+++ b/src/components/input/LanguageButton.tsx
@@ -0,0 +1,41 @@
+import { Button, Dialog, Grid, Icon, MenuTrigger, Popover, Text } from '@umami/react-zen';
+import { Globe } from 'lucide-react';
+import { useLocale } from '@/components/hooks';
+import { languages } from '@/lib/lang';
+
+export function LanguageButton() {
+ const { locale, saveLocale } = useLocale();
+ const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
+
+ function handleSelect(value: string) {
+ saveLocale(value);
+ }
+
+ return (
+ <MenuTrigger key="language">
+ <Button variant="quiet">
+ <Icon>
+ <Globe />
+ </Icon>
+ </Button>
+ <Popover placement="bottom end">
+ <Dialog variant="menu">
+ <Grid columns="repeat(3, minmax(200px, 1fr))" overflow="hidden">
+ {items.map(({ value, label }) => {
+ return (
+ <Button key={value} variant="quiet" onPress={() => handleSelect(value)}>
+ <Text
+ weight={value === locale ? 'bold' : 'medium'}
+ color={value === locale ? undefined : 'muted'}
+ >
+ {label}
+ </Text>
+ </Button>
+ );
+ })}
+ </Grid>
+ </Dialog>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/components/input/LookupField.tsx b/src/components/input/LookupField.tsx
new file mode 100644
index 0000000..c1d419f
--- /dev/null
+++ b/src/components/input/LookupField.tsx
@@ -0,0 +1,65 @@
+import { ComboBox, type ComboBoxProps, ListItem, Loading, useDebounce } from '@umami/react-zen';
+import { endOfDay, subMonths } from 'date-fns';
+import { type SetStateAction, useMemo, useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { useMessages, useWebsiteValuesQuery } from '@/components/hooks';
+
+export interface LookupFieldProps extends ComboBoxProps {
+ websiteId: string;
+ type: string;
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export function LookupField({ websiteId, type, value, onChange, ...props }: LookupFieldProps) {
+ const { formatMessage, messages } = useMessages();
+ const [search, setSearch] = useState(value);
+ const searchValue = useDebounce(search, 300);
+ const startDate = subMonths(endOfDay(new Date()), 6);
+ const endDate = endOfDay(new Date());
+
+ const { data, isLoading } = useWebsiteValuesQuery({
+ websiteId,
+ type,
+ search: searchValue,
+ startDate,
+ endDate,
+ });
+
+ const items: string[] = useMemo(() => {
+ return data?.map(({ value }) => value) || [];
+ }, [data]);
+
+ const handleSearch = (value: SetStateAction<string>) => {
+ setSearch(value);
+ };
+
+ return (
+ <ComboBox
+ aria-label="LookupField"
+ {...props}
+ items={items}
+ inputValue={value}
+ onInputChange={value => {
+ handleSearch(value);
+ onChange?.(value);
+ }}
+ formValue="text"
+ allowsEmptyCollection
+ allowsCustomValue
+ renderEmptyState={() =>
+ isLoading ? (
+ <Loading placement="center" icon="dots" />
+ ) : (
+ <Empty message={formatMessage(messages.noResultsFound)} />
+ )
+ }
+ >
+ {items.map(item => (
+ <ListItem key={item} id={item}>
+ {item}
+ </ListItem>
+ ))}
+ </ComboBox>
+ );
+}
diff --git a/src/components/input/MenuButton.tsx b/src/components/input/MenuButton.tsx
new file mode 100644
index 0000000..bac307f
--- /dev/null
+++ b/src/components/input/MenuButton.tsx
@@ -0,0 +1,32 @@
+import { Button, DialogTrigger, Icon, Menu, Popover } from '@umami/react-zen';
+import type { Key, ReactNode } from 'react';
+import { Ellipsis } from '@/components/icons';
+
+export function MenuButton({
+ children,
+ onAction,
+ isDisabled,
+}: {
+ children: ReactNode;
+ onAction?: (action: string) => void;
+ isDisabled?: boolean;
+}) {
+ const handleAction = (key: Key) => {
+ onAction?.(key.toString());
+ };
+
+ return (
+ <DialogTrigger>
+ <Button variant="quiet" isDisabled={isDisabled}>
+ <Icon>
+ <Ellipsis />
+ </Icon>
+ </Button>
+ <Popover placement="bottom start">
+ <Menu aria-label="menu" onAction={handleAction} style={{ minWidth: '140px' }}>
+ {children}
+ </Menu>
+ </Popover>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/MobileMenuButton.tsx b/src/components/input/MobileMenuButton.tsx
new file mode 100644
index 0000000..5e59cbb
--- /dev/null
+++ b/src/components/input/MobileMenuButton.tsx
@@ -0,0 +1,17 @@
+import { Button, Dialog, type DialogProps, DialogTrigger, Icon, Modal } from '@umami/react-zen';
+import { Menu } from '@/components/icons';
+
+export function MobileMenuButton(props: DialogProps) {
+ return (
+ <DialogTrigger>
+ <Button>
+ <Icon>
+ <Menu />
+ </Icon>
+ </Button>
+ <Modal placement="left" offset="80px">
+ <Dialog variant="sheet" {...props} />
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/MonthFilter.tsx b/src/components/input/MonthFilter.tsx
new file mode 100644
index 0000000..dec64b0
--- /dev/null
+++ b/src/components/input/MonthFilter.tsx
@@ -0,0 +1,18 @@
+import { useDateRange, useNavigation } from '@/components/hooks';
+import { getMonthDateRangeValue } from '@/lib/date';
+import { MonthSelect } from './MonthSelect';
+
+export function MonthFilter() {
+ const { router, updateParams } = useNavigation();
+ const {
+ dateRange: { startDate },
+ } = useDateRange();
+
+ const handleMonthSelect = (date: Date) => {
+ const range = getMonthDateRangeValue(date);
+
+ router.push(updateParams({ date: range, offset: undefined }));
+ };
+
+ return <MonthSelect date={startDate} onChange={handleMonthSelect} />;
+}
diff --git a/src/components/input/MonthSelect.tsx b/src/components/input/MonthSelect.tsx
new file mode 100644
index 0000000..241634e
--- /dev/null
+++ b/src/components/input/MonthSelect.tsx
@@ -0,0 +1,47 @@
+import { ListItem, Row, Select } from '@umami/react-zen';
+import { useLocale } from '@/components/hooks';
+import { formatDate } from '@/lib/date';
+
+export function MonthSelect({ date = new Date(), onChange }) {
+ const { locale } = useLocale();
+ const month = date.getMonth();
+ const year = date.getFullYear();
+ const currentYear = new Date().getFullYear();
+
+ const months = [...Array(12)].map((_, i) => i);
+ const years = [...Array(10)].map((_, i) => currentYear - i);
+
+ const handleMonthChange = (month: number) => {
+ const d = new Date(date);
+ d.setMonth(month);
+ onChange?.(d);
+ };
+ const handleYearChange = (year: number) => {
+ const d = new Date(date);
+ d.setFullYear(year);
+ onChange?.(d);
+ };
+
+ return (
+ <Row gap>
+ <Select value={month} onChange={handleMonthChange}>
+ {months.map(m => {
+ return (
+ <ListItem id={m} key={m}>
+ {formatDate(new Date(year, m, 1), 'MMMM', locale)}
+ </ListItem>
+ );
+ })}
+ </Select>
+ <Select value={year} onChange={handleYearChange}>
+ {years.map(y => {
+ return (
+ <ListItem id={y} key={y}>
+ {y}
+ </ListItem>
+ );
+ })}
+ </Select>
+ </Row>
+ );
+}
diff --git a/src/components/input/NavButton.tsx b/src/components/input/NavButton.tsx
new file mode 100644
index 0000000..ab77ef0
--- /dev/null
+++ b/src/components/input/NavButton.tsx
@@ -0,0 +1,188 @@
+import {
+ Column,
+ Icon,
+ IconLabel,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+ Pressable,
+ Row,
+ SubmenuTrigger,
+ Text,
+} from '@umami/react-zen';
+import { ArrowRight } from 'lucide-react';
+import type { Key } from 'react';
+import {
+ useConfig,
+ useLoginQuery,
+ useMessages,
+ useMobile,
+ useNavigation,
+} from '@/components/hooks';
+import {
+ BookText,
+ ChevronRight,
+ ExternalLink,
+ LifeBuoy,
+ LockKeyhole,
+ LogOut,
+ Settings,
+ User,
+ Users,
+} from '@/components/icons';
+import { Switch } from '@/components/svg';
+import { DOCS_URL, LAST_TEAM_CONFIG } from '@/lib/constants';
+import { removeItem } from '@/lib/storage';
+
+export interface TeamsButtonProps {
+ showText?: boolean;
+ onAction?: (id: any) => void;
+}
+
+export function NavButton({ showText = true }: TeamsButtonProps) {
+ const { user } = useLoginQuery();
+ const { cloudMode } = useConfig();
+ const { formatMessage, labels } = useMessages();
+ const { teamId, router } = useNavigation();
+ const { isMobile } = useMobile();
+ const team = user?.teams?.find(({ id }) => id === teamId);
+ const selectedKeys = new Set([teamId || 'user']);
+ const label = teamId ? team?.name : user.username;
+
+ const getUrl = (url: string) => {
+ return cloudMode ? `${process.env.cloudUrl}${url}` : url;
+ };
+
+ const handleAction = async (key: Key) => {
+ if (key === 'user') {
+ removeItem(LAST_TEAM_CONFIG);
+ if (cloudMode) {
+ window.location.href = '/';
+ } else {
+ router.push('/');
+ }
+ }
+ };
+
+ return (
+ <MenuTrigger>
+ <Pressable>
+ <Row
+ alignItems="center"
+ justifyContent="space-between"
+ flexGrow={1}
+ padding
+ border
+ borderRadius
+ shadow="1"
+ maxHeight="40px"
+ role="button"
+ style={{ cursor: 'pointer', textWrap: 'nowrap', overflow: 'hidden', outline: 'none' }}
+ >
+ <Row alignItems="center" position="relative" gap maxHeight="40px">
+ <Icon>{teamId ? <Users /> : <User />}</Icon>
+ {showText && <Text>{label}</Text>}
+ </Row>
+ {showText && (
+ <Icon rotate={90} size="sm">
+ <ChevronRight />
+ </Icon>
+ )}
+ </Row>
+ </Pressable>
+ <Popover placement="bottom start">
+ <Column minWidth="300px">
+ <Menu autoFocus="last">
+ <SubmenuTrigger>
+ <MenuItem id="teams" showChecked={false} showSubMenuIcon>
+ <IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} />
+ </MenuItem>
+ <Popover placement={isMobile ? 'bottom start' : 'right top'}>
+ <Column minWidth="300px">
+ <Menu selectionMode="single" selectedKeys={selectedKeys} onAction={handleAction}>
+ <MenuSection title={formatMessage(labels.myAccount)}>
+ <MenuItem id="user">
+ <IconLabel icon={<User />} label={user.username} />
+ </MenuItem>
+ </MenuSection>
+ <MenuSeparator />
+ <MenuSection title={formatMessage(labels.teams)}>
+ {user?.teams?.map(({ id, name }) => (
+ <MenuItem key={id} id={id} href={getUrl(`/teams/${id}`)}>
+ <IconLabel icon={<Users />}>
+ <Text wrap="nowrap">{name}</Text>
+ </IconLabel>
+ </MenuItem>
+ ))}
+ {user?.teams?.length === 0 && (
+ <MenuItem id="manage-teams">
+ <a href="/settings/teams" style={{ width: '100%' }}>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Text align="center">Manage teams</Text>
+ <Icon>
+ <ArrowRight />
+ </Icon>
+ </Row>
+ </a>
+ </MenuItem>
+ )}
+ </MenuSection>
+ </Menu>
+ </Column>
+ </Popover>
+ </SubmenuTrigger>
+ <MenuSeparator />
+ <MenuItem
+ id="settings"
+ href={getUrl('/settings')}
+ icon={<Settings />}
+ label={formatMessage(labels.settings)}
+ />
+ {cloudMode && (
+ <>
+ <MenuItem
+ id="docs"
+ href={DOCS_URL}
+ target="_blank"
+ icon={<BookText />}
+ label={formatMessage(labels.documentation)}
+ >
+ <Icon color="muted">
+ <ExternalLink />
+ </Icon>
+ </MenuItem>
+ <MenuItem
+ id="support"
+ href={getUrl('/settings/support')}
+ icon={<LifeBuoy />}
+ label={formatMessage(labels.support)}
+ />
+ </>
+ )}
+ {!cloudMode && user.isAdmin && (
+ <>
+ <MenuSeparator />
+ <MenuItem
+ id="/admin"
+ href="/admin"
+ icon={<LockKeyhole />}
+ label={formatMessage(labels.admin)}
+ />
+ </>
+ )}
+ <MenuSeparator />
+ <MenuItem
+ id="logout"
+ href={getUrl('/logout')}
+ icon={<LogOut />}
+ label={formatMessage(labels.logout)}
+ />
+ </Menu>
+ </Column>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/components/input/PanelButton.tsx b/src/components/input/PanelButton.tsx
new file mode 100644
index 0000000..500c40c
--- /dev/null
+++ b/src/components/input/PanelButton.tsx
@@ -0,0 +1,19 @@
+import { Button, type ButtonProps, Icon } from '@umami/react-zen';
+import { useGlobalState } from '@/components/hooks';
+import { PanelLeft } from '@/components/icons';
+
+export function PanelButton(props: ButtonProps) {
+ const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed');
+ return (
+ <Button
+ onPress={() => setIsCollapsed(!isCollapsed)}
+ variant="zero"
+ {...props}
+ style={{ padding: 0 }}
+ >
+ <Icon strokeColor="muted">
+ <PanelLeft />
+ </Icon>
+ </Button>
+ );
+}
diff --git a/src/components/input/PreferencesButton.tsx b/src/components/input/PreferencesButton.tsx
new file mode 100644
index 0000000..710a7fa
--- /dev/null
+++ b/src/components/input/PreferencesButton.tsx
@@ -0,0 +1,32 @@
+import { Button, Column, DialogTrigger, Icon, Label, Popover } from '@umami/react-zen';
+import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting';
+import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetting';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { Settings } from '@/components/icons';
+
+export function PreferencesButton() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="quiet">
+ <Icon>
+ <Settings />
+ </Icon>
+ </Button>
+ <Popover placement="bottom end">
+ <Panel gap="3">
+ <Column>
+ <Label>{formatMessage(labels.timezone)}</Label>
+ <TimezoneSetting />
+ </Column>
+ <Column>
+ <Label>{formatMessage(labels.defaultDateRange)}</Label>
+ <DateRangeSetting />
+ </Column>
+ </Panel>
+ </Popover>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/ProfileButton.tsx b/src/components/input/ProfileButton.tsx
new file mode 100644
index 0000000..505cd88
--- /dev/null
+++ b/src/components/input/ProfileButton.tsx
@@ -0,0 +1,74 @@
+import {
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+ Row,
+ Text,
+} from '@umami/react-zen';
+import { Fragment } from 'react';
+import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
+import { LockKeyhole, LogOut, UserCircle } from '@/components/icons';
+
+export function ProfileButton() {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+ const { renderUrl } = useNavigation();
+
+ const items = [
+ {
+ id: 'settings',
+ label: formatMessage(labels.profile),
+ path: renderUrl('/settings/profile'),
+ icon: <UserCircle />,
+ },
+ user.isAdmin &&
+ !process.env.cloudMode && {
+ id: 'admin',
+ label: formatMessage(labels.admin),
+ path: '/admin',
+ icon: <LockKeyhole />,
+ },
+ {
+ id: 'logout',
+ label: formatMessage(labels.logout),
+ path: '/logout',
+ icon: <LogOut />,
+ separator: true,
+ },
+ ].filter(n => n);
+
+ return (
+ <MenuTrigger>
+ <Button data-test="button-profile" variant="quiet">
+ <Icon>
+ <UserCircle />
+ </Icon>
+ </Button>
+ <Popover placement="bottom end">
+ <Menu autoFocus="last">
+ <MenuSection title={user.username}>
+ <MenuSeparator />
+ {items.map(({ id, path, label, icon, separator }) => {
+ return (
+ <Fragment key={id}>
+ {separator && <MenuSeparator />}
+ <MenuItem id={id} href={path}>
+ <Row alignItems="center" gap>
+ <Icon>{icon}</Icon>
+ <Text>{label}</Text>
+ </Row>
+ </MenuItem>
+ </Fragment>
+ );
+ })}
+ </MenuSection>
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/components/input/RefreshButton.tsx b/src/components/input/RefreshButton.tsx
new file mode 100644
index 0000000..b52f830
--- /dev/null
+++ b/src/components/input/RefreshButton.tsx
@@ -0,0 +1,32 @@
+import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { RefreshCw } from '@/components/icons';
+import { setWebsiteDateRange } from '@/store/websites';
+
+export function RefreshButton({
+ websiteId,
+ isLoading,
+}: {
+ websiteId: string;
+ isLoading?: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { dateRange } = useDateRange();
+
+ function handleClick() {
+ if (!isLoading && dateRange) {
+ setWebsiteDateRange(websiteId, dateRange);
+ }
+ }
+
+ return (
+ <TooltipTrigger>
+ <LoadingButton isLoading={isLoading} onPress={handleClick}>
+ <Icon>
+ <RefreshCw />
+ </Icon>
+ </LoadingButton>
+ <Tooltip>{formatMessage(labels.refresh)}</Tooltip>
+ </TooltipTrigger>
+ );
+}
diff --git a/src/components/input/ReportEditButton.tsx b/src/components/input/ReportEditButton.tsx
new file mode 100644
index 0000000..b333077
--- /dev/null
+++ b/src/components/input/ReportEditButton.tsx
@@ -0,0 +1,99 @@
+import {
+ AlertDialog,
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuTrigger,
+ Modal,
+ Popover,
+ Row,
+ Text,
+} from '@umami/react-zen';
+import { type ReactNode, useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
+import { Edit, MoreHorizontal, Trash } from '@/components/icons';
+
+export function ReportEditButton({
+ id,
+ name,
+ type,
+ children,
+ onDelete,
+}: {
+ id: string;
+ name: string;
+ type: string;
+ onDelete?: () => void;
+ children: ({ close }: { close: () => void }) => ReactNode;
+}) {
+ const { formatMessage, labels, messages } = useMessages();
+ const [showEdit, setShowEdit] = useState(false);
+ const [showDelete, setShowDelete] = useState(false);
+ const { mutateAsync, touch } = useDeleteQuery(`/reports/${id}`);
+
+ const handleAction = (id: any) => {
+ if (id === 'edit') {
+ setShowEdit(true);
+ } else if (id === 'delete') {
+ setShowDelete(true);
+ }
+ };
+
+ const handleClose = () => {
+ setShowEdit(false);
+ setShowDelete(false);
+ };
+
+ const handleDelete = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch(`reports:${type}`);
+ setShowDelete(false);
+ onDelete?.();
+ },
+ });
+ };
+
+ return (
+ <>
+ <MenuTrigger>
+ <Button variant="quiet">
+ <Icon>
+ <MoreHorizontal />
+ </Icon>
+ </Button>
+ <Popover placement="bottom">
+ <Menu onAction={handleAction}>
+ <MenuItem id="edit">
+ <Icon>
+ <Edit />
+ </Icon>
+ <Text>{formatMessage(labels.edit)}</Text>
+ </MenuItem>
+ <MenuItem id="delete">
+ <Icon>
+ <Trash />
+ </Icon>
+ <Text>{formatMessage(labels.delete)}</Text>
+ </MenuItem>
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ <Modal isOpen={showEdit || showDelete} isDismissable={true}>
+ {showEdit && children({ close: handleClose })}
+ {showDelete && (
+ <AlertDialog
+ title={formatMessage(labels.delete)}
+ onConfirm={handleDelete}
+ onCancel={handleClose}
+ isDanger
+ >
+ <Row gap="1">{formatMessage(messages.confirmDelete, { target: name })}</Row>
+ </AlertDialog>
+ )}
+ </Modal>
+ </>
+ );
+}
diff --git a/src/components/input/SegmentFilters.tsx b/src/components/input/SegmentFilters.tsx
new file mode 100644
index 0000000..f03a1de
--- /dev/null
+++ b/src/components/input/SegmentFilters.tsx
@@ -0,0 +1,42 @@
+import { IconLabel, List, ListItem } from '@umami/react-zen';
+import { Empty } from '@/components/common/Empty';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useWebsiteSegmentsQuery } from '@/components/hooks';
+import { ChartPie, UserPlus } from '@/components/icons';
+
+export interface SegmentFiltersProps {
+ websiteId: string;
+ segmentId: string;
+ type?: string;
+ onChange?: (id: string, type: string) => void;
+}
+
+export function SegmentFilters({
+ websiteId,
+ segmentId,
+ type = 'segment',
+ onChange,
+}: SegmentFiltersProps) {
+ const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type });
+
+ const handleChange = (id: string) => {
+ onChange?.(id, type);
+ };
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} overflowY="auto">
+ {data?.data?.length === 0 && <Empty />}
+ <List selectionMode="single" value={[segmentId]} onChange={id => handleChange(id[0])}>
+ {data?.data?.map(item => {
+ return (
+ <ListItem key={item.id} id={item.id}>
+ <IconLabel icon={type === 'segment' ? <ChartPie /> : <UserPlus />}>
+ {item.name}
+ </IconLabel>
+ </ListItem>
+ );
+ })}
+ </List>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/input/SegmentSaveButton.tsx b/src/components/input/SegmentSaveButton.tsx
new file mode 100644
index 0000000..5f6cac1
--- /dev/null
+++ b/src/components/input/SegmentSaveButton.tsx
@@ -0,0 +1,26 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+
+export function SegmentSaveButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.segment)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.segment)} style={{ width: 800 }}>
+ {({ close }) => {
+ return <SegmentEditForm websiteId={websiteId} onClose={close} />;
+ }}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/SettingsButton.tsx b/src/components/input/SettingsButton.tsx
new file mode 100644
index 0000000..bd51fb5
--- /dev/null
+++ b/src/components/input/SettingsButton.tsx
@@ -0,0 +1,84 @@
+import {
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+} from '@umami/react-zen';
+import type { Key } from 'react';
+import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
+import {
+ BookText,
+ ExternalLink,
+ LifeBuoy,
+ LockKeyhole,
+ LogOut,
+ Settings,
+ UserCircle,
+} from '@/components/icons';
+import { DOCS_URL } from '@/lib/constants';
+
+export function SettingsButton() {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+ const { router } = useNavigation();
+ const { cloudMode } = useConfig();
+
+ const handleAction = (id: Key) => {
+ const url = id.toString();
+
+ if (cloudMode) {
+ if (url === '/docs') {
+ window.open(DOCS_URL, '_blank');
+ } else {
+ window.location.href = url;
+ }
+ } else {
+ router.push(url);
+ }
+ };
+
+ return (
+ <MenuTrigger>
+ <Button data-test="button-profile" variant="quiet" autoFocus={false}>
+ <Icon>
+ <UserCircle />
+ </Icon>
+ </Button>
+ <Popover placement="bottom end">
+ <Menu autoFocus="last" onAction={handleAction}>
+ <MenuSection title={user.username}>
+ <MenuSeparator />
+ <MenuItem id="/settings" icon={<Settings />} label={formatMessage(labels.settings)} />
+ {!cloudMode && user.isAdmin && (
+ <MenuItem id="/admin" icon={<LockKeyhole />} label={formatMessage(labels.admin)} />
+ )}
+ {cloudMode && (
+ <>
+ <MenuItem
+ id="/docs"
+ icon={<BookText />}
+ label={formatMessage(labels.documentation)}
+ >
+ <Icon color="muted">
+ <ExternalLink />
+ </Icon>
+ </MenuItem>
+ <MenuItem
+ id="/settings/support"
+ icon={<LifeBuoy />}
+ label={formatMessage(labels.support)}
+ />
+ </>
+ )}
+ <MenuSeparator />
+ <MenuItem id="/logout" icon={<LogOut />} label={formatMessage(labels.logout)} />
+ </MenuSection>
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx
new file mode 100644
index 0000000..18b4f13
--- /dev/null
+++ b/src/components/input/WebsiteDateFilter.tsx
@@ -0,0 +1,102 @@
+import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen';
+import { isAfter } from 'date-fns';
+import { useMemo } from 'react';
+import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks';
+import { ChevronRight } from '@/components/icons';
+import { getDateRangeValue } from '@/lib/date';
+import { DateFilter } from './DateFilter';
+
+export interface WebsiteDateFilterProps {
+ websiteId: string;
+ compare?: string;
+ showAllTime?: boolean;
+ showButtons?: boolean;
+ allowCompare?: boolean;
+}
+
+export function WebsiteDateFilter({
+ websiteId,
+ showAllTime = true,
+ showButtons = true,
+ allowCompare,
+}: WebsiteDateFilterProps) {
+ const { dateRange, isAllTime, isCustomRange } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const {
+ router,
+ updateParams,
+ query: { compare = 'prev', offset = 0 },
+ } = useNavigation();
+ const disableForward = isAllTime || isAfter(dateRange.endDate, new Date());
+ const showCompare = allowCompare && !isAllTime;
+
+ const websiteDateRange = useDateRangeQuery(websiteId);
+
+ const handleChange = (date: string) => {
+ if (date === 'all') {
+ router.push(
+ updateParams({
+ date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`,
+ offset: undefined,
+ }),
+ );
+ } else {
+ router.push(updateParams({ date, offset: undefined }));
+ }
+ };
+
+ const handleIncrement = increment => {
+ router.push(updateParams({ offset: Number(offset) + increment }));
+ };
+ const handleSelect = (compare: any) => {
+ router.push(updateParams({ compare }));
+ };
+
+ const dateValue = useMemo(() => {
+ return offset !== 0
+ ? getDateRangeValue(dateRange.startDate, dateRange.endDate)
+ : dateRange.value;
+ }, [dateRange]);
+
+ return (
+ <Row wrap="wrap" gap>
+ {showButtons && !isAllTime && !isCustomRange && (
+ <Row gap="1">
+ <Button onPress={() => handleIncrement(-1)} variant="outline">
+ <Icon rotate={180}>
+ <ChevronRight />
+ </Icon>
+ </Button>
+ <Button onPress={() => handleIncrement(1)} variant="outline" isDisabled={disableForward}>
+ <Icon>
+ <ChevronRight />
+ </Icon>
+ </Button>
+ </Row>
+ )}
+ <Row minWidth="200px">
+ <DateFilter
+ value={dateValue}
+ onChange={handleChange}
+ showAllTime={showAllTime}
+ renderDate={+offset !== 0}
+ />
+ </Row>
+ {showCompare && (
+ <Row alignItems="center" gap>
+ <Text weight="bold">VS</Text>
+ <Row width="200px">
+ <Select
+ value={compare}
+ onChange={handleSelect}
+ popoverProps={{ style: { width: 200 } }}
+ >
+ <ListItem id="prev">{formatMessage(labels.previousPeriod)}</ListItem>
+ <ListItem id="yoy">{formatMessage(labels.previousYear)}</ListItem>
+ </Select>
+ </Row>
+ </Row>
+ )}
+ </Row>
+ );
+}
diff --git a/src/components/input/WebsiteFilterButton.tsx b/src/components/input/WebsiteFilterButton.tsx
new file mode 100644
index 0000000..7db850a
--- /dev/null
+++ b/src/components/input/WebsiteFilterButton.tsx
@@ -0,0 +1,32 @@
+import { useMessages, useNavigation } from '@/components/hooks';
+import { ListFilter } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { FilterEditForm } from '@/components/input/FilterEditForm';
+import { filtersArrayToObject } from '@/lib/params';
+
+export function WebsiteFilterButton({
+ websiteId,
+}: {
+ websiteId: string;
+ position?: 'bottom' | 'top' | 'left' | 'right';
+ alignment?: 'end' | 'center' | 'start';
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { updateParams, router } = useNavigation();
+
+ const handleChange = ({ filters, segment, cohort }: any) => {
+ const params = filtersArrayToObject(filters);
+
+ const url = updateParams({ ...params, segment, cohort });
+
+ router.push(url);
+ };
+
+ return (
+ <DialogButton icon={<ListFilter />} label={formatMessage(labels.filter)} variant="outline">
+ {({ close }) => {
+ return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/components/input/WebsiteSelect.tsx b/src/components/input/WebsiteSelect.tsx
new file mode 100644
index 0000000..8d81eb9
--- /dev/null
+++ b/src/components/input/WebsiteSelect.tsx
@@ -0,0 +1,74 @@
+import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
+import { useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import {
+ useLoginQuery,
+ useMessages,
+ useUserWebsitesQuery,
+ useWebsiteQuery,
+} from '@/components/hooks';
+
+export function WebsiteSelect({
+ websiteId,
+ teamId,
+ onChange,
+ includeTeams,
+ ...props
+}: {
+ websiteId?: string;
+ teamId?: string;
+ includeTeams?: boolean;
+} & SelectProps) {
+ const { formatMessage, messages } = useMessages();
+ const { data: website } = useWebsiteQuery(websiteId);
+ const [name, setName] = useState<string>(website?.name);
+ const [search, setSearch] = useState('');
+ const { user } = useLoginQuery();
+ const { data, isLoading } = useUserWebsitesQuery(
+ { userId: user?.id, teamId },
+ { search, pageSize: 10, includeTeams },
+ );
+ const listItems: { id: string; name: string }[] = data?.data || [];
+
+ const handleSearch = (value: string) => {
+ setSearch(value);
+ };
+
+ const handleOpenChange = () => {
+ setSearch('');
+ };
+
+ const handleChange = (id: string) => {
+ setName(listItems.find(item => item.id === id)?.name);
+ onChange(id);
+ };
+
+ const renderValue = () => {
+ return (
+ <Row maxWidth="160px">
+ <Text truncate>{name}</Text>
+ </Row>
+ );
+ };
+
+ return (
+ <Select
+ {...props}
+ items={listItems}
+ value={websiteId}
+ isLoading={isLoading}
+ allowSearch={true}
+ searchValue={search}
+ onSearch={handleSearch}
+ onChange={handleChange}
+ onOpenChange={handleOpenChange}
+ renderValue={renderValue}
+ listProps={{
+ renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
+ style: { maxHeight: '400px' },
+ }}
+ >
+ {({ id, name }: any) => <ListItem key={id}>{name}</ListItem>}
+ </Select>
+ );
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
new file mode 100644
index 0000000..0438c06
--- /dev/null
+++ b/src/components/messages.ts
@@ -0,0 +1,518 @@
+import { defineMessages } from 'react-intl';
+
+export const labels = defineMessages({
+ ok: { id: 'label.ok', defaultMessage: 'OK' },
+ unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
+ required: { id: 'label.required', defaultMessage: 'Required' },
+ save: { id: 'label.save', defaultMessage: 'Save' },
+ cancel: { id: 'label.cancel', defaultMessage: 'Cancel' },
+ continue: { id: 'label.continue', defaultMessage: 'Continue' },
+ delete: { id: 'label.delete', defaultMessage: 'Delete' },
+ leave: { id: 'label.leave', defaultMessage: 'Leave' },
+ users: { id: 'label.users', defaultMessage: 'Users' },
+ createUser: { id: 'label.create-user', defaultMessage: 'Create user' },
+ deleteUser: { id: 'label.delete-user', defaultMessage: 'Delete user' },
+ username: { id: 'label.username', defaultMessage: 'Username' },
+ password: { id: 'label.password', defaultMessage: 'Password' },
+ role: { id: 'label.role', defaultMessage: 'Role' },
+ user: { id: 'label.user', defaultMessage: 'User' },
+ viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
+ manage: { id: 'label.manage', defaultMessage: 'Manage' },
+ admin: { id: 'label.admin', defaultMessage: 'Admin' },
+ confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
+ details: { id: 'label.details', defaultMessage: 'Details' },
+ website: { id: 'label.website', defaultMessage: 'Website' },
+ websites: { id: 'label.websites', defaultMessage: 'Websites' },
+ myWebsites: { id: 'label.my-websites', defaultMessage: 'My websites' },
+ teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team websites' },
+ created: { id: 'label.created', defaultMessage: 'Created' },
+ createdBy: { id: 'label.created-by', defaultMessage: 'Created By' },
+ edit: { id: 'label.edit', defaultMessage: 'Edit' },
+ name: { id: 'label.name', defaultMessage: 'Name' },
+ manager: { id: 'label.manager', defaultMessage: 'Manager' },
+ member: { id: 'label.member', defaultMessage: 'Member' },
+ members: { id: 'label.members', defaultMessage: 'Members' },
+ accessCode: { id: 'label.access-code', defaultMessage: 'Access code' },
+ teamId: { id: 'label.team-id', defaultMessage: 'Team ID' },
+ team: { id: 'label.team', defaultMessage: 'Team' },
+ teamName: { id: 'label.team-name', defaultMessage: 'Team name' },
+ regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' },
+ remove: { id: 'label.remove', defaultMessage: 'Remove' },
+ join: { id: 'label.join', defaultMessage: 'Join' },
+ createTeam: { id: 'label.create-team', defaultMessage: 'Create team' },
+ joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' },
+ settings: { id: 'label.settings', defaultMessage: 'Settings' },
+ owner: { id: 'label.owner', defaultMessage: 'Owner' },
+ teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
+ teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' },
+ teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
+ teamViewOnly: { id: 'label.team-view-only', defaultMessage: 'Team view only' },
+ enableShareUrl: { id: 'label.enable-share-url', defaultMessage: 'Enable share URL' },
+ data: { id: 'label.data', defaultMessage: 'Data' },
+ trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' },
+ shareUrl: { id: 'label.share-url', defaultMessage: 'Share URL' },
+ action: { id: 'label.action', defaultMessage: 'Action' },
+ actions: { id: 'label.actions', defaultMessage: 'Actions' },
+ domain: { id: 'label.domain', defaultMessage: 'Domain' },
+ websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' },
+ resetWebsite: { id: 'label.reset-website', defaultMessage: 'Reset website' },
+ deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
+ transferWebsite: { id: 'label.transfer-website', defaultMessage: 'Transfer website' },
+ deleteReport: { id: 'label.delete-report', defaultMessage: 'Delete report' },
+ reset: { id: 'label.reset', defaultMessage: 'Reset' },
+ addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
+ addMember: { id: 'label.add-member', defaultMessage: 'Add member' },
+ editMember: { id: 'label.edit-member', defaultMessage: 'Edit member' },
+ removeMember: { id: 'label.remove-member', defaultMessage: 'Remove member' },
+ addDescription: { id: 'label.add-description', defaultMessage: 'Add description' },
+ changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
+ currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
+ newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
+ confirmPassword: { id: 'label.confirm-password', defaultMessage: 'Confirm password' },
+ timezone: { id: 'label.timezone', defaultMessage: 'Timezone' },
+ defaultDateRange: { id: 'label.default-date-range', defaultMessage: 'Default date range' },
+ language: { id: 'label.language', defaultMessage: 'Language' },
+ theme: { id: 'label.theme', defaultMessage: 'Theme' },
+ profile: { id: 'label.profile', defaultMessage: 'Profile' },
+ profiles: { id: 'label.profiles', defaultMessage: 'Profiles' },
+ dashboard: { id: 'label.dashboard', defaultMessage: 'Dashboard' },
+ more: { id: 'label.more', defaultMessage: 'More' },
+ realtime: { id: 'label.realtime', defaultMessage: 'Realtime' },
+ queries: { id: 'label.queries', defaultMessage: 'Queries' },
+ teams: { id: 'label.teams', defaultMessage: 'Teams' },
+ teamSettings: { id: 'label.team-settings', defaultMessage: 'Team settings' },
+ analytics: { id: 'label.analytics', defaultMessage: 'Analytics' },
+ login: { id: 'label.login', defaultMessage: 'Login' },
+ logout: { id: 'label.logout', defaultMessage: 'Logout' },
+ singleDay: { id: 'label.single-day', defaultMessage: 'Single day' },
+ dateRange: { id: 'label.date-range', defaultMessage: 'Date range' },
+ viewDetails: { id: 'label.view-details', defaultMessage: 'View details' },
+ deleteTeam: { id: 'label.delete-team', defaultMessage: 'Delete team' },
+ leaveTeam: { id: 'label.leave-team', defaultMessage: 'Leave team' },
+ refresh: { id: 'label.refresh', defaultMessage: 'Refresh' },
+ page: { id: 'label.page', defaultMessage: 'Page' },
+ pages: { id: 'label.pages', defaultMessage: 'Pages' },
+ entry: { id: 'label.entry', defaultMessage: 'Entry' },
+ exit: { id: 'label.exit', defaultMessage: 'Exit' },
+ referrers: { id: 'label.referrers', defaultMessage: 'Referrers' },
+ screen: { id: 'label.screen', defaultMessage: 'Screen' },
+ screens: { id: 'label.screens', defaultMessage: 'Screens' },
+ browsers: { id: 'label.browsers', defaultMessage: 'Browsers' },
+ os: { id: 'label.os', defaultMessage: 'OS' },
+ devices: { id: 'label.devices', defaultMessage: 'Devices' },
+ countries: { id: 'label.countries', defaultMessage: 'Countries' },
+ languages: { id: 'label.languages', defaultMessage: 'Languages' },
+ tags: { id: 'label.tags', defaultMessage: 'Tags' },
+ segments: { id: 'label.segments', defaultMessage: 'Segments' },
+ cohorts: { id: 'label.cohorts', defaultMessage: 'Cohorts' },
+ count: { id: 'label.count', defaultMessage: 'Count' },
+ average: { id: 'label.average', defaultMessage: 'Average' },
+ sum: { id: 'label.sum', defaultMessage: 'Sum' },
+ event: { id: 'label.event', defaultMessage: 'Event' },
+ events: { id: 'label.events', defaultMessage: 'Events' },
+ eventName: { id: 'label.event-name', defaultMessage: 'Event name' },
+ query: { id: 'label.query', defaultMessage: 'Query' },
+ queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
+ back: { id: 'label.back', defaultMessage: 'Back' },
+ visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
+ visits: { id: 'label.visits', defaultMessage: 'Visits' },
+ filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
+ filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' },
+ views: { id: 'label.views', defaultMessage: 'Views' },
+ none: { id: 'label.none', defaultMessage: 'None' },
+ clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' },
+ property: { id: 'label.property', defaultMessage: 'Property' },
+ today: { id: 'label.today', defaultMessage: 'Today' },
+ lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' },
+ yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' },
+ thisWeek: { id: 'label.this-week', defaultMessage: 'This week' },
+ lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' },
+ lastMonths: { id: 'label.last-months', defaultMessage: 'Last {x} months' },
+ thisMonth: { id: 'label.this-month', defaultMessage: 'This month' },
+ thisYear: { id: 'label.this-year', defaultMessage: 'This year' },
+ allTime: { id: 'label.all-time', defaultMessage: 'All time' },
+ customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
+ selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
+ selectRole: { id: 'label.select-role', defaultMessage: 'Select role' },
+ selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
+ selectFilter: { id: 'label.select-filter', defaultMessage: 'Select filter' },
+ all: { id: 'label.all', defaultMessage: 'All' },
+ session: { id: 'label.session', defaultMessage: 'Session' },
+ sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
+ distinctId: { id: 'label.distinct-id', defaultMessage: 'Distinct ID' },
+ pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
+ activity: { id: 'label.activity', defaultMessage: 'Activity' },
+ dismiss: { id: 'label.dismiss', defaultMessage: 'Dismiss' },
+ poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' },
+ pageViews: { id: 'label.page-views', defaultMessage: 'Page views' },
+ uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' },
+ bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' },
+ viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' },
+ visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' },
+ desktop: { id: 'label.desktop', defaultMessage: 'Desktop' },
+ laptop: { id: 'label.laptop', defaultMessage: 'Laptop' },
+ tablet: { id: 'label.tablet', defaultMessage: 'Tablet' },
+ mobile: { id: 'label.mobile', defaultMessage: 'Mobile' },
+ toggleCharts: { id: 'label.toggle-charts', defaultMessage: 'Toggle charts' },
+ editDashboard: { id: 'label.edit-dashboard', defaultMessage: 'Edit dashboard' },
+ title: { id: 'label.title', defaultMessage: 'Title' },
+ view: { id: 'label.view', defaultMessage: 'View' },
+ cities: { id: 'label.cities', defaultMessage: 'Cities' },
+ regions: { id: 'label.regions', defaultMessage: 'Regions' },
+ reports: { id: 'label.reports', defaultMessage: 'Reports' },
+ eventData: { id: 'label.event-data', defaultMessage: 'Event data' },
+ sessionData: { id: 'label.session-data', defaultMessage: 'Session data' },
+ funnel: { id: 'label.funnel', defaultMessage: 'Funnel' },
+ funnels: { id: 'label.funnels', defaultMessage: 'Funnels' },
+ funnelDescription: {
+ id: 'label.funnel-description',
+ defaultMessage: 'Understand the conversion and drop-off rate of users.',
+ },
+ revenue: { id: 'label.revenue', defaultMessage: 'Revenue' },
+ revenueDescription: {
+ id: 'label.revenue-description',
+ defaultMessage: 'Look into your revenue data and how users are spending.',
+ },
+ attribution: { id: 'label.attribution', defaultMessage: 'Attribution' },
+ attributionDescription: {
+ id: 'label.attribution-description',
+ defaultMessage: 'See how users engage with your marketing and what drives conversions.',
+ },
+ currency: { id: 'label.currency', defaultMessage: 'Currency' },
+ model: { id: 'label.model', defaultMessage: 'Model' },
+ path: { id: 'label.path', defaultMessage: 'Path' },
+ paths: { id: 'label.paths', defaultMessage: 'Paths' },
+ add: { id: 'label.add', defaultMessage: 'Add' },
+ update: { id: 'label.update', defaultMessage: 'Update' },
+ window: { id: 'label.window', defaultMessage: 'Window' },
+ runQuery: { id: 'label.run-query', defaultMessage: 'Run query' },
+ field: { id: 'label.field', defaultMessage: 'Field' },
+ fields: { id: 'label.fields', defaultMessage: 'Fields' },
+ createReport: { id: 'label.create-report', defaultMessage: 'Create report' },
+ description: { id: 'label.description', defaultMessage: 'Description' },
+ untitled: { id: 'label.untitled', defaultMessage: 'Untitled' },
+ type: { id: 'label.type', defaultMessage: 'Type' },
+ filter: { id: 'label.filter', defaultMessage: 'Filter' },
+ filters: { id: 'label.filters', defaultMessage: 'Filters' },
+ breakdown: { id: 'label.breakdown', defaultMessage: 'Breakdown' },
+ true: { id: 'label.true', defaultMessage: 'True' },
+ false: { id: 'label.false', defaultMessage: 'False' },
+ is: { id: 'label.is', defaultMessage: 'Is' },
+ isNot: { id: 'label.is-not', defaultMessage: 'Is not' },
+ isSet: { id: 'label.is-set', defaultMessage: 'Is set' },
+ isNotSet: { id: 'label.is-not-set', defaultMessage: 'Is not set' },
+ greaterThan: { id: 'label.greater-than', defaultMessage: 'Greater than' },
+ lessThan: { id: 'label.less-than', defaultMessage: 'Less than' },
+ greaterThanEquals: { id: 'label.greater-than-equals', defaultMessage: 'Greater than or equals' },
+ lessThanEquals: { id: 'label.less-than-equals', defaultMessage: 'Less than or equals' },
+ contains: { id: 'label.contains', defaultMessage: 'Contains' },
+ doesNotContain: { id: 'label.does-not-contain', defaultMessage: 'Does not contain' },
+ includes: { id: 'label.includes', defaultMessage: 'Includes' },
+ doesNotInclude: { id: 'label.does-not-include', defaultMessage: 'Does not include' },
+ before: { id: 'label.before', defaultMessage: 'Before' },
+ after: { id: 'label.after', defaultMessage: 'After' },
+ isTrue: { id: 'label.is-true', defaultMessage: 'Is true' },
+ isFalse: { id: 'label.is-false', defaultMessage: 'Is false' },
+ exists: { id: 'label.exists', defaultMessage: 'Exists' },
+ doesNotExist: { id: 'label.doest-not-exist', defaultMessage: 'Does not exist' },
+ total: { id: 'label.total', defaultMessage: 'Total' },
+ min: { id: 'label.min', defaultMessage: 'Min' },
+ max: { id: 'label.max', defaultMessage: 'Max' },
+ unique: { id: 'label.unique', defaultMessage: 'Unique' },
+ value: { id: 'label.value', defaultMessage: 'Value' },
+ overview: { id: 'label.overview', defaultMessage: 'Overview' },
+ totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
+ insight: { id: 'label.insight', defaultMessage: 'Insight' },
+ insights: { id: 'label.insights', defaultMessage: 'Insights' },
+ insightsDescription: {
+ id: 'label.insights-description',
+ defaultMessage: 'Dive deeper into your data by using segments and filters.',
+ },
+ retention: { id: 'label.retention', defaultMessage: 'Retention' },
+ retentionDescription: {
+ id: 'label.retention-description',
+ defaultMessage: 'Measure your website stickiness by tracking how often users return.',
+ },
+ dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
+ referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
+ hostname: { id: 'label.hostname', defaultMessage: 'Hostname' },
+ country: { id: 'label.country', defaultMessage: 'Country' },
+ region: { id: 'label.region', defaultMessage: 'Region' },
+ city: { id: 'label.city', defaultMessage: 'City' },
+ browser: { id: 'label.browser', defaultMessage: 'Browser' },
+ device: { id: 'label.device', defaultMessage: 'Device' },
+ pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
+ tag: { id: 'label.tag', defaultMessage: 'Tag' },
+ segment: { id: 'label.segment', defaultMessage: 'Segment' },
+ cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
+ day: { id: 'label.day', defaultMessage: 'Day' },
+ date: { id: 'label.date', defaultMessage: 'Date' },
+ pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
+ create: { id: 'label.create', defaultMessage: 'Create' },
+ search: { id: 'label.search', defaultMessage: 'Search' },
+ numberOfRecords: {
+ id: 'label.number-of-records',
+ defaultMessage: '{x} {x, plural, one {record} other {records}}',
+ },
+ select: { id: 'label.select', defaultMessage: 'Select' },
+ myAccount: { id: 'label.my-account', defaultMessage: 'My account' },
+ transfer: { id: 'label.transfer', defaultMessage: 'Transfer' },
+ transactions: { id: 'label.transactions', defaultMessage: 'Transactions' },
+ uniqueCustomers: { id: 'label.uniqueCustomers', defaultMessage: 'Unique Customers' },
+ viewedPage: {
+ id: 'message.viewed-page',
+ defaultMessage: 'Viewed page',
+ },
+ collectedData: {
+ id: 'message.collected-data',
+ defaultMessage: 'Collected data',
+ },
+ triggeredEvent: {
+ id: 'message.triggered-event',
+ defaultMessage: 'Triggered event',
+ },
+ utm: { id: 'label.utm', defaultMessage: 'UTM' },
+ utmDescription: {
+ id: 'label.utm-description',
+ defaultMessage: 'Track your campaigns through UTM parameters.',
+ },
+ conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion step' },
+ conversionRate: { id: 'label.conversion-rate', defaultMessage: 'Conversion rate' },
+ steps: { id: 'label.steps', defaultMessage: 'Steps' },
+ startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
+ endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
+ addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
+ goal: { id: 'label.goal', defaultMessage: 'Goal' },
+ goals: { id: 'label.goals', defaultMessage: 'Goals' },
+ goalsDescription: {
+ id: 'label.goals-description',
+ defaultMessage: 'Track your goals for pageviews and events.',
+ },
+ journey: { id: 'label.journey', defaultMessage: 'Journey' },
+ journeys: { id: 'label.journeys', defaultMessage: 'Journeys' },
+ journeyDescription: {
+ id: 'label.journey-description',
+ defaultMessage: 'Understand how users navigate through your website.',
+ },
+ compareDates: { id: 'label.compare-dates', defaultMessage: 'Compare dates' },
+ compare: { id: 'label.compare', defaultMessage: 'Compare' },
+ current: { id: 'label.current', defaultMessage: 'Current' },
+ previous: { id: 'label.previous', defaultMessage: 'Previous' },
+ previousPeriod: { id: 'label.previous-period', defaultMessage: 'Previous period' },
+ previousYear: { id: 'label.previous-year', defaultMessage: 'Previous year' },
+ lastSeen: { id: 'label.last-seen', defaultMessage: 'Last seen' },
+ firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' },
+ properties: { id: 'label.properties', defaultMessage: 'Properties' },
+ channel: { id: 'label.channel', defaultMessage: 'Channel' },
+ channels: { id: 'label.channels', defaultMessage: 'Channels' },
+ sources: { id: 'label.sources', defaultMessage: 'Sources' },
+ medium: { id: 'label.medium', defaultMessage: 'Medium' },
+ campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' },
+ content: { id: 'label.content', defaultMessage: 'Content' },
+ terms: { id: 'label.terms', defaultMessage: 'Terms' },
+ direct: { id: 'label.direct', defaultMessage: 'Direct' },
+ referral: { id: 'label.referral', defaultMessage: 'Referral' },
+ affiliate: { id: 'label.affiliate', defaultMessage: 'Affiliate' },
+ email: { id: 'label.email', defaultMessage: 'Email' },
+ sms: { id: 'label.sms', defaultMessage: 'SMS' },
+ organicSearch: { id: 'label.organic-search', defaultMessage: 'Organic search' },
+ organicSocial: { id: 'label.organic-social', defaultMessage: 'Organic social' },
+ organicShopping: { id: 'label.organic-shopping', defaultMessage: 'Organic shopping' },
+ organicVideo: { id: 'label.organic-video', defaultMessage: 'Organic video' },
+ paidAds: { id: 'label.paid-ads', defaultMessage: 'Paid ads' },
+ paidSearch: { id: 'label.paid-search', defaultMessage: 'Paid search' },
+ paidSocial: { id: 'label.paid-social', defaultMessage: 'Paid social' },
+ paidShopping: { id: 'label.paid-shopping', defaultMessage: 'Paid shopping' },
+ paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' },
+ grouped: { id: 'label.grouped', defaultMessage: 'Grouped' },
+ other: { id: 'label.other', defaultMessage: 'Other' },
+ boards: { id: 'label.boards', defaultMessage: 'Boards' },
+ apply: { id: 'label.apply', defaultMessage: 'Apply' },
+ link: { id: 'label.link', defaultMessage: 'Link' },
+ links: { id: 'label.links', defaultMessage: 'Links' },
+ pixel: { id: 'label.pixel', defaultMessage: 'Pixel' },
+ pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
+ addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
+ addLink: { id: 'label.add-link', defaultMessage: 'Add link' },
+ addPixel: { id: 'label.add-pixel', defaultMessage: 'Add pixel' },
+ maximize: { id: 'label.maximize', defaultMessage: 'Maximize' },
+ remaining: { id: 'label.remaining', defaultMessage: 'Remaining' },
+ conversion: { id: 'label.conversion', defaultMessage: 'Conversion' },
+ firstClick: { id: 'label.first-click', defaultMessage: 'First click' },
+ lastClick: { id: 'label.last-click', defaultMessage: 'Last click' },
+ online: { id: 'label.online', defaultMessage: 'Online' },
+ preferences: { id: 'label.preferences', defaultMessage: 'Preferences' },
+ location: { id: 'label.location', defaultMessage: 'Location' },
+ chart: { id: 'label.chart', defaultMessage: 'Chart' },
+ table: { id: 'label.table', defaultMessage: 'Table' },
+ download: { id: 'label.download', defaultMessage: 'Download' },
+ traffic: { id: 'label.traffic', defaultMessage: 'Traffic' },
+ behavior: { id: 'label.behavior', defaultMessage: 'Behavior' },
+ growth: { id: 'label.growth', defaultMessage: 'Growth' },
+ account: { id: 'label.account', defaultMessage: 'Account' },
+ application: { id: 'label.application', defaultMessage: 'Application' },
+ saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' },
+ saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' },
+ analysis: { id: 'label.analysis', defaultMessage: 'Analysis' },
+ destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' },
+ audience: { id: 'label.audience', defaultMessage: 'Audience' },
+ invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
+ environment: { id: 'label.environment', defaultMessage: 'Environment' },
+ criteria: { id: 'label.criteria', defaultMessage: 'Criteria' },
+ share: { id: 'label.share', defaultMessage: 'Share' },
+ support: { id: 'label.support', defaultMessage: 'Support' },
+ documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
+ switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' },
+});
+
+export const messages = defineMessages({
+ error: { id: 'message.error', defaultMessage: 'Something went wrong.' },
+ saved: { id: 'message.saved', defaultMessage: 'Saved successfully.' },
+ noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
+ userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
+ noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' },
+ nothingSelected: { id: 'message.nothing-selected', defaultMessage: 'Nothing selected.' },
+ confirmReset: {
+ id: 'message.confirm-reset',
+ defaultMessage: 'Are you sure you want to reset {target}?',
+ },
+ confirmDelete: {
+ id: 'message.confirm-delete',
+ defaultMessage: 'Are you sure you want to delete {target}?',
+ },
+ confirmRemove: {
+ id: 'message.confirm-remove',
+ defaultMessage: 'Are you sure you want to remove {target}?',
+ },
+ confirmLeave: {
+ id: 'message.confirm-leave',
+ defaultMessage: 'Are you sure you want to leave {target}?',
+ },
+ minPasswordLength: {
+ id: 'message.min-password-length',
+ defaultMessage: 'Minimum length of {n} characters',
+ },
+ noTeams: {
+ id: 'message.no-teams',
+ defaultMessage: 'You have not created any teams.',
+ },
+ shareUrl: {
+ id: 'message.share-url',
+ defaultMessage: 'Your website stats are publicly available at the following URL:',
+ },
+ trackingCode: {
+ id: 'message.tracking-code',
+ defaultMessage:
+ 'To track stats for this website, place the following code in the <head>...</head> section of your HTML.',
+ },
+ joinTeamWarning: {
+ id: 'message.team-already-member',
+ defaultMessage: 'You are already a member of the team.',
+ },
+ actionConfirmation: {
+ id: 'message.action-confirmation',
+ defaultMessage: 'Type {confirmation} in the box below to confirm.',
+ },
+ resetWebsite: {
+ id: 'message.reset-website',
+ defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.',
+ },
+ invalidDomain: {
+ id: 'message.invalid-domain',
+ defaultMessage: 'Invalid domain. Do not include http/https.',
+ },
+ resetWebsiteWarning: {
+ id: 'message.reset-website-warning',
+ defaultMessage:
+ 'All statistics for this website will be deleted, but your settings will remain intact.',
+ },
+ deleteWebsiteWarning: {
+ id: 'message.delete-website-warning',
+ defaultMessage: 'All website data will be deleted.',
+ },
+ deleteTeamWarning: {
+ id: 'message.delete-team-warning',
+ defaultMessage: 'Deleting a team will also delete all team websites.',
+ },
+ noResultsFound: {
+ id: 'message.no-results-found',
+ defaultMessage: 'No results found.',
+ },
+ noWebsitesConfigured: {
+ id: 'message.no-websites-configured',
+ defaultMessage: 'You do not have any websites configured.',
+ },
+ noTeamWebsites: {
+ id: 'message.no-team-websites',
+ defaultMessage: 'This team does not have any websites.',
+ },
+ teamWebsitesInfo: {
+ id: 'message.team-websites-info',
+ defaultMessage: 'Websites can be viewed by anyone on the team.',
+ },
+ noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' },
+ goToSettings: {
+ id: 'message.go-to-settings',
+ defaultMessage: 'Go to settings',
+ },
+ activeUsers: {
+ id: 'message.active-users',
+ defaultMessage: '{x} current {x, plural, one {visitor} other {visitors}}',
+ },
+ teamNotFound: {
+ id: 'message.team-not-found',
+ defaultMessage: 'Team not found.',
+ },
+ visitorLog: {
+ id: 'message.visitor-log',
+ defaultMessage: 'Visitor from {country} using {browser} on {os} {device}',
+ },
+ eventLog: {
+ id: 'message.event-log',
+ defaultMessage: '{event} on {url}',
+ },
+ incorrectUsernamePassword: {
+ id: 'message.incorrect-username-password',
+ defaultMessage: 'Incorrect username and/or password.',
+ },
+ noEventData: {
+ id: 'message.no-event-data',
+ defaultMessage: 'No event data is available.',
+ },
+ newVersionAvailable: {
+ id: 'message.new-version-available',
+ defaultMessage: 'A new version of Umami {version} is available!',
+ },
+ transferWebsite: {
+ id: 'message.transfer-website',
+ defaultMessage: 'Transfer website ownership to your account or another team.',
+ },
+ transferTeamWebsiteToUser: {
+ id: 'message.transfer-team-website-to-user',
+ defaultMessage: 'Transfer this website to your account?',
+ },
+ transferUserWebsiteToTeam: {
+ id: 'message.transfer-user-website-to-team',
+ defaultMessage: 'Select the team to transfer this website to.',
+ },
+ unauthorized: {
+ id: 'message.unauthorized',
+ defaultMessage: 'Unauthorized',
+ },
+ badRequest: {
+ id: 'message.bad-request',
+ defaultMessage: 'Bad request',
+ },
+ forbidden: {
+ id: 'message.forbidden',
+ defaultMessage: 'Forbidden',
+ },
+ notFound: {
+ id: 'message.not-found',
+ defaultMessage: 'Not found',
+ },
+ serverError: {
+ id: 'message.sever-error',
+ defaultMessage: 'Server error',
+ },
+});
diff --git a/src/components/metrics/ActiveUsers.tsx b/src/components/metrics/ActiveUsers.tsx
new file mode 100644
index 0000000..a4bc7da
--- /dev/null
+++ b/src/components/metrics/ActiveUsers.tsx
@@ -0,0 +1,39 @@
+import { StatusLight, Text } from '@umami/react-zen';
+import { useMemo } from 'react';
+import { LinkButton } from '@/components/common/LinkButton';
+import { useActyiveUsersQuery, useMessages } from '@/components/hooks';
+
+export function ActiveUsers({
+ websiteId,
+ value,
+ refetchInterval = 60000,
+}: {
+ websiteId: string;
+ value?: number;
+ refetchInterval?: number;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useActyiveUsersQuery(websiteId, { refetchInterval });
+
+ const count = useMemo(() => {
+ if (websiteId) {
+ return data?.visitors || 0;
+ }
+
+ return value !== undefined ? value : 0;
+ }, [data, value, websiteId]);
+
+ if (count === 0) {
+ return null;
+ }
+
+ return (
+ <LinkButton href={`/websites/${websiteId}/realtime`} variant="quiet">
+ <StatusLight variant="success">
+ <Text size="2" weight="medium">
+ {count} {formatMessage(labels.online)}
+ </Text>
+ </StatusLight>
+ </LinkButton>
+ );
+}
diff --git a/src/components/metrics/ChangeLabel.tsx b/src/components/metrics/ChangeLabel.tsx
new file mode 100644
index 0000000..192f0ff
--- /dev/null
+++ b/src/components/metrics/ChangeLabel.tsx
@@ -0,0 +1,60 @@
+import { Icon, Row, type RowProps, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { ArrowRight } from '@/components/icons';
+
+const STYLES = {
+ positive: {
+ color: `var(--success-color)`,
+ background: `color-mix(in srgb, var(--success-color), var(--background-color) 95%)`,
+ },
+ negative: {
+ color: `var(--danger-color)`,
+ background: `color-mix(in srgb, var(--danger-color), var(--background-color) 95%)`,
+ },
+ neutral: {
+ color: `var(--font-color-muted)`,
+ background: `var(--base-color-2)`,
+ },
+};
+
+export function ChangeLabel({
+ value,
+ size,
+ reverseColors,
+ children,
+ ...props
+}: {
+ value: number;
+ size?: 'xs' | 'sm' | 'md' | 'lg';
+ title?: string;
+ reverseColors?: boolean;
+ showPercentage?: boolean;
+ children?: ReactNode;
+} & RowProps) {
+ const positive = value >= 0;
+ const negative = value < 0;
+ const neutral = value === 0 || Number.isNaN(value);
+ const good = reverseColors ? negative : positive;
+
+ const style =
+ STYLES[good && 'positive'] || STYLES[!good && 'negative'] || STYLES[neutral && 'neutral'];
+
+ return (
+ <Row
+ {...props}
+ style={style}
+ alignItems="center"
+ alignSelf="flex-start"
+ paddingX="2"
+ paddingY="1"
+ gap="2"
+ >
+ {!neutral && (
+ <Icon rotate={positive ? -90 : 90} size={size}>
+ <ArrowRight />
+ </Icon>
+ )}
+ <Text>{children || value}</Text>
+ </Row>
+ );
+}
diff --git a/src/components/metrics/DatePickerForm.tsx b/src/components/metrics/DatePickerForm.tsx
new file mode 100644
index 0000000..59d1709
--- /dev/null
+++ b/src/components/metrics/DatePickerForm.tsx
@@ -0,0 +1,74 @@
+import { Button, Calendar, Column, Row, ToggleGroup, ToggleGroupItem } from '@umami/react-zen';
+import { endOfDay, isAfter, isBefore, isSameDay, startOfDay } from 'date-fns';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+
+const FILTER_DAY = 'filter-day';
+const FILTER_RANGE = 'filter-range';
+
+export function DatePickerForm({
+ startDate: defaultStartDate,
+ endDate: defaultEndDate,
+ minDate,
+ maxDate,
+ onChange,
+ onClose,
+}) {
+ const [selected, setSelected] = useState<any>([
+ isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
+ ]);
+ const [date, setDate] = useState(defaultStartDate || new Date());
+ const [startDate, setStartDate] = useState(defaultStartDate || new Date());
+ const [endDate, setEndDate] = useState(defaultEndDate || new Date());
+ const { formatMessage, labels } = useMessages();
+
+ const disabled = selected.includes(FILTER_DAY)
+ ? isAfter(minDate, date) && isBefore(maxDate, date)
+ : isAfter(startDate, endDate);
+
+ const handleSave = () => {
+ if (selected.includes(FILTER_DAY)) {
+ onChange(`range:${startOfDay(date).getTime()}:${endOfDay(date).getTime()}`);
+ } else {
+ onChange(`range:${startOfDay(startDate).getTime()}:${endOfDay(endDate).getTime()}`);
+ }
+ };
+
+ return (
+ <Column gap>
+ <Row justifyContent="center">
+ <ToggleGroup disallowEmptySelection value={selected} onChange={setSelected}>
+ <ToggleGroupItem id={FILTER_DAY}>{formatMessage(labels.singleDay)}</ToggleGroupItem>
+ <ToggleGroupItem id={FILTER_RANGE}>{formatMessage(labels.dateRange)}</ToggleGroupItem>
+ </ToggleGroup>
+ </Row>
+ <Column>
+ {selected.includes(FILTER_DAY) && (
+ <Calendar value={date} minValue={minDate} maxValue={maxDate} onChange={setDate} />
+ )}
+ {selected.includes(FILTER_RANGE) && (
+ <Row gap wrap="wrap" style={{ margin: '0 auto' }}>
+ <Calendar
+ value={startDate}
+ minValue={minDate}
+ maxValue={endDate}
+ onChange={setStartDate}
+ />
+ <Calendar
+ value={endDate}
+ minValue={startDate}
+ maxValue={maxDate}
+ onChange={setEndDate}
+ />
+ </Row>
+ )}
+ </Column>
+ <Row justifyContent="end" gap>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <Button variant="primary" onPress={handleSave} isDisabled={disabled}>
+ {formatMessage(labels.apply)}
+ </Button>
+ </Row>
+ </Column>
+ );
+}
diff --git a/src/components/metrics/EventData.tsx b/src/components/metrics/EventData.tsx
new file mode 100644
index 0000000..48d21c5
--- /dev/null
+++ b/src/components/metrics/EventData.tsx
@@ -0,0 +1,22 @@
+import { Column, Grid, Label, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useEventDataQuery } from '@/components/hooks';
+
+export function EventData({ websiteId, eventId }: { websiteId: string; eventId: string }) {
+ const { data, isLoading, error } = useEventDataQuery(websiteId, eventId);
+
+ return (
+ <LoadingPanel isLoading={isLoading} error={error}>
+ <Grid columns="1fr 1fr" gap="5">
+ {data?.map(({ dataKey, stringValue }) => {
+ return (
+ <Column key={dataKey}>
+ <Label>{dataKey}</Label>
+ <Text>{stringValue}</Text>
+ </Column>
+ );
+ })}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx
new file mode 100644
index 0000000..3a53ba9
--- /dev/null
+++ b/src/components/metrics/EventsChart.tsx
@@ -0,0 +1,93 @@
+import { colord } from 'colord';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { BarChart, type BarChartProps } from '@/components/charts/BarChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useDateRange,
+ useLocale,
+ useTimezone,
+ useWebsiteEventsSeriesQuery,
+} from '@/components/hooks';
+import { renderDateLabels } from '@/lib/charts';
+import { CHART_COLORS } from '@/lib/constants';
+import { generateTimeSeries } from '@/lib/date';
+
+export interface EventsChartProps extends BarChartProps {
+ websiteId: string;
+ focusLabel?: string;
+}
+
+export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
+ const { timezone } = useTimezone();
+ const {
+ dateRange: { startDate, endDate, unit },
+ } = useDateRange({ timezone: timezone });
+ const { locale, dateLocale } = useLocale();
+ const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId);
+ const [label, setLabel] = useState<string>(focusLabel);
+
+ const chartData: any = useMemo(() => {
+ if (!data) return;
+
+ const map = (data as any[]).reduce((obj, { x, t, y }) => {
+ if (!obj[x]) {
+ obj[x] = [];
+ }
+
+ obj[x].push({ x: t, y });
+
+ return obj;
+ }, {});
+
+ if (!map || Object.keys(map).length === 0) {
+ return {
+ datasets: [
+ {
+ data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ borderWidth: 1,
+ },
+ ],
+ };
+ } else {
+ return {
+ datasets: Object.keys(map).map((key, index) => {
+ const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
+ return {
+ label: key,
+ data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ backgroundColor: color.alpha(0.6).toRgbString(),
+ borderColor: color.alpha(0.7).toRgbString(),
+ borderWidth: 1,
+ };
+ }),
+ focusLabel,
+ };
+ }
+ }, [data, startDate, endDate, unit, focusLabel]);
+
+ useEffect(() => {
+ if (label !== focusLabel) {
+ setLabel(focusLabel);
+ }
+ }, [focusLabel]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <LoadingPanel isLoading={isLoading} error={error} minHeight="400px">
+ {chartData && (
+ <BarChart
+ chartData={chartData}
+ minDate={startDate}
+ maxDate={endDate}
+ unit={unit}
+ stacked={true}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/Legend.tsx b/src/components/metrics/Legend.tsx
new file mode 100644
index 0000000..34ddb5a
--- /dev/null
+++ b/src/components/metrics/Legend.tsx
@@ -0,0 +1,39 @@
+import { Row, StatusLight, Text } from '@umami/react-zen';
+import type { LegendItem } from 'chart.js/auto';
+import { colord } from 'colord';
+
+export function Legend({
+ items = [],
+ onClick,
+}: {
+ items: any[];
+ onClick: (index: LegendItem) => void;
+}) {
+ if (!items.find(({ text }) => text)) {
+ return null;
+ }
+
+ return (
+ <Row gap wrap="wrap" justifyContent="center">
+ {items.map(item => {
+ const { text, fillStyle, hidden } = item;
+ const color = colord(fillStyle);
+
+ return (
+ <Row key={text} onClick={() => onClick(item)}>
+ <StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
+ <Text
+ size="2"
+ color={hidden ? 'disabled' : undefined}
+ truncate={true}
+ style={{ maxWidth: '300px' }}
+ >
+ {text}
+ </Text>
+ </StatusLight>
+ </Row>
+ );
+ })}
+ </Row>
+ );
+}
diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx
new file mode 100644
index 0000000..f233bfe
--- /dev/null
+++ b/src/components/metrics/ListTable.tsx
@@ -0,0 +1,152 @@
+import { config, useSpring } from '@react-spring/web';
+import { Column, Grid, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { FixedSizeList } from 'react-window';
+import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { Empty } from '@/components/common/Empty';
+import { useMessages, useMobile } from '@/components/hooks';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+const ITEM_SIZE = 30;
+
+interface ListData {
+ label: string;
+ count: number;
+ percent: number;
+}
+
+export interface ListTableProps {
+ data?: ListData[];
+ title?: string;
+ metric?: string;
+ className?: string;
+ renderLabel?: (data: ListData, index: number) => ReactNode;
+ renderChange?: (data: ListData, index: number) => ReactNode;
+ animate?: boolean;
+ virtualize?: boolean;
+ showPercentage?: boolean;
+ itemCount?: number;
+ currency?: string;
+}
+
+export function ListTable({
+ data = [],
+ title,
+ metric,
+ renderLabel,
+ renderChange,
+ animate = true,
+ virtualize = false,
+ showPercentage = true,
+ itemCount = 10,
+ currency,
+}: ListTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { isPhone } = useMobile();
+
+ const getRow = (row: ListData, index: number) => {
+ const { label, count, percent } = row;
+
+ return (
+ <AnimatedRow
+ key={`${label}${index}`}
+ label={renderLabel ? renderLabel(row, index) : (label ?? formatMessage(labels.unknown))}
+ value={count}
+ percent={percent}
+ animate={animate && !virtualize}
+ showPercentage={showPercentage}
+ change={renderChange ? renderChange(row, index) : null}
+ currency={currency}
+ isPhone={isPhone}
+ />
+ );
+ };
+
+ const ListTableRow = ({ index, style }) => {
+ return <div style={style}>{getRow(data[index], index)}</div>;
+ };
+
+ return (
+ <Column gap>
+ <Grid alignItems="center" justifyContent="space-between" paddingLeft="2" columns="1fr 100px">
+ <Text weight="bold">{title}</Text>
+ <Text weight="bold" align="center">
+ {metric}
+ </Text>
+ </Grid>
+ <Column gap="1">
+ {data?.length === 0 && <Empty />}
+ {virtualize && data.length > 0 ? (
+ <FixedSizeList
+ width="100%"
+ height={itemCount * ITEM_SIZE}
+ itemCount={data.length}
+ itemSize={ITEM_SIZE}
+ >
+ {ListTableRow}
+ </FixedSizeList>
+ ) : (
+ data.map(getRow)
+ )}
+ </Column>
+ </Column>
+ );
+}
+
+const AnimatedRow = ({
+ label,
+ value = 0,
+ percent,
+ change,
+ animate,
+ showPercentage = true,
+ currency,
+ isPhone,
+}) => {
+ const props = useSpring({
+ width: percent,
+ y: !Number.isNaN(value) ? value : 0,
+ from: { width: 0, y: 0 },
+ config: animate ? config.default : { duration: 0 },
+ });
+
+ return (
+ <Grid
+ columns="1fr 50px 50px"
+ paddingLeft="2"
+ alignItems="center"
+ hoverBackgroundColor="2"
+ borderRadius
+ gap
+ >
+ <Row alignItems="center">
+ <Text truncate={true} style={{ maxWidth: isPhone ? '200px' : '400px' }}>
+ {label}
+ </Text>
+ </Row>
+ <Row alignItems="center" height="30px" justifyContent="flex-end">
+ {change}
+ <Text weight="bold">
+ <AnimatedDiv title={props?.y as any}>
+ {currency
+ ? props.y?.to(n => formatLongCurrency(n, currency))
+ : props.y?.to(formatLongNumber)}
+ </AnimatedDiv>
+ </Text>
+ </Row>
+ {showPercentage && (
+ <Row
+ alignItems="center"
+ justifyContent="flex-start"
+ position="relative"
+ border="left"
+ borderColor="8"
+ color="muted"
+ paddingLeft="3"
+ >
+ <AnimatedDiv>{props.width.to(n => `${n?.toFixed?.(0)}%`)}</AnimatedDiv>
+ </Row>
+ )}
+ </Grid>
+ );
+};
diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx
new file mode 100644
index 0000000..d15bcf1
--- /dev/null
+++ b/src/components/metrics/MetricCard.tsx
@@ -0,0 +1,56 @@
+import { useSpring } from '@react-spring/web';
+import { Column, Text } from '@umami/react-zen';
+import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { formatNumber } from '@/lib/format';
+
+export interface MetricCardProps {
+ value: number;
+ previousValue?: number;
+ change?: number;
+ label?: string;
+ reverseColors?: boolean;
+ formatValue?: (n: any) => string;
+ showLabel?: boolean;
+ showChange?: boolean;
+}
+
+export const MetricCard = ({
+ value = 0,
+ change = 0,
+ label,
+ reverseColors = false,
+ formatValue = formatNumber,
+ showLabel = true,
+ showChange = false,
+}: MetricCardProps) => {
+ const diff = value - change;
+ const pct = ((value - diff) / diff) * 100;
+ const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
+ const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });
+
+ return (
+ <Column
+ justifyContent="center"
+ paddingX="6"
+ paddingY="4"
+ borderRadius="3"
+ backgroundColor
+ border
+ >
+ {showLabel && (
+ <Text weight="bold" wrap="nowrap">
+ {label}
+ </Text>
+ )}
+ <Text size="8" weight="bold" wrap="nowrap">
+ <AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
+ </Text>
+ {showChange && (
+ <ChangeLabel value={change} title={formatValue(change)} reverseColors={reverseColors}>
+ <AnimatedDiv>{changeProps?.x?.to(x => `${Math.abs(~~x)}%`)}</AnimatedDiv>
+ </ChangeLabel>
+ )}
+ </Column>
+ );
+};
diff --git a/src/components/metrics/MetricLabel.tsx b/src/components/metrics/MetricLabel.tsx
new file mode 100644
index 0000000..31c331f
--- /dev/null
+++ b/src/components/metrics/MetricLabel.tsx
@@ -0,0 +1,142 @@
+import { Row } from '@umami/react-zen';
+import { Favicon } from '@/components/common/Favicon';
+import { FilterLink } from '@/components/common/FilterLink';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import {
+ useCountryNames,
+ useFormat,
+ useLocale,
+ useMessages,
+ useRegionNames,
+} from '@/components/hooks';
+import { GROUPED_DOMAINS } from '@/lib/constants';
+
+export interface MetricLabelProps {
+ type: string;
+ data: any;
+ onClick?: () => void;
+}
+
+export function MetricLabel({ type, data }: MetricLabelProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue, formatCity } = useFormat();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { getRegionName } = useRegionNames(locale);
+
+ const { label, country, domain } = data;
+
+ switch (type) {
+ case 'browser':
+ case 'os':
+ return (
+ <FilterLink
+ type={type}
+ value={label}
+ label={formatValue(label, type)}
+ icon={<TypeIcon type={type} value={label} />}
+ />
+ );
+
+ case 'channel':
+ return formatMessage(labels[label]);
+
+ case 'city':
+ return (
+ <FilterLink
+ type="city"
+ value={label}
+ label={formatCity(label, country)}
+ icon={
+ country && (
+ <img
+ src={`${process.env.basePath || ''}/images/country/${
+ country?.toLowerCase() || 'xx'
+ }.png`}
+ alt={country}
+ />
+ )
+ }
+ />
+ );
+
+ case 'region':
+ return (
+ <FilterLink
+ type="region"
+ value={label}
+ label={getRegionName(label, country)}
+ icon={<TypeIcon type="country" value={country} />}
+ />
+ );
+
+ case 'country':
+ return (
+ <FilterLink
+ type="country"
+ value={(countryNames[label] && label) || label}
+ label={formatValue(label, 'country')}
+ icon={<TypeIcon type="country" value={label} />}
+ />
+ );
+
+ case 'path':
+ case 'entry':
+ case 'exit':
+ return (
+ <FilterLink
+ type={type === 'entry' || type === 'exit' ? 'path' : type}
+ value={label}
+ label={!label && formatMessage(labels.none)}
+ externalUrl={
+ domain ? `${domain?.startsWith('http') ? domain : `https://${domain}`}${label}` : null
+ }
+ />
+ );
+
+ case 'device':
+ return (
+ <FilterLink
+ type="device"
+ value={labels[label] && label}
+ label={formatValue(label, 'device')}
+ icon={<TypeIcon type="device" value={label} />}
+ />
+ );
+
+ case 'referrer':
+ return (
+ <FilterLink
+ type="referrer"
+ value={label}
+ externalUrl={`https://${label}`}
+ label={!label && formatMessage(labels.none)}
+ icon={<Favicon domain={label} />}
+ />
+ );
+
+ case 'domain':
+ if (label === 'Other') {
+ return `(${formatMessage(labels.other)})`;
+ } else {
+ const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name;
+
+ if (!name) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center" gap="3">
+ <Favicon domain={label} />
+ {name}
+ </Row>
+ );
+ }
+
+ case 'language':
+ return formatValue(label, 'language');
+
+ default:
+ return <FilterLink type={type} value={label} />;
+ }
+}
diff --git a/src/components/metrics/MetricsBar.tsx b/src/components/metrics/MetricsBar.tsx
new file mode 100644
index 0000000..850c6bc
--- /dev/null
+++ b/src/components/metrics/MetricsBar.tsx
@@ -0,0 +1,14 @@
+import { Grid, type GridProps } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export interface MetricsBarProps extends GridProps {
+ children?: ReactNode;
+}
+
+export function MetricsBar({ children, ...props }: MetricsBarProps) {
+ return (
+ <Grid columns="repeat(auto-fit, minmax(160px, 1fr))" gap {...props}>
+ {children}
+ </Grid>
+ );
+}
diff --git a/src/components/metrics/MetricsExpandedTable.tsx b/src/components/metrics/MetricsExpandedTable.tsx
new file mode 100644
index 0000000..f24c952
--- /dev/null
+++ b/src/components/metrics/MetricsExpandedTable.tsx
@@ -0,0 +1,139 @@
+import { Button, Column, DataColumn, DataTable, Icon, Row, SearchField } from '@umami/react-zen';
+import { type ReactNode, useState } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useWebsiteExpandedMetricsQuery } from '@/components/hooks';
+import { X } from '@/components/icons';
+import { DownloadButton } from '@/components/input/DownloadButton';
+import { MetricLabel } from '@/components/metrics/MetricLabel';
+import { SESSION_COLUMNS } from '@/lib/constants';
+import { formatShortTime } from '@/lib/format';
+
+export interface MetricsExpandedTableProps {
+ websiteId: string;
+ type?: string;
+ title?: string;
+ dataFilter?: (data: any) => any;
+ onSearch?: (search: string) => void;
+ params?: { [key: string]: any };
+ allowSearch?: boolean;
+ allowDownload?: boolean;
+ renderLabel?: (row: any, index: number) => ReactNode;
+ onClose?: () => void;
+ children?: ReactNode;
+}
+
+export function MetricsExpandedTable({
+ websiteId,
+ type,
+ title,
+ params,
+ allowSearch = true,
+ allowDownload = true,
+ onClose,
+ children,
+}: MetricsExpandedTableProps) {
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const isType = ['browser', 'country', 'device', 'os'].includes(type);
+ const showBounceDuration = SESSION_COLUMNS.includes(type);
+
+ const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, {
+ type,
+ search: isType ? undefined : search,
+ ...params,
+ });
+
+ const items = data?.map(({ name, ...props }) => ({ label: name, ...props }));
+
+ return (
+ <>
+ <Row alignItems="center" paddingBottom="3">
+ {allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
+ <Row justifyContent="flex-end" flexGrow={1} gap>
+ {children}
+ {allowDownload && <DownloadButton filename={type} data={data} />}
+ {onClose && (
+ <Button onPress={onClose} variant="quiet">
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ )}
+ </Row>
+ </Row>
+ <LoadingPanel
+ data={data}
+ isFetching={isFetching}
+ isLoading={isLoading}
+ error={error}
+ height="100%"
+ loadingIcon="spinner"
+ >
+ <Column overflow="auto" minHeight="0" height="100%" paddingRight="3">
+ {items && (
+ <DataTable data={items}>
+ <DataColumn id="label" label={title} width="minmax(200px, 2fr)" align="start">
+ {row => (
+ <Row overflow="hidden">
+ <MetricLabel type={type} data={row} />
+ </Row>
+ )}
+ </DataColumn>
+ <DataColumn
+ id="visitors"
+ label={formatMessage(labels.visitors)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visitors?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="visits"
+ label={formatMessage(labels.visits)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visits?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="pageviews"
+ label={formatMessage(labels.views)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.pageviews?.toLocaleString()}
+ </DataColumn>
+ {showBounceDuration && [
+ <DataColumn
+ key="bounceRate"
+ id="bounceRate"
+ label={formatMessage(labels.bounceRate)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
+ return `${Math.round(+n)}%`;
+ }}
+ </DataColumn>,
+
+ <DataColumn
+ key="visitDuration"
+ id="visitDuration"
+ label={formatMessage(labels.visitDuration)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = row?.totaltime / row?.visits;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+ </DataColumn>,
+ ]}
+ </DataTable>
+ )}
+ </Column>
+ </LoadingPanel>
+ </>
+ );
+}
diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx
new file mode 100644
index 0000000..e99bd21
--- /dev/null
+++ b/src/components/metrics/MetricsTable.tsx
@@ -0,0 +1,95 @@
+import { Grid, Icon, Row, Text } from '@umami/react-zen';
+import { useEffect, useMemo } from 'react';
+import { LinkButton } from '@/components/common/LinkButton';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks';
+import { Maximize } from '@/components/icons';
+import { MetricLabel } from '@/components/metrics/MetricLabel';
+import { percentFilter } from '@/lib/filters';
+import { ListTable, type ListTableProps } from './ListTable';
+
+export interface MetricsTableProps extends ListTableProps {
+ websiteId: string;
+ type: string;
+ dataFilter?: (data: any) => any;
+ limit?: number;
+ showMore?: boolean;
+ filterLink?: boolean;
+ params?: Record<string, any>;
+ onDataLoad?: (data: any) => void;
+}
+
+export function MetricsTable({
+ websiteId,
+ type,
+ dataFilter,
+ limit,
+ showMore = false,
+ filterLink = true,
+ params,
+ onDataLoad,
+ ...props
+}: MetricsTableProps) {
+ const { updateParams } = useNavigation();
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(websiteId, {
+ type,
+ limit,
+ ...params,
+ });
+
+ const filteredData = useMemo(() => {
+ if (data) {
+ let items = data as any[];
+
+ if (dataFilter) {
+ if (Array.isArray(dataFilter)) {
+ items = dataFilter.reduce((arr, filter) => {
+ return filter(arr);
+ }, items);
+ } else {
+ items = dataFilter(items);
+ }
+ }
+
+ items = percentFilter(items);
+
+ return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props }));
+ }
+ return [];
+ }, [data, dataFilter, limit, type]);
+
+ useEffect(() => {
+ if (data) {
+ onDataLoad?.(data);
+ }
+ }, [data]);
+
+ const renderLabel = (row: any) => {
+ return filterLink ? <MetricLabel type={type} data={row} /> : row.label;
+ };
+
+ return (
+ <LoadingPanel
+ data={data}
+ isFetching={isFetching}
+ isLoading={isLoading}
+ error={error}
+ minHeight="400px"
+ >
+ <Grid>
+ {data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
+ {showMore && limit && (
+ <Row justifyContent="center" alignItems="flex-end">
+ <LinkButton href={updateParams({ view: type })} variant="quiet">
+ <Icon size="sm">
+ <Maximize />
+ </Icon>
+ <Text>{formatMessage(labels.more)}</Text>
+ </LinkButton>
+ </Row>
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/PageviewsChart.tsx b/src/components/metrics/PageviewsChart.tsx
new file mode 100644
index 0000000..b83f8dc
--- /dev/null
+++ b/src/components/metrics/PageviewsChart.tsx
@@ -0,0 +1,98 @@
+import { useTheme } from '@umami/react-zen';
+import { useCallback, useMemo } from 'react';
+import { BarChart, type BarChartProps } from '@/components/charts/BarChart';
+import { useLocale, useMessages } from '@/components/hooks';
+import { renderDateLabels } from '@/lib/charts';
+import { getThemeColors } from '@/lib/colors';
+import { generateTimeSeries } from '@/lib/date';
+
+export interface PageviewsChartProps extends BarChartProps {
+ data: {
+ pageviews: any[];
+ sessions: any[];
+ compare?: {
+ pageviews: any[];
+ sessions: any[];
+ };
+ };
+ unit: string;
+}
+
+export function PageviewsChart({ data, unit, minDate, maxDate, ...props }: PageviewsChartProps) {
+ const { formatMessage, labels } = useMessages();
+ const { theme } = useTheme();
+ const { locale, dateLocale } = useLocale();
+ const { colors } = useMemo(() => getThemeColors(theme), [theme]);
+
+ const chartData: any = useMemo(() => {
+ if (!data) return;
+
+ return {
+ __id: Date.now(),
+ datasets: [
+ {
+ type: 'bar',
+ label: formatMessage(labels.visitors),
+ data: generateTimeSeries(data.sessions, minDate, maxDate, unit, dateLocale),
+ borderWidth: 1,
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ ...colors.chart.visitors,
+ order: 3,
+ },
+ {
+ type: 'bar',
+ label: formatMessage(labels.views),
+ data: generateTimeSeries(data.pageviews, minDate, maxDate, unit, dateLocale),
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ borderWidth: 1,
+ ...colors.chart.views,
+ order: 4,
+ },
+ ...(data.compare
+ ? [
+ {
+ type: 'line',
+ label: `${formatMessage(labels.views)} (${formatMessage(labels.previous)})`,
+ data: generateTimeSeries(
+ data.compare.pageviews,
+ minDate,
+ maxDate,
+ unit,
+ dateLocale,
+ ),
+ borderWidth: 2,
+ backgroundColor: '#8601B0',
+ borderColor: '#8601B0',
+ order: 1,
+ },
+ {
+ type: 'line',
+ label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`,
+ data: generateTimeSeries(data.compare.sessions, minDate, maxDate, unit, dateLocale),
+ borderWidth: 2,
+ backgroundColor: '#f15bb5',
+ borderColor: '#f15bb5',
+ order: 2,
+ },
+ ]
+ : []),
+ ],
+ };
+ }, [data, locale]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <BarChart
+ {...props}
+ chartData={chartData}
+ unit={unit}
+ minDate={minDate}
+ maxDate={maxDate}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ );
+}
diff --git a/src/components/metrics/RealtimeChart.tsx b/src/components/metrics/RealtimeChart.tsx
new file mode 100644
index 0000000..f42b96d
--- /dev/null
+++ b/src/components/metrics/RealtimeChart.tsx
@@ -0,0 +1,59 @@
+import { isBefore, startOfMinute, subMinutes } from 'date-fns';
+import { useMemo, useRef } from 'react';
+import { useTimezone } from '@/components/hooks';
+import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants';
+import type { RealtimeData } from '@/lib/types';
+import { PageviewsChart } from './PageviewsChart';
+
+export interface RealtimeChartProps {
+ data: RealtimeData;
+ unit: string;
+ className?: string;
+}
+
+export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
+ const { formatSeriesTimezone, fromUtc, timezone } = useTimezone();
+ const endDate = startOfMinute(new Date());
+ const startDate = subMinutes(endDate, REALTIME_RANGE);
+ const prevEndDate = useRef(endDate);
+ const prevData = useRef<string | null>(null);
+
+ const chartData = useMemo(() => {
+ if (!data) {
+ return { pageviews: [], sessions: [] };
+ }
+
+ return {
+ pageviews: formatSeriesTimezone(data.series.views, 'x', timezone),
+ sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone),
+ };
+ }, [data, startDate, endDate, unit]);
+
+ const animationDuration = useMemo(() => {
+ // Don't animate the bars shifting over because it looks weird
+ if (isBefore(prevEndDate.current, endDate)) {
+ prevEndDate.current = endDate;
+ return 0;
+ }
+
+ // Don't animate when data hasn't changed
+ const serialized = JSON.stringify(chartData);
+ if (prevData.current === serialized) {
+ return 0;
+ }
+ prevData.current = serialized;
+
+ return DEFAULT_ANIMATION_DURATION;
+ }, [endDate, chartData]);
+
+ return (
+ <PageviewsChart
+ {...props}
+ minDate={fromUtc(startDate)}
+ maxDate={fromUtc(endDate)}
+ unit={unit}
+ data={chartData}
+ animationDuration={animationDuration}
+ />
+ );
+}
diff --git a/src/components/metrics/WeeklyTraffic.tsx b/src/components/metrics/WeeklyTraffic.tsx
new file mode 100644
index 0000000..90e47c6
--- /dev/null
+++ b/src/components/metrics/WeeklyTraffic.tsx
@@ -0,0 +1,112 @@
+import { Focusable, Grid, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { addHours, format, startOfDay } from 'date-fns';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useLocale, useMessages, useWeeklyTrafficQuery } from '@/components/hooks';
+import { getDayOfWeekAsDate } from '@/lib/date';
+
+export function WeeklyTraffic({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useWeeklyTrafficQuery(websiteId);
+ const { dateLocale } = useLocale();
+ const { labels, formatMessage } = useMessages();
+ const { weekStartsOn } = dateLocale.options;
+ const daysOfWeek = Array(7)
+ .fill(weekStartsOn)
+ .map((d, i) => (d + i) % 7);
+
+ const [, max = 1] = data
+ ? data.reduce((arr: number[], hours: number[], index: number) => {
+ const min = Math.min(...hours);
+ const max = Math.max(...hours);
+
+ if (index === 0) {
+ return [min, max];
+ }
+
+ if (min < arr[0]) {
+ arr[0] = min;
+ }
+
+ if (max > arr[1]) {
+ arr[1] = max;
+ }
+
+ return arr;
+ }, [])
+ : [];
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Grid columns="repeat(8, 1fr)" gap>
+ {data && (
+ <>
+ <Grid rows="repeat(25, 16px)" gap="1">
+ <Row>&nbsp;</Row>
+ {Array(24)
+ .fill(null)
+ .map((_, i) => {
+ const label = format(addHours(startOfDay(new Date()), i), 'haaa', {
+ locale: dateLocale,
+ });
+ return (
+ <Row key={i} justifyContent="flex-end">
+ <Text color="muted" size="2">
+ {label}
+ </Text>
+ </Row>
+ );
+ })}
+ </Grid>
+ {daysOfWeek.map((index: number) => {
+ const day = data[index];
+ return (
+ <Grid
+ rows="repeat(24, 16px)"
+ justifyContent="center"
+ alignItems="center"
+ key={index}
+ gap="1"
+ >
+ <Row alignItems="center" justifyContent="center" marginBottom="3">
+ <Text weight="bold" align="center">
+ {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
+ </Text>
+ </Row>
+ {day?.map((count: number, j) => {
+ const pct = max ? count / max : 0;
+ return (
+ <TooltipTrigger key={j} delay={0} isDisabled={count <= 0}>
+ <Focusable>
+ <Row
+ alignItems="center"
+ justifyContent="center"
+ backgroundColor="2"
+ width="16px"
+ height="16px"
+ borderRadius="full"
+ style={{ margin: '0 auto' }}
+ role="button"
+ >
+ <Row
+ backgroundColor="primary"
+ width="16px"
+ height="16px"
+ borderRadius="full"
+ style={{ opacity: pct, transform: `scale(${pct})` }}
+ />
+ </Row>
+ </Focusable>
+ <Tooltip placement="right">{`${formatMessage(
+ labels.visitors,
+ )}: ${count}`}</Tooltip>
+ </TooltipTrigger>
+ );
+ })}
+ </Grid>
+ );
+ })}
+ </>
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/WorldMap.tsx b/src/components/metrics/WorldMap.tsx
new file mode 100644
index 0000000..3c8fadb
--- /dev/null
+++ b/src/components/metrics/WorldMap.tsx
@@ -0,0 +1,105 @@
+import { Column, type ColumnProps, FloatingTooltip, useTheme } from '@umami/react-zen';
+import { colord } from 'colord';
+import { useMemo, useState } from 'react';
+import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
+import {
+ useCountryNames,
+ useLocale,
+ useMessages,
+ useWebsiteMetricsQuery,
+} from '@/components/hooks';
+import { getThemeColors } from '@/lib/colors';
+import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants';
+import { percentFilter } from '@/lib/filters';
+import { formatLongNumber } from '@/lib/format';
+
+export interface WorldMapProps extends ColumnProps {
+ websiteId?: string;
+ data?: any[];
+}
+
+export function WorldMap({ websiteId, data, ...props }: WorldMapProps) {
+ const [tooltip, setTooltipPopup] = useState();
+ const { theme } = useTheme();
+ const { colors } = getThemeColors(theme);
+ const { locale } = useLocale();
+ const { formatMessage, labels } = useMessages();
+ const { countryNames } = useCountryNames(locale);
+ const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale);
+ const unknownLabel = formatMessage(labels.unknown);
+
+ const { data: mapData } = useWebsiteMetricsQuery(websiteId, {
+ type: 'country',
+ });
+
+ const metrics = useMemo(
+ () => (data || mapData ? percentFilter((data || mapData) as any[]) : []),
+ [data, mapData],
+ );
+
+ const getFillColor = (code: string) => {
+ if (code === 'AQ') return;
+ const country = metrics?.find(({ x }) => x === code);
+
+ if (!country) {
+ return colors.map.fillColor;
+ }
+
+ return colord(colors.map.baseColor)
+ [theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
+ .toHex();
+ };
+
+ const getOpacity = (code: string) => {
+ return code === 'AQ' ? 0 : 1;
+ };
+
+ const handleHover = (code: string) => {
+ if (code === 'AQ') return;
+ const country = metrics?.find(({ x }) => x === code);
+ setTooltipPopup(
+ `${countryNames[code] || unknownLabel}: ${formatLongNumber(
+ country?.y || 0,
+ )} ${visitorsLabel}` as any,
+ );
+ };
+
+ return (
+ <Column
+ {...props}
+ data-tip=""
+ data-for="world-map-tooltip"
+ style={{ margin: 'auto 0', overflow: 'hidden' }}
+ >
+ <ComposableMap projection="geoMercator">
+ <ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
+ <Geographies geography={`${process.env.basePath || ''}${MAP_FILE}`}>
+ {({ geographies }) => {
+ return geographies.map(geo => {
+ const code = ISO_COUNTRIES[geo.id];
+
+ return (
+ <Geography
+ key={geo.rsmKey}
+ geography={geo}
+ fill={getFillColor(code)}
+ stroke={colors.map.strokeColor}
+ opacity={getOpacity(code)}
+ style={{
+ default: { outline: 'none' },
+ hover: { outline: 'none', fill: colors.map.hoverColor },
+ pressed: { outline: 'none' },
+ }}
+ onMouseOver={() => handleHover(code)}
+ onMouseOut={() => setTooltipPopup(null)}
+ />
+ );
+ });
+ }}
+ </Geographies>
+ </ZoomableGroup>
+ </ComposableMap>
+ {tooltip && <FloatingTooltip>{tooltip}</FloatingTooltip>}
+ </Column>
+ );
+}
diff --git a/src/components/svg/AddUser.tsx b/src/components/svg/AddUser.tsx
new file mode 100644
index 0000000..d1eb509
--- /dev/null
+++ b/src/components/svg/AddUser.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgAddUser = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ data-name="Layer 2"
+ viewBox="0 0 30 30"
+ {...props}
+ >
+ <path d="M15 14a5.5 5.5 0 1 1 5.5-5.5A5.51 5.51 0 0 1 15 14m0-9a3.5 3.5 0 1 0 3.5 3.5A3.5 3.5 0 0 0 15 5M7.5 24.5a1 1 0 0 1-1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1-1.2 1.6A6.44 6.44 0 0 0 15 17a6.51 6.51 0 0 0-6.5 6.5 1 1 0 0 1-1 1M23 27a1 1 0 0 1-1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1-1 1" />
+ <path d="M26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2" />
+ </svg>
+);
+export default SvgAddUser;
diff --git a/src/components/svg/BarChart.tsx b/src/components/svg/BarChart.tsx
new file mode 100644
index 0000000..96ebe00
--- /dev/null
+++ b/src/components/svg/BarChart.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBarChart = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path d="M7 13v9a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1m7-12h-4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1m8 5h-4a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1" />
+ </svg>
+);
+export default SvgBarChart;
diff --git a/src/components/svg/Bars.tsx b/src/components/svg/Bars.tsx
new file mode 100644
index 0000000..1ce88f7
--- /dev/null
+++ b/src/components/svg/Bars.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBars = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path d="M424 392H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24m0-320H24C10.8 72 0 82.8 0 96s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24m0 160H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24" />
+ </svg>
+);
+export default SvgBars;
diff --git a/src/components/svg/Bolt.tsx b/src/components/svg/Bolt.tsx
new file mode 100644
index 0000000..23b1e76
--- /dev/null
+++ b/src/components/svg/Bolt.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBolt = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" {...props}>
+ <path d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36" />
+ </svg>
+);
+export default SvgBolt;
diff --git a/src/components/svg/Bookmark.tsx b/src/components/svg/Bookmark.tsx
new file mode 100644
index 0000000..089f61f
--- /dev/null
+++ b/src/components/svg/Bookmark.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBookmark = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <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.875M5 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>
+);
+export default SvgBookmark;
diff --git a/src/components/svg/Calendar.tsx b/src/components/svg/Calendar.tsx
new file mode 100644
index 0000000..dfb848a
--- /dev/null
+++ b/src/components/svg/Calendar.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgCalendar = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path d="M400 64h-48V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H128V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48M48 96h352c8.8 0 16 7.2 16 16v48H32v-48c0-8.8 7.2-16 16-16m352 384H48c-8.8 0-16-7.2-16-16V192h384v272c0 8.8-7.2 16-16 16M148 320h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m-96 96h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m-96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m192 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12" />
+ </svg>
+);
+export default SvgCalendar;
diff --git a/src/components/svg/Change.tsx b/src/components/svg/Change.tsx
new file mode 100644
index 0000000..935a2f7
--- /dev/null
+++ b/src/components/svg/Change.tsx
@@ -0,0 +1,13 @@
+import type { SVGProps } from 'react';
+
+const SvgChange = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ viewBox="0 0 512.013 512.013"
+ {...props}
+ >
+ <path d="m372.653 244.726 22.56 22.56 112-112c6.204-6.241 6.204-16.319 0-22.56l-112-112-22.56 22.72 84.8 84.64H.013v32h457.44zm139.36 107.36H54.573l84.8-84.64-22.72-22.72-112 112c-6.204 6.241-6.204 16.319 0 22.56l112 112 22.56-22.56-84.64-84.64h457.44z" />
+ </svg>
+);
+export default SvgChange;
diff --git a/src/components/svg/Clock.tsx b/src/components/svg/Clock.tsx
new file mode 100644
index 0000000..2dfa6a6
--- /dev/null
+++ b/src/components/svg/Clock.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgClock = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <g clipRule="evenodd">
+ <path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12" />
+ <path d="M11.168 11.445a1 1 0 0 1 1.387-.277l3 2a1 1 0 0 1-1.11 1.664l-3-2a1 1 0 0 1-.277-1.387" />
+ <path d="M12 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1" />
+ </g>
+ </svg>
+);
+export default SvgClock;
diff --git a/src/components/svg/Compare.tsx b/src/components/svg/Compare.tsx
new file mode 100644
index 0000000..3434461
--- /dev/null
+++ b/src/components/svg/Compare.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgCompare = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path d="M6 22a1 1 0 0 1-.71-.29l-4-4a1 1 0 0 1 0-1.42l4-4a1 1 0 0 1 1.42 1.42L4.41 16H22a1 1 0 0 1 0 2H4.41l2.3 2.29a1 1 0 0 1 0 1.42A1 1 0 0 1 6 22m12-10a1 1 0 0 1-.71-.29 1 1 0 0 1 0-1.42L19.59 8H2a1 1 0 0 1 0-2h17.59l-2.3-2.29a1 1 0 0 1 1.42-1.42l4 4a1 1 0 0 1 0 1.42l-4 4A1 1 0 0 1 18 12" />
+ </svg>
+);
+export default SvgCompare;
diff --git a/src/components/svg/Dashboard.tsx b/src/components/svg/Dashboard.tsx
new file mode 100644
index 0000000..5696244
--- /dev/null
+++ b/src/components/svg/Dashboard.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react';
+
+const SvgDashboard = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ className="dashboard_svg__lucide dashboard_svg__lucide-layout-dashboard"
+ viewBox="0 0 24 24"
+ {...props}
+ >
+ <rect width={7} height={9} x={3} y={3} rx={1} />
+ <rect width={7} height={5} x={14} y={3} rx={1} />
+ <rect width={7} height={9} x={14} y={12} rx={1} />
+ <rect width={7} height={5} x={3} y={16} rx={1} />
+ </svg>
+);
+export default SvgDashboard;
diff --git a/src/components/svg/Download.tsx b/src/components/svg/Download.tsx
new file mode 100644
index 0000000..5f58724
--- /dev/null
+++ b/src/components/svg/Download.tsx
@@ -0,0 +1,9 @@
+import type { SVGProps } from 'react';
+
+const SvgDownload = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
+ <path d="M97.5 82.656V71.357a3.545 3.545 0 0 0-3.545-3.544H89.17a3.545 3.545 0 0 0-3.545 3.544v11.3c0 1.639-1.33 2.968-2.969 2.968H17.344a2.97 2.97 0 0 1-2.969-2.969V71.357a3.545 3.545 0 0 0-3.545-3.545H6.045A3.545 3.545 0 0 0 2.5 71.357v11.3C2.5 90.853 9.146 97.5 17.344 97.5h65.312c8.198 0 14.844-6.646 14.844-14.844" />
+ <path d="m29.68 44.105-3.387 3.388a3.545 3.545 0 0 0 0 5.014l19.506 19.506a5.94 5.94 0 0 0 8.397.005l.005-.005 19.506-19.506a3.545 3.545 0 0 0 0-5.014l-3.388-3.388a3.545 3.545 0 0 0-5.013 0l-9.368 9.368V6.045A3.545 3.545 0 0 0 52.393 2.5h-4.786a3.545 3.545 0 0 0-3.544 3.545v47.428l-9.369-9.368a3.545 3.545 0 0 0-5.013 0" />
+ </svg>
+);
+export default SvgDownload;
diff --git a/src/components/svg/Expand.tsx b/src/components/svg/Expand.tsx
new file mode 100644
index 0000000..a0f472e
--- /dev/null
+++ b/src/components/svg/Expand.tsx
@@ -0,0 +1,18 @@
+import type { SVGProps } from 'react';
+
+const SvgExpand = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fillRule="evenodd"
+ strokeLinejoin="round"
+ strokeMiterlimit={2}
+ clipRule="evenodd"
+ viewBox="0 0 48 48"
+ {...props}
+ >
+ <path d="M7.5 40.018v-10.5c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5v11a4.5 4.5 0 0 0 4.5 4.5h12a2.5 2.5 0 0 0 0-5zm33 0H29a2.5 2.5 0 0 0 0 5h12a4.5 4.5 0 0 0 4.5-4.5v-11c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5zm-33-33H19a2.5 2.5 0 0 0 0-5H7a4.5 4.5 0 0 0-4.5 4.5v11a2.5 2.5 0 0 0 5 0zm33 0v10.5a2.5 2.5 0 0 0 5 0v-11a4.5 4.5 0 0 0-4.5-4.5H29a2.5 2.5 0 0 0 0 5z" />
+ </svg>
+);
+export default SvgExpand;
diff --git a/src/components/svg/Export.tsx b/src/components/svg/Export.tsx
new file mode 100644
index 0000000..5c1ef14
--- /dev/null
+++ b/src/components/svg/Export.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgExport = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <switch>
+ <g>
+ <path d="M8.7 7.7 11 5.4V15c0 .6.4 1 1 1s1-.4 1-1V5.4l2.3 2.3c.4.4 1 .4 1.4 0s.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0M21 14c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1H5c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1" />
+ </g>
+ </switch>
+ </svg>
+);
+export default SvgExport;
diff --git a/src/components/svg/Flag.tsx b/src/components/svg/Flag.tsx
new file mode 100644
index 0000000..34af943
--- /dev/null
+++ b/src/components/svg/Flag.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgFlag = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 510 510" {...props}>
+ <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 30m-57.429-60h-60c0-16.542 13.458-30 30-30s30 13.458 30 30m-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.583" />
+ </svg>
+);
+export default SvgFlag;
diff --git a/src/components/svg/Funnel.tsx b/src/components/svg/Funnel.tsx
new file mode 100644
index 0000000..63cf47d
--- /dev/null
+++ b/src/components/svg/Funnel.tsx
@@ -0,0 +1,18 @@
+import type { SVGProps } from 'react';
+
+const SvgFunnel = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ viewBox="0 0 32 32"
+ {...props}
+ >
+ <path d="M29 11H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h26a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1M4 9h24V5H4z" />
+ <path d="M25 17H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1M8 15h16v-4H8z" />
+ <path d="M22 23H10a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1m-11-2h10v-4H11z" />
+ <path d="M19 29h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1m-5-2h4v-4h-4z" />
+ </svg>
+);
+export default SvgFunnel;
diff --git a/src/components/svg/Gear.tsx b/src/components/svg/Gear.tsx
new file mode 100644
index 0000000..539b838
--- /dev/null
+++ b/src/components/svg/Gear.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgGear = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M504.265 315.978c0-8.652-4.607-16.844-12.359-21.392l-32.908-18.971a199 199 0 0 0 0-39.23l32.908-18.971c7.752-4.548 12.359-12.74 12.359-21.392 0-21.267-49.318-128.176-84.519-128.176-4.244 0-8.51 1.093-12.367 3.357l-32.78 18.969a195 195 0 0 0-34.068-19.744v-37.94c0-11.226-7.484-21.035-18.326-23.875C300.654 2.871 278.425 0 256.181 0a257.7 257.7 0 0 0-66.121 8.613c-10.842 2.84-18.326 12.649-18.326 23.875v37.94a195 195 0 0 0-34.068 19.744l-32.78-18.969a24.36 24.36 0 0 0-12.367-3.357h-.007C60.048 67.846 8 169.591 8 196.022c0 8.652 4.607 16.844 12.359 21.392l32.908 18.971a199 199 0 0 0 0 39.23l-32.908 18.971C12.607 299.134 8 307.326 8 315.978c0 21.267 49.318 128.176 84.519 128.176 4.244 0 8.51-1.093 12.367-3.357l32.78-18.969a195 195 0 0 0 34.068 19.744v37.94c0 11.226 7.484 21.035 18.326 23.875 21.551 5.742 43.78 8.613 66.024 8.613 22.246 0 44.506-2.871 66.121-8.613 10.842-2.84 18.326-12.649 18.326-23.875v-37.94a195 195 0 0 0 34.068-19.744l32.78 18.969a24.36 24.36 0 0 0 12.367 3.357c32.463 0 84.519-101.731 84.519-128.176m-88.904 73.981c-23.8-13.773-11.26-6.515-43.656-25.264-42.056 30.395-32.33 24.731-79.174 45.887v50.238a210 210 0 0 1-36.438 3.18 209 209 0 0 1-36.359-3.176v-50.242c-46.955-21.206-37.182-15.538-79.174-45.887l-43.636 25.254a207.4 207.4 0 0 1-36.407-63.109c21.126-12.177 11.844-6.826 43.571-25.117-2.539-25.64-3.811-35.644-3.811-45.683 0-10.022 1.268-20.08 3.811-45.763-31.89-18.385-22.517-12.982-43.584-25.125a207.1 207.1 0 0 1 36.4-63.111c23.8 13.773 11.26 6.515 43.656 25.264 42.056-30.395 32.33-24.731 79.174-45.887V51.18A210 210 0 0 1 256.172 48c15.425 0 27.954 1.694 36.359 3.176v50.242c46.955 21.206 37.182 15.538 79.174 45.887l43.638-25.254a207.4 207.4 0 0 1 36.405 63.109c-21.126 12.177-11.844 6.826-43.571 25.117 2.539 25.64 3.811 35.644 3.811 45.683 0 10.022-1.268 20.08-3.811 45.763 31.89 18.385 22.517 12.982 43.584 25.125a207.1 207.1 0 0 1-36.4 63.111M256.133 160c-52.875 0-96 43.125-96 96s43.125 96 96 96 96-43.125 96-96-43.125-96-96-96m0 144c-26.467 0-48-21.533-48-48s21.533-48 48-48 48 21.533 48 48-21.534 48-48 48" />
+ </svg>
+);
+export default SvgGear;
diff --git a/src/components/svg/Globe.tsx b/src/components/svg/Globe.tsx
new file mode 100644
index 0000000..385017d
--- /dev/null
+++ b/src/components/svg/Globe.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgGlobe = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" {...props}>
+ <path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8m179.3 160h-67.2c-6.7-36.5-17.5-68.8-31.2-94.7 42.9 19 77.7 52.7 98.4 94.7M248 56c18.6 0 48.6 41.2 63.2 112H184.8C199.4 97.2 229.4 56 248 56M48 256c0-13.7 1.4-27.1 4-40h77.7c-1 13.1-1.7 26.3-1.7 40s.7 26.9 1.7 40H52c-2.6-12.9-4-26.3-4-40m20.7 88h67.2c6.7 36.5 17.5 68.8 31.2 94.7-42.9-19-77.7-52.7-98.4-94.7m67.2-176H68.7c20.7-42 55.5-75.7 98.4-94.7-13.7 25.9-24.5 58.2-31.2 94.7M248 456c-18.6 0-48.6-41.2-63.2-112h126.5c-14.7 70.8-44.7 112-63.3 112m70.1-160H177.9c-1.1-12.8-1.9-26-1.9-40s.8-27.2 1.9-40h140.3c1.1 12.8 1.9 26 1.9 40s-.9 27.2-2 40m10.8 142.7c13.7-25.9 24.4-58.2 31.2-94.7h67.2c-20.7 42-55.5 75.7-98.4 94.7M366.3 296c1-13.1 1.7-26.3 1.7-40s-.7-26.9-1.7-40H444c2.6 12.9 4 26.3 4 40s-1.4 27.1-4 40z" />
+ </svg>
+);
+export default SvgGlobe;
diff --git a/src/components/svg/Lightbulb.tsx b/src/components/svg/Lightbulb.tsx
new file mode 100644
index 0000000..8d86170
--- /dev/null
+++ b/src/components/svg/Lightbulb.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgLightbulb = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ fill="currentColor"
+ viewBox="0 0 512 512"
+ {...props}
+ >
+ <path d="M223.718 124.76c-48.027 11.198-86.688 49.285-98.494 97.031-11.843 47.899 1.711 96.722 36.259 130.601C173.703 364.377 181 383.586 181 403.777V407c0 13.296 5.801 25.26 15 33.505V467c0 24.813 20.187 45 45 45h30c24.813 0 45-20.187 45-45v-26.495c9.199-8.245 15-20.208 15-33.505v-3.282c0-19.884 7.687-39.458 20.563-52.361C376.994 325.87 391 292.005 391 256c0-86.079-79.769-151.638-167.282-131.24M286 467c0 8.271-6.729 15-15 15h-30c-8.271 0-15-6.729-15-15v-15h60zm44.326-136.834C311.689 348.843 301 375.651 301 403.718V407c0 8.271-6.729 15-15 15h-60c-8.271 0-15-6.729-15-15v-3.223c0-28.499-10.393-55.035-28.513-72.804-26.89-26.37-37.409-64.493-28.141-101.981 9.125-36.907 39.029-66.353 76.184-75.015C299.202 137.964 361 189.228 361 256c0 28.004-10.894 54.343-30.674 74.166M139.327 118.114 96.9 75.688c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213l42.427 42.426c5.857 5.858 15.356 5.858 21.213 0s5.858-15.355 0-21.213M76 241H15c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15m421 0h-61c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15M436.313 75.688c-5.856-5.858-15.354-5.858-21.213 0l-42.427 42.426c-5.858 5.857-5.858 15.355 0 21.213s15.355 5.858 21.213 0l42.427-42.426c5.858-5.857 5.858-15.355 0-21.213M256 0c-8.284 0-15 6.716-15 15v61c0 8.284 6.716 15 15 15s15-6.716 15-15V15c0-8.284-6.716-15-15-15" />
+ <path d="M256 181c-6.166 0-12.447.739-18.658 2.194-25.865 6.037-47.518 27.328-53.879 52.979-1.994 8.041 2.907 16.175 10.947 18.17 8.042 1.994 16.176-2.909 18.17-10.948 3.661-14.758 16.647-27.5 31.593-30.989 3.982-.933 7.962-1.406 11.827-1.406 8.284 0 15-6.716 15-15s-6.716-15-15-15" />
+ </svg>
+);
+export default SvgLightbulb;
diff --git a/src/components/svg/Lightning.tsx b/src/components/svg/Lightning.tsx
new file mode 100644
index 0000000..9539a96
--- /dev/null
+++ b/src/components/svg/Lightning.tsx
@@ -0,0 +1,33 @@
+import type { SVGProps } from 'react';
+
+const SvgLightning = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ viewBox="0 0 682.667 682.667"
+ {...props}
+ >
+ <defs>
+ <clipPath id="lightning_svg__a" clipPathUnits="userSpaceOnUse">
+ <path d="M0 512h512V0H0Z" />
+ </clipPath>
+ </defs>
+ <g clipPath="url(#lightning_svg__a)" transform="matrix(1.33333 0 0 -1.33333 0 682.667)">
+ <path
+ d="M0 0h137.962L69.319-155.807h140.419L.242-482l55.349 222.794h-155.853z"
+ style={{
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: 30,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ strokeMiterlimit: 10,
+ strokeDasharray: 'none',
+ strokeOpacity: 1,
+ }}
+ transform="translate(201.262 496.994)"
+ />
+ </g>
+ </svg>
+);
+export default SvgLightning;
diff --git a/src/components/svg/Link.tsx b/src/components/svg/Link.tsx
new file mode 100644
index 0000000..4ce88e7
--- /dev/null
+++ b/src/components/svg/Link.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLink = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M314.222 197.78c51.091 51.091 54.377 132.287 9.75 187.16-6.242 7.73-2.784 3.865-84.94 86.02-54.696 54.696-143.266 54.745-197.99 0-54.711-54.69-54.734-143.255 0-197.99 32.773-32.773 51.835-51.899 63.409-63.457 7.463-7.452 20.331-2.354 20.486 8.192a173.3 173.3 0 0 0 4.746 37.828c.966 4.029-.272 8.269-3.202 11.198L80.632 312.57c-32.755 32.775-32.887 85.892 0 118.8 32.775 32.755 85.892 32.887 118.8 0l75.19-75.2c32.718-32.725 32.777-86.013 0-118.79a83.7 83.7 0 0 0-22.814-16.229c-4.623-2.233-7.182-7.25-6.561-12.346 1.356-11.122 6.296-21.885 14.815-30.405l4.375-4.375c3.625-3.626 9.177-4.594 13.76-2.294 12.999 6.524 25.187 15.211 36.025 26.049M470.958 41.04c-54.724-54.745-143.294-54.696-197.99 0-82.156 82.156-78.698 78.29-84.94 86.02-44.627 54.873-41.341 136.069 9.75 187.16 10.838 10.838 23.026 19.525 36.025 26.049 4.582 2.3 10.134 1.331 13.76-2.294l4.375-4.375c8.52-8.519 13.459-19.283 14.815-30.405.621-5.096-1.938-10.113-6.561-12.346a83.7 83.7 0 0 1-22.814-16.229c-32.777-32.777-32.718-86.065 0-118.79l75.19-75.2c32.908-32.887 86.025-32.755 118.8 0 32.887 32.908 32.755 86.025 0 118.8l-45.848 45.84c-2.93 2.929-4.168 7.169-3.202 11.198a173.3 173.3 0 0 1 4.746 37.828c.155 10.546 13.023 15.644 20.486 8.192 11.574-11.558 30.636-30.684 63.409-63.457 54.733-54.735 54.71-143.3-.001-197.991" />
+ </svg>
+);
+export default SvgLink;
diff --git a/src/components/svg/Location.tsx b/src/components/svg/Location.tsx
new file mode 100644
index 0000000..0fd7d16
--- /dev/null
+++ b/src/components/svg/Location.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLocation = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 64 64" {...props}>
+ <path d="M32 0A24.03 24.03 0 0 0 8 24c0 17.23 22.36 38.81 23.31 39.72a.99.99 0 0 0 1.38 0C33.64 62.81 56 41.23 56 24A24.03 24.03 0 0 0 32 0m0 35a11 11 0 1 1 11-11 11.007 11.007 0 0 1-11 11" />
+ </svg>
+);
+export default SvgLocation;
diff --git a/src/components/svg/Lock.tsx b/src/components/svg/Lock.tsx
new file mode 100644
index 0000000..2b62eb9
--- /dev/null
+++ b/src/components/svg/Lock.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLock = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9M8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722" />
+ </svg>
+);
+export default SvgLock;
diff --git a/src/components/svg/Logo.tsx b/src/components/svg/Logo.tsx
new file mode 100644
index 0000000..eb9fdf5
--- /dev/null
+++ b/src/components/svg/Logo.tsx
@@ -0,0 +1,17 @@
+import type { SVGProps } from 'react';
+
+const SvgLogo = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={20}
+ height={20}
+ fill="currentColor"
+ stroke="currentColor"
+ viewBox="0 0 428 389.11"
+ {...props}
+ >
+ <circle cx={214.15} cy={181} r={171} fill="none" strokeMiterlimit={10} strokeWidth={20} />
+ <path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z" />
+ </svg>
+);
+export default SvgLogo;
diff --git a/src/components/svg/LogoWhite.tsx b/src/components/svg/LogoWhite.tsx
new file mode 100644
index 0000000..fb8c5f9
--- /dev/null
+++ b/src/components/svg/LogoWhite.tsx
@@ -0,0 +1,26 @@
+import type { SVGProps } from 'react';
+
+const SvgLogoWhite = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={20}
+ height={20}
+ viewBox="0 0 428 389.11"
+ {...props}
+ >
+ <circle
+ cx={214.15}
+ cy={181}
+ r={171}
+ fill="none"
+ stroke="#fff"
+ strokeMiterlimit={10}
+ strokeWidth={20}
+ />
+ <path
+ fill="#fff"
+ d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15"
+ />
+ </svg>
+);
+export default SvgLogoWhite;
diff --git a/src/components/svg/Magnet.tsx b/src/components/svg/Magnet.tsx
new file mode 100644
index 0000000..88b0f03
--- /dev/null
+++ b/src/components/svg/Magnet.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgMagnet = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ viewBox="0 0 508.467 508.467"
+ {...props}
+ >
+ <path d="M426.815 239.006c-11.722-11.724-30.702-11.729-42.427-.001L267.67 355.723c-53.811 53.809-142.478 19.197-140.68-54.511.547-22.415 9.826-43.738 26.129-60.041l116.717-116.717c11.724-11.722 11.728-30.702 0-42.427l-46.668-46.669c-11.725-11.725-30.702-11.726-42.427 0L60.629 155.47C21.579 194.52.047 246.44 0 301.665c-.093 110.827 88.182 206.288 206.244 206.394 56.778 0 109.204-21.924 148.29-61.01l118.948-118.948c11.724-11.722 11.728-30.702 0-42.427zM201.954 56.572l46.669 46.669-58.455 58.456-46.669-46.669zm131.367 369.264c-69.043 69.043-182.868 70.02-251.708.933-68.763-69.009-68.66-181.196.229-250.086l40.443-40.443 46.669 46.669-37.049 37.049c-45.115 45.112-46.916 116.85-3.395 160.371 43.279 43.279 115.221 41.756 160.372-3.394l37.049-37.049 46.669 46.669zm60.494-60.493-46.669-46.669 58.456-58.456 46.669 46.669zM379.357 95.099c15.199 3.839 30.418 19.07 34.336 34.192 2.089 8.058 10.303 12.828 18.283 10.758 8.02-2.078 12.836-10.264 10.758-18.283-6.651-25.662-30.176-49.223-56.03-55.753-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.837 16.189 10.87 18.217m128.627 7.025C495.968 55.749 452.769 12.62 406.239.868c-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.838 16.188 10.87 18.217 35.882 9.063 70.769 43.871 80.051 79.695 2.088 8.058 10.304 12.828 18.283 10.758 8.02-2.078 12.836-10.263 10.758-18.283" />
+ </svg>
+);
+export default SvgMagnet;
diff --git a/src/components/svg/Money.tsx b/src/components/svg/Money.tsx
new file mode 100644
index 0000000..7d7b1e5
--- /dev/null
+++ b/src/components/svg/Money.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgMoney = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ fill="currentColor"
+ viewBox="0 0 512 512"
+ {...props}
+ >
+ <path d="M347 302c8.271 0 15 6.639 15 14.8h30c0-19.468-12.541-36.067-30-42.231V242h-30v32.58c-17.459 6.192-30 22.865-30 42.42 0 24.813 20.187 45 45 45 8.271 0 15 6.729 15 15s-6.729 15-15 15-15-6.729-15-15h-30c0 19.555 12.541 36.228 30 42.42v32.38h30v-32.38c17.459-6.192 30-22.865 30-42.42 0-24.813-20.187-45-45-45-8.271 0-15-6.729-15-15s6.729-15 15-15" />
+ <path d="M347 182c-5.057 0-10.058.242-15 .689V90c0-26.011-18.548-49.61-52.226-66.449C249.4 8.364 209.35 0 167 0 124.564 0 84.193 8.347 53.323 23.502 18.938 40.385 0 64 0 90v272c0 26 18.938 49.616 53.323 66.498C84.193 443.653 124.564 452 167 452c17.009 0 33.647-1.358 49.615-4.004C246.826 486.909 294.035 512 347 512c90.981 0 165-74.019 165-165s-74.019-165-165-165M66.545 50.432C92.992 37.447 129.606 30 167 30c79.558 0 135 31.621 135 60s-55.442 60-135 60c-37.394 0-74.008-7.447-100.455-20.432C43.32 118.166 30 103.744 30 90s13.32-28.166 36.545-39.568M30 142.265c6.724 5.137 14.512 9.907 23.323 14.233C84.193 171.653 124.564 180 167 180c42.35 0 82.4-8.364 112.774-23.551 8.359-4.18 15.783-8.776 22.226-13.722v45.51c-29.896 8.485-56.359 25.209-76.778 47.548C206.946 239.908 187.386 242 167 242c-37.394 0-74.008-7.447-100.455-20.432C43.32 210.166 30 195.744 30 182zm0 92c6.724 5.137 14.512 9.907 23.323 14.233C84.193 263.653 124.564 272 167 272c11.581 0 22.942-.621 34.021-1.839a163.7 163.7 0 0 0-18.293 61.395c-5.211.286-10.465.444-15.728.444-37.394 0-74.008-7.447-100.455-20.432C43.32 300.166 30 285.744 30 272zM167 422c-37.394 0-74.008-7.447-100.455-20.432C43.32 390.166 30 375.744 30 362v-37.736c6.724 5.137 14.512 9.907 23.323 14.233C84.193 353.653 124.564 362 167 362c5.23 0 10.459-.132 15.654-.388a163.7 163.7 0 0 0 16.486 58.557A281 281 0 0 1 167 422m180 60c-74.439 0-135-60.561-135-135s60.561-135 135-135 135 60.561 135 135-60.561 135-135 135" />
+ </svg>
+);
+export default SvgMoney;
diff --git a/src/components/svg/Moon.tsx b/src/components/svg/Moon.tsx
new file mode 100644
index 0000000..40e3e8b
--- /dev/null
+++ b/src/components/svg/Moon.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgMoon = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1399.98 1400" {...props}>
+ <path d="M562.44 837.55C335.89 611 288.08 273.54 418.71 0a734.3 734.3 0 0 0-203.17 143.73c-287.39 287.39-287.39 753.33 0 1040.72s753.33 287.4 1040.74 0A733.8 733.8 0 0 0 1400 981.29c-273.55 130.63-611 82.8-837.56-143.74" />
+ </svg>
+);
+export default SvgMoon;
diff --git a/src/components/svg/Network.tsx b/src/components/svg/Network.tsx
new file mode 100644
index 0000000..15941a9
--- /dev/null
+++ b/src/components/svg/Network.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgNetwork = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ viewBox="0 0 32 32"
+ {...props}
+ >
+ <path d="M28 19c-.809 0-1.54.325-2.08.847l-6.011-3.01c.058-.271.091-.55.091-.837s-.033-.566-.091-.837l6.011-3.01c.54.522 1.271.847 2.08.847 1.654 0 3-1.346 3-3s-1.346-3-3-3-3 1.346-3 3c0 .123.022.24.036.359L19 13.382a3.98 3.98 0 0 0-2-1.24V6.816A3 3 0 0 0 19 4c0-1.654-1.346-3-3-3s-3 1.346-3 3c0 1.302.838 2.401 2 2.815v5.327a4 4 0 0 0-2 1.24L6.963 10.36c.015-.12.037-.237.037-.36 0-1.654-1.346-3-3-3s-3 1.346-3 3 1.346 3 3 3c.809 0 1.54-.325 2.08-.847l6.011 3.01q-.089.407-.091.837c-.002.43.033.566.091.837l-6.011 3.01A2.98 2.98 0 0 0 4 19c-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3c0-.123-.022-.24-.036-.359L13 18.618a3.98 3.98 0 0 0 2 1.24v5.326A3 3 0 0 0 13 28c0 1.654 1.346 3 3 3s3-1.346 3-3a3 3 0 0 0-2-2.816v-5.326a4 4 0 0 0 2-1.24l6.037 3.022c-.015.12-.037.237-.037.36 0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3m0-10c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1M4 11c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1m0 12c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1M16 3c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1m0 26c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1m0-11c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2m12 5c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1" />
+ </svg>
+);
+export default SvgNetwork;
diff --git a/src/components/svg/Nodes.tsx b/src/components/svg/Nodes.tsx
new file mode 100644
index 0000000..1adfcb8
--- /dev/null
+++ b/src/components/svg/Nodes.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgNodes = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path
+ fillRule="evenodd"
+ d="M19 9.874A4.002 4.002 0 0 0 18 2a4 4 0 0 0-3.874 3H9.874A4.002 4.002 0 0 0 2 6a4 4 0 0 0 3 3.874v4.252A4.002 4.002 0 0 0 6 22a4 4 0 0 0 3.874-3h4.252A4.002 4.002 0 0 0 22 18a4 4 0 0 0-3-3.874zM6 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4m3.874 3A4.01 4.01 0 0 1 7 9.874v4.252A4.01 4.01 0 0 1 9.874 17h4.252A4.01 4.01 0 0 1 17 14.126V9.874A4.01 4.01 0 0 1 14.126 7zM18 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4m0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4M8 18a2 2 0 1 0-4 0 2 2 0 0 0 4 0"
+ clipRule="evenodd"
+ />
+ </svg>
+);
+export default SvgNodes;
diff --git a/src/components/svg/Overview.tsx b/src/components/svg/Overview.tsx
new file mode 100644
index 0000000..67e6af1
--- /dev/null
+++ b/src/components/svg/Overview.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgOverview = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve" viewBox="0 0 512 512" {...props}>
+ <path d="M452 36H60C26.916 36 0 62.916 0 96v240c0 33.084 26.916 60 60 60h176v40H132v40h248v-40H276v-40h176c33.084 0 60-26.916 60-60V96c0-33.084-26.916-60-60-60m20 300c0 11.028-8.972 20-20 20H60c-11.028 0-20-8.972-20-20V96c0-11.028 8.972-20 20-20h392c11.028 0 20 8.972 20 20z" />
+ </svg>
+);
+export default SvgOverview;
diff --git a/src/components/svg/Path.tsx b/src/components/svg/Path.tsx
new file mode 100644
index 0000000..7538ba4
--- /dev/null
+++ b/src/components/svg/Path.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgPath = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ viewBox="0 0 64 64"
+ {...props}
+ >
+ <path d="m56.4 47.6-6-6c-.8-.8-2-.8-2.8 0s-.8 2 0 2.8l2.6 2.6H18.5c-3.6 0-6.5-2.9-6.5-6.5s2.9-6.5 6.5-6.5h27C51.3 34 56 29.3 56 23.5S51.3 13 45.5 13H22.7c-.9-3.4-4-6-7.7-6-4.4 0-8 3.6-8 8s3.6 8 8 8c3.7 0 6.8-2.6 7.7-6h22.8c3.6 0 6.5 2.9 6.5 6.5S49.1 30 45.5 30h-27C12.7 30 8 34.7 8 40.5S12.7 51 18.5 51h31.7l-2.6 2.6c-.8.8-.8 2 0 2.8.4.4.9.6 1.4.6s1-.2 1.4-.6l6-6c.8-.8.8-2 0-2.8M15 19c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4" />
+ </svg>
+);
+export default SvgPath;
diff --git a/src/components/svg/Profile.tsx b/src/components/svg/Profile.tsx
new file mode 100644
index 0000000..c955fce
--- /dev/null
+++ b/src/components/svg/Profile.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgProfile = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02M111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703M256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934m0 0" />
+ </svg>
+);
+export default SvgProfile;
diff --git a/src/components/svg/Pushpin.tsx b/src/components/svg/Pushpin.tsx
new file mode 100644
index 0000000..d19e98e
--- /dev/null
+++ b/src/components/svg/Pushpin.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgPushpin = (props: SVGProps<SVGSVGElement>) => (
+ <svg width="1em" height="1em" fill="currentColor" viewBox="0 0 1024 1024" {...props}>
+ <path d="M878.3 392.1 631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 0 0-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8" />
+ </svg>
+);
+export default SvgPushpin;
diff --git a/src/components/svg/Redo.tsx b/src/components/svg/Redo.tsx
new file mode 100644
index 0000000..04c389f
--- /dev/null
+++ b/src/components/svg/Redo.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgRedo = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.568C418.075 51.834 341.788 7.73 255.207 8.001 118.82 8.428 7.787 120.009 8 256.396 8.214 393.181 119.165 504 256 504c63.926 0 122.202-24.187 166.178-63.908 5.113-4.618 5.354-12.561.482-17.433l-19.738-19.738c-4.498-4.498-11.753-4.785-16.501-.552C351.787 433.246 306.105 452 256 452c-108.322 0-196-87.662-196-196 0-108.322 87.662-196 196-196 79.545 0 147.941 47.282 178.675 115.302l-126.389-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12" />
+ </svg>
+);
+export default SvgRedo;
diff --git a/src/components/svg/Reports.tsx b/src/components/svg/Reports.tsx
new file mode 100644
index 0000000..b548966
--- /dev/null
+++ b/src/components/svg/Reports.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgReports = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" {...props}>
+ <path d="M61.17 18.91A32 32 0 1 0 46.4 60.54l.15-.06.16-.1a31.93 31.93 0 0 0 14.47-41.44s-.01-.02-.01-.03m-4.53-.16L34 28.91V4.1a28 28 0 0 1 22.64 14.65M4 32A28 28 0 0 1 30 4.1V32a1.7 1.7 0 0 0 0 .39.2.2 0 0 0 0 .07 1.5 1.5 0 0 0 .15.4l12.76 24.9A28 28 0 0 1 4 32m42.47 23.94L34.74 33l23.54-10.6a28 28 0 0 1-11.81 33.54" />
+ </svg>
+);
+export default SvgReports;
diff --git a/src/components/svg/Security.tsx b/src/components/svg/Security.tsx
new file mode 100644
index 0000000..d075a93
--- /dev/null
+++ b/src/components/svg/Security.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgSecurity = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ data-name="Layer 1"
+ viewBox="0 0 36 36"
+ {...props}
+ >
+ <path d="M18 34a1.1 1.1 0 0 1-.48-.11l-4.87-2.43A13.79 13.79 0 0 1 5 19.05V6.91a1.07 1.07 0 0 1 1.05-1.07h3.47a7.45 7.45 0 0 0 4-1.19l3.87-2.48a1.07 1.07 0 0 1 1.15 0l3.87 2.48a7.45 7.45 0 0 0 4 1.19h3.47A1.07 1.07 0 0 1 31 6.91v12.14a13.79 13.79 0 0 1-7.67 12.4l-4.87 2.43A1.1 1.1 0 0 1 18 34M7.12 8v11.05a11.67 11.67 0 0 0 6.49 10.49l4.39 2.2 4.39-2.2a11.67 11.67 0 0 0 6.49-10.49V8h-2.4a9.57 9.57 0 0 1-5.19-1.53L18 4.33l-3.29 2.12A9.57 9.57 0 0 1 9.52 8z" />
+ <path d="M18 18.8a4.8 4.8 0 1 1 4.8-4.8 4.81 4.81 0 0 1-4.8 4.8m0-7.47A2.67 2.67 0 1 0 20.67 14 2.67 2.67 0 0 0 18 11.34zM24.4 24.67h-2.13a2.14 2.14 0 0 0-2.13-2.13h-4.28a2.13 2.13 0 0 0-2.13 2.13H11.6a4.26 4.26 0 0 1 4.26-4.26h4.27a4.27 4.27 0 0 1 4.27 4.26" />
+ </svg>
+);
+export default SvgSecurity;
diff --git a/src/components/svg/Speaker.tsx b/src/components/svg/Speaker.tsx
new file mode 100644
index 0000000..eb724ae
--- /dev/null
+++ b/src/components/svg/Speaker.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgSpeaker = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <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.961q1.185 0 2.398-.181c3.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.1 30.1 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.742M260.1 469.653l-25.33 25.33a4.1 4.1 0 0 1-1.197.85L162.45 424.71a1244 1244 0 0 0 26.786-27.968l71.714 71.713a4 4 0 0 1-.85 1.198m-38.055-62.731-21.982-21.981a2608 2608 0 0 0 14.157-15.763l2.712-3.035c8.895 11.831 10.752 27.329 5.113 40.779m-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.686m173.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.91m-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.343m122.699 107.695a8 8 0 0 1-8 8h-62.09a8 8 0 0 1 0-16h62.09a8 8 0 0 1 8 8M237.061 70.09V8a8 8 0 0 1 16 0v62.09a8 8 0 0 1-16 0" />
+ </svg>
+);
+export default SvgSpeaker;
diff --git a/src/components/svg/Sun.tsx b/src/components/svg/Sun.tsx
new file mode 100644
index 0000000..61880f5
--- /dev/null
+++ b/src/components/svg/Sun.tsx
@@ -0,0 +1,9 @@
+import type { SVGProps } from 'react';
+
+const SvgSun = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400" {...props}>
+ <path d="M367.43 422.13a54.44 54.44 0 0 1-38.66-16L205 282.35A54.69 54.69 0 0 1 282.37 205l123.74 123.79a54.68 54.68 0 0 1-38.68 93.34M1156.3 1211a54.5 54.5 0 0 1-38.67-16l-123.74-123.79a54.68 54.68 0 1 1 77.34-77.33L1195 1117.65a54.7 54.7 0 0 1-38.7 93.35m-912.6 0a54.7 54.7 0 0 1-38.7-93.35l123.74-123.76a54.69 54.69 0 0 1 77.36 77.32L282.37 1195a54.5 54.5 0 0 1-38.67 16m788.87-788.87a54.68 54.68 0 0 1-38.68-93.34L1117.61 205a54.69 54.69 0 0 1 77.39 77.35l-123.77 123.76a54.44 54.44 0 0 1-38.66 16.02M229.69 754.69h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38m1115.62 0h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38M700 1400a54.68 54.68 0 0 1-54.69-54.69v-175a54.69 54.69 0 0 1 109.38 0v175A54.68 54.68 0 0 1 700 1400m0-1115.62a54.7 54.7 0 0 1-54.69-54.69v-175a54.69 54.69 0 0 1 109.38 0v175A54.7 54.7 0 0 1 700 284.38" />
+ <circle cx={700} cy={700} r={306.25} />
+ </svg>
+);
+export default SvgSun;
diff --git a/src/components/svg/Switch.tsx b/src/components/svg/Switch.tsx
new file mode 100644
index 0000000..0196d85
--- /dev/null
+++ b/src/components/svg/Switch.tsx
@@ -0,0 +1,19 @@
+import type { SVGProps } from 'react';
+
+const SvgSwitch = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={200}
+ height={200}
+ fill="none"
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ viewBox="0 0 24 24"
+ {...props}
+ >
+ <path d="m16 3 4 4-4 4M10 7h10M8 13l-4 4 4 4M4 17h9" />
+ </svg>
+);
+export default SvgSwitch;
diff --git a/src/components/svg/Tag.tsx b/src/components/svg/Tag.tsx
new file mode 100644
index 0000000..2ff51f4
--- /dev/null
+++ b/src/components/svg/Tag.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgTag = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="437pt"
+ height="437pt"
+ fill="currentColor"
+ viewBox="0 0 437.004 437"
+ {...props}
+ >
+ <path d="M229 14.645A50.17 50.17 0 0 0 192.371.015L52.293 3.586C25.672 4.25 4.246 25.673 3.582 52.298L.016 192.37a50.22 50.22 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.13 30.13 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.13 30.13 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.145m0 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>
+);
+export default SvgTag;
diff --git a/src/components/svg/Target.tsx b/src/components/svg/Target.tsx
new file mode 100644
index 0000000..3fe76d2
--- /dev/null
+++ b/src/components/svg/Target.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react';
+
+const SvgTarget = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ fillRule="evenodd"
+ strokeLinejoin="round"
+ strokeMiterlimit={2}
+ clipRule="evenodd"
+ viewBox="0 0 24 24"
+ {...props}
+ >
+ <path d="M19.393 10.825a.75.75 0 0 1 1.458-.352c.181.75.277 1.533.277 2.338 0 5.485-4.453 9.939-9.939 9.939S1.25 18.296 1.25 12.811s4.454-9.939 9.939-9.939c.805 0 1.588.096 2.338.277a.75.75 0 1 1-.352 1.458A8.442 8.442 0 0 0 2.75 12.811a8.44 8.44 0 0 0 8.439 8.439 8.442 8.442 0 0 0 8.204-10.425" />
+ <path d="M14.764 12.811a.75.75 0 0 1 1.5 0c0 2.8-2.274 5.074-5.075 5.074a5.077 5.077 0 0 1-5.074-5.074 5.077 5.077 0 0 1 5.074-5.075.75.75 0 0 1 0 1.5 3.575 3.575 0 1 0 3.575 3.575m7.766-7.223-3.057 3.058a.75.75 0 0 1-.531.22h-3.058a.75.75 0 0 1-.75-.75V5.058a.75.75 0 0 1 .22-.531l3.058-3.057a.75.75 0 0 1 1.242.293L20.3 3.7l1.937.646a.75.75 0 0 1 .293 1.242m-1.918-.202-1.142-.381a.75.75 0 0 1-.475-.475l-.381-1.142-1.98 1.98v1.998h1.998z" />
+ <path d="M15.354 7.585a.75.75 0 1 1 1.061 1.061l-4.587 4.586a.749.749 0 1 1-1.06-1.06z" />
+ </svg>
+);
+export default SvgTarget;
diff --git a/src/components/svg/Visitor.tsx b/src/components/svg/Visitor.tsx
new file mode 100644
index 0000000..16db585
--- /dev/null
+++ b/src/components/svg/Visitor.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgVisitor = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve" viewBox="0 0 512 512" {...props}>
+ <path d="M256 0c-74.439 0-135 60.561-135 135s60.561 135 135 135 135-60.561 135-135S330.439 0 256 0m167.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.805" />
+ </svg>
+);
+export default SvgVisitor;
diff --git a/src/components/svg/Website.tsx b/src/components/svg/Website.tsx
new file mode 100644
index 0000000..20a18a4
--- /dev/null
+++ b/src/components/svg/Website.tsx
@@ -0,0 +1,13 @@
+import type { SVGProps } from 'react';
+
+const SvgWebsite = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ viewBox="0 0 511.999 511.999"
+ {...props}
+ >
+ <path d="M437.019 74.981C388.667 26.628 324.38 0 256 0S123.332 26.628 74.981 74.98C26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98s132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018M96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230 230 0 0 1 15.806-17.528m-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4m-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437m35.622 46.146a230 230 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679m144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157m-34.934-44.964a230 230 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888m-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505zm-.002-208.845zc22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249m144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230 230 0 0 1-16.884 18.873m34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993" />
+ </svg>
+);
+export default SvgWebsite;
diff --git a/src/components/svg/index.ts b/src/components/svg/index.ts
new file mode 100644
index 0000000..76756af
--- /dev/null
+++ b/src/components/svg/index.ts
@@ -0,0 +1,37 @@
+export { default as AddUser } from './AddUser';
+export { default as BarChart } from './BarChart';
+export { default as Bars } from './Bars';
+export { default as Bolt } from './Bolt';
+export { default as Bookmark } from './Bookmark';
+export { default as Change } from './Change';
+export { default as Compare } from './Compare';
+export { default as Dashboard } from './Dashboard';
+export { default as Download } from './Download';
+export { default as Expand } from './Expand';
+export { default as Export } from './Export';
+export { default as Flag } from './Flag';
+export { default as Funnel } from './Funnel';
+export { default as Gear } from './Gear';
+export { default as Lightbulb } from './Lightbulb';
+export { default as Lightning } from './Lightning';
+export { default as Location } from './Location';
+export { default as Lock } from './Lock';
+export { default as Logo } from './Logo';
+export { default as LogoWhite } from './LogoWhite';
+export { default as Magnet } from './Magnet';
+export { default as Money } from './Money';
+export { default as Network } from './Network';
+export { default as Nodes } from './Nodes';
+export { default as Overview } from './Overview';
+export { default as Path } from './Path';
+export { default as Profile } from './Profile';
+export { default as Pushpin } from './Pushpin';
+export { default as Redo } from './Redo';
+export { default as Reports } from './Reports';
+export { default as Security } from './Security';
+export { default as Speaker } from './Speaker';
+export { default as Switch } from './Switch';
+export { default as Tag } from './Tag';
+export { default as Target } from './Target';
+export { default as Visitor } from './Visitor';
+export { default as Website } from './Website';
diff --git a/src/declaration.d.ts b/src/declaration.d.ts
new file mode 100644
index 0000000..14bae12
--- /dev/null
+++ b/src/declaration.d.ts
@@ -0,0 +1,18 @@
+declare module '*.css';
+declare module '*.svg';
+declare module '*.json';
+declare module 'bcryptjs';
+declare module 'chartjs-adapter-date-fns';
+declare module 'cors';
+declare module 'date-fns-tz';
+declare module 'debug';
+declare module 'fs-extra';
+declare module 'jsonwebtoken';
+declare module 'md5';
+declare module 'papaparse';
+declare module 'prettier';
+declare module 'react-simple-maps';
+declare module 'semver';
+declare module 'tsup';
+declare module 'uuid';
+declare module '@umami/esbuild-plugin-css-modules';
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..907c562
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,82 @@
+export * from '@/app/(main)/settings/preferences/LanguageSetting';
+export * from '@/app/(main)/settings/preferences/PreferenceSettings';
+export * from '@/app/(main)/settings/preferences/PreferencesPage';
+export * from '@/app/(main)/settings/preferences/ThemeSetting';
+export * from '@/app/(main)/teams/[teamId]/TeamDeleteForm';
+export * from '@/app/(main)/teams/[teamId]/TeamEditForm';
+export * from '@/app/(main)/teams/[teamId]/TeamManage';
+export * from '@/app/(main)/teams/[teamId]/TeamMemberEditButton';
+export * from '@/app/(main)/teams/[teamId]/TeamMemberEditForm';
+export * from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton';
+export * from '@/app/(main)/teams/[teamId]/TeamMembersDataTable';
+export * from '@/app/(main)/teams/[teamId]/TeamMembersTable';
+export * from '@/app/(main)/teams/[teamId]/TeamSettings';
+export * from '@/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton';
+export * from '@/app/(main)/teams/[teamId]/TeamWebsitesDataTable';
+export * from '@/app/(main)/teams/[teamId]/TeamWebsitesTable';
+
+export * from '@/app/(main)/teams/TeamAddForm';
+export * from '@/app/(main)/teams/TeamJoinForm';
+export * from '@/app/(main)/teams/TeamLeaveButton';
+export * from '@/app/(main)/teams/TeamLeaveForm';
+export * from '@/app/(main)/teams/TeamProvider';
+export * from '@/app/(main)/teams/TeamsAddButton';
+export * from '@/app/(main)/teams/TeamsDataTable';
+export * from '@/app/(main)/teams/TeamsHeader';
+export * from '@/app/(main)/teams/TeamsJoinButton';
+export * from '@/app/(main)/teams/TeamsTable';
+export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData';
+export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
+export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteEditForm';
+export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteResetForm';
+export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
+export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
+export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode';
+
+export * from '@/app/(main)/websites/WebsiteAddButton';
+export * from '@/app/(main)/websites/WebsiteAddForm';
+export * from '@/app/(main)/websites/WebsiteProvider';
+export * from '@/app/(main)/websites/WebsitesDataTable';
+export * from '@/app/(main)/websites/WebsitesHeader';
+export * from '@/app/(main)/websites/WebsitesTable';
+
+export * from '@/components/charts/BarChart';
+export * from '@/components/charts/BubbleChart';
+export * from '@/components/charts/Chart';
+export * from '@/components/charts/ChartTooltip';
+export * from '@/components/charts/PieChart';
+
+export * from '@/components/common/ActionForm';
+export * from '@/components/common/ConfirmationForm';
+export * from '@/components/common/DataGrid';
+export * from '@/components/common/DateDisplay';
+export * from '@/components/common/DateDistance';
+export * from '@/components/common/Empty';
+export * from '@/components/common/EmptyPlaceholder';
+export * from '@/components/common/ErrorBoundary';
+export * from '@/components/common/ErrorMessage';
+export * from '@/components/common/ExternalLink';
+export * from '@/components/common/Favicon';
+export * from '@/components/common/LinkButton';
+export * from '@/components/common/LoadingPanel';
+export * from '@/components/common/PageBody';
+export * from '@/components/common/PageHeader';
+export * from '@/components/common/Pager';
+export * from '@/components/common/Panel';
+export * from '@/components/common/SectionHeader';
+export * from '@/components/common/SideMenu';
+export * from '@/components/common/TypeConfirmationForm';
+export * from '@/components/hooks';
+export * from '@/components/input/DateFilter';
+export * from '@/components/input/DialogButton';
+export * from '@/components/input/DownloadButton';
+export * from '@/components/input/ExportButton';
+export * from '@/components/input/FilterButtons';
+export * from '@/components/input/NavButton';
+export * from '@/components/input/ProfileButton';
+export * from '@/components/input/WebsiteSelect';
+export * from '@/components/metrics/ChangeLabel';
+export * from '@/components/metrics/ListTable';
+export * from '@/components/metrics/MetricCard';
+export * from '@/components/metrics/MetricLabel';
+export * from '@/components/metrics/MetricsBar';
diff --git a/src/lang/ar-SA.json b/src/lang/ar-SA.json
new file mode 100644
index 0000000..5b5cfa9
--- /dev/null
+++ b/src/lang/ar-SA.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "كود الدعوة",
+ "label.actions": "الإجراءات",
+ "label.activity": "سجل الأحداث",
+ "label.add": "أضِف",
+ "label.add-board": "أضف لوحة",
+ "label.add-description": "أضِف وصف",
+ "label.add-member": "أضِف عضو",
+ "label.add-step": "إضافة خطوة",
+ "label.add-website": "إضافة موقع",
+ "label.admin": "مدير",
+ "label.affiliate": "Affiliate",
+ "label.after": "يعد",
+ "label.all": "الكل",
+ "label.all-time": "كل الوقت",
+ "label.analytics": "تحليلات",
+ "label.apply": "تطبيق",
+ "label.attribution": "الإسناد",
+ "label.attribution-description": "شاهد كيف يتفاعل المستخدمون مع حملاتك التسويقية وما الذي يحفز التحويلات.",
+ "label.average": "المتوسط",
+ "label.back": "للخلف",
+ "label.before": "قبل",
+ "label.boards": "لوحات",
+ "label.bounce-rate": "معدل الارتداد",
+ "label.breakdown": "التصنيف",
+ "label.browser": "المتصفح",
+ "label.browsers": "المتصفحات",
+ "label.campaigns": "حملات",
+ "label.cancel": "إلغاء",
+ "label.change-password": "تغيير كلمة المرور",
+ "label.channels": "قنوات",
+ "label.cities": "المدن",
+ "label.city": "المدينة",
+ "label.clear-all": "مسح الكل",
+ "label.cohort": "مجموعة",
+ "label.compare": "المقارنة",
+ "label.compare-dates": "قارن التواريخ",
+ "label.confirm": "تأكيد",
+ "label.confirm-password": "تأكيد كلمة المرور",
+ "label.contains": "يحتوي على",
+ "label.content": "المحتوى",
+ "label.continue": "تابع",
+ "label.conversion": "تحويل",
+ "label.conversion-rate": "معدل التحويل",
+ "label.conversion-step": "خطوة التحويل",
+ "label.count": "العدد",
+ "label.countries": "الدول",
+ "label.country": "الدولة",
+ "label.create": "أنشِئ",
+ "label.create-report": "أنشِئ تقرير",
+ "label.create-team": "أنشِئ فريق",
+ "label.create-user": "أنشِئ مستخدم",
+ "label.created": "أُنشئت",
+ "label.created-by": "أُنشئ من قبل",
+ "label.currency": "العملة",
+ "label.current": "الحالي",
+ "label.current-password": "كلمة المرور الحالية",
+ "label.custom-range": "فترة مخصّصة",
+ "label.dashboard": "لوحة التحكم",
+ "label.data": "البيانات",
+ "label.date": "التاريخ",
+ "label.date-range": "فترة مخصّصة",
+ "label.day": "يوم",
+ "label.default-date-range": "الفترة المخصّصة الافتراضية",
+ "label.delete": "حذف",
+ "label.delete-report": "احذف التقرير",
+ "label.delete-team": "حذف الفريق",
+ "label.delete-user": "جذف مستخدم",
+ "label.delete-website": "حذف الموقع",
+ "label.description": "الوصف",
+ "label.desktop": "كمبيوتر",
+ "label.details": "تفاصيل",
+ "label.device": "الجهاز",
+ "label.devices": "الأجهزة",
+ "label.direct": "مباشر",
+ "label.dismiss": "تجاهل",
+ "label.distinct-id": "معرّف مميز",
+ "label.does-not-contain": "لا يحتوي على",
+ "label.does-not-include": "لا يتضمن",
+ "label.doest-not-exist": "غير موجود",
+ "label.domain": "النطاق",
+ "label.dropoff": "إنزال",
+ "label.edit": "تعديل",
+ "label.edit-dashboard": "عدّل لوحة التحكم",
+ "label.edit-member": "عدّل العضو",
+ "label.email": "Email",
+ "label.enable-share-url": "فعّل مشاركة الرابط",
+ "label.end-step": "الخطوة الأخيرة",
+ "label.entry": "رابط الدخول",
+ "label.event": "الحدث",
+ "label.event-data": "تاريخ الحدث",
+ "label.event-name": "اسم الحدث",
+ "label.events": "الأحداث",
+ "label.exists": "موجود",
+ "label.exit": "رابط المغادرة",
+ "label.false": "خطأ",
+ "label.field": "الحقل",
+ "label.fields": "الحقول",
+ "label.filter": "تصفيَة",
+ "label.filter-combined": "مُجمّعة",
+ "label.filter-raw": "خام",
+ "label.filters": "التصفيات",
+ "label.first-click": "النقرة الأولى",
+ "label.first-seen": "أول ظهور",
+ "label.funnel": "قمع",
+ "label.funnel-description": "فهم معدل التحويل والانقطاع عن المستخدمين.",
+ "label.funnels": "قمعات",
+ "label.goal": "الهدف",
+ "label.goals": "الأهداف",
+ "label.goals-description": "تابع تحقق أهدافك المرتبطة بمشاهدات الصفحات والأحداث.",
+ "label.greater-than": "أكبَر مِن",
+ "label.greater-than-equals": "أكبَر مِن أو يساوي",
+ "label.grouped": "مجمع",
+ "label.hostname": "اسم المضيف",
+ "label.includes": "يتضمن",
+ "label.insight": "رؤية معمقة",
+ "label.insights": "نتائج التحليلات",
+ "label.insights-description": "تعمق في بياناتك باستخدام الشرائح والتصفيات.",
+ "label.is": "يساوي",
+ "label.is-false": "غير صحيح",
+ "label.is-not": "لا يساوي",
+ "label.is-not-set": "لم ضُبط",
+ "label.is-set": "ضُبط",
+ "label.is-true": "صحيح",
+ "label.join": "انضم",
+ "label.join-team": "انضم للفريق",
+ "label.journey": "رحلة المستخدم",
+ "label.journey-description": "تعرّف على كيفية تنقّل المستخدمين داخل موقعك.",
+ "label.journeys": "رحلات المستخدم",
+ "label.language": "اللغة",
+ "label.languages": "اللغات",
+ "label.laptop": "لابتوب",
+ "label.last-click": "النقرة الأخيرة",
+ "label.last-days": "آخر {x} يوم/ايام",
+ "label.last-hours": "آخر {x} ساعة",
+ "label.last-months": "آخر {x} شهر/أشهر",
+ "label.last-seen": "آخر ظهور",
+ "label.leave": "غادر",
+ "label.leave-team": "مغادرة المجموعة",
+ "label.less-than": "أقل مِن",
+ "label.less-than-equals": "أقل مِن أو يساوي",
+ "label.links": "روابط",
+ "label.login": "تسجيل الدخول",
+ "label.logout": "تسجيل الخروج",
+ "label.manage": "التحكم",
+ "label.manager": "مدير",
+ "label.max": "الحد الأقصى",
+ "label.maximize": "توسيع",
+ "label.medium": "وسيط",
+ "label.member": "عضو",
+ "label.members": "الأعضاء",
+ "label.min": "الحد الأدنى",
+ "label.mobile": "جوال",
+ "label.model": "نموذج",
+ "label.more": "المزيد",
+ "label.my-account": "حسابي",
+ "label.my-websites": "مواقعي",
+ "label.name": "الاسم",
+ "label.new-password": "كلمة مرور جديدة",
+ "label.none": "لا شيء",
+ "label.number-of-records": "{x} {x, plural, one {سجل} other {سجلات}}",
+ "label.ok": "نعم",
+ "label.online": "Online",
+ "label.organic-search": "بحث عضوي",
+ "label.organic-shopping": "تسوق عضوي",
+ "label.organic-social": "اجتماعي عضوي",
+ "label.organic-video": "فيديو عضوي",
+ "label.os": "نظام التشغيل",
+ "label.other": "أخرى",
+ "label.overview": "نظرة عامة",
+ "label.owner": "المالك",
+ "label.page": "صفحة",
+ "label.page-of": "صفحة {current} من {total}",
+ "label.page-views": "مشاهدات الصفحة",
+ "label.pageTitle": "عنوان الصفحة",
+ "label.pages": "صفحات",
+ "label.paid-ads": "إعلانات مدفوعة",
+ "label.paid-search": "بحث مدفوع",
+ "label.paid-shopping": "تسوق مدفوع",
+ "label.paid-social": "اجتماعي مدفوع",
+ "label.paid-video": "فيديو مدفوع",
+ "label.password": "كلمة المرور",
+ "label.path": "مسار",
+ "label.paths": "مسارات",
+ "label.pixels": "بكسلات",
+ "label.powered-by": "مشغل بواسطة {name}",
+ "label.previous": "السابق",
+ "label.previous-period": "الفترة السابقة",
+ "label.previous-year": "العام السابق",
+ "label.profile": "الملف الشخصي",
+ "label.properties": "خصائص",
+ "label.property": "خاصية",
+ "label.queries": "استعلامات",
+ "label.query": "استعلام",
+ "label.query-parameters": "معاملات الاستعلام",
+ "label.realtime": "الوقت الفعلي",
+ "label.referral": "إحالة",
+ "label.referrer": "المرجع",
+ "label.referrers": "التحويلات",
+ "label.refresh": "تحديث",
+ "label.regenerate": "إعادة توليد",
+ "label.region": "المنطقة",
+ "label.regions": "المناطق",
+ "label.remaining": "متبقي",
+ "label.remove": "أزِل",
+ "label.remove-member": "احذف عضو",
+ "label.reports": "التقارير",
+ "label.required": "اجباري",
+ "label.reset": "اعادة تعيين",
+ "label.reset-website": "اعادة تعيين الإحصائيات",
+ "label.retention": "الاحتفاظ",
+ "label.retention-description": "قس مدى ثبات موقعك على الويب من خلال تتبع عدد مرات عودة المستخدمين.",
+ "label.revenue": "الإيرادات",
+ "label.revenue-description": "قم بإلقاء نظرة على بيانات إيراداتك وكيفية إنفاق المستخدمين.",
+ "label.role": "الصلاحية",
+ "label.run-query": "شغّل الاستعلام",
+ "label.save": "حفظ",
+ "label.screens": "الشاشات",
+ "label.search": "بحث",
+ "label.select": "اختر",
+ "label.select-date": "حدد التاريخ",
+ "label.select-filter": "اختر تصفية",
+ "label.select-role": "حدد الدور",
+ "label.select-website": "حدد موقع",
+ "label.session": "الزيارة",
+ "label.session-data": "بيانات الجلسة",
+ "label.sessions": "الزيارات",
+ "label.settings": "الإعدادات",
+ "label.share": "مشاركة",
+ "label.share-url": "مشاركة الرابط",
+ "label.single-day": "يوم واحد",
+ "label.sms": "SMS",
+ "label.sources": "مصادر",
+ "label.start-step": "الخطوة الأولى",
+ "label.steps": "الخطوات",
+ "label.sum": "المجموع",
+ "label.tablet": "تابلت",
+ "label.tag": "الوسم",
+ "label.tags": "الوسوم",
+ "label.team": "الفريق",
+ "label.team-id": "معرّف الفريق",
+ "label.team-manager": "مدير الفريق",
+ "label.team-member": "عضو الفريق",
+ "label.team-name": "اسم الفريق",
+ "label.team-owner": "مدير الفريق",
+ "label.team-settings": "إعدادات الفريق",
+ "label.team-view-only": "عرض الفريق فقط",
+ "label.team-websites": "مواقع الفريق",
+ "label.teams": "الفرق",
+ "label.terms": "مصطلحات",
+ "label.theme": "السمة",
+ "label.this-month": "الشهر الحالي",
+ "label.this-week": "الاسبوع الحالي",
+ "label.this-year": "السنة الحالية",
+ "label.timezone": "المنطقة الزمنية",
+ "label.title": "العنوان",
+ "label.today": "اليوم",
+ "label.toggle-charts": "تغيير الإحصائيات",
+ "label.total": "الإجمالي",
+ "label.total-records": "إجمالي السجلات",
+ "label.tracking-code": "كود التتبع",
+ "label.transactions": "المعاملات",
+ "label.transfer": "نقل",
+ "label.transfer-website": "انقل الموقع",
+ "label.true": "حقيقي",
+ "label.type": "النوع",
+ "label.unique": "فريد",
+ "label.unique-visitors": "زائرون فريدون",
+ "label.uniqueCustomers": "العملاء الفريدون",
+ "label.unknown": "غير معروف",
+ "label.untitled": "بدون عنوان",
+ "label.update": "تحديث",
+ "label.user": "المستخدم",
+ "label.username": "اسم المستخدم",
+ "label.users": "المستخدمين",
+ "label.utm": "UTM",
+ "label.utm-description": "تابع حملاتك التسويقية باستخدام معلمات UTM.",
+ "label.value": "القيمة",
+ "label.view": "عرض",
+ "label.view-details": "عرض التفاصيل",
+ "label.view-only": "عرض فقط",
+ "label.views": "المشاهدات",
+ "label.views-per-visit": "مشاهدات لكل زيارة",
+ "label.visit-duration": "متوسط وقت الزيارة",
+ "label.visitors": "الزوار",
+ "label.visits": "الزيارات",
+ "label.website": "الموقع",
+ "label.website-id": "معرّف الموقع",
+ "label.websites": "المواقع",
+ "label.window": "النافذة",
+ "label.yesterday": "الأمس",
+ "label.behavior": "السلوك",
+ "message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.",
+ "message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "البيانات المجمعة",
+ "message.confirm-delete": "هل أنت متأكد من حذف {target}?",
+ "message.confirm-leave": "هل أنت متأكد من مغادرة {target}?",
+ "message.confirm-remove": "هل انت متأكد من حذف {target}?",
+ "message.confirm-reset": "هل أنت متأكد من اعادة تعيين الإحصائيات لـ {target}؟",
+ "message.delete-team-warning": "سيؤدي حذف الفريق أيضًا إلى حذف كافة مواقع الفريق",
+ "message.delete-website-warning": "سيتم حذف كافة بيانات الموقع.",
+ "message.error": "حدث خطأ ما.",
+ "message.event-log": "{event} في {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "الذهاب إلى الإعدادات",
+ "message.incorrect-username-password": "اسم المستخدم او كلمة المرور غير صحيحة.",
+ "message.invalid-domain": "النطاق غير صحيح",
+ "message.min-password-length": "اقل عدد مسموح به {n} حرف/أحرف",
+ "message.new-version-available": "إصدار جديد من Umami {version} متاح!",
+ "message.no-data-available": "لا توجد بيانات متاحة.",
+ "message.no-event-data": "لا توجد بيانات الحدث متاحة.",
+ "message.no-match-password": "كلمة المرور غير متطابقة",
+ "message.no-results-found": "لا توجد نتائج.",
+ "message.no-team-websites": "هذا الفريق ليس لديه أي مواقع.",
+ "message.no-teams": "لم تنشِئ اي فرق.",
+ "message.no-users": "لا يوجد مستخدمين.",
+ "message.no-websites-configured": "لم تقم بإعداد اي موقع.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "الصفحة غير موجودة.",
+ "message.reset-website": "لإعادة ضبط موقع الويب هذا، اكتب {confirmation} في المربع أدناه للتأكيد.",
+ "message.reset-website-warning": "سيتم اعادة تعيين كافة الإحصائيات لهذا الموقع، لكن لن يتم تغيير كود التتبع",
+ "message.saved": "تم الحفظ بنجاح.",
+ "message.sever-error": "Server error",
+ "message.share-url": "إحصائيات موقعك متاحة للجميع على الرابط التالي:",
+ "message.team-already-member": "أنت عضو في الفريق",
+ "message.team-not-found": "لم يتم العثور على الفريق",
+ "message.team-websites-info": "يمكن مشاهدة الموقع من اي عضو في الفريق.",
+ "message.tracking-code": "كود التتبع",
+ "message.transfer-team-website-to-user": "نقل هذا الموقع إلى حسابك؟",
+ "message.transfer-user-website-to-team": "اختر الفريق الذي تريد نقل الموقع إليه.",
+ "message.transfer-website": "نقل ملكية الموقع لحسابك أو فريق أخر.",
+ "message.triggered-event": "أُطلق الحدث",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "تم حذف المستخدم.",
+ "message.viewed-page": "شوهدت الصفحة",
+ "message.visitor-log": "زائر من {country} يستخدم {browser} على {os} {device}"
+}
diff --git a/src/lang/be-BY.json b/src/lang/be-BY.json
new file mode 100644
index 0000000..1a866a9
--- /dev/null
+++ b/src/lang/be-BY.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Код доступу",
+ "label.actions": "Дзеянні",
+ "label.activity": "Журнал актыўнасці",
+ "label.add": "Дадаць",
+ "label.add-board": "Дадаць дошку",
+ "label.add-description": "Дадаць апісанне",
+ "label.add-member": "Дадаць удзельніка",
+ "label.add-step": "Дадаць крок",
+ "label.add-website": "Дадаць сайт",
+ "label.admin": "Адміністратар",
+ "label.affiliate": "Партнёр",
+ "label.after": "Пасля",
+ "label.all": "Усё",
+ "label.all-time": "Увесь час",
+ "label.analytics": "Аналітыка",
+ "label.apply": "Ужыць",
+ "label.attribution": "Атрыбуцыя",
+ "label.attribution-description": "Глядзіце, як карыстальнікі ўзаемадзейнічаюць з вашым маркетынгам і што прыводзіць да канверсій.",
+ "label.average": "Сярэдняе",
+ "label.back": "Назад",
+ "label.before": "Да",
+ "label.behavior": "Паводзіны",
+ "label.boards": "Дошкі",
+ "label.bounce-rate": "Паказчык адмоваў",
+ "label.breakdown": "Разбіўка",
+ "label.browser": "Браўзер",
+ "label.browsers": "Браўзеры",
+ "label.campaigns": "Кампаніі",
+ "label.cancel": "Адмена",
+ "label.change-password": "Змяніць пароль",
+ "label.channels": "Каналы",
+ "label.cities": "Гарады",
+ "label.city": "Горад",
+ "label.clear-all": "Ачысціць усё",
+ "label.cohort": "Кагорта",
+ "label.compare": "Параўнаць",
+ "label.compare-dates": "Параўнаць даты",
+ "label.confirm": "Падцвердзіць",
+ "label.confirm-password": "Падцвердзіць пароль",
+ "label.contains": "Уключае",
+ "label.content": "Змест",
+ "label.continue": "Працягнуць",
+ "label.conversion": "Канверсія",
+ "label.conversion-rate": "Канверсійная стаўка",
+ "label.conversion-step": "Крок канверсіі",
+ "label.count": "Колькасць",
+ "label.countries": "Краіны",
+ "label.country": "Краіна",
+ "label.create": "Стварыць",
+ "label.create-report": "Стварыць справаздачу",
+ "label.create-team": "Стварыць каманду",
+ "label.create-user": "Стварыць карыстальніка",
+ "label.created": "Створана",
+ "label.created-by": "Створана",
+ "label.currency": "Валюта",
+ "label.current": "Цяперашні",
+ "label.current-password": "Цяперашні пароль",
+ "label.custom-range": "Іншы дыяпазон",
+ "label.dashboard": "Інфармацыйная панэль",
+ "label.data": "Дадзеныя",
+ "label.date": "Дата",
+ "label.date-range": "Дыяпазон дат",
+ "label.day": "Дзень",
+ "label.default-date-range": "Дыяпазон дат па змаўчанню",
+ "label.delete": "Выдаліць",
+ "label.delete-report": "Выдаліць справаздачу",
+ "label.delete-team": "Выдаліць каманду",
+ "label.delete-user": "Выдаліць карыстальніка",
+ "label.delete-website": "Выдаліць сайт",
+ "label.description": "Апісанне",
+ "label.desktop": "Настольны ПК",
+ "label.details": "Дэталі",
+ "label.device": "Прылада",
+ "label.devices": "Прылады",
+ "label.direct": "Прама",
+ "label.dismiss": "Адхіліць",
+ "label.distinct-id": "Унікальны ID",
+ "label.does-not-contain": "Не ўключае",
+ "label.does-not-include": "Не ўключае",
+ "label.doest-not-exist": "Не існуе",
+ "label.domain": "Дамен",
+ "label.dropoff": "Адмовы",
+ "label.edit": "Змяніць",
+ "label.edit-dashboard": "Змяніць інфармацыйную панэль",
+ "label.edit-member": "Рэдагаваць удзельніка",
+ "label.email": "Email",
+ "label.enable-share-url": "Дазволіць дзяліцца спасылкай",
+ "label.end-step": "Канчатковы крок",
+ "label.entry": "URL уваходу",
+ "label.event": "Падзея",
+ "label.event-data": "Дадзеныя падзеі",
+ "label.event-name": "Назва падзеі",
+ "label.events": "Падзеі",
+ "label.exists": "Існуе",
+ "label.exit": "URL выхаду",
+ "label.false": "Ложна",
+ "label.field": "Поле",
+ "label.fields": "Палі",
+ "label.filter": "Фільтр",
+ "label.filter-combined": "Камбініраваны",
+ "label.filter-raw": "Сырыя",
+ "label.filters": "Фільтры",
+ "label.first-click": "Першы клік",
+ "label.first-seen": "Першы раз убачана",
+ "label.funnel": "Варонка",
+ "label.funnel-description": "Разумець паказчыкі канверсіі і адмоваў.",
+ "label.funnels": "Варонкі",
+ "label.goal": "Мэта",
+ "label.goals": "Мэты",
+ "label.goals-description": "Сачыць за мэтамі па праглядах старонак і падзеях.",
+ "label.greater-than": "Больш чым",
+ "label.greater-than-equals": "Больш чым або роўна",
+ "label.grouped": "Групаваны",
+ "label.hostname": "Імя хаста",
+ "label.includes": "Уключае",
+ "label.insight": "Інсайт",
+ "label.insights": "Інсайты",
+ "label.insights-description": "Даследваць дадзеныя з дапамогай сегментаў і фільтраў.",
+ "label.is": "З'яўляецца",
+ "label.is-false": "Ложна",
+ "label.is-not": "Не з'яўляецца",
+ "label.is-not-set": "Не ўстаноўлена",
+ "label.is-set": "Устаноўлена",
+ "label.is-true": "Праўда",
+ "label.join": "Далучыцца",
+ "label.join-team": "Далучыцца да каманды",
+ "label.journey": "Маршрут карыстальніка",
+ "label.journey-description": "Разумець як карыстальнікі навігуюць па сайце.",
+ "label.journeys": "Маршруты",
+ "label.language": "Мова",
+ "label.languages": "Мовы",
+ "label.laptop": "Ноўтбук",
+ "label.last-click": "Апошні клік",
+ "label.last-days": "Апошнія {x} дзён",
+ "label.last-hours": "Апошнія {x} гадзіны",
+ "label.last-months": "Апошнія {x} месяцаў",
+ "label.last-seen": "Last seen",
+ "label.leave": "Пакінуць",
+ "label.leave-team": "Пакінуць каманду",
+ "label.less-than": "Менш чым",
+ "label.less-than-equals": "Менш чым або роўна",
+ "label.links": "Спасылкі",
+ "label.login": "Увайсці",
+ "label.logout": "Выйсці",
+ "label.manage": "Кіраваць",
+ "label.manager": "Кіраўнік",
+ "label.max": "Максімум",
+ "label.maximize": "Разгарнуць",
+ "label.medium": "Сярэдні",
+ "label.member": "Удзельнік",
+ "label.members": "Удзельнікі",
+ "label.min": "Мінімум",
+ "label.mobile": "Мабільны",
+ "label.model": "Мадэль",
+ "label.more": "Болей",
+ "label.my-account": "Мой уліковы запіс",
+ "label.my-websites": "Мае сайты",
+ "label.name": "Імя",
+ "label.new-password": "Новы пароль",
+ "label.none": "Няма",
+ "label.number-of-records": "{x} {x, plural, one {запіс} other {запісаў}}",
+ "label.ok": "ОК",
+ "label.online": "Online",
+ "label.organic-search": "Арганічны пошук",
+ "label.organic-shopping": "Арганічныя пакупкі",
+ "label.organic-social": "Арганічныя сацыяльныя сеткі",
+ "label.organic-video": "Арганічнае відэа",
+ "label.os": "Аперацыйная сістэма",
+ "label.other": "Іншае",
+ "label.overview": "Агляд",
+ "label.owner": "Уласнік",
+ "label.page": "Старонка",
+ "label.page-of": "Старонка {current} з {total}",
+ "label.page-views": "Прагляды старонкі",
+ "label.pageTitle": "Загаловак старонкі",
+ "label.pages": "Старонкі",
+ "label.paid-ads": "Платная рэклама",
+ "label.paid-search": "Платаны пошук",
+ "label.paid-shopping": "Платныя пакупкі",
+ "label.paid-social": "Платныя сацыяльныя сеткі",
+ "label.paid-video": "Платнае відэа",
+ "label.password": "Пароль",
+ "label.path": "Шлях",
+ "label.paths": "Шляхи",
+ "label.pixels": "Пікселі",
+ "label.powered-by": "Зроблена {name}",
+ "label.previous": "Папярэдні",
+ "label.previous-period": "Папярэдні перыяд",
+ "label.previous-year": "Папярэдні год",
+ "label.profile": "Профіль",
+ "label.properties": "Уласцівасці",
+ "label.property": "Уласцівасць",
+ "label.queries": "Запыты",
+ "label.query": "Запыт",
+ "label.query-parameters": "Параметры запыту",
+ "label.realtime": "У рэяльным часе",
+ "label.referral": "Рэферал",
+ "label.referrer": "Рэферэр",
+ "label.referrers": "Рэферэры",
+ "label.refresh": "Аднавіць",
+ "label.regenerate": "Рэгенераваць",
+ "label.region": "Рэгіён",
+ "label.regions": "Рэгіёны",
+ "label.remaining": "Засталося",
+ "label.remove": "Выдаліць",
+ "label.remove-member": "Выдаліць удзельніка",
+ "label.reports": "Справаздачы",
+ "label.required": "Абавязкова",
+ "label.reset": "Скінуць",
+ "label.reset-website": "Скінуць статыстыку",
+ "label.retention": "Утрыманне",
+ "label.retention-description": "Ацаніць прыцягальнасць сайта, адсочваючы павяртанні карыстальнікаў.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Роля",
+ "label.run-query": "Запусціць запыт",
+ "label.save": "Захаваць",
+ "label.screens": "Экраны",
+ "label.search": "Пошук",
+ "label.select": "Выбраць",
+ "label.select-date": "Выбраць дату",
+ "label.select-filter": "Выбраць фільтр",
+ "label.select-role": "Выбраць ролю",
+ "label.select-website": "Выбраць сайт",
+ "label.session": "Сесія",
+ "label.session-data": "Дадзеныя сесіі",
+ "label.sessions": "Сесіі",
+ "label.settings": "Налады",
+ "label.share": "Падзяліцца",
+ "label.share-url": "Падзяліцца спасылкай",
+ "label.single-day": "Адзін дзень",
+ "label.sms": "SMS",
+ "label.sources": "Крыніцы",
+ "label.start-step": "Першы кроку",
+ "label.steps": "Крокі",
+ "label.sum": "Сума",
+ "label.tablet": "Планшэт",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Каманда",
+ "label.team-id": "Ідэнтыфікатар каманды",
+ "label.team-manager": "Кіраўнік каманды",
+ "label.team-member": "Удзельнік каманды",
+ "label.team-name": "Назва каманды",
+ "label.team-owner": "Уласнік каманды",
+ "label.team-settings": "Налады каманды",
+ "label.team-view-only": "Толькі для каманднага прагляду",
+ "label.team-websites": "Сайты каманды",
+ "label.teams": "Каманды",
+ "label.terms": "Тэрміны",
+ "label.theme": "Тэма",
+ "label.this-month": "Гэты месяц",
+ "label.this-week": "Гэты тыдзень",
+ "label.this-year": "Гэты год",
+ "label.timezone": "Часавы пояс",
+ "label.title": "Загаловак",
+ "label.today": "Сёння",
+ "label.toggle-charts": "Пераключыць графікі",
+ "label.total": "Агульная колькасць",
+ "label.total-records": "Агульная колькасць запісаў",
+ "label.tracking-code": "Код адсочвання",
+ "label.transactions": "Transactions",
+ "label.transfer": "Перадаць",
+ "label.transfer-website": "Перадаць сайт",
+ "label.true": "Ісціна",
+ "label.type": "Тып",
+ "label.unique": "Унікальны",
+ "label.unique-visitors": "Унікальныя наведвальнікі",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Невядома",
+ "label.untitled": "Без назвы",
+ "label.update": "Абнавіць",
+ "label.user": "Карыстальнік",
+ "label.username": "Імя карыстальніка",
+ "label.users": "Карыстальнікі",
+ "label.utm": "UTM",
+ "label.utm-description": "Сачыць за кампаніямі з дапамогай UTM-метак.",
+ "label.value": "Значэнне",
+ "label.view": "Паглядзець",
+ "label.view-details": "Паглядзець дэталі",
+ "label.view-only": "Толькі прагляд",
+ "label.views": "Прагляды",
+ "label.views-per-visit": "Прагляды за наведванне",
+ "label.visit-duration": "Сярэдняя даўжыня наведвання",
+ "label.visitors": "Наведвальнікі",
+ "label.visits": "Наведванні",
+ "label.website": "Сайт",
+ "label.website-id": "Ідэнтыфікатар сайта",
+ "label.websites": "Сайты",
+ "label.window": "Вакно",
+ "label.yesterday": "Учора",
+ "message.action-confirmation": "Увядзіце {confirmation} у поле ніжэй, каб пацвердзіць.",
+ "message.active-users": "{x} цякучых {x, plural, one {наведвальнік} other {наведвальнікаў}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Сабраныя дадзеныя",
+ "message.confirm-delete": "Вы дакладна хочаце выдаліць {target}?",
+ "message.confirm-leave": "Вы дакладна хочаце пакінуць {target}?",
+ "message.confirm-remove": "Вы дакладна хочаце выдаліць {target}?",
+ "message.confirm-reset": "Вы дакладна хочаце скінуць {target} статыстыку?",
+ "message.delete-team-warning": "Выдаленне каманды таксама выдаліць усе сайты каманды.",
+ "message.delete-website-warning": "Усе асацыяваныя дадзеныя будуць таксама выдалены.",
+ "message.error": "Нешта пайшло не так.",
+ "message.event-log": "{event} на {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Да налад",
+ "message.incorrect-username-password": "Некарэктнае імя карыстальніка/пароль.",
+ "message.invalid-domain": "Некарэктны дамен",
+ "message.min-password-length": "Мінімальная даўжыня {n} знакаў",
+ "message.new-version-available": "Даступная новая версія Umami {version}!",
+ "message.no-data-available": "Няма дадзеных.",
+ "message.no-event-data": "Дадзеныя падзеі недаступныя.",
+ "message.no-match-password": "Паролі не супадаюць",
+ "message.no-results-found": "Вынікаў не знойдзена.",
+ "message.no-team-websites": "Гэтая каманда не мае ніводнага сайта.",
+ "message.no-teams": "Вы не стварылі ніводнай каманды.",
+ "message.no-users": "Няма карыстальнікаў.",
+ "message.no-websites-configured": "Вы не наладзілі ніводнага сайта.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Старонка не знойдзена.",
+ "message.reset-website": "Каб скінуць гэты сайт, увядзіце {confirmation} у поле ніжэй для пацверджання.",
+ "message.reset-website-warning": "Уся статыстыка для гэтага сайта будзе выдалена, але код адсочвання будзе працягваць працаваць.",
+ "message.saved": "Захавана паспяхова.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Гэта публічная спасылка для {target}.",
+ "message.team-already-member": "Вы ўжо ўдзельнік каманды.",
+ "message.team-not-found": "Каманда не знойдзена.",
+ "message.team-websites-info": "Сайты могуць быць праглядацца любым удзельнікам каманды.",
+ "message.tracking-code": "Код адсочвання",
+ "message.transfer-team-website-to-user": "Перадаць гэты сайт на ваш уліковы запіс?",
+ "message.transfer-user-website-to-team": "Выберыце каманду для перадачы гэтага сайта.",
+ "message.transfer-website": "Перадача сайта на ваш уліковы запіс або іншай камандзе.",
+ "message.triggered-event": "Падзея якая спрацавала",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Карыстальнік выдалены.",
+ "message.viewed-page": "Праглядзеў старонку",
+ "message.visitor-log": "Наведвальнік з {country} праз {browser} на {os} {device}"
+}
diff --git a/src/lang/bg-BG.json b/src/lang/bg-BG.json
new file mode 100644
index 0000000..4b0effc
--- /dev/null
+++ b/src/lang/bg-BG.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Код за достъп",
+ "label.actions": "Действия",
+ "label.activity": "Активностти",
+ "label.add": "Добави",
+ "label.add-board": "Добави дъска",
+ "label.add-description": "Добави описание",
+ "label.add-member": "Добави член",
+ "label.add-step": "Добави стъпка",
+ "label.add-website": "Добави уебсайт",
+ "label.admin": "Администратор",
+ "label.affiliate": "Партньор",
+ "label.after": "След",
+ "label.all": "Всички",
+ "label.all-time": "За всички времена",
+ "label.analytics": "Анализи",
+ "label.apply": "Приложи",
+ "label.attribution": "Атрибуция",
+ "label.attribution-description": "Вижте как потребителите взаимодействат с вашия маркетинг и какво води до конверсии.",
+ "label.average": "Средно",
+ "label.back": "Назад",
+ "label.before": "Преди",
+ "label.behavior": "Поведение",
+ "label.boards": "Дъски",
+ "label.bounce-rate": "Kоефициент на отказ",
+ "label.breakdown": "Разбивка",
+ "label.browser": "Браузър",
+ "label.browsers": "Браузъри",
+ "label.campaigns": "Кампании",
+ "label.cancel": "Отмени",
+ "label.change-password": "Смени парола",
+ "label.channels": "Канали",
+ "label.cities": "Градове",
+ "label.city": "Град",
+ "label.clear-all": "Изчисти всички",
+ "label.cohort": "Кохорта",
+ "label.compare": "Сравни",
+ "label.compare-dates": "Сравни дати",
+ "label.confirm": "Потвърди",
+ "label.confirm-password": "Потвърди парола",
+ "label.contains": "Съдържа",
+ "label.content": "Съдържание",
+ "label.continue": "Продължи",
+ "label.conversion": "Конверсия",
+ "label.conversion-rate": "Процент на конверсия",
+ "label.conversion-step": "Стъпка на конверсия",
+ "label.count": "Брой",
+ "label.countries": "Държави",
+ "label.country": "Държава",
+ "label.create": "Създай",
+ "label.create-report": "Създай отчет",
+ "label.create-team": "Създай екип",
+ "label.create-user": "Създай потребител",
+ "label.created": "Създадено",
+ "label.created-by": "Създадено от",
+ "label.currency": "Валута",
+ "label.current": "Текущ",
+ "label.current-password": "Текуща парола",
+ "label.custom-range": "Обхват",
+ "label.dashboard": "Табло",
+ "label.data": "Данни",
+ "label.date": "Дата",
+ "label.date-range": "Диапазон от дати",
+ "label.day": "Ден",
+ "label.default-date-range": "Диапазон от дати по подразбиране",
+ "label.delete": "Изтрий",
+ "label.delete-report": "Изтрий отчет",
+ "label.delete-team": "Изтрий екип",
+ "label.delete-user": "Изтрий потребител",
+ "label.delete-website": "Изтрий уебсайт",
+ "label.description": "Описание",
+ "label.desktop": "Десктоп",
+ "label.details": "Детайли",
+ "label.device": "Устройство",
+ "label.devices": "Устройства",
+ "label.direct": "Директно",
+ "label.dismiss": "Отхвърли",
+ "label.distinct-id": "Уникален ID",
+ "label.does-not-contain": "Не съдържа",
+ "label.does-not-include": "Не включва",
+ "label.doest-not-exist": "Не съществува",
+ "label.domain": "Домейн",
+ "label.dropoff": "Отпадане",
+ "label.edit": "Редактирай",
+ "label.edit-dashboard": "Редактирай табло",
+ "label.edit-member": "Редактирай член",
+ "label.email": "Имейл",
+ "label.enable-share-url": "Активирай Линк за споделяне",
+ "label.end-step": "Крайна стъпка",
+ "label.entry": "URL на вход",
+ "label.event": "Събитие",
+ "label.event-data": "Данни за събитие",
+ "label.event-name": "Име на събитие",
+ "label.events": "Събития",
+ "label.exists": "Съществува",
+ "label.exit": "Exit URL",
+ "label.false": "Грешно",
+ "label.field": "Поле",
+ "label.fields": "Полета",
+ "label.filter": "Филтър",
+ "label.filter-combined": "Комбиниран",
+ "label.filter-raw": "Суров",
+ "label.filters": "Филтри",
+ "label.first-click": "Първо кликване",
+ "label.first-seen": "Първо видяно",
+ "label.funnel": "Фуния",
+ "label.funnel-description": "Разберете процента на конверсия и отпадане на потребителите.",
+ "label.funnels": "Фунии",
+ "label.goal": "Цел",
+ "label.goals": "Цели",
+ "label.goals-description": "Следете целите си за прегледи на страници и събития.",
+ "label.greater-than": "По-голямо от",
+ "label.greater-than-equals": "По-голямо или равно на",
+ "label.grouped": "Групирано",
+ "label.hostname": "Име на хост",
+ "label.includes": "Включва",
+ "label.insight": "Прозрение",
+ "label.insights": "Изводи",
+ "label.insights-description": "Навлезте по-дълбоко в данните си, като използвате сегменти и филтри.",
+ "label.is": "Е",
+ "label.is-false": "Грешно",
+ "label.is-not": "Не е",
+ "label.is-not-set": "Не е зададено",
+ "label.is-set": "Зададено е",
+ "label.is-true": "Вярно",
+ "label.join": "Присъедини се",
+ "label.join-team": "Присъедини се към екип",
+ "label.journey": "Пътешествие",
+ "label.journey-description": "Разберете как потребителите навигират във вашия уебсайт.",
+ "label.journeys": "Пътешествия",
+ "label.language": "Език",
+ "label.languages": "Езици",
+ "label.laptop": "Лаптоп",
+ "label.last-click": "Последно кликване",
+ "label.last-days": "Последните {x} дни",
+ "label.last-hours": "Последните {x} часа",
+ "label.last-months": "Последните {x} месеца",
+ "label.last-seen": "Last seen",
+ "label.leave": "Напусни",
+ "label.leave-team": "Напусни екип",
+ "label.less-than": "По-малко от",
+ "label.less-than-equals": "По-малко или равно на",
+ "label.links": "Връзки",
+ "label.login": "Вход",
+ "label.logout": "Изход",
+ "label.manage": "Управлявай",
+ "label.manager": "Мениджър",
+ "label.max": "Максимум",
+ "label.maximize": "Разшири",
+ "label.medium": "Среден",
+ "label.member": "Член",
+ "label.members": "Членове",
+ "label.min": "Минимум",
+ "label.mobile": "Мобилен",
+ "label.model": "Модел",
+ "label.more": "Още",
+ "label.my-account": "Моят акаунт",
+ "label.my-websites": "Моите уебсайтове",
+ "label.name": "Име",
+ "label.new-password": "Нова парола",
+ "label.none": "Няма",
+ "label.number-of-records": "{x} {x, plural, one {един} other {други}}",
+ "label.ok": "Добре",
+ "label.online": "Online",
+ "label.organic-search": "Органично търсене",
+ "label.organic-shopping": "Органично пазаруване",
+ "label.organic-social": "Органични социални мрежи",
+ "label.organic-video": "Органично видео",
+ "label.os": "ОС",
+ "label.other": "Друго",
+ "label.overview": "Общ преглед",
+ "label.owner": "Собственик",
+ "label.page": "Страница",
+ "label.page-of": "Страница {current} от {total}",
+ "label.page-views": "Прегледи на страницата",
+ "label.pageTitle": "Заглавие на страница",
+ "label.pages": "Страници",
+ "label.paid-ads": "Платени реклами",
+ "label.paid-search": "Платено търсене",
+ "label.paid-shopping": "Платено пазаруване",
+ "label.paid-social": "Платени социални мрежи",
+ "label.paid-video": "Платено видео",
+ "label.password": "Парола",
+ "label.path": "Път",
+ "label.paths": "Пътища",
+ "label.pixels": "Пиксели",
+ "label.powered-by": "Поддържано от {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Профил",
+ "label.properties": "Свойства",
+ "label.property": "Свойство",
+ "label.queries": "Запитвания",
+ "label.query": "Запитване",
+ "label.query-parameters": "Параметри на търсене",
+ "label.realtime": "В реално време",
+ "label.referral": "Реферал",
+ "label.referrer": "Референт",
+ "label.referrers": "Референти",
+ "label.refresh": "Обнови",
+ "label.regenerate": "Регенерирай",
+ "label.region": "Регион",
+ "label.regions": "Региони",
+ "label.remaining": "Оставащи",
+ "label.remove": "Премахни",
+ "label.remove-member": "Премахни член",
+ "label.reports": "Отчети",
+ "label.required": "Задължително",
+ "label.reset": "Нулирай",
+ "label.reset-website": "Нулирай уебсайт",
+ "label.retention": "Привързване",
+ "label.retention-description": "Измерете привързаността към вашия уебсайт, като проследявате колко често потребителите се връщат.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Роля",
+ "label.run-query": "Изпълни запитване",
+ "label.save": "Запази",
+ "label.screens": "Екрани",
+ "label.search": "Търсене",
+ "label.select": "Избери",
+ "label.select-date": "Избери дата",
+ "label.select-filter": "Избери филтър",
+ "label.select-role": "Избери роля",
+ "label.select-website": "Избери уебсайт",
+ "label.session": "Сесия",
+ "label.session-data": "Данни за сесия",
+ "label.sessions": "Сесии",
+ "label.settings": "Настройки",
+ "label.share": "Сподели",
+ "label.share-url": "Сподели Линк",
+ "label.single-day": "Един ден",
+ "label.sms": "SMS",
+ "label.sources": "Източници",
+ "label.start-step": "Начална стъпка",
+ "label.steps": "Стъпки",
+ "label.sum": "Сума",
+ "label.tablet": "Таблет",
+ "label.tag": "Етикет",
+ "label.tags": "Етикети",
+ "label.team": "Екип",
+ "label.team-id": "ID на екип",
+ "label.team-manager": "Мениджър на екип",
+ "label.team-member": "Член на екипа",
+ "label.team-name": "Име на екипа",
+ "label.team-owner": "Собственик на екипа",
+ "label.team-settings": "Настройки на екипа",
+ "label.team-view-only": "Видимо само за членове на екипа",
+ "label.team-websites": "Уебсайтове на екипа",
+ "label.teams": "Екипи",
+ "label.terms": "Термини",
+ "label.theme": "Тема",
+ "label.this-month": "Този месец",
+ "label.this-week": "Тази седмица",
+ "label.this-year": "Тази година",
+ "label.timezone": "Часова зона",
+ "label.title": "Заглавие",
+ "label.today": "Днес",
+ "label.toggle-charts": "Виж диаграми",
+ "label.total": "Общо",
+ "label.total-records": "Общо записи",
+ "label.tracking-code": "Код за проследяване",
+ "label.transactions": "Transactions",
+ "label.transfer": "Прехвърли",
+ "label.transfer-website": "Прехвърляне на уебсайт",
+ "label.true": "Вярно",
+ "label.type": "Вид",
+ "label.unique": "Уникален",
+ "label.unique-visitors": "Уникални посетители",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Неизвестен",
+ "label.untitled": "Без заглавие",
+ "label.update": "Актуализирай",
+ "label.user": "Потребител",
+ "label.username": "Потребителско име",
+ "label.users": "Потребители",
+ "label.utm": "UTM",
+ "label.utm-description": "Следете кампаниите си чрез UTM параметри.",
+ "label.value": "Стойност",
+ "label.view": "Преглед",
+ "label.view-details": "Преглед на детайлите",
+ "label.view-only": "Само за преглед",
+ "label.views": "Прегледи",
+ "label.views-per-visit": "Прегледи на посещение",
+ "label.visit-duration": "Visit duration",
+ "label.visitors": "Посетители",
+ "label.visits": "Посещения",
+ "label.website": "Уебсайт",
+ "label.website-id": "Идентификатор на уебсайт",
+ "label.websites": "Уебсайтове",
+ "label.window": "Прозорец",
+ "label.yesterday": "Вчера",
+ "message.action-confirmation": "Въведете {confirmation} в полето по-долу, за да потвърдите.",
+ "message.active-users": "{x} {x, plural, one {активен един} other {активни други}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Сигурни ли сте, че искате да изтриете {target}?",
+ "message.confirm-leave": "Сигурни ли сте, че искате да напуснете {target}?",
+ "message.confirm-remove": "Сигурни ли сте, че искате да премахнете {target}?",
+ "message.confirm-reset": "Сигурни ли сте, че искате да нулирате {target}?",
+ "message.delete-team-warning": "Изтриването на екип ще изтрие и всички уебсайтове създадени от екипа.",
+ "message.delete-website-warning": "Всички данни за уебсайта ще бъдат изтрити.",
+ "message.error": "Възникна грешка.",
+ "message.event-log": "{event} на {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Отидете в настройките",
+ "message.incorrect-username-password": "Неправилно потребителско име и/или парола.",
+ "message.invalid-domain": "Невалиден домейн. Не включвайте http/https.",
+ "message.min-password-length": "Минимална дължина от {n} символа",
+ "message.new-version-available": "Има нова версия на Umami {version}!",
+ "message.no-data-available": "Няма налични данни.",
+ "message.no-event-data": "Няма налични данни за събитие.",
+ "message.no-match-password": "Паролите не съвпадат.",
+ "message.no-results-found": "Няма намерени резултати.",
+ "message.no-team-websites": "Този екип няма никакви уебсайтове.",
+ "message.no-teams": "Няма създадени екипи.",
+ "message.no-users": "Няма потребители.",
+ "message.no-websites-configured": "Нямате конфигурирани уебсайтове.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Страницата не е намерена",
+ "message.reset-website": "За да нулирате този уебсайт, въведете {confirmation} в полето по-долу, за да потвърдите.",
+ "message.reset-website-warning": "Всички статистически данни за този уебсайт ще бъдат изтрити, но вашите настройки ще останат непроменени.",
+ "message.saved": "Запазено.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Статистиката за вашия уебсайт е публично достъпна на следния URL адрес:",
+ "message.team-already-member": "Вече сте член на екипа.",
+ "message.team-not-found": "Екипът не е намерен.",
+ "message.team-websites-info": "Уебсайтовете могат да бъдат преглеждани от всеки член на екипа.",
+ "message.tracking-code": "За активирате проследяването на статистиката във вашият уебсайт, поставете следния код в секцията <head>...</head> намираща се в вашия HTML.",
+ "message.transfer-team-website-to-user": "Искате да прехвърлите този уебсайт към вашия акаунт?",
+ "message.transfer-user-website-to-team": "Изберете екипът на който да бъде прехвърлен уебсайта.",
+ "message.transfer-website": "Прехвърли собствеността на уебсайта към вашия акаунт или към друг екип.",
+ "message.triggered-event": "Активирано събитие",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Потребителят е изтрит.",
+ "message.viewed-page": "Страницата е видяна",
+ "message.visitor-log": "Посетител от {country}, използващ {browser} на {os} {device}"
+}
diff --git a/src/lang/bn-BD.json b/src/lang/bn-BD.json
new file mode 100644
index 0000000..9b9ad2f
--- /dev/null
+++ b/src/lang/bn-BD.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "এক্সেস কোড",
+ "label.actions": "অ্যাকশনস",
+ "label.activity": "একটিভিটি দেখুন",
+ "label.add": "যুক্ত করুন",
+ "label.add-board": "বোর্ড যুক্ত করুন",
+ "label.add-description": "বর্ননা যোগ করুন",
+ "label.add-member": "সদস্য যোগ করুন",
+ "label.add-step": "পদ যোগ করুন",
+ "label.add-website": "ওয়েবসাইট যুক্ত করুন",
+ "label.admin": "অ্যাডমিন",
+ "label.affiliate": "সহযোগী",
+ "label.after": "পরে",
+ "label.all": "সবগুলো",
+ "label.all-time": "সব সময়",
+ "label.analytics": "বিশ্লেষণ",
+ "label.apply": "প্রয়োগ করুন",
+ "label.attribution": "অ্যাট্রিবিউশন",
+ "label.attribution-description": "দেখুন ব্যবহারকারীরা কীভাবে আপনার মার্কেটিংয়ের সাথে যুক্ত হয় এবং কীভাবে রূপান্তর ঘটে।",
+ "label.average": "গড়",
+ "label.back": "পেছনে",
+ "label.before": "পূর্বে",
+ "label.behavior": "আচরণ",
+ "label.boards": "বোর্ডসমূহ",
+ "label.bounce-rate": "উপরে উঠার হার",
+ "label.breakdown": "ভাঙ্গন",
+ "label.browser": "ব্রাউজার",
+ "label.browsers": "ব্রাউজার সমূহ",
+ "label.campaigns": "প্রচারণা",
+ "label.cancel": "বাতিল",
+ "label.change-password": "পাসওয়ার্ড পরিবর্তন করুন",
+ "label.channels": "চ্যানেলসমূহ",
+ "label.cities": "শহরসমূহ",
+ "label.city": "শহর",
+ "label.clear-all": "সব মুছে ফেলুন",
+ "label.cohort": "কোহর্ট",
+ "label.compare": "তুলনা করুন",
+ "label.compare-dates": "তারিখ তুলনা করুন",
+ "label.confirm": "নিশ্চিত করুন",
+ "label.confirm-password": "পাসওয়ার্ড নিশ্চিত করুন",
+ "label.contains": "রয়েছে",
+ "label.content": "বিষয়বস্তু",
+ "label.continue": "পরবর্তিতে",
+ "label.conversion": "রূপান্তর",
+ "label.conversion-rate": "রূপান্তর হার",
+ "label.conversion-step": "রূপান্তর ধাপ",
+ "label.count": "গণনা",
+ "label.countries": "দেশসমূহ",
+ "label.country": "দেশ",
+ "label.create": "তৈরি করুন",
+ "label.create-report": "রিপোর্ট তৈরি করুন",
+ "label.create-team": "দল তৈরি করুন",
+ "label.create-user": "ব্যবহারকারী তৈরি করুন",
+ "label.created": "তৈরি করা হয়েছে",
+ "label.created-by": "তৈরি করেছেন",
+ "label.currency": "মুদ্রা",
+ "label.current": "বর্তমান",
+ "label.current-password": "বর্তমান পাসওয়ার্ড",
+ "label.custom-range": "কাস্টম রেঞ্জ",
+ "label.dashboard": "ড্যাশবোর্ড",
+ "label.data": "ডেটা",
+ "label.date": "তারিখ",
+ "label.date-range": "তারিখের পরিসীমা",
+ "label.day": "দিন",
+ "label.default-date-range": "ডিফল্ট তারিখের পরিসীমা",
+ "label.delete": "মুছে ফেলুন",
+ "label.delete-report": "রিপোর্ট মুছুন",
+ "label.delete-team": "দল মুছুন",
+ "label.delete-user": "ব্যবহারকারী মুছুন",
+ "label.delete-website": "ওয়েবসাইট মুছুন",
+ "label.description": "বর্ণনা",
+ "label.desktop": "ডেস্কটপ",
+ "label.details": "বিস্তারিত",
+ "label.device": "ডিভাইস",
+ "label.devices": "ডিভাইস গুলো",
+ "label.direct": "সরাসরি",
+ "label.dismiss": "বাতিল",
+ "label.distinct-id": "স্বতন্ত্র আইডি",
+ "label.does-not-contain": "ধারণ করে না",
+ "label.does-not-include": "অন্তর্ভুক্ত নয়",
+ "label.doest-not-exist": "অস্তিত্ব নেই",
+ "label.domain": "ডোমেইন",
+ "label.dropoff": "ছেড়ে যাওয়া",
+ "label.edit": "সম্পাদনা করুন",
+ "label.edit-dashboard": "ড্যাশবোর্ড সম্পাদনা করুন",
+ "label.edit-member": "সদস্য সম্পাদনা করুন",
+ "label.email": "Email",
+ "label.enable-share-url": "শেয়ার ইউআরএল শেয়ার করুন",
+ "label.end-step": "শেষ ধাপ",
+ "label.entry": "প্রবেশ URL",
+ "label.event": "ইভেন্ট",
+ "label.event-data": "ইভেন্ট ডেটা",
+ "label.event-name": "ইভেন্টের নাম",
+ "label.events": "ঘটনা",
+ "label.exists": "অস্তিত্ব আছে",
+ "label.exit": "প্রস্থান URL",
+ "label.false": "মিথ্যা",
+ "label.field": "ক্ষেত্র",
+ "label.fields": "ক্ষেত্রসমূহ",
+ "label.filter": "ফিল্টার",
+ "label.filter-combined": "সম্মিলিত",
+ "label.filter-raw": "অপরিশোধিত",
+ "label.filters": "ফিল্টারসমূহ",
+ "label.first-click": "প্রথম ক্লিক",
+ "label.first-seen": "প্রথম দেখা",
+ "label.funnel": "ফানেল",
+ "label.funnel-description": "ব্যবহারকারীদের রূপান্তর ও ছেড়ে যাওয়ার হার বুঝুন।",
+ "label.funnels": "ফানেলসমূহ",
+ "label.goal": "লক্ষ্য",
+ "label.goals": "লক্ষ্যসমূহ",
+ "label.goals-description": "পৃষ্ঠাদর্শন ও ইভেন্টের লক্ষ্য ট্র্যাক করুন।",
+ "label.greater-than": "এর চেয়ে বেশি",
+ "label.greater-than-equals": "এর চেয়ে বেশি বা সমান",
+ "label.grouped": "গ্রুপ করা",
+ "label.hostname": "হোস্টনেম",
+ "label.includes": "অন্তর্ভুক্ত",
+ "label.insight": "অন্তর্দৃষ্টি",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "হয়",
+ "label.is-false": "মিথ্যা",
+ "label.is-not": "নয়",
+ "label.is-not-set": "নির্ধারিত নয়",
+ "label.is-set": "নির্ধারিত",
+ "label.is-true": "সত্য",
+ "label.join": "যোগ দিন",
+ "label.join-team": "দলে যোগ দিন",
+ "label.journey": "যাত্রা",
+ "label.journey-description": "ব্যবহারকারীরা কীভাবে আপনার ওয়েবসাইটে নেভিগেট করে তা বুঝুন।",
+ "label.journeys": "যাত্রাসমূহ",
+ "label.language": "ভাষা",
+ "label.languages": "ভাষা",
+ "label.laptop": "ল্যাপটপ",
+ "label.last-click": "শেষ ক্লিক",
+ "label.last-days": "শেষ {x} দিন",
+ "label.last-hours": "শেষ {x} ঘন্টা",
+ "label.last-months": "শেষ {x} মাস",
+ "label.last-seen": "শেষ দেখা",
+ "label.leave": "ত্যাগ করুন",
+ "label.leave-team": "দল ত্যাগ করুন",
+ "label.less-than": "এর চেয়ে কম",
+ "label.less-than-equals": "এর চেয়ে কম বা সমান",
+ "label.links": "লিঙ্কসমূহ",
+ "label.login": "লগিন",
+ "label.logout": "লগ আউট",
+ "label.manage": "পরিচালনা করুন",
+ "label.manager": "পরিচালক",
+ "label.max": "সর্বাধিক",
+ "label.maximize": "বিস্তৃত করুন",
+ "label.medium": "মাঝারি",
+ "label.member": "সদস্য",
+ "label.members": "সদস্যগণ",
+ "label.min": "সর্বনিম্ন",
+ "label.mobile": "মুঠোফোন",
+ "label.model": "মডেল",
+ "label.more": "আরও",
+ "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.online": "Online",
+ "label.organic-search": "অর্গানিক সার্চ",
+ "label.organic-shopping": "অর্গানিক শপিং",
+ "label.organic-social": "অর্গানিক সোশ্যাল",
+ "label.organic-video": "অর্গানিক ভিডিও",
+ "label.os": "OS",
+ "label.other": "অন্যান্য",
+ "label.overview": "Overview",
+ "label.owner": "মালিক",
+ "label.page": "পৃষ্ঠা",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "পৃষ্ঠা পরিদর্শন গুলো",
+ "label.pageTitle": "Page title",
+ "label.pages": "পৃষ্ঠাগুলি",
+ "label.paid-ads": "পেইড বিজ্ঞাপন",
+ "label.paid-search": "পেইড সার্চ",
+ "label.paid-shopping": "পেইড শপিং",
+ "label.paid-social": "পেইড সোশ্যাল",
+ "label.paid-video": "পেইড ভিডিও",
+ "label.password": "পাসওয়ার্ড",
+ "label.path": "পথ",
+ "label.paths": "পথসমূহ",
+ "label.pixels": "পিক্সেল",
+ "label.powered-by": "{name} দ্বারা চালিত",
+ "label.previous": "পূর্ববর্তী",
+ "label.previous-period": "পূর্ববর্তী সময়কাল",
+ "label.previous-year": "গত বছর",
+ "label.profile": "প্রোফাইল",
+ "label.properties": "বৈশিষ্ট্যসমূহ",
+ "label.property": "বৈশিষ্ট্য",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "সরাসরি",
+ "label.referral": "রেফারেল",
+ "label.referrer": "Referrer",
+ "label.referrers": "রেফারার্স",
+ "label.refresh": "রিফ্রেশ",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "বাকি আছে",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "প্রয়োজনীয়",
+ "label.reset": "রিসেট",
+ "label.reset-website": "ওয়েবসাইট রিসেট করুন",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "আয়",
+ "label.revenue-description": "সময়ের সাথে সাথে আপনার আয় দেখুন।",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "সংরক্ষণ",
+ "label.screens": "স্ক্রিনগুলি",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "ফিল্টার নির্বাচন করুন",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "সেশন",
+ "label.session-data": "সেশন ডেটা",
+ "label.sessions": "Sessions",
+ "label.settings": "সেটিংস",
+ "label.share": "শেয়ার করুন",
+ "label.share-url": "এটি {target} এর জন্য প্রকাশ্যে শেয়ার করার ইউআরএল।",
+ "label.single-day": "একদিন",
+ "label.sms": "SMS",
+ "label.sources": "উৎসসমূহ",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "ট্যাবলেট",
+ "label.tag": "ট্যাগ",
+ "label.tags": "ট্যাগসমূহ",
+ "label.team": "দল",
+ "label.team-id": "দল আইডি",
+ "label.team-manager": "দল ব্যবস্থাপক",
+ "label.team-member": "দলের সদস্য",
+ "label.team-name": "দলের নাম",
+ "label.team-owner": "দলের মালিক",
+ "label.team-settings": "দলের সেটিংস",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "শর্তাবলী",
+ "label.theme": "থিম",
+ "label.this-month": "এই মাস",
+ "label.this-week": "এই সপ্তাহ",
+ "label.this-year": "এই বছর",
+ "label.timezone": "সময়স্থান",
+ "label.title": "Title",
+ "label.today": "আজ",
+ "label.toggle-charts": "চার্ট পরিবর্তন করুন",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "ট্র্যাকিং কোড",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "অনন্য ভিজিটর",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "অজানা",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "ব্যবহারকারীর নাম",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "বিস্তারিত দেখুন",
+ "label.view-only": "View only",
+ "label.views": "ভিউস",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "গড় পরিদর্শনের সময়",
+ "label.visitors": "পরিদর্শনার্থী",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "সবগুলো ওয়েবসাইট",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} বর্তমান {x, plural, one {visitor} other {visitors}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "আপনি কি নিশ্চিত যে আপনি {target} মুছতে চান?",
+ "message.confirm-leave": "Are you sure you want to 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.delete-website-warning": "সমস্ত সম্পর্কিত ডেটা পাশাপাশি মুছে ফেলা হবে।",
+ "message.error": "কিছু ভুল হয়েছে।",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "সেটিংস এ যান",
+ "message.incorrect-username-password": "ভুল ব্যবহারকারীর নাম/পাসওয়ার্ড।",
+ "message.invalid-domain": "ভুল ডোমেন",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "কোন তথ্য নেই।",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "পাসওয়ার্ড মেলে না",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "কোনও ওয়েবসাইট কনফিগার করা নেই।",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "পৃষ্ঠা খুঁজে পাওয়া যায়নি।",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "এই ওয়েবসাইটের সমস্ত পরিসংখ্যান মুছে ফেলা হবে, তবে আপনার ট্র্যাকিং কোডটি অক্ষত থাকবে।",
+ "message.saved": "সংরক্ষিত হয়েছে।",
+ "message.sever-error": "Server error",
+ "message.share-url": "এটি {target} এর জন্য প্রকাশ্যে শেয়ার করার ইউআরএল।",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "{country} থেকে একজন ভিসিটর {ব্রাউজার}, ব্যবহার করছেন {os} {device} এর মধ্যে।"
+}
diff --git a/src/lang/bs-BA.json b/src/lang/bs-BA.json
new file mode 100644
index 0000000..5684877
--- /dev/null
+++ b/src/lang/bs-BA.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Pristupni kod",
+ "label.actions": "Akcije",
+ "label.activity": "Log aktivnosti",
+ "label.add": "Dodaj",
+ "label.add-board": "Dodaj ploču",
+ "label.add-description": "Dodaj opis",
+ "label.add-member": "Dodaj člana",
+ "label.add-step": "Dodaj korak",
+ "label.add-website": "Dodaj web stranicu",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partner",
+ "label.after": "Nakon",
+ "label.all": "Sve",
+ "label.all-time": "Cijelo vrijeme",
+ "label.analytics": "Analitike",
+ "label.apply": "Primijeni",
+ "label.attribution": "Atribucija",
+ "label.attribution-description": "Pogledajte kako korisnici komuniciraju s vašim marketingom i šta dovodi do konverzija.",
+ "label.average": "Prosjek",
+ "label.back": "Nazad",
+ "label.before": "Prije",
+ "label.behavior": "Ponašanje",
+ "label.boards": "Ploče",
+ "label.bounce-rate": "Stopa napuštanja",
+ "label.breakdown": "Pregled po kategorijama",
+ "label.browser": "Browser",
+ "label.browsers": "Browseri",
+ "label.campaigns": "Kampanje",
+ "label.cancel": "Otkaži",
+ "label.change-password": "Promijeni šifru",
+ "label.channels": "Kanali",
+ "label.cities": "Gradovi",
+ "label.city": "Grad",
+ "label.clear-all": "Očisti sve",
+ "label.cohort": "Kohorta",
+ "label.compare": "Uporedi",
+ "label.compare-dates": "Uporedi datume",
+ "label.confirm": "Potvrdi",
+ "label.confirm-password": "Potvrdi šifru",
+ "label.contains": "Sadrži",
+ "label.content": "Sadržaj",
+ "label.continue": "Nastavi",
+ "label.conversion": "Konverzija",
+ "label.conversion-rate": "Stopa konverzije",
+ "label.conversion-step": "Korak konverzije",
+ "label.count": "Broj",
+ "label.countries": "Zemlje",
+ "label.country": "Zemlja",
+ "label.create": "Kreiraj",
+ "label.create-report": "Kreiraj izvještaj",
+ "label.create-team": "Kreiraj tim",
+ "label.create-user": "Kreiraj korisnika",
+ "label.created": "Kreiraj",
+ "label.created-by": "Kreirao",
+ "label.currency": "Valuta",
+ "label.current": "Trenutno",
+ "label.current-password": "Trenutna šifra",
+ "label.custom-range": "Proizvoljni raspon",
+ "label.dashboard": "Dashboard",
+ "label.data": "Podaci",
+ "label.date": "Datum",
+ "label.date-range": "Datumski raspon",
+ "label.day": "Dan",
+ "label.default-date-range": "Defaultni datumski raspon",
+ "label.delete": "Izbriši",
+ "label.delete-report": "Izbriši report",
+ "label.delete-team": "Izbriši tim",
+ "label.delete-user": "Izbriši korisnika",
+ "label.delete-website": "Izbriši web stranicu",
+ "label.description": "Opis",
+ "label.desktop": "Desktop",
+ "label.details": "Detalji",
+ "label.device": "Uređaj",
+ "label.devices": "Uređaji",
+ "label.direct": "Direktno",
+ "label.dismiss": "Odbaci",
+ "label.distinct-id": "Jedinstveni ID",
+ "label.does-not-contain": "Ne sadrži",
+ "label.does-not-include": "Ne uključuje",
+ "label.doest-not-exist": "Ne postoji",
+ "label.domain": "Domena",
+ "label.dropoff": "Odlazak",
+ "label.edit": "Uredi",
+ "label.edit-dashboard": "Uredi dashboard",
+ "label.edit-member": "Uredi člana",
+ "label.email": "E-mail",
+ "label.enable-share-url": "Omogući URL za dijeljenje",
+ "label.end-step": "Završni korak",
+ "label.entry": "URL ulaza",
+ "label.event": "Događaj",
+ "label.event-data": "Podaci o događaju",
+ "label.event-name": "Naziv događaja",
+ "label.events": "Događaji",
+ "label.exists": "Postoji",
+ "label.exit": "Exit URL",
+ "label.false": "Ne",
+ "label.field": "Polje",
+ "label.fields": "Polja",
+ "label.filter": "Filter",
+ "label.filter-combined": "Kombinovano",
+ "label.filter-raw": "Sirovo",
+ "label.filters": "Filtri",
+ "label.first-click": "Prvi klik",
+ "label.first-seen": "Prvi put viđeno",
+ "label.funnel": "Lijevak",
+ "label.funnel-description": "Razumite koverziju i drop-off učestalost korisnika.",
+ "label.funnels": "Lijevci",
+ "label.goal": "Cilj",
+ "label.goals": "Ciljevi",
+ "label.goals-description": "Pratite svoje ciljeve za prikaze stranica i događaje.",
+ "label.greater-than": "Veće od",
+ "label.greater-than-equals": "Veće od ili jednako",
+ "label.grouped": "Grupisano",
+ "label.hostname": "Naziv hosta",
+ "label.includes": "Uključuje",
+ "label.insight": "Uvid",
+ "label.insights": "Uvidi",
+ "label.insights-description": "Zaronite dublje u vaše podatke korištenjem segmenata i filtera",
+ "label.is": "Jeste",
+ "label.is-false": "Nije tačno",
+ "label.is-not": "Nije",
+ "label.is-not-set": "Nije setano",
+ "label.is-set": "Jeste setano",
+ "label.is-true": "Tačno",
+ "label.join": "Učlani se",
+ "label.join-team": "Učlani se u tim",
+ "label.journey": "Putovanje",
+ "label.journey-description": "Saznajte kako korisnici navigiraju vašom web stranicom.",
+ "label.journeys": "Putovanja",
+ "label.language": "Jezik",
+ "label.languages": "Jezici",
+ "label.laptop": "Laptop",
+ "label.last-click": "Zadnji klik",
+ "label.last-days": "Zadnjih {x} dana",
+ "label.last-hours": "Zadnjih {x} sati",
+ "label.last-months": "Zadnjih {x} mjeseci",
+ "label.last-seen": "Last seen",
+ "label.leave": "Napusti",
+ "label.leave-team": "Napusti tim",
+ "label.less-than": "Manje od",
+ "label.less-than-equals": "Manje od ili jednako",
+ "label.links": "Linkovi",
+ "label.login": "Login",
+ "label.logout": "Logout",
+ "label.manage": "Manage",
+ "label.manager": "Menadžer",
+ "label.max": "Max",
+ "label.maximize": "Proširi",
+ "label.medium": "Srednje",
+ "label.member": "Član",
+ "label.members": "Članovi",
+ "label.min": "Min",
+ "label.mobile": "Mobile",
+ "label.model": "Model",
+ "label.more": "Više",
+ "label.my-account": "Moj račun",
+ "label.my-websites": "Moje web stranice",
+ "label.name": "Ime",
+ "label.new-password": "Nova šifra",
+ "label.none": "Nijedno",
+ "label.number-of-records": "{x} {x, plural, one {zapis} other {zapisa}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organska pretraga",
+ "label.organic-shopping": "Organska kupovina",
+ "label.organic-social": "Organske društvene mreže",
+ "label.organic-video": "Organski video",
+ "label.os": "OS",
+ "label.other": "Drugo",
+ "label.overview": "Pregled",
+ "label.owner": "Vlasnik",
+ "label.page": "Stranica",
+ "label.page-of": "Strana {current} od {total}",
+ "label.page-views": "Pregleda stranica",
+ "label.pageTitle": "Naslov stranice",
+ "label.pages": "Stranice",
+ "label.paid-ads": "Plaćeni oglasi",
+ "label.paid-search": "Plaćena pretraga",
+ "label.paid-shopping": "Plaćena kupovina",
+ "label.paid-social": "Plaćene društvene mreže",
+ "label.paid-video": "Plaćeni video",
+ "label.password": "Šifra",
+ "label.path": "Putanja",
+ "label.paths": "Putanje",
+ "label.pixels": "Pikseli",
+ "label.powered-by": "Omogućeno s {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profil",
+ "label.properties": "Svojstva",
+ "label.property": "Svojstvo",
+ "label.queries": "Upiti",
+ "label.query": "Upit",
+ "label.query-parameters": "Parametri upita",
+ "label.realtime": "Realno vrijeme",
+ "label.referral": "Preporuka",
+ "label.referrer": "Preporučilac",
+ "label.referrers": "Preporučioci",
+ "label.refresh": "Osvježi",
+ "label.regenerate": "Regeneriši",
+ "label.region": "Region",
+ "label.regions": "Regioni",
+ "label.remaining": "Preostalo",
+ "label.remove": "Ukloni",
+ "label.remove-member": "Ukloni člana",
+ "label.reports": "Izvještaji",
+ "label.required": "Obavezno",
+ "label.reset": "Resetuj",
+ "label.reset-website": "Resetuj web stranicu",
+ "label.retention": "Zadržavanje",
+ "label.retention-description": "Izmjeri 'ljepljivost' svoje web stranice praćenjem koliko često set korisnici vraćaju.",
+ "label.revenue": "Prihod",
+ "label.revenue-description": "Pogledajte svoje prihode tokom vremena.",
+ "label.role": "Rola",
+ "label.run-query": "Pokreni query",
+ "label.save": "Sačuvaj",
+ "label.screens": "Ekrani",
+ "label.search": "Traži",
+ "label.select": "Odaberi",
+ "label.select-date": "Odaberi datum",
+ "label.select-filter": "Odaberi filter",
+ "label.select-role": "Odaberi rolu",
+ "label.select-website": "Odaberi web stranicu",
+ "label.session": "Sesija",
+ "label.session-data": "Podaci o sesiji",
+ "label.sessions": "Sesije",
+ "label.settings": "Postavke",
+ "label.share": "Podijeli",
+ "label.share-url": "URL za dijeljenje",
+ "label.single-day": "Jedan dan",
+ "label.sms": "SMS",
+ "label.sources": "Izvori",
+ "label.start-step": "Početni korak",
+ "label.steps": "Koraci",
+ "label.sum": "Suma",
+ "label.tablet": "Tablet",
+ "label.tag": "Oznaka",
+ "label.tags": "Oznake",
+ "label.team": "Tim",
+ "label.team-id": "Tim ID",
+ "label.team-manager": "Menadžer tima",
+ "label.team-member": "Član tima",
+ "label.team-name": "Naziv tima",
+ "label.team-owner": "Vlasnik tima",
+ "label.team-settings": "Postavke tima",
+ "label.team-view-only": "Samo tim može vidjeti",
+ "label.team-websites": "Timske web stranice",
+ "label.teams": "Timovi",
+ "label.terms": "Pojmovi",
+ "label.theme": "Teme",
+ "label.this-month": "Ovaj mjesec",
+ "label.this-week": "Ova sedmica",
+ "label.this-year": "Ova godina",
+ "label.timezone": "Vremenska zona",
+ "label.title": "Naslov",
+ "label.today": "Danas",
+ "label.toggle-charts": "Uklj/isklj grafikone",
+ "label.total": "Ukupno",
+ "label.total-records": "Ukupno redova",
+ "label.tracking-code": "Kod za praćenje",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer web stranice",
+ "label.true": "Da",
+ "label.type": "Tip",
+ "label.unique": "Jedinstveno",
+ "label.unique-visitors": "Jedinstvenih posjetitelja",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Nepoznato",
+ "label.untitled": "Bezimeno",
+ "label.update": "Update",
+ "label.user": "Korisnik",
+ "label.username": "Korisničko ime",
+ "label.users": "Korisnici",
+ "label.utm": "UTM",
+ "label.utm-description": "Pratite vaše kampanje kroz UTM parametre.",
+ "label.value": "Vrijednost",
+ "label.view": "Pregled",
+ "label.view-details": "Pogledaj detalje",
+ "label.view-only": "Samo gledanje",
+ "label.views": "Pregledi",
+ "label.views-per-visit": "Pregledi po posjeti",
+ "label.visit-duration": "Prosječno vrijeme posjete",
+ "label.visitors": "Posjetitelji",
+ "label.visits": "Posjete",
+ "label.website": "Web stranica",
+ "label.website-id": "ID web stranice",
+ "label.websites": "Web stranice",
+ "label.window": "Prozor",
+ "label.yesterday": "Jučer",
+ "message.action-confirmation": "Unesite {confirmation} ispod da potvrdite.",
+ "message.active-users": "{x} trenutno {x, plural, one {posjetitelj} other {posjetitelja}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Jeste li sigurni da želite obrisati {target}?",
+ "message.confirm-leave": "Jeste li sigurni da želite napustiti {target}?",
+ "message.confirm-remove": "Jeste li sigurni da želite ukloniti {target}?",
+ "message.confirm-reset": "Jeste li sigurni da želite resetovati {target}?",
+ "message.delete-team-warning": "Brisanje tima će također obrisati sve web stranice tima.",
+ "message.delete-website-warning": "Svi podaci web stranice biće obrisani.",
+ "message.error": "Nešto je pošlo po zlu.",
+ "message.event-log": "{event} na {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Idi na postavke",
+ "message.incorrect-username-password": "Pogrešno korisničko ime i/ili šifra.",
+ "message.invalid-domain": "Nevalidna domena. Ne uključujte http/https.",
+ "message.min-password-length": "Minimalna dužina od {n} karaktera",
+ "message.new-version-available": "Nova verzija Umami {version} je dostupna!",
+ "message.no-data-available": "Nema dostupnih podataka.",
+ "message.no-event-data": "Nema dostupnih podataka o događajima.",
+ "message.no-match-password": "Šifre se ne poklapaju.",
+ "message.no-results-found": "Nema rezultata.",
+ "message.no-team-websites": "Ovaj tim nema nikakvih web stranica.",
+ "message.no-teams": "Niste kreirali nijedan tim.",
+ "message.no-users": "Nema nikakvih korisnika.",
+ "message.no-websites-configured": "Nemate iskonfigurisanu nijednu web stranicu.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Stranica nije pronađena",
+ "message.reset-website": "Da resetujete ovu web stranicu, upišite {confirmation} dole da potvrdite.",
+ "message.reset-website-warning": "Sve statistike o ovoj web stranici će biti obrisane, ali vaše postavke neće biti dirane.",
+ "message.saved": "Sačuvano.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Statistike vaše web stranice su javno dostupne na sljedećem URLu:",
+ "message.team-already-member": "Već ste član tima.",
+ "message.team-not-found": "Tim nije pronađen.",
+ "message.team-websites-info": "Web stranice može vidjeti bilo ko iz tima.",
+ "message.tracking-code": "Da pratite statistike ove web stranice, stavite sljedeći kod u <head>...</head> sekciju vašeg HTMLa.",
+ "message.transfer-team-website-to-user": "Prebacite ovu web stranicu na vaš račun?",
+ "message.transfer-user-website-to-team": "Odaberite tim u koji želite prebaciti ovu web stranicu.",
+ "message.transfer-website": "Prebacite vlasništvo web stranice na vaš račun ili drugi tim.",
+ "message.triggered-event": "Trigerovani događaj",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Korisnik obrisan.",
+ "message.viewed-page": "Pogledana stranica",
+ "message.visitor-log": "Posjetitelj iz {country} koristi {browser} na {os} {device}"
+}
diff --git a/src/lang/ca-ES.json b/src/lang/ca-ES.json
new file mode 100644
index 0000000..ab5444c
--- /dev/null
+++ b/src/lang/ca-ES.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Codi d'accés",
+ "label.actions": "Accions",
+ "label.activity": "Registre d'activitat",
+ "label.add": "Afegir",
+ "label.add-board": "Afegir tauler",
+ "label.add-description": "Afegir descripció",
+ "label.add-member": "Afegir membre",
+ "label.add-step": "Afegir pas",
+ "label.add-website": "Afegir lloc web",
+ "label.admin": "Administrador",
+ "label.affiliate": "Afiliat",
+ "label.after": "Després",
+ "label.all": "Tots",
+ "label.all-time": "Sempre",
+ "label.analytics": "Analítiques",
+ "label.apply": "Aplica",
+ "label.attribution": "Atribució",
+ "label.attribution-description": "Vegeu com els usuaris interactuen amb el vostre màrqueting i què impulsa les conversions.",
+ "label.average": "Mitjana",
+ "label.back": "Enrere",
+ "label.before": "Abans",
+ "label.behavior": "Comportament",
+ "label.boards": "Taulers",
+ "label.bounce-rate": "Percentatge de rebot",
+ "label.breakdown": "Desglossament",
+ "label.browser": "Navegador",
+ "label.browsers": "Navegadors",
+ "label.campaigns": "Campanyes",
+ "label.cancel": "Cancel·la",
+ "label.change-password": "Canvia la contrasenya",
+ "label.channels": "Canals",
+ "label.cities": "Ciutats",
+ "label.city": "Ciutat",
+ "label.clear-all": "Netejar tot",
+ "label.cohort": "Cohort",
+ "label.compare": "Comparar",
+ "label.compare-dates": "Comparar dates",
+ "label.confirm": "Confirmar",
+ "label.confirm-password": "Confirma la contrasenya",
+ "label.contains": "Conté",
+ "label.content": "Contingut",
+ "label.continue": "Continuar",
+ "label.conversion": "Conversió",
+ "label.conversion-rate": "Taxa de conversió",
+ "label.conversion-step": "Pas de conversió",
+ "label.count": "Recompte",
+ "label.countries": "Països",
+ "label.country": "País",
+ "label.create": "Crear",
+ "label.create-report": "Crear informe",
+ "label.create-team": "Crear equip",
+ "label.create-user": "Crear usuari",
+ "label.created": "Creat",
+ "label.created-by": "Creat Per",
+ "label.currency": "Moneda",
+ "label.current": "Actual",
+ "label.current-password": "Contrasenya actual",
+ "label.custom-range": "Rang personalitzat",
+ "label.dashboard": "Panell",
+ "label.data": "Dades",
+ "label.date": "Data",
+ "label.date-range": "Interval de dates",
+ "label.day": "Dia",
+ "label.default-date-range": "Interval de dates per defecte",
+ "label.delete": "Esborra",
+ "label.delete-report": "Eliminar informe",
+ "label.delete-team": "Eliminar equip",
+ "label.delete-user": "Eliminar usuari",
+ "label.delete-website": "Esborra el lloc web",
+ "label.description": "Descripció",
+ "label.desktop": "Escriptori",
+ "label.details": "Detalls",
+ "label.device": "Dispositiu",
+ "label.devices": "Dispositius",
+ "label.direct": "Directe",
+ "label.dismiss": "Descarta",
+ "label.distinct-id": "ID distintiu",
+ "label.does-not-contain": "No conté",
+ "label.does-not-include": "No inclou",
+ "label.doest-not-exist": "No existeix",
+ "label.domain": "Domini",
+ "label.dropoff": "Abandonament",
+ "label.edit": "Edita",
+ "label.edit-dashboard": "Edita panell",
+ "label.edit-member": "Edita membre",
+ "label.email": "Email",
+ "label.enable-share-url": "Activa l'enllaç per compartir",
+ "label.end-step": "Pas Final",
+ "label.entry": "URL d'entrada",
+ "label.event": "Esdeveniment",
+ "label.event-data": "Dades de l'esdeveniment",
+ "label.event-name": "Nom de l'esdeveniment",
+ "label.events": "Esdeveniments",
+ "label.exists": "Existeix",
+ "label.exit": "URL de sortida",
+ "label.false": "Fals",
+ "label.field": "Camp",
+ "label.fields": "Camps",
+ "label.filter": "Filtre",
+ "label.filter-combined": "Combinat",
+ "label.filter-raw": "En cru",
+ "label.filters": "Filtres",
+ "label.first-click": "Primer clic",
+ "label.first-seen": "Vist per primer cop",
+ "label.funnel": "Embut",
+ "label.funnel-description": "Entengui la taxa de conversió i abandonament dels usuaris.",
+ "label.funnels": "Embuts",
+ "label.goal": "Meta",
+ "label.goals": "Metes",
+ "label.goals-description": "Feu un seguiment de les seves metes per a pàgines vistes i esdeveniments.",
+ "label.greater-than": "Més gran que",
+ "label.greater-than-equals": "Més gran que o igual a",
+ "label.grouped": "Agrupat",
+ "label.hostname": "Nom de host",
+ "label.includes": "Inclou",
+ "label.insight": "Visió",
+ "label.insights": "Insights",
+ "label.insights-description": "Aprofundeixi en les seves dades mitjançant l'ús de segments i filtres.",
+ "label.is": "És igual a",
+ "label.is-false": "És fals",
+ "label.is-not": "No és igual a",
+ "label.is-not-set": "No està establert",
+ "label.is-set": "Està establert",
+ "label.is-true": "És cert",
+ "label.join": "Unir",
+ "label.join-team": "Unir-se al equip",
+ "label.journey": "Trajecte",
+ "label.journey-description": "Entengui com naveguen els usuaris pel seu lloc web.",
+ "label.journeys": "Trajectes",
+ "label.language": "Idioma",
+ "label.languages": "Idiomes",
+ "label.laptop": "Portàtil",
+ "label.last-click": "Últim clic",
+ "label.last-days": "Últims {x} dies",
+ "label.last-hours": "Últimes {x} hores",
+ "label.last-months": "Últims {x} mesos",
+ "label.last-seen": "Vist per últim cop",
+ "label.leave": "Abandonar",
+ "label.leave-team": "Abandonar equip",
+ "label.less-than": "Menor que",
+ "label.less-than-equals": "Menor que o igual a",
+ "label.links": "Enllaços",
+ "label.login": "Connecta't",
+ "label.logout": "Desconnecta't",
+ "label.manage": "Administrar",
+ "label.manager": "Responsable",
+ "label.max": "Màx",
+ "label.maximize": "Expandeix",
+ "label.medium": "Mitjà",
+ "label.member": "Membre",
+ "label.members": "Membres",
+ "label.min": "Mín",
+ "label.mobile": "Mòbil",
+ "label.model": "Model",
+ "label.more": "Més",
+ "label.my-account": "El meu compte",
+ "label.my-websites": "Els meus llocs web",
+ "label.name": "Nom",
+ "label.new-password": "Contrasenya nova",
+ "label.none": "Cap",
+ "label.number-of-records": "{x} {x, plural, one {registre} other {registres}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Cerca orgànica",
+ "label.organic-shopping": "Compra orgànica",
+ "label.organic-social": "Social orgànic",
+ "label.organic-video": "Vídeo orgànic",
+ "label.os": "SO",
+ "label.other": "Altres",
+ "label.overview": "Resum",
+ "label.owner": "Propietari",
+ "label.page": "Pàgina",
+ "label.page-of": "Pàgina {current} de {total}",
+ "label.page-views": "Pàgines vistes",
+ "label.pageTitle": "Títol de la pàgina",
+ "label.pages": "Pàgines",
+ "label.paid-ads": "Anuncis de pagament",
+ "label.paid-search": "Cerca de pagament",
+ "label.paid-shopping": "Compra de pagament",
+ "label.paid-social": "Social de pagament",
+ "label.paid-video": "Vídeo de pagament",
+ "label.password": "Contrasenya",
+ "label.path": "Camí",
+ "label.paths": "Camins",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Funciona amb {name}",
+ "label.previous": "Anterior",
+ "label.previous-period": "Període anterior",
+ "label.previous-year": "Any anterior",
+ "label.profile": "Perfil",
+ "label.properties": "Propietats",
+ "label.property": "Propietat",
+ "label.queries": "Consultes",
+ "label.query": "Consulta",
+ "label.query-parameters": "Paràmetres de consulta",
+ "label.realtime": "Temps real",
+ "label.referral": "Referència",
+ "label.referrer": "Referent",
+ "label.referrers": "Referents",
+ "label.refresh": "Refresca",
+ "label.regenerate": "Regenerar",
+ "label.region": "Regió",
+ "label.regions": "Regions",
+ "label.remaining": "Restant",
+ "label.remove": "Treure",
+ "label.remove-member": "Eliminar membre",
+ "label.reports": "Informes",
+ "label.required": "Obligatori",
+ "label.reset": "Restableix",
+ "label.reset-website": "Restableix estadístiques",
+ "label.retention": "Retenció",
+ "label.retention-description": "Mesuri la retenció del seu lloc web fent un seguiment de la freqüència amb què tornen els usuaris.",
+ "label.revenue": "Ingressos",
+ "label.revenue-description": "Observi els seus ingressos al llarg del temps.",
+ "label.role": "Rol",
+ "label.run-query": "Executar consulta",
+ "label.save": "Desa",
+ "label.screens": "Pantalles",
+ "label.search": "Buscar",
+ "label.select": "Seleccionar",
+ "label.select-date": "Seleccionar data",
+ "label.select-filter": "Seleccionar filtre",
+ "label.select-role": "Seleccionar rol",
+ "label.select-website": "Seleccionar lloc web",
+ "label.session": "Sessió",
+ "label.session-data": "Dades de sessió",
+ "label.sessions": "Sessions",
+ "label.settings": "Configuració",
+ "label.share": "Comparteix",
+ "label.share-url": "Enllaç per compartir",
+ "label.single-day": "Un sol dia",
+ "label.sms": "SMS",
+ "label.sources": "Fonts",
+ "label.start-step": "Pas inicial",
+ "label.steps": "Pasos",
+ "label.sum": "Suma",
+ "label.tablet": "Tauleta",
+ "label.tag": "Etiqueta",
+ "label.tags": "Etiquetes",
+ "label.team": "Equip",
+ "label.team-id": "ID del equip",
+ "label.team-manager": "Responsable d'equip",
+ "label.team-member": "Membre de l'equip",
+ "label.team-name": "Nom de l'equip",
+ "label.team-owner": "Propietari de l'equip",
+ "label.team-settings": "Configuració de l'equip",
+ "label.team-view-only": "Vista només de l'equip",
+ "label.team-websites": "Llocs web de l'equip",
+ "label.teams": "Equips",
+ "label.terms": "Termes",
+ "label.theme": "Tema",
+ "label.this-month": "Aquest mes",
+ "label.this-week": "Aquesta setmana",
+ "label.this-year": "Aquest any",
+ "label.timezone": "Zona horària",
+ "label.title": "Títol",
+ "label.today": "Avui",
+ "label.toggle-charts": "Mostra/amaga gràfics",
+ "label.total": "Total",
+ "label.total-records": "Total de registres",
+ "label.tracking-code": "Codi de seguiment",
+ "label.transactions": "Transaccions",
+ "label.transfer": "Transferir",
+ "label.transfer-website": "Transferir lloc web",
+ "label.true": "Cert",
+ "label.type": "Tipus",
+ "label.unique": "Únic",
+ "label.unique-visitors": "Visitants únics",
+ "label.uniqueCustomers": "Clients Únics",
+ "label.unknown": "Desconegut",
+ "label.untitled": "Sense títol",
+ "label.update": "Actualitzar",
+ "label.user": "Usuari",
+ "label.username": "Nom d'usuari",
+ "label.users": "Usuaris",
+ "label.utm": "UTM",
+ "label.utm-description": "Rastreji les seves campanyes a través de paràmetres UTM.",
+ "label.value": "Valor",
+ "label.view": "Visualitzar",
+ "label.view-details": "Veure els detalls",
+ "label.view-only": "Només veure",
+ "label.views": "Vistes",
+ "label.views-per-visit": "Vistes per visita",
+ "label.visit-duration": "Temps mitjà de visita",
+ "label.visitors": "Visitants",
+ "label.visits": "Visites",
+ "label.website": "Lloc web",
+ "label.website-id": "ID del lloc web",
+ "label.websites": "Llocs web",
+ "label.window": "Finestra",
+ "label.yesterday": "Ahir",
+ "message.action-confirmation": "Escrigui {confirmation} al cuadre inferior per confirmar.",
+ "message.active-users": "{x} {x, plural, one {visitant actual} other {visitants actuals}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Dades recol·lectades",
+ "message.confirm-delete": "Segur que vol esborrar {target}?",
+ "message.confirm-leave": "Segur que vol abandonar {target}?",
+ "message.confirm-remove": "Segur que vol eliminar {target}?",
+ "message.confirm-reset": "Segur que vol restablir les estadístiques de {target}?",
+ "message.delete-team-warning": "Al eliminar un equip també s'eliminaran tots els llocs web de l'equip.",
+ "message.delete-website-warning": "També s'esborraran totes les dades relacionades.",
+ "message.error": "S'ha produït un error.",
+ "message.event-log": "{event} a {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Vés a la configuració",
+ "message.incorrect-username-password": "Nom d'usuari o contrasenya incorrectes.",
+ "message.invalid-domain": "Domini invàlid",
+ "message.min-password-length": "Longitud mínima de {n} caràcters",
+ "message.new-version-available": "Una nova versió d'Umami {version} està disponible!",
+ "message.no-data-available": "No hi ha dades disponibles.",
+ "message.no-event-data": "No hi ha dades d'esdeveniments disponibles.",
+ "message.no-match-password": "Les contrasenyes no coincideixen",
+ "message.no-results-found": "No s'han trobat resultats.",
+ "message.no-team-websites": "Aquest equip no té cap lloc web.",
+ "message.no-teams": "No ha creat cap equip.",
+ "message.no-users": "No hi ha cap usuari.",
+ "message.no-websites-configured": "No hi ha cap lloc web configurat.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "No s'ha trobat la pàgina.",
+ "message.reset-website": "Per restablir aquest lloc web, escrigui {confirmation} al cuadre inferior per confirmar.",
+ "message.reset-website-warning": "S'esborraran totes les estadístiques per aquest lloc web, però el codi de seguiment es mantindrà.",
+ "message.saved": "S'ha desat amb èxit.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Aquest és l'enllaç públic per compartir de {target}.",
+ "message.team-already-member": "Ja és membre d'aquest equip.",
+ "message.team-not-found": "Equip no trobat.",
+ "message.team-websites-info": "Els llocs web poden ser visualitzats per qualsevol membre de l'equip.",
+ "message.tracking-code": "Codi de seguiment",
+ "message.transfer-team-website-to-user": "Transferir aquest lloc web al seu compte?",
+ "message.transfer-user-website-to-team": "Seleccioni l'equip al qui transferir aquest lloc web.",
+ "message.transfer-website": "Transferir la propietat del lloc web al seu compte o a un altre equip.",
+ "message.triggered-event": "Esdeveniment desencadenat",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Usuari eliminat.",
+ "message.viewed-page": "Pàgina vista",
+ "message.visitor-log": "Visitant de {country} usant {browser} a {os} {device}"
+}
diff --git a/src/lang/cs-CZ.json b/src/lang/cs-CZ.json
new file mode 100644
index 0000000..77d45a7
--- /dev/null
+++ b/src/lang/cs-CZ.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Přístupový kód",
+ "label.actions": "Akce",
+ "label.activity": "Log aktivity",
+ "label.add": "Přidat",
+ "label.add-board": "Přidat nástěnku",
+ "label.add-description": "Přidat popis",
+ "label.add-member": "Přidat člena",
+ "label.add-step": "Přidat krok",
+ "label.add-website": "Přidat web",
+ "label.admin": "Administrátor",
+ "label.affiliate": "Partner",
+ "label.after": "Po",
+ "label.all": "Vše",
+ "label.all-time": "Celá doba",
+ "label.analytics": "Analytika",
+ "label.apply": "Použít",
+ "label.attribution": "Atribuce",
+ "label.attribution-description": "Podívejte se, jak uživatelé interagují s vaším marketingem a co vede ke konverzím.",
+ "label.average": "Průměr",
+ "label.back": "Zpět",
+ "label.before": "Před",
+ "label.behavior": "Chování",
+ "label.boards": "Nástěnky",
+ "label.bounce-rate": "Okamžité opuštění",
+ "label.breakdown": "Rozpis",
+ "label.browser": "Prohlížeč",
+ "label.browsers": "Prohlížeče",
+ "label.campaigns": "Kampaně",
+ "label.cancel": "Zrušit",
+ "label.change-password": "Změnit heslo",
+ "label.channels": "Kanály",
+ "label.cities": "Města",
+ "label.city": "Město",
+ "label.clear-all": "Vyčistit vše",
+ "label.cohort": "Kohorta",
+ "label.compare": "Porovnat",
+ "label.compare-dates": "Porovnat data",
+ "label.confirm": "Potvrdit",
+ "label.confirm-password": "Potvrdit heslo",
+ "label.contains": "Obsahuje",
+ "label.content": "Obsah",
+ "label.continue": "Pokračovat",
+ "label.conversion": "Konverze",
+ "label.conversion-rate": "Míra konverze",
+ "label.conversion-step": "Krok konverze",
+ "label.count": "Počet",
+ "label.countries": "Státy",
+ "label.country": "Stát",
+ "label.create": "Vytvořit",
+ "label.create-report": "Vytvořit hlášení",
+ "label.create-team": "Vytvořit tým",
+ "label.create-user": "Vytvořit uživatele",
+ "label.created": "Vytvořeno",
+ "label.created-by": "Created By",
+ "label.currency": "Měna",
+ "label.current": "Aktuální",
+ "label.current-password": "Aktuální heslo",
+ "label.custom-range": "Vlastní rozsah",
+ "label.dashboard": "Přehled",
+ "label.data": "Data",
+ "label.date": "Datum",
+ "label.date-range": "Období",
+ "label.day": "Den",
+ "label.default-date-range": "Výchozí období",
+ "label.delete": "Smazat",
+ "label.delete-report": "Smazat hlášení",
+ "label.delete-team": "Smazat tým",
+ "label.delete-user": "Smazat uživatele",
+ "label.delete-website": "Smazat web",
+ "label.description": "Popis",
+ "label.desktop": "Stolní počítač",
+ "label.details": "Detaily",
+ "label.device": "Zařízení",
+ "label.devices": "Zařízení",
+ "label.direct": "Přímý",
+ "label.dismiss": "Odejít",
+ "label.distinct-id": "Jedinečné ID",
+ "label.does-not-contain": "Neobsahuje",
+ "label.does-not-include": "Nezahrnuje",
+ "label.doest-not-exist": "Neexistuje",
+ "label.domain": "Doména",
+ "label.dropoff": "Opuštění",
+ "label.edit": "Upravit",
+ "label.edit-dashboard": "Upravit dashboard",
+ "label.edit-member": "Upravit člena",
+ "label.email": "E-mail",
+ "label.enable-share-url": "Povolit sdílení URL",
+ "label.end-step": "Konečný krok",
+ "label.entry": "Vstupní URL",
+ "label.event": "Událost",
+ "label.event-data": "Data události",
+ "label.event-name": "Název události",
+ "label.events": "Události",
+ "label.exists": "Existuje",
+ "label.exit": "Exit URL",
+ "label.false": "Nepravda",
+ "label.field": "Pole",
+ "label.fields": "Pole",
+ "label.filter": "Filtr",
+ "label.filter-combined": "Kombinace",
+ "label.filter-raw": "Nezpracované",
+ "label.filters": "Filtry",
+ "label.first-click": "První kliknutí",
+ "label.first-seen": "Poprvé viděno",
+ "label.funnel": "Trychtýř",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Trychtýře",
+ "label.goal": "Cíl",
+ "label.goals": "Cíle",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Větší než",
+ "label.greater-than-equals": "Větší nebo rovno",
+ "label.grouped": "Seskupeno",
+ "label.hostname": "Název hostitele",
+ "label.includes": "Zahrnuje",
+ "label.insight": "Pohled",
+ "label.insights": "Pohledy",
+ "label.insights-description": "Ponořte se hlouběji do svých dat pomocí segmentů a filtrů.",
+ "label.is": "Je",
+ "label.is-false": "Nepravda",
+ "label.is-not": "Není",
+ "label.is-not-set": "Není nastaveno",
+ "label.is-set": "Nastaveno",
+ "label.is-true": "Pravda",
+ "label.join": "Připojit se",
+ "label.join-team": "Připojit se k týmu",
+ "label.journey": "Cesta",
+ "label.journey-description": "Zjistěte, jak uživatelé procházejí vaším webem.",
+ "label.journeys": "Cesty",
+ "label.language": "Jazyk",
+ "label.languages": "Jazyky",
+ "label.laptop": "Přenosný počítač",
+ "label.last-click": "Poslední kliknutí",
+ "label.last-days": "Posledních {x} dnů",
+ "label.last-hours": "Posledních {x} hodin",
+ "label.last-months": "Posledních {x} měsíců",
+ "label.last-seen": "Last seen",
+ "label.leave": "Opustit",
+ "label.leave-team": "Opustit tým",
+ "label.less-than": "Méně než",
+ "label.less-than-equals": "Méně nebo rovno",
+ "label.links": "Odkazy",
+ "label.login": "Přihlásit",
+ "label.logout": "Odhlásit",
+ "label.manage": "Spravovat",
+ "label.manager": "Správce",
+ "label.max": "Max",
+ "label.maximize": "Rozbalit",
+ "label.medium": "Střední",
+ "label.member": "Člen",
+ "label.members": "Členové",
+ "label.min": "Min",
+ "label.mobile": "Mobilní telefon",
+ "label.model": "Model",
+ "label.more": "Více",
+ "label.my-account": "Můj účet",
+ "label.my-websites": "Mé weby",
+ "label.name": "Jméno",
+ "label.new-password": "Nové heslo",
+ "label.none": "Žádný",
+ "label.number-of-records": "{x} {x, plural, one {záznam} other {záznamů}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organické vyhledávání",
+ "label.organic-shopping": "Organický nákup",
+ "label.organic-social": "Organická sociální síť",
+ "label.organic-video": "Organické video",
+ "label.os": "OS",
+ "label.other": "Jiné",
+ "label.overview": "Přehled",
+ "label.owner": "Vlastník",
+ "label.page": "Stránka",
+ "label.page-of": "Stránka {current} z {total}",
+ "label.page-views": "Zobrazení stránek",
+ "label.pageTitle": "Název stránky",
+ "label.pages": "Stránky",
+ "label.paid-ads": "Placené reklamy",
+ "label.paid-search": "Placené vyhledávání",
+ "label.paid-shopping": "Placený nákup",
+ "label.paid-social": "Placená sociální síť",
+ "label.paid-video": "Placené video",
+ "label.password": "Heslo",
+ "label.path": "Cesta",
+ "label.paths": "Cesty",
+ "label.pixels": "Pixely",
+ "label.powered-by": "Běží na {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profil",
+ "label.properties": "Vlastnosti",
+ "label.property": "Vlastnost",
+ "label.queries": "Dotazy",
+ "label.query": "Dotaz",
+ "label.query-parameters": "Parametry dotazu",
+ "label.realtime": "Aktuálně",
+ "label.referral": "Doporučení",
+ "label.referrer": "Odkazující",
+ "label.referrers": "Odkazující",
+ "label.refresh": "Obnovit",
+ "label.regenerate": "Regenerovat",
+ "label.region": "Region",
+ "label.regions": "Regiony",
+ "label.remaining": "Zbývá",
+ "label.remove": "Odstranit",
+ "label.remove-member": "Odstranit člena",
+ "label.reports": "Hlášení",
+ "label.required": "Povinné",
+ "label.reset": "Resetovat",
+ "label.reset-website": "Resetovat statistiky",
+ "label.retention": "Udržení",
+ "label.retention-description": "Měřte přilnavost svého webu sledováním, jak často se uživatelé vracejí.",
+ "label.revenue": "Příjem",
+ "label.revenue-description": "Podívejte se na své příjmy v průběhu času.",
+ "label.role": "Role",
+ "label.run-query": "Spustit dotaz",
+ "label.save": "Uložit",
+ "label.screens": "Obrazovky",
+ "label.search": "Hledat",
+ "label.select": "Vybrat",
+ "label.select-date": "Vybrat datum",
+ "label.select-filter": "Vybrat filtr",
+ "label.select-role": "Vybrat roli",
+ "label.select-website": "Vybrat web",
+ "label.session": "Relace",
+ "label.session-data": "Data relace",
+ "label.sessions": "Relace",
+ "label.settings": "Nastavení",
+ "label.share": "Sdílet",
+ "label.share-url": "URL pro sdílení",
+ "label.single-day": "Jeden den",
+ "label.sms": "SMS",
+ "label.sources": "Zdroje",
+ "label.start-step": "Počáteční krok",
+ "label.steps": "Kroky",
+ "label.sum": "Součet",
+ "label.tablet": "Tablet",
+ "label.tag": "Štítek",
+ "label.tags": "Štítky",
+ "label.team": "Tým",
+ "label.team-id": "ID týmu",
+ "label.team-manager": "Manažer týmu",
+ "label.team-member": "Člen týmu",
+ "label.team-name": "Název týmu",
+ "label.team-owner": "Vlastník týmu",
+ "label.team-settings": "Nastavení týmu",
+ "label.team-view-only": "Pouze pro zobrazení týmu",
+ "label.team-websites": "Weby týmu",
+ "label.teams": "Týmy",
+ "label.terms": "Termíny",
+ "label.theme": "Téma",
+ "label.this-month": "Tento měsíc",
+ "label.this-week": "Tento týden",
+ "label.this-year": "Tento rok",
+ "label.timezone": "Časová zóna",
+ "label.title": "Title",
+ "label.today": "Dnes",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Sledovací kód",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Jedinečné návštěvy",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Neznámý",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Uživatelské jméno",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Zobrazit detaily",
+ "label.view-only": "View only",
+ "label.views": "Zobrazení",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Průměrný čas návštěvy",
+ "label.visitors": "Návštěvníci",
+ "label.visits": "Návštěvy",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Weby",
+ "label.window": "Okno",
+ "label.yesterday": "Včera",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} aktuálně {x, plural, one {návštěvník} other {návštěvníci}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Opravdu smazat {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Všechna související data budou také smazána.",
+ "message.error": "Něco se pokazilo.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Jít do nastavení",
+ "message.incorrect-username-password": "Nesprávné jméno/heslo.",
+ "message.invalid-domain": "Neplatná doména",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Žádná data.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Hesla se neschodují",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Nemáte nastavený žádný web.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Stránka nenalezena.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "Úspěšně uloženo.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Toto je sdílené URL pro {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Sledovací kód",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Návštěvník z {country} s prohlížečem {browser} na {os} {device}"
+}
diff --git a/src/lang/da-DK.json b/src/lang/da-DK.json
new file mode 100644
index 0000000..f6c447f
--- /dev/null
+++ b/src/lang/da-DK.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Adgangskode",
+ "label.actions": "Handlinger",
+ "label.activity": "Aktivitetslog",
+ "label.add": "Tilføj",
+ "label.add-board": "Tilføj tavle",
+ "label.add-description": "Tilføj beskrivelse",
+ "label.add-member": "Tilføj medlem",
+ "label.add-step": "Tilføj trin",
+ "label.add-website": "Tilføj hjemmeside",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partner",
+ "label.after": "Efter",
+ "label.all": "Alle",
+ "label.all-time": "Altid",
+ "label.analytics": "Analyser",
+ "label.apply": "Anvend",
+ "label.attribution": "Attribuering",
+ "label.attribution-description": "Se, hvordan brugere interagerer med din markedsføring, og hvad der driver konverteringer.",
+ "label.average": "Gennemsnit",
+ "label.back": "Tilbage",
+ "label.before": "Før",
+ "label.behavior": "Adfærd",
+ "label.boards": "Tavler",
+ "label.bounce-rate": "Afvisningsprocent",
+ "label.breakdown": "Opdeling",
+ "label.browser": "Browser",
+ "label.browsers": "Browsere",
+ "label.campaigns": "Kampagner",
+ "label.cancel": "Afvis",
+ "label.change-password": "Skift adgangskode",
+ "label.channels": "Kanaler",
+ "label.cities": "Byer",
+ "label.city": "By",
+ "label.clear-all": "Ryd alt",
+ "label.cohort": "Kohorte",
+ "label.compare": "Sammenlign",
+ "label.compare-dates": "Sammenlign datoer",
+ "label.confirm": "Bekræft",
+ "label.confirm-password": "Godkendt adgangskode",
+ "label.contains": "Contains",
+ "label.content": "Indhold",
+ "label.continue": "Fortsæt",
+ "label.conversion": "Konvertering",
+ "label.conversion-rate": "Konverteringsrate",
+ "label.conversion-step": "Konverteringstrin",
+ "label.count": "Antal",
+ "label.countries": "Lande",
+ "label.country": "Land",
+ "label.create": "Opret",
+ "label.create-report": "Opret rapport",
+ "label.create-team": "Opret team",
+ "label.create-user": "Opret bruger",
+ "label.created": "Oprettet",
+ "label.created-by": "Oprettet af",
+ "label.currency": "Valuta",
+ "label.current": "Nuværende",
+ "label.current-password": "Nuværende adgangskode",
+ "label.custom-range": "Tilpasset interval",
+ "label.dashboard": "Betjeningspanel",
+ "label.data": "Data",
+ "label.date": "Dato",
+ "label.date-range": "Datointerval",
+ "label.day": "Dag",
+ "label.default-date-range": "Standard datointerval",
+ "label.delete": "Slet",
+ "label.delete-report": "Slet rapport",
+ "label.delete-team": "Slet team",
+ "label.delete-user": "Slet bruger",
+ "label.delete-website": "Slet hjemmeside",
+ "label.description": "Beskrivelse",
+ "label.desktop": "Skrivebord",
+ "label.details": "Detaljer",
+ "label.device": "Enhed",
+ "label.devices": "Enheder",
+ "label.direct": "Direkte",
+ "label.dismiss": "Afvis",
+ "label.distinct-id": "Unikt ID",
+ "label.does-not-contain": "Indeholder ikke",
+ "label.does-not-include": "Inkluderer ikke",
+ "label.doest-not-exist": "Findes ikke",
+ "label.domain": "Domæne",
+ "label.dropoff": "Frafald",
+ "label.edit": "Rediger",
+ "label.edit-dashboard": "Rediger betjeningspanel",
+ "label.edit-member": "Rediger medlem",
+ "label.email": "E-mail",
+ "label.enable-share-url": "Aktivér delings-URL",
+ "label.end-step": "Sluttrin",
+ "label.entry": "Indgangs-URL",
+ "label.event": "Hændelse",
+ "label.event-data": "Hændelsesdata",
+ "label.event-name": "Hændelsesnavn",
+ "label.events": "Hændelser",
+ "label.exists": "Findes",
+ "label.exit": "Udgangs-URL",
+ "label.false": "Falsk",
+ "label.field": "Felt",
+ "label.fields": "Felter",
+ "label.filter": "Filter",
+ "label.filter-combined": "Kombineret",
+ "label.filter-raw": "Rå",
+ "label.filters": "Filtre",
+ "label.first-click": "Første klik",
+ "label.first-seen": "Først set",
+ "label.funnel": "Tragt",
+ "label.funnel-description": "Forstå brugernes konverterings- og frafaldsrate.",
+ "label.funnels": "Tragte",
+ "label.goal": "Mål",
+ "label.goals": "Mål",
+ "label.goals-description": "Følg dine mål for sidevisninger og hændelser.",
+ "label.greater-than": "Større end",
+ "label.greater-than-equals": "Større end eller lig med",
+ "label.grouped": "Gruperet",
+ "label.hostname": "Værtsnavn",
+ "label.includes": "Inkluderer",
+ "label.insight": "Indsigt",
+ "label.insights": "Indsigter",
+ "label.insights-description": "Dyk dybere ned i dine data ved at bruge segmenter og filtre.",
+ "label.is": "Er",
+ "label.is-false": "Er falsk",
+ "label.is-not": "Er ikke",
+ "label.is-not-set": "Er ikke sat",
+ "label.is-set": "Er sat",
+ "label.is-true": "Er sandt",
+ "label.join": "Deltag",
+ "label.join-team": "Deltag i team",
+ "label.journey": "Rejse",
+ "label.journey-description": "Forstå hvordan brugere navigerer på din hjemmeside.",
+ "label.journeys": "Rejser",
+ "label.language": "Sprog",
+ "label.languages": "Sprog",
+ "label.laptop": "Laptop",
+ "label.last-click": "Sidste klik",
+ "label.last-days": "Sidste {x} dage",
+ "label.last-hours": "Sidste {x} timer",
+ "label.last-months": "Sidste {x} måneder",
+ "label.last-seen": "Sidst set",
+ "label.leave": "Forlad",
+ "label.leave-team": "Forlad team",
+ "label.less-than": "Mindre end",
+ "label.less-than-equals": "Mindre end eller lig med",
+ "label.links": "Links",
+ "label.login": "Log ind",
+ "label.logout": "Log ud",
+ "label.manage": "Administrer",
+ "label.manager": "Leder",
+ "label.max": "Maks",
+ "label.maximize": "Udvid",
+ "label.medium": "Medium",
+ "label.member": "Medlem",
+ "label.members": "Medlemmer",
+ "label.min": "Min",
+ "label.mobile": "Mobil",
+ "label.model": "Model",
+ "label.more": "Mere",
+ "label.my-account": "Min konto",
+ "label.my-websites": "Mine hjemmesider",
+ "label.name": "Navn",
+ "label.new-password": "Ny adgangskode",
+ "label.none": "Ingen",
+ "label.number-of-records": "{x} {x, plural, one {post} other {poster}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organisk søgning",
+ "label.organic-shopping": "Organisk shopping",
+ "label.organic-social": "Organisk social",
+ "label.organic-video": "Organisk video",
+ "label.os": "OS",
+ "label.other": "Andet",
+ "label.overview": "Oversigt",
+ "label.owner": "Ejer",
+ "label.page": "Side",
+ "label.page-of": "Side {current} af {total}",
+ "label.page-views": "Sidevisninger",
+ "label.pageTitle": "Sidetitel",
+ "label.pages": "Sider",
+ "label.paid-ads": "Betalte annoncer",
+ "label.paid-search": "Betalt søgning",
+ "label.paid-shopping": "Betalt shopping",
+ "label.paid-social": "Betalt social",
+ "label.paid-video": "Betalt video",
+ "label.password": "Adgangskode",
+ "label.path": "Sti",
+ "label.paths": "Stier",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Drevet af {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profil",
+ "label.properties": "Egenskaber",
+ "label.property": "Egenskab",
+ "label.queries": "Forespørgsler",
+ "label.query": "Forespørgsel",
+ "label.query-parameters": "Forespørgselsparametre",
+ "label.realtime": "Realtid",
+ "label.referral": "Henvisning",
+ "label.referrer": "Henviser",
+ "label.referrers": "Henvisninger",
+ "label.refresh": "Opdater",
+ "label.regenerate": "Gendan",
+ "label.region": "Region",
+ "label.regions": "Regioner",
+ "label.remaining": "Tilbageværende",
+ "label.remove": "Fjern",
+ "label.remove-member": "Fjern medlem",
+ "label.reports": "Rapporter",
+ "label.required": "Påkrævet",
+ "label.reset": "Nulstil",
+ "label.reset-website": "Nulstil statistik",
+ "label.retention": "Fastholdelse",
+ "label.retention-description": "Mål hvor ofte brugere vender tilbage til din hjemmeside.",
+ "label.revenue": "Indtægt",
+ "label.revenue-description": "Se din indtægt over tid.",
+ "label.role": "Rolle",
+ "label.run-query": "Kør forespørgsel",
+ "label.save": "Gem",
+ "label.screens": "Skærme",
+ "label.search": "Søg",
+ "label.select": "Vælg",
+ "label.select-date": "Vælg dato",
+ "label.select-filter": "Vælg filter",
+ "label.select-role": "Vælg rolle",
+ "label.select-website": "Vælg hjemmeside",
+ "label.session": "Session",
+ "label.session-data": "Sessionsdata",
+ "label.sessions": "Sessioner",
+ "label.settings": "Indstillinger",
+ "label.share": "Del",
+ "label.share-url": "Del URL",
+ "label.single-day": "Enkelt dag",
+ "label.sms": "SMS",
+ "label.sources": "Kilder",
+ "label.start-step": "Starttrin",
+ "label.steps": "Trin",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Teamleder",
+ "label.team-member": "Teammedlem",
+ "label.team-name": "Teamnavn",
+ "label.team-owner": "Teamejer",
+ "label.team-settings": "Teamindstillinger",
+ "label.team-view-only": "Kun visning af team",
+ "label.team-websites": "Teamets hjemmesider",
+ "label.teams": "Teams",
+ "label.terms": "Vilkår",
+ "label.theme": "Tema",
+ "label.this-month": "Denne måned",
+ "label.this-week": "Denne uge",
+ "label.this-year": "Dette år",
+ "label.timezone": "Tidszone",
+ "label.title": "Title",
+ "label.today": "Idag",
+ "label.toggle-charts": "Ændre graf",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Sporingskode",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Unikke besøgende",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Ukendt",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Brugernavn",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Vis detajler",
+ "label.view-only": "View only",
+ "label.views": "Visninger",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Gennemsnitlig besøgstid",
+ "label.visitors": "Besøgende",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Hjemmesider",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} nuværende {x, plural, one {bruger} other {brugere}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Er du sikker på at du vil slette {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Er du sikker på at du ville nulstille {target}'s statistikker?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Alle tilknyttede data slettes også.",
+ "message.error": "Noget gik galt.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Gå til betjeningspanel",
+ "message.incorrect-username-password": "Ugyldigt brugernavn/adgangskode.",
+ "message.invalid-domain": "Ugyldigt domæne",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Ingen data tilgængelig.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Adgangskoderne matcher ikke",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Du har ikke konfigureret nogen hjemmesider.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Side ikke fundet.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "Alle statistikker for denne hjemmeside ville blive slettet, men sporingskode ville forblive intakt.",
+ "message.saved": "Gemt!",
+ "message.sever-error": "Server error",
+ "message.share-url": "Dette er den offentlige delings-URL til {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Sporingskode",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Besøgende fra {country} bruger {browser} på {os} {device}"
+}
diff --git a/src/lang/de-CH.json b/src/lang/de-CH.json
new file mode 100644
index 0000000..55734eb
--- /dev/null
+++ b/src/lang/de-CH.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Zuegangscode",
+ "label.actions": "Aktione",
+ "label.activity": "Aktivitätsverlauf",
+ "label.add": "hinzuefüege",
+ "label.add-board": "Board hinzuefüege",
+ "label.add-description": "Beschriibig hinzuefüege",
+ "label.add-member": "Mitglied hinzuefüege",
+ "label.add-step": "Schritt hinzuefüege",
+ "label.add-website": "Websiite hinzuefüege",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partnerprogramm",
+ "label.after": "Nach",
+ "label.all": "Alli",
+ "label.all-time": "Gsamte Zitruum",
+ "label.analytics": "Analytik",
+ "label.apply": "Aawände",
+ "label.attribution": "Zuordnig",
+ "label.attribution-description": "Lueg wie d'Benutzer mit dim Marketing interagiere und was zu Umwandlige führt.",
+ "label.average": "Durchschnitt",
+ "label.back": "Zrugg",
+ "label.before": "Vor",
+ "label.behavior": "Verhalte",
+ "label.boards": "Boards",
+ "label.bounce-rate": "Absprungsrate",
+ "label.breakdown": "Uufschlüsselig",
+ "label.browser": "Browser",
+ "label.browsers": "Browser",
+ "label.campaigns": "Kampagne",
+ "label.cancel": "Abbreche",
+ "label.change-password": "Passwort ändere",
+ "label.channels": "Kanäle",
+ "label.cities": "Städt",
+ "label.city": "Stadt",
+ "label.clear-all": "Alles lösche",
+ "label.cohort": "Gruppe",
+ "label.compare": "Vergliiche",
+ "label.compare-dates": "Datum vergleiche",
+ "label.confirm": "Bestätige",
+ "label.confirm-password": "Passwort widerhole",
+ "label.contains": "Enthaltet",
+ "label.content": "Inhalt",
+ "label.continue": "Wiiter",
+ "label.conversion": "Umwandlig",
+ "label.conversion-rate": "Umwandligsrate",
+ "label.conversion-step": "Umwandligsschritt",
+ "label.count": "Azahl",
+ "label.countries": "Länder",
+ "label.country": "Land",
+ "label.create": "Erstelle",
+ "label.create-report": "Bricht erstelle",
+ "label.create-team": "Team erstelle",
+ "label.create-user": "Benutzer erstelle",
+ "label.created": "Erstellt",
+ "label.created-by": "Erstellt vo",
+ "label.currency": "Währung",
+ "label.current": "Aktuell",
+ "label.current-password": "Aktuells Passwort",
+ "label.custom-range": "Benutzerdefinierte Bereich",
+ "label.dashboard": "Übersicht",
+ "label.data": "Datä",
+ "label.date": "Datum",
+ "label.date-range": "Datumsbereich",
+ "label.day": "Tag",
+ "label.default-date-range": "Voriigstellte Datumsbereich",
+ "label.delete": "Lösche",
+ "label.delete-report": "Bricht lösche",
+ "label.delete-team": "Team lösche",
+ "label.delete-user": "Benutzer lösche",
+ "label.delete-website": "Websiite lösche",
+ "label.description": "Beschriibig",
+ "label.desktop": "Desktop",
+ "label.details": "Details",
+ "label.device": "Grät",
+ "label.devices": "Grät",
+ "label.direct": "Direkt",
+ "label.dismiss": "Verwärfe",
+ "label.distinct-id": "Eindeutigi ID",
+ "label.does-not-contain": "Enthaltet nid",
+ "label.does-not-include": "Isch nid debii",
+ "label.doest-not-exist": "Existiert nid",
+ "label.domain": "Domain",
+ "label.dropoff": "Absprung",
+ "label.edit": "Bearbeite",
+ "label.edit-dashboard": "Dashboard bearbeite",
+ "label.edit-member": "Mitglied bearbeite",
+ "label.email": "Email",
+ "label.enable-share-url": "Freigab-URL aktiviere",
+ "label.end-step": "Schlussschritt",
+ "label.entry": "Iigangs URL",
+ "label.event": "Ereigniss",
+ "label.event-data": "Ereigniss Date",
+ "label.event-name": "Ereignissname",
+ "label.events": "Ereigniss",
+ "label.exists": "Existiert",
+ "label.exit": "Uusgangs URL",
+ "label.false": "Falsch",
+ "label.field": "Fäld",
+ "label.fields": "Fälder",
+ "label.filter": "Filter",
+ "label.filter-combined": "Kombiniert",
+ "label.filter-raw": "Rohdate",
+ "label.filters": "Filters",
+ "label.first-click": "Erste Klick",
+ "label.first-seen": "Erstmal gse",
+ "label.funnel": "Tunnel",
+ "label.funnel-description": "Verstönd Sie d Konversions- und Abspruungsrate vo Nutzer.",
+ "label.funnels": "Funnels",
+ "label.goal": "Ziel",
+ "label.goals": "Ziele",
+ "label.goals-description": "verfolged Sie Ihri Ziel für Siitenufrüef und Ereigniss.",
+ "label.greater-than": "Grösser als",
+ "label.greater-than-equals": "Grösser oder gliich",
+ "label.grouped": "Gruppiert",
+ "label.hostname": "Hostnam",
+ "label.includes": "Isch debii",
+ "label.insight": "Iiblick",
+ "label.insights": "Iiblick",
+ "label.insights-description": "Vertüfed Sie sich i Ihri Date, mit Hilf vo Segment und Filter.",
+ "label.is": "Isch",
+ "label.is-false": "Isch falsch",
+ "label.is-not": "Isch nid",
+ "label.is-not-set": "Isch ned gsetzt",
+ "label.is-set": "Isch gsetzt",
+ "label.is-true": "Isch wahr",
+ "label.join": "Biträte",
+ "label.join-team": "Team biträte",
+ "label.journey": "Reis",
+ "label.journey-description": "Verstönd Sie, wie Nutzer dur Ihri Website navigiered.",
+ "label.journeys": "Reise",
+ "label.language": "Sprach",
+ "label.languages": "Sprache",
+ "label.laptop": "Laptop",
+ "label.last-click": "Letzte Klick",
+ "label.last-days": "Letzti {x} Täg",
+ "label.last-hours": "Letzti {x} Stunde",
+ "label.last-months": "Letzti {x} Mönet",
+ "label.last-seen": "Zletzt gse",
+ "label.leave": "Verlah",
+ "label.leave-team": "Team verlah",
+ "label.less-than": "Kliiner als",
+ "label.less-than-equals": "Kliiner oder gliich",
+ "label.links": "Links",
+ "label.login": "Aamälde",
+ "label.logout": "Abmälde",
+ "label.manage": "Verwalte",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Uusklappe",
+ "label.medium": "Medium",
+ "label.member": "Mitglied",
+ "label.members": "Mitglieder",
+ "label.min": "Min",
+ "label.mobile": "Händy",
+ "label.model": "Model",
+ "label.more": "Meh",
+ "label.my-account": "Min Account",
+ "label.my-websites": "Mini Websiite",
+ "label.name": "Name",
+ "label.new-password": "Neus Passwort",
+ "label.none": "Keis",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organischi Suechi",
+ "label.organic-shopping": "Organischi Iikauf",
+ "label.organic-social": "Organischi Social Media",
+ "label.organic-video": "Organischi Video",
+ "label.os": "OS",
+ "label.other": "Anderi",
+ "label.overview": "Übersicht",
+ "label.owner": "Bsitzer",
+ "label.page": "Siite",
+ "label.page-of": "Siite {current} vo {total}",
+ "label.page-views": "Siitenufrüef",
+ "label.pageTitle": "Siitetitel",
+ "label.pages": "Siite",
+ "label.paid-ads": "Bezahlti Werbung",
+ "label.paid-search": "Bezahlti Suechi",
+ "label.paid-shopping": "Bezahlti Iikauf",
+ "label.paid-social": "Bezahlti Social Media",
+ "label.paid-video": "Bezahlti Video",
+ "label.password": "Passwort",
+ "label.path": "Pfad",
+ "label.paths": "Pfade",
+ "label.pixels": "Pixel",
+ "label.powered-by": "Betriibe dur {name}",
+ "label.previous": "Vorherig",
+ "label.previous-period": "Vorherigi Periode",
+ "label.previous-year": "Vorherigs Jahr",
+ "label.profile": "Profil",
+ "label.properties": "Eigeschafte",
+ "label.property": "Eigeschafte",
+ "label.queries": "Abfrage",
+ "label.query": "Abfrag",
+ "label.query-parameters": "Abfragparameter",
+ "label.realtime": "Echtzit",
+ "label.referral": "Empfehlig",
+ "label.referrer": "Verwiiser",
+ "label.referrers": "Verwiisendi",
+ "label.refresh": "Aktualisiere",
+ "label.regenerate": "Erneuere",
+ "label.region": "Region",
+ "label.regions": "Regionä",
+ "label.remaining": "Verblibe",
+ "label.remove": "Entferne",
+ "label.remove-member": "Mitglied entferne",
+ "label.reports": "Brichte",
+ "label.required": "Erforderlich",
+ "label.reset": "Zruggsetze",
+ "label.reset-website": "Statistik zruggsetze",
+ "label.retention": "Retention",
+ "label.retention-description": "Mässed Sie d Verwiilduur vo Ihrere Website, indem Sie verfolged wie oft ihri Nutzer zruggkehred.",
+ "label.revenue": "Umsatz",
+ "label.revenue-description": "Lueged Sie sich Ihre Umsatz im Lauf vor Ziit a.",
+ "label.role": "Rollä",
+ "label.run-query": "Abfrag starte",
+ "label.save": "Speichere",
+ "label.screens": "Bildschirmuflösige",
+ "label.search": "Sueche",
+ "label.select": "Auswähle",
+ "label.select-date": "Datä uuswähle",
+ "label.select-filter": "Filter uuswähle",
+ "label.select-role": "Rollä uuswähle",
+ "label.select-website": "Websiite uuswähle",
+ "label.session": "Sitzig",
+ "label.session-data": "Sitzigsdate",
+ "label.sessions": "Sitzige",
+ "label.settings": "Istellige",
+ "label.share": "Teile",
+ "label.share-url": "Freigab-URL",
+ "label.single-day": "Ein Tag",
+ "label.sms": "SMS",
+ "label.sources": "Quälle",
+ "label.start-step": "Startschritt",
+ "label.steps": "Schritt",
+ "label.sum": "Summe",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Stichwort",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team Manager",
+ "label.team-member": "Team Mitglied",
+ "label.team-name": "Team Name",
+ "label.team-owner": "Team Bsitzer",
+ "label.team-settings": "Team Istellige",
+ "label.team-view-only": "Nur für Teammitglieder sichtbar",
+ "label.team-websites": "Team Websiite",
+ "label.teams": "Teams",
+ "label.terms": "Bedingige",
+ "label.theme": "Thema",
+ "label.this-month": "Dä Monet",
+ "label.this-week": "Diä Wuuche",
+ "label.this-year": "Das Johr",
+ "label.timezone": "Ziitzone",
+ "label.title": "Titel",
+ "label.today": "Hüt",
+ "label.toggle-charts": "Charts umschalte",
+ "label.total": "Total",
+ "label.total-records": "Gsamti Datesätz",
+ "label.tracking-code": "Tracking Code",
+ "label.transactions": "Transaktione",
+ "label.transfer": "Transferiere",
+ "label.transfer-website": "Websiite transferiere",
+ "label.true": "Wahr",
+ "label.type": "Typ",
+ "label.unique": "Einzigartigi",
+ "label.unique-visitors": "Einzigartigi Bsuecher",
+ "label.uniqueCustomers": "Einzigartigi Kunde",
+ "label.unknown": "Unbekannt",
+ "label.untitled": "Unbennant",
+ "label.update": "Update",
+ "label.user": "Benutzer",
+ "label.username": "Benutzername",
+ "label.users": "Benutzer",
+ "label.utm": "UTM",
+ "label.utm-description": "Tracked Sie Ihri Kampagnen mit UTM Parameters.",
+ "label.value": "Wärt",
+ "label.view": "Azeige",
+ "label.view-details": "Details azeige",
+ "label.view-only": "Nume aluege",
+ "label.views": "Ufrüef",
+ "label.views-per-visit": "Ufrüef pro Bsuech",
+ "label.visit-duration": "Durchschn. Bsuechsziit",
+ "label.visitors": "Bsuecher",
+ "label.visits": "Bsüech",
+ "label.website": "Website",
+ "label.website-id": "Websiite ID",
+ "label.websites": "Websiite",
+ "label.window": "Fenster",
+ "label.yesterday": "Gester",
+ "message.action-confirmation": "Typed Sie {confirmation} is Feld underhalb um z bestätige.",
+ "message.active-users": "{x} {x, plural, one {aktive Bsuecher} other {aktivi Bsuecher}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Gsammleti Date",
+ "message.confirm-delete": "Sind Sie sich sicher, {target} zlösche?",
+ "message.confirm-leave": "Sind Sie sich sicher, {target} zverlah?",
+ "message.confirm-remove": "Sind Sie sich sicher, dass Sie {target} wänd entferne?",
+ "message.confirm-reset": "Sind Sie sicher, dass Sie d Statistike vo {target} zruggsetze wänd?",
+ "message.delete-team-warning": "Es Team lösche dued ebefalls alli team Websiite lösche.",
+ "message.delete-website-warning": "Alli dezueghörige Date werded ebefalls glöscht.",
+ "message.error": "Es isch en Fehler ufträte.",
+ "message.event-log": "{event} uf {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Zu de Istellige",
+ "message.incorrect-username-password": "Falsches Passwort oder Benutzername.",
+ "message.invalid-domain": "Ungültigi Domain",
+ "message.min-password-length": "Miminamli längi vo {n} Zeiche",
+ "message.new-version-available": "Es isch en neue Version vo Umami {version} verfügbar!",
+ "message.no-data-available": "Kei Date vorhande.",
+ "message.no-event-data": "Es sind kei Event Date verfügbar.",
+ "message.no-match-password": "Passwörter stimmed ned überi",
+ "message.no-results-found": "Kei Ergäbnis gfunde.",
+ "message.no-team-websites": "Dem Team sind kei Websiite zuegordnet.",
+ "message.no-teams": "Bisher sind no kei Teams erstellt worde.",
+ "message.no-users": "Da gits kei Benutzer",
+ "message.no-websites-configured": "Es isch kei Websiite vorhande.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Siite ned gfunde.",
+ "message.reset-website": "Um die Websiite zruggzsetze, typed Sie {confirmation} is Feld unde dran.",
+ "message.reset-website-warning": "Alli Date für die Websiite werdet glöscht, nur de Tracking Code blibt bestah.",
+ "message.saved": "Erfolgrich gspeichert.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Ihri Websiitestatistik isch under de folgende URL öffentlich zuegänglich:",
+ "message.team-already-member": "Sie sind bereits es Mitglied vo däm Team.",
+ "message.team-not-found": "Team nöd gfunde.",
+ "message.team-websites-info": "Websiite chöi vo jedem im Team agluegt werde",
+ "message.tracking-code": "Tracking Code",
+ "message.transfer-team-website-to-user": "Websiite uf zu Ihrem Account transferiere?",
+ "message.transfer-user-website-to-team": "Wähled Sie s Team zum däm Websiite transferiert werde söll.",
+ "message.transfer-website": "Übertraged Sie d Websiite Eigetümerrecht uf Ihre Account oder uf es anders Team",
+ "message.triggered-event": "Usglösts Ereigniss",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Bnutzer glöscht.",
+ "message.viewed-page": "Siite agluegt",
+ "message.visitor-log": "Bsuecher us {country} nutzt {browser} uf {os} {device}"
+}
diff --git a/src/lang/de-DE.json b/src/lang/de-DE.json
new file mode 100644
index 0000000..3436eb8
--- /dev/null
+++ b/src/lang/de-DE.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Zugangscode",
+ "label.actions": "Aktionen",
+ "label.activity": "Aktivitätsverlauf",
+ "label.add": "Hinzufügen",
+ "label.add-board": "Board hinzufügen",
+ "label.add-description": "Beschreibung hinzufügen",
+ "label.add-member": "Mitglied hinzufügen",
+ "label.add-step": "Schritt hinzufügen",
+ "label.add-website": "Website hinzufügen",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partnerprogramm",
+ "label.after": "Nach",
+ "label.all": "Alle",
+ "label.all-time": "Gesamter Zeitraum",
+ "label.analytics": "Analysen",
+ "label.apply": "Anwenden",
+ "label.attribution": "Zuordnung",
+ "label.attribution-description": "Sehen Sie, wie Nutzer mit Ihrem Marketing interagieren und was zu Konversionen führt.",
+ "label.average": "Durchschnitt",
+ "label.back": "Zurück",
+ "label.before": "Vor",
+ "label.behavior": "Verhalten",
+ "label.boards": "Boards",
+ "label.bounce-rate": "Absprungrate",
+ "label.breakdown": "Aufschlüsselung",
+ "label.browser": "Browser",
+ "label.browsers": "Browser",
+ "label.campaigns": "Kampagnen",
+ "label.cancel": "Abbrechen",
+ "label.change-password": "Passwort ändern",
+ "label.channels": "Kanäle",
+ "label.cities": "Städte",
+ "label.city": "Stadt",
+ "label.clear-all": "Alles löschen",
+ "label.cohort": "Gruppe",
+ "label.compare": "Vergleichen",
+ "label.compare-dates": "Daten vergleichen",
+ "label.confirm": "Bestätigen",
+ "label.confirm-password": "Passwort wiederholen",
+ "label.contains": "Enthält",
+ "label.content": "Inhalt",
+ "label.continue": "Weiter",
+ "label.conversion": "Konversion",
+ "label.conversion-rate": "Konversionsrate",
+ "label.conversion-step": "Konversionsschritt",
+ "label.count": "Anzahl",
+ "label.countries": "Länder",
+ "label.country": "Land",
+ "label.create": "Erstellen",
+ "label.create-report": "Bericht erstellen",
+ "label.create-team": "Team erstellen",
+ "label.create-user": "Benutzer erstellen",
+ "label.created": "Erstellt",
+ "label.created-by": "Erstellt von",
+ "label.currency": "Währung",
+ "label.current": "Aktuell",
+ "label.current-password": "Derzeitiges Passwort",
+ "label.custom-range": "Benutzerdefinierter Bereich",
+ "label.dashboard": "Übersicht",
+ "label.data": "Daten",
+ "label.date": "Datum",
+ "label.date-range": "Datumsbereich",
+ "label.day": "Tag",
+ "label.default-date-range": "Voreingestellter Datumsbereich",
+ "label.delete": "Löschen",
+ "label.delete-report": "Bericht löschen",
+ "label.delete-team": "Team löschen",
+ "label.delete-user": "Benutzer löschen",
+ "label.delete-website": "Website löschen",
+ "label.description": "Beschreibung",
+ "label.desktop": "Desktop",
+ "label.details": "Details",
+ "label.device": "Gerät",
+ "label.devices": "Geräte",
+ "label.direct": "Direkt",
+ "label.dismiss": "Verwerfen",
+ "label.distinct-id": "Eindeutige ID",
+ "label.does-not-contain": "Enthält nicht",
+ "label.does-not-include": "Nicht enthalten",
+ "label.doest-not-exist": "Existiert nicht",
+ "label.domain": "Domain",
+ "label.dropoff": "Absprung",
+ "label.edit": "Bearbeiten",
+ "label.edit-dashboard": "Dashboard bearbeiten",
+ "label.edit-member": "Mitglied bearbeiten",
+ "label.email": "Email",
+ "label.enable-share-url": "Freigabe-URL aktivieren",
+ "label.end-step": "Schlussschritt",
+ "label.entry": "Eingangs-URL",
+ "label.event": "Ereignis",
+ "label.event-data": "Ereignisdaten",
+ "label.event-name": "Ereignisname",
+ "label.events": "Ereignisse",
+ "label.exists": "Existiert",
+ "label.exit": "Ausgangs-URL",
+ "label.false": "Falsch",
+ "label.field": "Feld",
+ "label.fields": "Felder",
+ "label.filter": "Filter",
+ "label.filter-combined": "Kombiniert",
+ "label.filter-raw": "Rohdaten",
+ "label.filters": "Filter",
+ "label.first-click": "Erster Klick",
+ "label.first-seen": "Erstmalig gesehen",
+ "label.funnel": "Trichter",
+ "label.funnel-description": "Verstehen Sie die Konversions- und Absprungrate Ihrer Nutzer.",
+ "label.funnels": "Funnels",
+ "label.goal": "Ziel",
+ "label.goals": "Ziele",
+ "label.goals-description": "Verfolgen Sie Ihre Ziele für Seitenaufrufe und Ereignisse.",
+ "label.greater-than": "Größer als",
+ "label.greater-than-equals": "Größer oder gleich",
+ "label.grouped": "Gruppiert",
+ "label.hostname": "Hostname",
+ "label.includes": "Enthält",
+ "label.insight": "Einblick",
+ "label.insights": "Einblicke",
+ "label.insights-description": "Vertiefen Sie sich mit Hilfe von Segmenten und Filtern in Ihre Daten.",
+ "label.is": "Ist",
+ "label.is-false": "Ist falsch",
+ "label.is-not": "Ist nicht",
+ "label.is-not-set": "Ist nicht gesetzt",
+ "label.is-set": "Ist gesetzt",
+ "label.is-true": "Ist wahr",
+ "label.join": "Beitreten",
+ "label.join-team": "Team beitreten",
+ "label.journey": "Reise",
+ "label.journey-description": "Verstehen Sie, wie Nutzer auf Ihrer Website navigieren.",
+ "label.journeys": "Reisen",
+ "label.language": "Sprache",
+ "label.languages": "Sprachen",
+ "label.laptop": "Laptop",
+ "label.last-click": "Letzter Klick",
+ "label.last-days": "Letzten {x} Tage",
+ "label.last-hours": "Letzten {x} Stunden",
+ "label.last-months": "Letzten {x} Monate",
+ "label.last-seen": "Zuletzt gesehen",
+ "label.leave": "Verlassen",
+ "label.leave-team": "Team verlassen",
+ "label.less-than": "Kleiner als",
+ "label.less-than-equals": "Kleiner oder gleich",
+ "label.links": "Links",
+ "label.login": "Anmelden",
+ "label.logout": "Abmelden",
+ "label.manage": "Verwalten",
+ "label.manager": "Verwaltung",
+ "label.max": "Max",
+ "label.maximize": "Erweitern",
+ "label.medium": "Medium",
+ "label.member": "Mitglied",
+ "label.members": "Mitglieder",
+ "label.min": "Min",
+ "label.mobile": "Handy",
+ "label.model": "Model",
+ "label.more": "Mehr",
+ "label.my-account": "Mein Account",
+ "label.my-websites": "Meine Websites",
+ "label.name": "Name",
+ "label.new-password": "Neues Passwort",
+ "label.none": "Keine",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organische Suche",
+ "label.organic-shopping": "Organisches Shopping",
+ "label.organic-social": "Organisches Social Media",
+ "label.organic-video": "Organisches Video",
+ "label.os": "OS",
+ "label.other": "Andere",
+ "label.overview": "Übersicht",
+ "label.owner": "Besitzer",
+ "label.page": "Seite",
+ "label.page-of": "Seite {current} von {total}",
+ "label.page-views": "Seitenaufrufe",
+ "label.pageTitle": "Seitentitel",
+ "label.pages": "Seiten",
+ "label.paid-ads": "Bezahlte Anzeigen",
+ "label.paid-search": "Bezahlte Suche",
+ "label.paid-shopping": "Bezahltes Shopping",
+ "label.paid-social": "Bezahltes Social Media",
+ "label.paid-video": "Bezahltes Video",
+ "label.password": "Passwort",
+ "label.path": "Pfad",
+ "label.paths": "Pfade",
+ "label.pixels": "Pixel",
+ "label.powered-by": "Betrieben durch {name}",
+ "label.previous": "Vorherig",
+ "label.previous-period": "Vorherige Periode",
+ "label.previous-year": "Vorheriges Jahr",
+ "label.profile": "Profil",
+ "label.properties": "Eigenschaften",
+ "label.property": "Eigenschaft",
+ "label.queries": "Abfragen",
+ "label.query": "Abfrage",
+ "label.query-parameters": "Abfrageparameter",
+ "label.realtime": "Echtzeit",
+ "label.referral": "Empfehlung",
+ "label.referrer": "Übermittler",
+ "label.referrers": "Übermittler",
+ "label.refresh": "Aktualisieren",
+ "label.regenerate": "Erneuern",
+ "label.region": "Region",
+ "label.regions": "Regionen",
+ "label.remaining": "Verbleibend",
+ "label.remove": "Entfernen",
+ "label.remove-member": "Mitglied entfernen",
+ "label.reports": "Berichte",
+ "label.required": "Erforderlich",
+ "label.reset": "Zurücksetzen",
+ "label.reset-website": "Statistik zurücksetzen",
+ "label.retention": "Erhalt",
+ "label.retention-description": "Messen Sie die Verweildauer auf Ihrer Website, indem Sie verfolgen, wie oft die Nutzer zurückkehren.",
+ "label.revenue": "Umsatz",
+ "label.revenue-description": "Haben Sie einen Blick auf Ihre Umsätze im Laufe der Zeit.",
+ "label.role": "Rolle",
+ "label.run-query": "Abfrage starten",
+ "label.save": "Speichern",
+ "label.screens": "Bildschirmauflösungen",
+ "label.search": "Suche",
+ "label.select": "Auswählen",
+ "label.select-date": "Datum auswählen",
+ "label.select-filter": "Filter auswählen",
+ "label.select-role": "Rolle auswählen",
+ "label.select-website": "Website auswählen",
+ "label.session": "Sitzung",
+ "label.session-data": "Sitzungsdaten",
+ "label.sessions": "Sitzungen",
+ "label.settings": "Einstellungen",
+ "label.share": "Teilen",
+ "label.share-url": "Freigabe-URL",
+ "label.single-day": "Ein Tag",
+ "label.sms": "SMS",
+ "label.sources": "Quellen",
+ "label.start-step": "Startschritt",
+ "label.steps": "Schritte",
+ "label.sum": "Summe",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Stichworte",
+ "label.team": "Team",
+ "label.team-id": "Team-ID",
+ "label.team-manager": "Team-Manager",
+ "label.team-member": "Team-Mitglied",
+ "label.team-name": "Name des Teams",
+ "label.team-owner": "Team-Eigentümer",
+ "label.team-settings": "Team-Einstellungen",
+ "label.team-view-only": "Nur für Team-Mitglieder sichtbar",
+ "label.team-websites": "Team-Websites",
+ "label.teams": "Teams",
+ "label.terms": "Bedingungen",
+ "label.theme": "Thema",
+ "label.this-month": "Diesen Monat",
+ "label.this-week": "Diese Woche",
+ "label.this-year": "Dieses Jahr",
+ "label.timezone": "Zeitzone",
+ "label.title": "Titel",
+ "label.today": "Heute",
+ "label.toggle-charts": "Schaubilder umschalten",
+ "label.total": "Gesamt",
+ "label.total-records": "Datensätze insgesamt",
+ "label.tracking-code": "Tracking Code",
+ "label.transactions": "Transaktionen",
+ "label.transfer": "Übertragung",
+ "label.transfer-website": "Website übertragen",
+ "label.true": "Wahr",
+ "label.type": "Typ",
+ "label.unique": "Einzigartig",
+ "label.unique-visitors": "Einzigartige Besucher",
+ "label.uniqueCustomers": "Einzigartige Kunden",
+ "label.unknown": "Unbekannt",
+ "label.untitled": "Unbenannt",
+ "label.update": "Update",
+ "label.user": "Benutzer",
+ "label.username": "Benutzername",
+ "label.users": "Benutzer",
+ "label.utm": "UTM",
+ "label.utm-description": "Tracken Sie Ihre Kampagnen mit UTM Parametern.",
+ "label.value": "Wert",
+ "label.view": "Anzeigen",
+ "label.view-details": "Details anzeigen",
+ "label.view-only": "Nur ansehen",
+ "label.views": "Aufrufe",
+ "label.views-per-visit": "Aufrufe pro Besuch",
+ "label.visit-duration": "Durchschn. Besuchszeit",
+ "label.visitors": "Besucher",
+ "label.visits": "Besuche",
+ "label.website": "Website",
+ "label.website-id": "Website-ID",
+ "label.websites": "Websites",
+ "label.window": "Fenster",
+ "label.yesterday": "Gestern",
+ "message.action-confirmation": "Schreibe {confirmation} in die Box zur bestätigung.",
+ "message.active-users": "{x} {x, plural, one {aktiver Besucher} other {aktive Besucher}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Gesammelte Daten",
+ "message.confirm-delete": "Sind Sie sich sicher, {target} zu löschen?",
+ "message.confirm-leave": "Sind Sie sicher, dass die {target} verlassen möchten?",
+ "message.confirm-remove": "Sind Sie sicher, {target} zu entfernen?",
+ "message.confirm-reset": "Sind Sie sicher, dass Sie die Statistiken von {target} zurücksetzen wollen?",
+ "message.delete-team-warning": "Ein Team zu löschen, wird auch alle Team-Websites löschen.",
+ "message.delete-website-warning": "Alle zugehörigen Daten werden ebenfalls gelöscht.",
+ "message.error": "Es ist ein Fehler aufgetreten.",
+ "message.event-log": "{event} auf {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Zu den Einstellungen",
+ "message.incorrect-username-password": "Falsches Passwort oder Benutzername.",
+ "message.invalid-domain": "Ungültige Domain",
+ "message.min-password-length": "Minimale Länge von {n} Zeichen",
+ "message.new-version-available": "Eine neue Version von Umami ist verfügbar: {version}",
+ "message.no-data-available": "Keine Daten vorhanden.",
+ "message.no-event-data": "Es sind keine Ereignisdaten verfügbar.",
+ "message.no-match-password": "Passwörter stimmen nicht überein",
+ "message.no-results-found": "Keine Ergebnisse gefunden.",
+ "message.no-team-websites": "Diesem Team sind keine Websites zugeordnet.",
+ "message.no-teams": "Bisher wurden keine Teams erstellt.",
+ "message.no-users": "Hier gibt es keine Benutzer.",
+ "message.no-websites-configured": "Es ist keine Website vorhanden.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Seite nicht gefunden.",
+ "message.reset-website": "Um diese Website zurückzusetzen, geben Sie zur Bestätigung {confirmation} in das Feld unten ein.",
+ "message.reset-website-warning": "Alle Daten für diese Website werden gelöscht, jedoch bleibt der Tracking Code bestehen.",
+ "message.saved": "Erfolgreich gespeichert.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Die Statistiken Ihrer Website sind unter folgender URL öffentlich zugänglich:",
+ "message.team-already-member": "Sie sind bereits Mitglied des Teams.",
+ "message.team-not-found": "Team nicht gefunden.",
+ "message.team-websites-info": "Websites können von jedem im Team eingesehen werden.",
+ "message.tracking-code": "Tracking Code",
+ "message.transfer-team-website-to-user": "Diese Website zu Ihrem Account transferieren?",
+ "message.transfer-user-website-to-team": "Wählen Sie ein Team aus, zu dem die Website transferiert werden soll.",
+ "message.transfer-website": "Übertragen Sie die Eigentümerrechte zu Ihrem Account oder einem anderen Team.",
+ "message.triggered-event": "Ereignis ausgelöst",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Benutzer gelöscht.",
+ "message.viewed-page": "Seite besucht",
+ "message.visitor-log": "Besucher aus {country} benutzt {browser} auf {os} {device}"
+}
diff --git a/src/lang/el-GR.json b/src/lang/el-GR.json
new file mode 100644
index 0000000..720ff5e
--- /dev/null
+++ b/src/lang/el-GR.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Access code",
+ "label.actions": "Ενέργειες",
+ "label.activity": "Activity log",
+ "label.add": "Add",
+ "label.add-board": "Add board",
+ "label.add-description": "Add description",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "Προσθήκη ιστότοπου",
+ "label.admin": "Διαχειριστής",
+ "label.affiliate": "Affiliate",
+ "label.after": "After",
+ "label.all": "All",
+ "label.all-time": "All time",
+ "label.analytics": "Analytics",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "Average",
+ "label.back": "Πίσω",
+ "label.before": "Before",
+ "label.boards": "Boards",
+ "label.bounce-rate": "Ποσοστό αναπήδησης",
+ "label.breakdown": "Breakdown",
+ "label.behavior": "Συμπεριφορά",
+ "label.browser": "Browser",
+ "label.browsers": "Προγράμματα περιήγησης",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "Ακύρωση",
+ "label.change-password": "Αλλαγή κωδικού",
+ "label.channels": "Channels",
+ "label.cities": "Cities",
+ "label.city": "City",
+ "label.clear-all": "Clear all",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "Επιβεβαίωση κωδικού",
+ "label.contains": "Contains",
+ "label.content": "Content",
+ "label.continue": "Continue",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "Χώρες",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "Create report",
+ "label.create-team": "Create team",
+ "label.create-user": "Create user",
+ "label.created": "Created",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "Τωρινός κωδικός πρόσβασης",
+ "label.custom-range": "Προσαρμοσμένο εύρος",
+ "label.dashboard": "Πίνακας",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "Εύρος ημερομηνιών",
+ "label.day": "Day",
+ "label.default-date-range": "Προεπιλεγμένο εύρος ημερομηνιών",
+ "label.delete": "Διαγραφή",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Delete team",
+ "label.delete-user": "Delete user",
+ "label.delete-website": "Διαγραφή ιστότοπου",
+ "label.description": "Description",
+ "label.desktop": "Σταθερός υπολογιστής",
+ "label.details": "Details",
+ "label.device": "Device",
+ "label.devices": "Συσκευές",
+ "label.direct": "Direct",
+ "label.dismiss": "Dismiss",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Does not contain",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "Τομέας",
+ "label.dropoff": "Dropoff",
+ "label.edit": "Επεξεργασία",
+ "label.edit-dashboard": "Edit dashboard",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "Ενεργοποίηση κοινής χρήσης URL",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Event",
+ "label.event-data": "Event data",
+ "label.event-name": "Event name",
+ "label.events": "Γεγονότα",
+ "label.exists": "Exists",
+ "label.exit": "Exit URL",
+ "label.false": "False",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "Filter",
+ "label.filter-combined": "Σε συνδυασμό",
+ "label.filter-raw": "Ακατέργαστο",
+ "label.filters": "Filters",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Greater than",
+ "label.greater-than-equals": "Greater than or equals",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "Join",
+ "label.join-team": "Join team",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "Language",
+ "label.languages": "Languages",
+ "label.laptop": "Λάπτοπ",
+ "label.last-click": "Last click",
+ "label.last-days": "Τελευταίες {x} ημέρες",
+ "label.last-hours": "Τελευταίες {x} ώρες",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "Leave",
+ "label.leave-team": "Leave team",
+ "label.less-than": "Less than",
+ "label.less-than-equals": "Less than or equals",
+ "label.links": "Links",
+ "label.login": "Είσοδος",
+ "label.logout": "Αποσύνδεση",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "Members",
+ "label.min": "Min",
+ "label.mobile": "Κινητό",
+ "label.model": "Model",
+ "label.more": "Περισσότερα",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "Όνομα",
+ "label.new-password": "Νέος κωδικός",
+ "label.none": "None",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "OS",
+ "label.other": "Other",
+ "label.overview": "Overview",
+ "label.owner": "Owner",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "Προβολές σελίδας",
+ "label.pageTitle": "Page title",
+ "label.pages": "Σελίδες",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "Κωδικός",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Με την υποστήριξη του {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Προφίλ",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "Realtime",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "Παραπομπές",
+ "label.refresh": "Ανανέωση",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Remaining",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Απαιτείται",
+ "label.reset": "Επαναφορά",
+ "label.reset-website": "Reset statistics",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Αποθήκευση",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "Sessions",
+ "label.settings": "Ρυθμίσεις",
+ "label.share": "Share",
+ "label.share-url": "Κοινοποίηση διεύθυνσης URL",
+ "label.single-day": "Ημερήσια",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "Τάμπλετ",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Terms",
+ "label.theme": "Theme",
+ "label.this-month": "Αυτο το μήνα",
+ "label.this-week": "Αυτή την εβδομάδα",
+ "label.this-year": "Αυτή την χρονιά",
+ "label.timezone": "Ζώνη ώρας",
+ "label.title": "Title",
+ "label.today": "Σήμερα",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Κωδικός παρακολούθησης",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Μοναδικοί επισκέπτες",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Άγνωστο",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Όνομα χρήστη",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Λεπτομέρειες",
+ "label.view-only": "View only",
+ "label.views": "Προβολές",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Μέσος χρόνος επίσκεψης",
+ "label.visitors": "Επισκέπτες",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Ιστότοποι",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} ενεργοί {x, plural, one {επισκέπτης} other {επισκέπτες}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το {target};",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Όλα τα σχετικά δεδομένα θα διαγραφούν επίσης.",
+ "message.error": "Κάτι πήγε στραβά.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Μεταβείτε στις ρυθμίσεις",
+ "message.incorrect-username-password": "Εσφαλμένο όνομα χρήστη / κωδικός πρόσβασης.",
+ "message.invalid-domain": "Μη έγκυρος τομέας",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Δεν υπάρχουν διαθέσιμα δεδομένα.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Οι κωδικοί πρόσβασης δεν ταιριάζουν",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Δεν έχετε ρυθμίσει κανένα ιστότοπο.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Η σελίδα δεν βρέθηκε.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "Αποθηκεύτηκε επιτυχώς.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Αυτό είναι το κοινόχρηστο URL για το {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}"
+}
diff --git a/src/lang/en-GB.json b/src/lang/en-GB.json
new file mode 100644
index 0000000..7803dd6
--- /dev/null
+++ b/src/lang/en-GB.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Access code",
+ "label.actions": "Actions",
+ "label.activity": "Activity log",
+ "label.add": "Add",
+ "label.add-board": "Add board",
+ "label.add-description": "Add description",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "Add website",
+ "label.admin": "Administrator",
+ "label.affiliate": "Affiliate",
+ "label.after": "After",
+ "label.all": "All",
+ "label.all-time": "All time",
+ "label.analytics": "Analytics",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "Average",
+ "label.back": "Back",
+ "label.before": "Before",
+ "label.behavior": "Behavior",
+ "label.boards": "Boards",
+ "label.bounce-rate": "Bounce rate",
+ "label.breakdown": "Breakdown",
+ "label.browser": "Browser",
+ "label.browsers": "Browsers",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "Cancel",
+ "label.change-password": "Change password",
+ "label.channels": "Channels",
+ "label.cities": "Cities",
+ "label.city": "City",
+ "label.clear-all": "Clear all",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "Confirm password",
+ "label.contains": "Contains",
+ "label.content": "Content",
+ "label.continue": "Continue",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "Countries",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "Create report",
+ "label.create-team": "Create team",
+ "label.create-user": "Create user",
+ "label.created": "Created",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "Current password",
+ "label.custom-range": "Custom range",
+ "label.dashboard": "Dashboard",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "Date range",
+ "label.day": "Day",
+ "label.default-date-range": "Default date range",
+ "label.delete": "Delete",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Delete team",
+ "label.delete-user": "Delete user",
+ "label.delete-website": "Delete website",
+ "label.description": "Description",
+ "label.desktop": "Desktop",
+ "label.details": "Details",
+ "label.device": "Device",
+ "label.devices": "Devices",
+ "label.direct": "Direct",
+ "label.dismiss": "Dismiss",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Does not contain",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "Domain",
+ "label.dropoff": "Dropoff",
+ "label.edit": "Edit",
+ "label.edit-dashboard": "Edit dashboard",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "Enable share URL",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Event",
+ "label.event-data": "Event data",
+ "label.event-name": "Event name",
+ "label.events": "Events",
+ "label.exists": "Exists",
+ "label.exit": "Exit URL",
+ "label.false": "False",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "Filter",
+ "label.filter-combined": "Combined",
+ "label.filter-raw": "Raw",
+ "label.filters": "Filters",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Greater than",
+ "label.greater-than-equals": "Greater than or equals",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "Join",
+ "label.join-team": "Join team",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "Language",
+ "label.languages": "Languages",
+ "label.laptop": "Laptop",
+ "label.last-click": "Last click",
+ "label.last-days": "Last {x} days",
+ "label.last-hours": "Last {x} hours",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "Leave",
+ "label.leave-team": "Leave team",
+ "label.less-than": "Less than",
+ "label.less-than-equals": "Less than or equals",
+ "label.links": "Links",
+ "label.login": "Login",
+ "label.logout": "Logout",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "Members",
+ "label.min": "Min",
+ "label.mobile": "Mobile",
+ "label.model": "Model",
+ "label.more": "More",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "Name",
+ "label.new-password": "New password",
+ "label.none": "None",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "OS",
+ "label.other": "Other",
+ "label.overview": "Overview",
+ "label.owner": "Owner",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "Page views",
+ "label.pageTitle": "Page title",
+ "label.pages": "Pages",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "Password",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profile",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "Realtime",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "Referrers",
+ "label.refresh": "Refresh",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Remaining",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Required",
+ "label.reset": "Reset",
+ "label.reset-website": "Reset statistics",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Save",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "Sessions",
+ "label.settings": "Settings",
+ "label.share": "Share",
+ "label.share-url": "Share URL",
+ "label.single-day": "Single day",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Terms",
+ "label.theme": "Theme",
+ "label.this-month": "This month",
+ "label.this-week": "This week",
+ "label.this-year": "This year",
+ "label.timezone": "Timezone",
+ "label.title": "Title",
+ "label.today": "Today",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Tracking code",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Unique visitors",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Unknown",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Username",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "View details",
+ "label.view-only": "View only",
+ "label.views": "Views",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Visit duration",
+ "label.visitors": "Visitors",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Websites",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Are you sure you want to delete {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are you sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "All associated data will be deleted as well.",
+ "message.error": "Something went wrong.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Go to settings",
+ "message.incorrect-username-password": "Incorrect username/password.",
+ "message.invalid-domain": "Invalid domain",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "No data available.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Passwords don't match",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "You don't have any websites configured.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Page not found.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "Saved successfully.",
+ "message.sever-error": "Server error",
+ "message.share-url": "This is the publicly shared URL for {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}"
+}
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
new file mode 100644
index 0000000..3e588f5
--- /dev/null
+++ b/src/lang/en-US.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Access code",
+ "label.actions": "Actions",
+ "label.activity": "Activity",
+ "label.add": "Add",
+ "label.add-board": "Add board",
+ "label.add-description": "Add description",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "Add website",
+ "label.admin": "Admin",
+ "label.affiliate": "Affiliate",
+ "label.after": "After",
+ "label.all": "All",
+ "label.all-time": "All time",
+ "label.analytics": "Analytics",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "Average",
+ "label.back": "Back",
+ "label.before": "Before",
+ "label.boards": "Boards",
+ "label.bounce-rate": "Bounce rate",
+ "label.breakdown": "Breakdown",
+ "label.browser": "Browser",
+ "label.browsers": "Browsers",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "Cancel",
+ "label.change-password": "Change password",
+ "label.channels": "Channels",
+ "label.cities": "Cities",
+ "label.city": "City",
+ "label.clear-all": "Clear all",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "Confirm password",
+ "label.contains": "Contains",
+ "label.content": "Content",
+ "label.continue": "Continue",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "Countries",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "Create report",
+ "label.create-team": "Create team",
+ "label.create-user": "Create user",
+ "label.created": "Created",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "Current password",
+ "label.custom-range": "Custom range",
+ "label.dashboard": "Dashboard",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "Date range",
+ "label.day": "Day",
+ "label.default-date-range": "Default date range",
+ "label.delete": "Delete",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Delete team",
+ "label.delete-user": "Delete user",
+ "label.delete-website": "Delete website",
+ "label.description": "Description",
+ "label.desktop": "Desktop",
+ "label.details": "Details",
+ "label.device": "Device",
+ "label.devices": "Devices",
+ "label.direct": "Direct",
+ "label.dismiss": "Dismiss",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Does not contain",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "Domain",
+ "label.dropoff": "Dropoff",
+ "label.edit": "Edit",
+ "label.edit-dashboard": "Edit dashboard",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "Enable share URL",
+ "label.end-step": "End Step",
+ "label.entry": "Entry page",
+ "label.event": "Event",
+ "label.event-data": "Event data",
+ "label.event-name": "Event name",
+ "label.events": "Events",
+ "label.exists": "Exists",
+ "label.exit": "Exit page",
+ "label.false": "False",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "Filter",
+ "label.filter-combined": "Combined",
+ "label.filter-raw": "Raw",
+ "label.filters": "Filters",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Greater than",
+ "label.greater-than-equals": "Greater than or equals",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "Join",
+ "label.join-team": "Join team",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "Language",
+ "label.languages": "Languages",
+ "label.laptop": "Laptop",
+ "label.last-click": "Last click",
+ "label.last-days": "Last {x} days",
+ "label.last-hours": "Last {x} hours",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "Leave",
+ "label.leave-team": "Leave team",
+ "label.less-than": "Less than",
+ "label.less-than-equals": "Less than or equals",
+ "label.links": "Links",
+ "label.login": "Login",
+ "label.logout": "Logout",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Maximize",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "Members",
+ "label.min": "Min",
+ "label.mobile": "Mobile",
+ "label.model": "Model",
+ "label.more": "More",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "Name",
+ "label.new-password": "New password",
+ "label.none": "None",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "OS",
+ "label.other": "Other",
+ "label.overview": "Overview",
+ "label.owner": "Owner",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "Page views",
+ "label.pageTitle": "Page title",
+ "label.pages": "Pages",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "Password",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profile",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "Realtime",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "Referrers",
+ "label.refresh": "Refresh",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Remaining",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Required",
+ "label.reset": "Reset",
+ "label.reset-website": "Reset website",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue data and how users are spending.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Save",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "Sessions",
+ "label.settings": "Settings",
+ "label.share": "Share",
+ "label.share-url": "Share URL",
+ "label.single-day": "Single day",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Terms",
+ "label.theme": "Theme",
+ "label.this-month": "This month",
+ "label.this-week": "This week",
+ "label.this-year": "This year",
+ "label.timezone": "Timezone",
+ "label.title": "Title",
+ "label.today": "Today",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Tracking code",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Unique visitors",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Unknown",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Username",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "View details",
+ "label.view-only": "View only",
+ "label.views": "Views",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Visit duration",
+ "label.visitors": "Visitors",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Websites",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "label.behavior": "Behavior",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Are you sure you want to delete {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are you sure you want to reset {target}?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "All website data will be deleted.",
+ "message.error": "Something went wrong.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Go to settings",
+ "message.incorrect-username-password": "Incorrect username and/or password.",
+ "message.invalid-domain": "Invalid domain. Do not include http/https.",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "No data available.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Passwords do not match.",
+ "message.no-results-found": "No results found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "You do not have any websites configured.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Page not found",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your settings will remain intact.",
+ "message.saved": "Saved.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Your website stats are publicly available at the following URL:",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "To track stats for this website, place the following code in the <head>...</head> section of your HTML.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}"
+}
diff --git a/src/lang/es-ES.json b/src/lang/es-ES.json
new file mode 100644
index 0000000..e3a4d38
--- /dev/null
+++ b/src/lang/es-ES.json
@@ -0,0 +1,340 @@
+{
+ "label.access-code": "Código de acceso",
+ "label.actions": "Acciones",
+ "label.activity": "Registro de actividad",
+ "label.add": "Añadir",
+ "label.add-board": "Añadir tablero",
+ "label.add-description": "Añadir descripción",
+ "label.add-member": "Añadir miembro",
+ "label.add-step": "Añadir paso",
+ "label.add-website": "Nuevo sitio web",
+ "label.admin": "Administrador",
+ "label.affiliate": "Afiliado",
+ "label.after": "Después",
+ "label.all": "Todos",
+ "label.all-time": "Todos los tiempos",
+ "label.analytics": "Analíticas",
+ "label.apply": "Aplicar",
+ "label.attribution": "Atribución",
+ "label.attribution-description": "Vea cómo los usuarios interactúan con su marketing y qué impulsa las conversiones.",
+ "label.average": "Media",
+ "label.back": "Atrás",
+ "label.before": "Antes",
+ "label.boards": "Tableros",
+ "label.bounce-rate": "Porcentaje de rebote",
+ "label.breakdown": "Desglose",
+ "label.browser": "Navegador",
+ "label.browsers": "Navegadores",
+ "label.campaigns": "Campañas",
+ "label.cancel": "Cancelar",
+ "label.change-password": "Cambiar contraseña",
+ "label.channels": "Canales",
+ "label.cities": "Ciudades",
+ "label.city": "Ciudad",
+ "label.clear-all": "Limpiar todo",
+ "label.cohort": "Cohorte",
+ "label.compare": "Comparar",
+ "label.compare-dates": "Comparar fechas",
+ "label.confirm": "Confirmar",
+ "label.confirm-password": "Confirmar contraseña",
+ "label.contains": "Contiene",
+ "label.content": "Contenido",
+ "label.continue": "Continuar",
+ "label.conversion": "Conversión",
+ "label.conversion-rate": "Tasa de conversión",
+ "label.conversion-step": "Paso de conversión",
+ "label.count": "Contar",
+ "label.countries": "Países",
+ "label.country": "País",
+ "label.create": "Crear",
+ "label.create-report": "Crear informe",
+ "label.create-team": "Crear equipo",
+ "label.create-user": "Crear usuario",
+ "label.created": "Creado",
+ "label.created-by": "Creado por",
+ "label.currency": "Moneda",
+ "label.current": "Actual",
+ "label.current-password": "Contraseña actual",
+ "label.custom-range": "Intervalo personalizado",
+ "label.dashboard": "Panel de control",
+ "label.data": "Datos",
+ "label.date": "Fecha",
+ "label.date-range": "Intervalo de fechas",
+ "label.day": "Día",
+ "label.default-date-range": "Intervalo por defecto",
+ "label.delete": "Eliminar",
+ "label.delete-report": "Eliminar reporte",
+ "label.delete-team": "Eliminar equipo",
+ "label.delete-user": "Eliminar usuario",
+ "label.delete-website": "Eliminar sitio",
+ "label.description": "Descripción",
+ "label.desktop": "Escritorio",
+ "label.details": "Detalles",
+ "label.device": "Dispositivo",
+ "label.devices": "Dispositivos",
+ "label.direct": "Directo",
+ "label.dismiss": "Cerrar",
+ "label.distinct-id": "ID distinto",
+ "label.does-not-contain": "No contiene",
+ "label.does-not-include": "No incluye",
+ "label.doest-not-exist": "No existe",
+ "label.domain": "Dominio",
+ "label.dropoff": "Abandono",
+ "label.edit": "Editar",
+ "label.edit-dashboard": "Editar panel",
+ "label.edit-member": "Editar miembro",
+ "label.email": "Email",
+ "label.enable-share-url": "Habilitar compartir URL",
+ "label.end-step": "Paso final",
+ "label.entry": "URL de entrada",
+ "label.event": "Evento",
+ "label.event-data": "Datos de evento",
+ "label.event-name": "Nombre del evento",
+ "label.events": "Eventos",
+ "label.exists": "Existe",
+ "label.exit": "URL de salida",
+ "label.false": "Falso",
+ "label.field": "Campo",
+ "label.fields": "Campos",
+ "label.filter": "Filtro",
+ "label.filter-combined": "Combinado",
+ "label.filter-raw": "En crudo",
+ "label.filters": "Filtros",
+ "label.first-click": "Primer clic",
+ "label.first-seen": "Primera vez visto",
+ "label.funnel": "Embudo",
+ "label.funnel-description": "Comprender conversión y abandono de usuarios.",
+ "label.funnels": "Embudos",
+ "label.goal": "Objetivo",
+ "label.goals": "Objetivos",
+ "label.goals-description": "Realice un seguimiento de sus objetivos de páginas vistas y eventos.",
+ "label.greater-than": "Mayor que",
+ "label.greater-than-equals": "Mayor que o igual a",
+ "label.grouped": "Agrupado",
+ "label.hostname": "Nombre de host",
+ "label.includes": "Incluye",
+ "label.insight": "Perspectiva",
+ "label.insights": "Perspectivas",
+ "label.insights-description": "Profundice en sus datos mediante el uso de segmentos y filtros.",
+ "label.is": "Es igual a",
+ "label.is-false": "Es falso",
+ "label.is-not": "No es igual a",
+ "label.is-not-set": "No está establecido",
+ "label.is-set": "Está establecido",
+ "label.is-true": "Es verdadero",
+ "label.join": "Unir",
+ "label.join-team": "Unirse al equipo",
+ "label.journey": "Viaje",
+ "label.journey-description": "Comprenda cómo los usuarios navegan por su sitio web.",
+ "label.journeys": "Viajes",
+ "label.language": "Idioma",
+ "label.languages": "Idiomas",
+ "label.laptop": "Portátil",
+ "label.last-click": "Último clic",
+ "label.last-days": "Últimos {x} días",
+ "label.last-hours": "Últimas {x} horas",
+ "label.last-months": "Últimos {x} meses",
+ "label.last-seen": "Visto por última vez",
+ "label.leave": "Abandonar",
+ "label.leave-team": "Abandonar equipo",
+ "label.less-than": "Menor que",
+ "label.less-than-equals": "Menor que o igual a",
+ "label.links": "Enlaces",
+ "label.login": "Iniciar sesión",
+ "label.logout": "Cerrar sesión",
+ "label.manage": "Administrar",
+ "label.manager": "Gerente",
+ "label.max": "Máximo",
+ "label.maximize": "Expandir",
+ "label.medium": "Medio",
+ "label.member": "Miembro",
+ "label.members": "Miembros",
+ "label.min": "Mínimo",
+ "label.mobile": "Móvil",
+ "label.model": "Modelo",
+ "label.more": "Más",
+ "label.my-account": "Mi cuenta",
+ "label.my-websites": "Mis sitios web",
+ "label.name": "Nombre",
+ "label.new-password": "Nueva contraseña",
+ "label.none": "Ninguno",
+ "label.number-of-records": "{x} {x, plural, one {registro} other {registros}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Búsqueda orgánica",
+ "label.organic-shopping": "Compras orgánicas",
+ "label.organic-social": "Social orgánico",
+ "label.organic-video": "Video orgánico",
+ "label.os": "Sistema",
+ "label.other": "Otro",
+ "label.overview": "Resumen",
+ "label.owner": "Propietario",
+ "label.page": "Página",
+ "label.page-of": "Página {current} de {total}",
+ "label.page-views": "Vistas",
+ "label.pageTitle": "Título de página",
+ "label.pages": "Páginas",
+ "label.paid-ads": "Anuncios pagados",
+ "label.paid-search": "Búsqueda pagada",
+ "label.paid-shopping": "Compras pagadas",
+ "label.paid-social": "Social pagado",
+ "label.paid-video": "Video pagado",
+ "label.password": "Contraseña",
+ "label.path": "Ruta",
+ "label.paths": "Rutas",
+ "label.pixels": "Píxeles",
+ "label.powered-by": "Analíticas de {name}",
+ "label.previous": "Anterior",
+ "label.previous-period": "Periodo anterior",
+ "label.previous-year": "Año anterior",
+ "label.profile": "Perfil",
+ "label.properties": "Propiedades",
+ "label.property": "Propiedad",
+ "label.queries": "Consultas",
+ "label.query": "Consulta",
+ "label.query-parameters": "Parámetros de consulta",
+ "label.realtime": "Tiempo real",
+ "label.referral": "Referencia",
+ "label.referrer": "Referido",
+ "label.referrers": "Referido desde",
+ "label.refresh": "Actualizar",
+ "label.regenerate": "Regenerar",
+ "label.region": "Región",
+ "label.regions": "Regiones",
+ "label.remaining": "Restante",
+ "label.remove": "Quitar",
+ "label.remove-member": "Eliminar miembro",
+ "label.reports": "Informes",
+ "label.required": "Obligatorio",
+ "label.reset": "Reiniciar",
+ "label.reset-website": "Reiniciar analíticas",
+ "label.retention": "Retención",
+ "label.retention-description": "Medir la frecuencia con la que los usuarios vuelven a tu sitio web.",
+ "label.revenue": "Ganancias",
+ "label.revenue-description": "Analice sus ganancias a lo largo del tiempo.",
+ "label.revenue-property": "Propiedad de ganancias",
+ "label.role": "Rol",
+ "label.run-query": "Ejecutar consulta",
+ "label.save": "Guardar",
+ "label.screens": "Pantallas",
+ "label.search": "Buscar",
+ "label.select": "Seleccionar",
+ "label.select-date": "Seleccionar fecha",
+ "label.select-filter": "Seleccionar filtro",
+ "label.select-role": "Seleccionar rol",
+ "label.select-website": "Seleccionar sitio web",
+ "label.session": "Sesión",
+ "label.sessions": "Sesiones",
+ "label.settings": "Ajustes",
+ "label.share": "Compartir",
+ "label.share-url": "Compartir URL",
+ "label.single-day": "Un solo día",
+ "label.sms": "SMS",
+ "label.sources": "Fuentes",
+ "label.start-step": "Paso inicial",
+ "label.steps": "Pasos",
+ "label.sum": "Suma",
+ "label.tablet": "Tableta",
+ "label.tag": "Etiqueta",
+ "label.tags": "Etiquetas",
+ "label.team": "Equipo",
+ "label.team-id": "ID del equipo",
+ "label.team-manager": "Jefe de equipo",
+ "label.team-member": "Miembro del equipo",
+ "label.team-name": "Nombre del equipo",
+ "label.team-owner": "Admin. del equipo",
+ "label.team-settings": "Configuración del equipo",
+ "label.team-view-only": "Vista solo del equipo",
+ "label.team-websites": "Sitios web del equipo",
+ "label.teams": "Equipos",
+ "label.terms": "Términos",
+ "label.theme": "Tema",
+ "label.this-month": "Este mes",
+ "label.this-week": "Esta semana",
+ "label.this-year": "Este año",
+ "label.timezone": "Zona horaria",
+ "label.title": "Título",
+ "label.today": "Hoy",
+ "label.toggle-charts": "Alternar gráficas",
+ "label.total": "Total",
+ "label.total-records": "Total de registros",
+ "label.tracking-code": "Código de rastreo",
+ "label.transactions": "Transacciones",
+ "label.transfer": "Transferir",
+ "label.transfer-website": "Transferir sitio web",
+ "label.true": "Verdadero",
+ "label.type": "Tipo",
+ "label.unique": "Único",
+ "label.unique-visitors": "Visitantes únicos",
+ "label.uniqueCustomers": "Clientes únicos",
+ "label.unknown": "Desconocida",
+ "label.untitled": "Sin título",
+ "label.update": "Actualizar",
+ "label.user": "Usuario",
+ "label.user-property": "Propiedad de usuario",
+ "label.username": "Nombre de usuario",
+ "label.users": "Usuarios",
+ "label.utm": "UTM",
+ "label.utm-description": "Realice un seguimiento de sus campañas a través de parámetros UTM.",
+ "label.value": "Valor",
+ "label.view": "Visualizar",
+ "label.view-details": "Ver detalles",
+ "label.view-only": "Ver sólo",
+ "label.views": "Vistas",
+ "label.views-per-visit": "Vistas por visita",
+ "label.visit-duration": "Tiempo promedio de visita",
+ "label.visitors": "Visitantes",
+ "label.visits": "Visitas",
+ "label.website": "Sitio web",
+ "label.website-id": "ID del sitio web",
+ "label.websites": "Sitios web",
+ "label.window": "Ventana",
+ "label.yesterday": "Ayer",
+ "label.behavior": "Comportamiento",
+ "message.action-confirmation": "Escriba {confirmation} en el cuadro a continuación para confirmar.",
+ "message.active-users": "{x} {x, plural, one {activo} other {activos}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Datos obtenidos",
+ "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}?",
+ "message.confirm-reset": "¿Seguro que quieres BORRAR las analíticas de {target}?",
+ "message.delete-team-warning": "Al eliminar un equipo, también se eliminarán todos los sitios web del equipo.",
+ "message.delete-website-warning": "Toda la información relacionada será eliminada.",
+ "message.error": "Algo falló.",
+ "message.event-log": "{event} en {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Ir a la configuración",
+ "message.incorrect-username-password": "Nombre de usuario o contraseña incorrectos.",
+ "message.invalid-domain": "Dominio inválido",
+ "message.min-password-length": "Longitud mínima de {n} caracteres",
+ "message.new-version-available": "Una nueva versión de Umami {version} está disponible",
+ "message.no-data-available": "No hay información disponible.",
+ "message.no-event-data": "No hay datos de eventos disponibles.",
+ "message.no-match-password": "Las contraseñas no coinciden",
+ "message.no-results-found": "No se encontraron resultados.",
+ "message.no-team-websites": "Este equipo no tiene ningún sitio web configurado.",
+ "message.no-teams": "No has creado ningún equipo.",
+ "message.no-users": "No hay usuarios.",
+ "message.no-websites-configured": "No tienes ningún sitio web configurado.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Página no encontrada",
+ "message.reset-website": "Para reiniciar este sitio web, escribe {confirmation} a continuación para confirmar.",
+ "message.reset-website-warning": "Todas las estadísticas de esta página serán eliminadas, pero el código de rastreo permanecerá intacto.",
+ "message.saved": "Guardado",
+ "message.sever-error": "Server error",
+ "message.share-url": "Esta es la URL pública para {target}.",
+ "message.team-already-member": "Ya eres miembro de este equipo.",
+ "message.team-not-found": "Equipo no encontrado.",
+ "message.team-websites-info": "Las analíticas de tus sitios web pueden ser vistas por cualquier miembro del equipo.",
+ "message.tracking-code": "Código de rastreo",
+ "message.transfer-team-website-to-user": "¿Transferir este sitio web a su cuenta?",
+ "message.transfer-user-website-to-team": "Seleccione el equipo al que transferir este sitio web.",
+ "message.transfer-website": "Seleccione el equipo al que transferir este sitio web.",
+ "message.triggered-event": "Evento lanzado",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Usuario eliminado.",
+ "message.viewed-page": "Página vista",
+ "message.visitor-log": "Visitante desde {country} usando {browser} en {os} {device}"
+}
diff --git a/src/lang/fa-IR.json b/src/lang/fa-IR.json
new file mode 100644
index 0000000..96b3da9
--- /dev/null
+++ b/src/lang/fa-IR.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "کد دسترسی",
+ "label.actions": "اقدامات",
+ "label.activity": "فعالیت",
+ "label.add": "افزودن",
+ "label.add-board": "افزودن برد",
+ "label.add-description": "افزودن توضیحات",
+ "label.add-member": "افزودن عضو",
+ "label.add-step": "افزودن قدم",
+ "label.add-website": "افزودن وب‌سایت",
+ "label.admin": "مدیر",
+ "label.affiliate": "همکار فروش",
+ "label.after": "بعد",
+ "label.all": "همه",
+ "label.all-time": "تمامی زمان‌ها",
+ "label.analytics": "تجزیه و تحلیل",
+ "label.apply": "اعمال",
+ "label.attribution": "انتساب",
+ "label.attribution-description": "ببینید کاربران چگونه با بازاریابی شما تعامل دارند و چه چیزی باعث تبدیل می‌شود.",
+ "label.average": "میانگین",
+ "label.back": "بازگشت",
+ "label.before": "قبل از",
+ "label.behavior": "رفتار",
+ "label.boards": "بردها",
+ "label.bounce-rate": "نرخ ریزش",
+ "label.breakdown": "تفکیک",
+ "label.browser": "مرورگر",
+ "label.browsers": "مرورگرها",
+ "label.campaigns": "کمپین‌ها",
+ "label.cancel": "انصراف",
+ "label.change-password": "تغییر رمز",
+ "label.channels": "کانال‌ها",
+ "label.cities": "شهرها",
+ "label.city": "شهر",
+ "label.clear-all": "پاک کردن همه",
+ "label.cohort": "گروه",
+ "label.compare": "مقایسه",
+ "label.compare-dates": "مقایسه تاریخ‌ها",
+ "label.confirm": "تأیید",
+ "label.confirm-password": "تأیید رمز",
+ "label.contains": "شامل",
+ "label.content": "محتوا",
+ "label.continue": "ادامه",
+ "label.conversion": "تبدیل",
+ "label.conversion-rate": "نرخ تبدیل",
+ "label.conversion-step": "گام تبدیل",
+ "label.count": "تعداد",
+ "label.countries": "کشورها",
+ "label.country": "کشور",
+ "label.create": "ایجاد",
+ "label.create-report": "ایجاد گزارش",
+ "label.create-team": "ایجاد تیم",
+ "label.create-user": "ایجاد کاربر",
+ "label.created": "ایجاد شد",
+ "label.created-by": "ایجاد شده توسط",
+ "label.currency": "واحد پول",
+ "label.current": "فعلی",
+ "label.current-password": "رمز فعلی",
+ "label.custom-range": "محدوده‌ی دلخواه",
+ "label.dashboard": "داشبورد",
+ "label.data": "داده",
+ "label.date": "تاریخ",
+ "label.date-range": "محدوده‌ی تاریخ",
+ "label.day": "روز",
+ "label.default-date-range": "محدوده‌ی پیش‌فرض تاریخ",
+ "label.delete": "حذف",
+ "label.delete-report": "حذف گزارش",
+ "label.delete-team": "حذف تیم",
+ "label.delete-user": "حذف کاربر",
+ "label.delete-website": "حذف وب‌سایت",
+ "label.description": "توضیحات",
+ "label.desktop": "دسکتاپ",
+ "label.details": "جزئیات",
+ "label.device": "دستگاه",
+ "label.devices": "دستگاه‌ها",
+ "label.direct": "مستقیم",
+ "label.dismiss": "رد کردن",
+ "label.distinct-id": "شناسه یکتا",
+ "label.does-not-contain": "شامل نمی‌شود",
+ "label.does-not-include": "شامل نمی‌شود",
+ "label.doest-not-exist": "وجود ندارد",
+ "label.domain": "دامنه",
+ "label.dropoff": "رها کردن",
+ "label.edit": "ویرایش",
+ "label.edit-dashboard": "ویرایش داشبورد",
+ "label.edit-member": "ویرایش عضو",
+ "label.email": "ایمیل",
+ "label.enable-share-url": "فعال کردن اشتراک گذاری آدرس اینترنتی",
+ "label.end-step": "قدم پایانی",
+ "label.entry": "آدرس اینترنتی ورودی",
+ "label.event": "رویداد",
+ "label.event-data": "داده‌های رویداد",
+ "label.event-name": "نام رویداد",
+ "label.events": "رویدادها",
+ "label.exists": "وجود دارد",
+ "label.exit": "آدرس اینترنتی خروجی",
+ "label.false": "نادرست",
+ "label.field": "فیلد",
+ "label.fields": "فیلد‌ها",
+ "label.filter": "فیلتر",
+ "label.filter-combined": "ترکیب شده",
+ "label.filter-raw": "خام",
+ "label.filters": "فیلترها",
+ "label.first-click": "اولین کلیک",
+ "label.first-seen": "اولین بار دیده شده",
+ "label.funnel": "فانل",
+ "label.funnel-description": "نرخ تبدیل و رها کردن کاربران را درک کنید.",
+ "label.funnels": "قیف‌ها",
+ "label.goal": "هدف",
+ "label.goals": "اهداف",
+ "label.goals-description": "اهداف خود را برای بازدید از صفحه و رویدادها دنبال کنید.",
+ "label.greater-than": "بزرگ‌تر از",
+ "label.greater-than-equals": "بزرگ‌تر یا مساوی",
+ "label.grouped": "گروه‌بندی شده",
+ "label.hostname": "نام میزبان",
+ "label.includes": "شامل می‌شود",
+ "label.insight": "بینش",
+ "label.insights": "بینش",
+ "label.insights-description": "با استفاده از بخش‌ها و فیلترها، در داده‌های خود عمیق‌تر شوید.",
+ "label.is": "برابر است با",
+ "label.is-false": "نادرست است",
+ "label.is-not": "برابر نیست با",
+ "label.is-not-set": "تعیین نشده",
+ "label.is-set": "تعیین شده",
+ "label.is-true": "درست است",
+ "label.join": "پیوستن",
+ "label.join-team": "پیوستن به تیم",
+ "label.journey": "مسیر",
+ "label.journey-description": "درک کنید که کاربران چگونه در وب‌سایت شما حرکت می کنند.",
+ "label.journeys": "مسیرها",
+ "label.language": "زبان",
+ "label.languages": "زبان‌ها",
+ "label.laptop": "لپ‌تاپ",
+ "label.last-click": "آخرین کلیک",
+ "label.last-days": "{x} روز گذشته",
+ "label.last-hours": "{x} ساعت گذشته",
+ "label.last-months": "{x} ماه گذشته",
+ "label.last-seen": "آخرین بار دیده شده",
+ "label.leave": "ترک کردن",
+ "label.leave-team": "ترک تیم",
+ "label.less-than": "کمتر از",
+ "label.less-than-equals": "کمتر یا مساوی",
+ "label.links": "لینک‌ها",
+ "label.login": "ورود",
+ "label.logout": "خروج",
+ "label.manage": "مدیریت",
+ "label.manager": "مدیر",
+ "label.max": "حداکثر",
+ "label.maximize": "گسترش",
+ "label.medium": "متوسط",
+ "label.member": "عضو",
+ "label.members": "اعضا",
+ "label.min": "حداقل",
+ "label.mobile": "موبایل",
+ "label.model": "مدل",
+ "label.more": "بیشتر",
+ "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": "تایید",
+ "label.online": "Online",
+ "label.organic-search": "جستجوی ارگانیک",
+ "label.organic-shopping": "خرید ارگانیک",
+ "label.organic-social": "شبکه اجتماعی ارگانیک",
+ "label.organic-video": "ویدیوی ارگانیک",
+ "label.os": "سیستم عامل",
+ "label.other": "سایر",
+ "label.overview": "بررسی کلی",
+ "label.owner": "مالک",
+ "label.page": "صفحه",
+ "label.page-of": "صفحه {current} از {total}",
+ "label.page-views": "بازدید صفحه",
+ "label.pageTitle": "عنوان صفحه",
+ "label.pages": "صفحه‌ها",
+ "label.paid-ads": "تبلیغات پولی",
+ "label.paid-search": "جستجوی پولی",
+ "label.paid-shopping": "خرید پولی",
+ "label.paid-social": "شبکه اجتماعی پولی",
+ "label.paid-video": "ویدیوی پولی",
+ "label.password": "رمز",
+ "label.path": "مسیر",
+ "label.paths": "مسیرها",
+ "label.pixels": "پیکسل‌ها",
+ "label.powered-by": "قدرت گرفته توسط {name}",
+ "label.previous": "قبلی",
+ "label.previous-period": "دوره‌ی قبل",
+ "label.previous-year": "سال قبل",
+ "label.profile": "پروفایل",
+ "label.properties": "ویژگی‌ها",
+ "label.property": "ویژگی",
+ "label.queries": "کوئری‌ها",
+ "label.query": "کوئری",
+ "label.query-parameters": "پارامترهای کوئری",
+ "label.realtime": "آمار زنده",
+ "label.referral": "ارجاع",
+ "label.referrer": "ارجاع دهنده",
+ "label.referrers": "ارجاع دهندگان",
+ "label.refresh": "به‌روزرسانی",
+ "label.regenerate": "تولید مجدد",
+ "label.region": "منطقه",
+ "label.regions": "مناطق",
+ "label.remaining": "باقی‌مانده",
+ "label.remove": "حذف",
+ "label.remove-member": "حذف عضو",
+ "label.reports": "گزارش‌ها",
+ "label.required": "ضروری",
+ "label.reset": "بازنشانی",
+ "label.reset-website": "بازنشانی وب‌سایت",
+ "label.retention": "نرخ بازگشت",
+ "label.retention-description": "چسبندگی وب‌سایت خود را با دنبال کردن تعداد دفعات بازگشت کاربران اندازه‌گیری کنید.",
+ "label.revenue": "درآمد",
+ "label.revenue-description": "به درآمد خود در طول زمان نگاه کنید.",
+ "label.role": "نقش",
+ "label.run-query": "اجرای کوئری",
+ "label.save": "ذخیره",
+ "label.screens": "صفحه",
+ "label.search": "جستجو",
+ "label.select": "انتخاب",
+ "label.select-date": "انتخاب تاریخ",
+ "label.select-filter": "انتخاب فیلتر",
+ "label.select-role": "انتخاب نقش",
+ "label.select-website": "انتخاب وب‌سایت",
+ "label.session": "نشست",
+ "label.session-data": "داده‌های نشست",
+ "label.sessions": "نشست‌ها",
+ "label.settings": "تنظیمات",
+ "label.share": "اشتراک‌گذاری",
+ "label.share-url": "به اشتراک گذاری آدرس اینترنتی",
+ "label.single-day": "یک روز",
+ "label.sms": "SMS",
+ "label.sources": "منابع",
+ "label.start-step": "قدم شروع",
+ "label.steps": "قدم‌ها",
+ "label.sum": "جمع",
+ "label.tablet": "تبلت",
+ "label.tag": "برچسب",
+ "label.tags": "برچسب‌ها",
+ "label.team": "تیم",
+ "label.team-id": "شناسه تیم",
+ "label.team-manager": "مدیر تیم",
+ "label.team-member": "عضو تیم",
+ "label.team-name": "نام تیم",
+ "label.team-owner": "مالک تیم",
+ "label.team-settings": "تنظیمات تیم",
+ "label.team-view-only": "فقط مشاهده‌ی تیم",
+ "label.team-websites": "وب‌سایت‌های تیم",
+ "label.teams": "تیم‌ها",
+ "label.terms": "شرایط",
+ "label.theme": "تم",
+ "label.this-month": "این ماه",
+ "label.this-week": "این هفته",
+ "label.this-year": "امسال",
+ "label.timezone": "منطقه‌ی زمانی",
+ "label.title": "عنوان",
+ "label.today": "امروز",
+ "label.toggle-charts": "نمایش / عدم نمایش نمودارها",
+ "label.total": "جمع",
+ "label.total-records": "جمع رکوردها",
+ "label.tracking-code": "کد رهگیری",
+ "label.transactions": "تراکنش‌ها",
+ "label.transfer": "انتقال",
+ "label.transfer-website": "انتقال وب‌سایت",
+ "label.true": "درست",
+ "label.type": "نوع",
+ "label.unique": "یکتا",
+ "label.unique-visitors": "بازدیدکننده‌های یکتا",
+ "label.uniqueCustomers": "مشتریان یکتا",
+ "label.unknown": "ناشناخته",
+ "label.untitled": "بدون عنوان",
+ "label.update": "به‌روزرسانی",
+ "label.user": "کاربر",
+ "label.username": "نام کاربری",
+ "label.users": "کاربران",
+ "label.utm": "UTM",
+ "label.utm-description": "با استفاده از پارامترهای UTM، کمپین‌های خود را بررسی کنید.",
+ "label.value": "مقدار",
+ "label.view": "مشاهده",
+ "label.view-details": "مشاهده‌ی جزئیات",
+ "label.view-only": "فقط مشاهده",
+ "label.views": "بازدید",
+ "label.views-per-visit": "نمایش‌ها در هر بازدید",
+ "label.visit-duration": "میانگین زمان بازدید",
+ "label.visitors": "بازدیدکننده",
+ "label.visits": "بازدیدها",
+ "label.website": "وب‌سایت",
+ "label.website-id": "شناسه وب‌سایت",
+ "label.websites": "وب‌سایت‌ها",
+ "label.window": "پنجره",
+ "label.yesterday": "دیروز",
+ "message.action-confirmation": "برای تأیید این عملیات، لطفاً {confirmation} را تایپ کنید.",
+ "message.active-users": "{x} فعلی {x, plural, one {یک} other {از میان}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "داده‌های جمع‌آوری شده",
+ "message.confirm-delete": "آیا مطمئن هستید می‌خواهید {target} را حذف کنید؟",
+ "message.confirm-leave": "آیا مطمئن هستید می‌خواهید از {target} خارج شوید؟",
+ "message.confirm-remove": "آیا مطمئن هستید می‌خواهید {target} را حذف کنید؟",
+ "message.confirm-reset": "آیا مطمئن هستید می‌خواهید {target} را بازنشانی کنید؟",
+ "message.delete-team-warning": "با حذف تیم، تمامی وب‌سایت‌های تیم هم حذف خواهند شد.",
+ "message.delete-website-warning": "همه‌ی داده‌های وب‌سایت هم حذف خواهد شد.",
+ "message.error": "مشکلی پیش آمده است.",
+ "message.event-log": "{event} در {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "رفتن به تنظیمات",
+ "message.incorrect-username-password": "نام کاربری / رمز نادرست است.",
+ "message.invalid-domain": "دامنه نامعتبر است.",
+ "message.min-password-length": "حداقل طول {n} کاراکتر است.",
+ "message.new-version-available": "نسخه‌ی جدیدی از Umami {version} در دسترس است.",
+ "message.no-data-available": "اطلاعاتی موجود نیست.",
+ "message.no-event-data": "هیچ داده‌ای برای این رویداد وجود ندارد.",
+ "message.no-match-password": "رمزها یکسان نیستند",
+ "message.no-results-found": "نتیجه‌ای یافت نشد.",
+ "message.no-team-websites": "هیچ وب‌سایتی برای این تیم وجود ندارد.",
+ "message.no-teams": "شما هیچ تیمی را ایجاد نکرده‌اید.",
+ "message.no-users": "هیچ کاربری وجود ندارد.",
+ "message.no-websites-configured": "شما هیچ وب‌سایتی را پیکربندی نکرده‌اید.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "صفحه یافت نشد.",
+ "message.reset-website": "برای بازنشانی وب‌سایت، لطفاً {confirmation} را تایپ کنید.",
+ "message.reset-website-warning": "تمامی آمارهای این وب‌سایت حذف خواهد شد اما کدهای رهگیری بدون تغییر باقی می‌ماند.",
+ "message.saved": "ذخیره شد.",
+ "message.sever-error": "Server error",
+ "message.share-url": "آمار وب‌سایت شما به صورت عمومی در آدرس زیر قابل مشاهده است.",
+ "message.team-already-member": "شما از قبل عضو این تیم هستید.",
+ "message.team-not-found": "تیم یافت نشد.",
+ "message.team-websites-info": "وب‌سایت‌ها توسط تمامی اعضای تیم قابل مشاهده هستند.",
+ "message.tracking-code": "کد رهگیری",
+ "message.transfer-team-website-to-user": "آیا می‌خواهید این وب‌سایت را به حساب خود منتقل کنید؟",
+ "message.transfer-user-website-to-team": "تیم مورد نظر را برای انتقال وب‌سایت انتخاب کنید.",
+ "message.transfer-website": "مالکیت وب‌سایت را به حساب خودت یا یک تیم دیگر منتقل کنید.",
+ "message.triggered-event": "رویداد فعال شده",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "کاربر حذف شد.",
+ "message.viewed-page": "صفحه مشاهده شد",
+ "message.visitor-log": "بازدیدکننده از کشور {country} با مروگر {browser} در {os} {device}"
+}
diff --git a/src/lang/fi-FI.json b/src/lang/fi-FI.json
new file mode 100644
index 0000000..daaa62f
--- /dev/null
+++ b/src/lang/fi-FI.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Pääsykoodi",
+ "label.actions": "Toiminnat",
+ "label.activity": "Toimintaloki",
+ "label.add": "Lisää",
+ "label.add-board": "Lisää taulu",
+ "label.add-description": "Lisää kuvaus",
+ "label.add-member": "Lisää jäsen",
+ "label.add-step": "Lisää vaihe",
+ "label.add-website": "Lisää verkkosivu",
+ "label.admin": "Järjestelmänvalvoja",
+ "label.affiliate": "Kumppani",
+ "label.after": "Jälkeen",
+ "label.all": "Kaikki",
+ "label.all-time": "Alusta lähtien",
+ "label.analytics": "Analytiikka",
+ "label.apply": "Käytä",
+ "label.attribution": "Attribuutio",
+ "label.attribution-description": "Katso, miten käyttäjät ovat vuorovaikutuksessa markkinointisi kanssa ja mikä johtaa konversioihin.",
+ "label.average": "Keskiarvo",
+ "label.back": "Takaisin",
+ "label.before": "Ennen",
+ "label.boards": "Taulut",
+ "label.bounce-rate": "Välitön poistuminen",
+ "label.breakdown": "Erittele",
+ "label.browser": "Selain",
+ "label.browsers": "Selaimet",
+ "label.campaigns": "Kampanjat",
+ "label.cancel": "Peruuta",
+ "label.change-password": "Vaihda salasana",
+ "label.channels": "Kanavat",
+ "label.cities": "Kaupungit",
+ "label.city": "Kaupunki",
+ "label.clear-all": "Tyhjennä kaikki",
+ "label.cohort": "Kohortti",
+ "label.compare": "Vertaa",
+ "label.compare-dates": "Vertaa päivämääriä",
+ "label.confirm": "Vahvista",
+ "label.confirm-password": "Vahvista salasana",
+ "label.contains": "Contains",
+ "label.content": "Sisältö",
+ "label.continue": "Jatka",
+ "label.conversion": "Konversio",
+ "label.conversion-rate": "Konversioprosentti",
+ "label.conversion-step": "Konversiovaihe",
+ "label.count": "Lukumäärä",
+ "label.countries": "Maat",
+ "label.country": "Maa",
+ "label.create": "Luo",
+ "label.create-report": "Luo raportti",
+ "label.create-team": "Luo tiimi",
+ "label.create-user": "Luo käyttäjä",
+ "label.created": "Luotu",
+ "label.created-by": "Luonut",
+ "label.currency": "Valuutta",
+ "label.current": "Nykyinen",
+ "label.current-password": "Nykyinen salasana",
+ "label.custom-range": "Mukautettu ajanjakso",
+ "label.dashboard": "Ohjauspaneeli",
+ "label.data": "Data",
+ "label.date": "Päivämäärä",
+ "label.date-range": "Ajanjakso",
+ "label.day": "Päivä",
+ "label.default-date-range": "Oletusajanjakso",
+ "label.delete": "Poista",
+ "label.delete-report": "Poista raportti",
+ "label.delete-team": "Poista tiimi",
+ "label.delete-user": "Poista käyttäjä",
+ "label.delete-website": "Poista verkkosivu",
+ "label.description": "Kuvaus",
+ "label.desktop": "Pöytäkone",
+ "label.details": "Tiedot",
+ "label.device": "Laite",
+ "label.devices": "Laitteet",
+ "label.direct": "Suora",
+ "label.dismiss": "Hylkää",
+ "label.distinct-id": "Yksilöllinen ID",
+ "label.does-not-contain": "Ei sisällä",
+ "label.does-not-include": "Ei sisällä",
+ "label.doest-not-exist": "Ei ole olemassa",
+ "label.domain": "Verkkotunnus",
+ "label.dropoff": "Poistuminen",
+ "label.edit": "Muokkaa",
+ "label.edit-dashboard": "Muokkaa ohjauspaneelia",
+ "label.edit-member": "Muokkaa jäsentä",
+ "label.email": "Sähköposti",
+ "label.enable-share-url": "Ota jakamisen URL-osoite käyttöön",
+ "label.end-step": "Loppuvaihe",
+ "label.entry": "Tulo-URL",
+ "label.event": "Tapahtuma",
+ "label.event-data": "Tapahtumatiedot",
+ "label.event-name": "Tapahtuman nimi",
+ "label.events": "Tapahtumat",
+ "label.exists": "On olemassa",
+ "label.exit": "Poistumis-URL",
+ "label.false": "Epätosi",
+ "label.field": "Kenttä",
+ "label.fields": "Kentät",
+ "label.filter": "Filter",
+ "label.filter-combined": "Yhdistetty",
+ "label.filter-raw": "Käsittelemätön",
+ "label.filters": "Suodattimet",
+ "label.first-click": "Ensimmäinen klikkaus",
+ "label.first-seen": "Ensimmäinen havainto",
+ "label.funnel": "Suppilo",
+ "label.funnel-description": "Ymmärrä käyttäjien konversio- ja poistumisprosentti.",
+ "label.funnels": "Suppilot",
+ "label.goal": "Tavoite",
+ "label.goals": "Tavoitteet",
+ "label.goals-description": "Seuraa sivun katselujen ja tapahtumien tavoitteitasi.",
+ "label.greater-than": "Suurempi kuin",
+ "label.greater-than-equals": "Suurempi tai yhtä suuri kuin",
+ "label.grouped": "Ryhmitelty",
+ "label.hostname": "Isäntänimi",
+ "label.includes": "Sisältää",
+ "label.insight": "Oivallus",
+ "label.insights": "Oivallukset",
+ "label.insights-description": "Sukella syvemmälle tietoihisi käyttämällä segmenttejä ja suodattimia.",
+ "label.is": "On",
+ "label.is-false": "On epätosi",
+ "label.is-not": "Ei ole",
+ "label.is-not-set": "Ei asetettu",
+ "label.is-set": "Asetettu",
+ "label.is-true": "On tosi",
+ "label.join": "Liity",
+ "label.join-team": "Liity tiimiin",
+ "label.journey": "Polku",
+ "label.journey-description": "Ymmärrä, miten käyttäjät navigoivat sivustollasi.",
+ "label.journeys": "Polut",
+ "label.language": "Kieli",
+ "label.languages": "Kielet",
+ "label.laptop": "Kannettava tietokone",
+ "label.last-click": "Viimeinen klikkaus",
+ "label.last-days": "Viimeisimmät {x} päivää",
+ "label.last-hours": "Viimeisimmät {x} tuntia",
+ "label.last-months": "Viimeiset {x} kuukautta",
+ "label.last-seen": "Viimeksi nähty",
+ "label.leave": "Poistu",
+ "label.leave-team": "Poistu tiimistä",
+ "label.less-than": "Vähemmän kuin",
+ "label.less-than-equals": "Vähemmän tai yhtä suuri kuin",
+ "label.links": "Linkit",
+ "label.login": "Kirjaudu sisään",
+ "label.logout": "Kirjaudu ulos",
+ "label.manage": "Hallinnoi",
+ "label.manager": "Päällikkö",
+ "label.max": "Maksimi",
+ "label.maximize": "Laajenna",
+ "label.medium": "Keskitaso",
+ "label.member": "Jäsen",
+ "label.members": "Jäsenet",
+ "label.min": "Minimi",
+ "label.mobile": "Puhelin",
+ "label.model": "Model",
+ "label.more": "Lisää",
+ "label.my-account": "Oma tili",
+ "label.my-websites": "Omat verkkosivut",
+ "label.name": "Nimi",
+ "label.new-password": "Uusi salasana",
+ "label.none": "Ei mitään",
+ "label.number-of-records": "{x} {x, plural, one {tietue} other {tietuetta}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Orgaaninen haku",
+ "label.organic-shopping": "Orgaaninen ostaminen",
+ "label.organic-social": "Orgaaninen sosiaalinen",
+ "label.organic-video": "Orgaaninen video",
+ "label.os": "OS",
+ "label.other": "Muu",
+ "label.overview": "Yleiskatsaus",
+ "label.owner": "Omistaja",
+ "label.page": "Sivu",
+ "label.page-of": "Sivu {current} / {total}",
+ "label.page-views": "Sivun näyttökerrat",
+ "label.pageTitle": "Sivun otsikko",
+ "label.pages": "Sivut",
+ "label.paid-ads": "Maksetut mainokset",
+ "label.paid-search": "Maksettu haku",
+ "label.paid-shopping": "Maksettu ostaminen",
+ "label.paid-social": "Maksettu sosiaalinen",
+ "label.paid-video": "Maksettu video",
+ "label.password": "Salasana",
+ "label.path": "Polku",
+ "label.paths": "Polut",
+ "label.pixels": "Pikselit",
+ "label.powered-by": "Voimanlähteenä {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profiili",
+ "label.properties": "Ominaisuudet",
+ "label.property": "Ominaisuus",
+ "label.queries": "Kyselyt",
+ "label.query": "Kysely",
+ "label.query-parameters": "Kyselyn parametrit",
+ "label.realtime": "Juuri nyt",
+ "label.referral": "Viittaus",
+ "label.referrer": "Referrer",
+ "label.referrers": "Viittaajat",
+ "label.refresh": "Päivitä",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Jäljellä",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Vaaditaan",
+ "label.reset": "Nollaa",
+ "label.reset-website": "Nollaa tilastot",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Tulot",
+ "label.revenue-description": "Katso tulosi ajan mittaan.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Tallenna",
+ "label.screens": "Näytöt",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Valitse suodatin",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Istunto",
+ "label.session-data": "Istuntotiedot",
+ "label.sessions": "Sessions",
+ "label.settings": "Asetukset",
+ "label.share": "Jaa",
+ "label.share-url": "Jaa URL",
+ "label.single-day": "Yksi päivä",
+ "label.sms": "SMS",
+ "label.sources": "Lähteet",
+ "label.start-step": "Aloitusvaihe",
+ "label.steps": "Vaiheet",
+ "label.sum": "Sum",
+ "label.tablet": "Tabletti",
+ "label.tag": "Tunniste",
+ "label.tags": "Tunnisteet",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Tiimin asetukset",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Ehdot",
+ "label.theme": "Teema",
+ "label.this-month": "Tämä kuukausi",
+ "label.this-week": "Tämä viikko",
+ "label.this-year": "Tämä vuosi",
+ "label.timezone": "Aikavyöhyke",
+ "label.title": "Title",
+ "label.today": "Tänään",
+ "label.toggle-charts": "Kytke kaaviot päälle/pois",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Seurantakoodi",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Yksittäiset kävijät",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Tuntematon",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Käyttäjänimi",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Katso tiedot",
+ "label.view-only": "View only",
+ "label.views": "Näyttökerrat",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Keskimääräinen vierailuaika",
+ "label.visitors": "Vierailijat",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Verkkosivut",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "label.behavior": "Behavior",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Haluatko varmasti poistaa sivuston {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Haluatko varmasti poistaa sivuston {target} tilastot?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Kaikki siihen liittyvät tiedot poistetaan.",
+ "message.error": "Jotain meni pieleen.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Mene asetuksiin",
+ "message.incorrect-username-password": "Väärä käyttäjänimi/salasana.",
+ "message.invalid-domain": "Virheellinen verkkotunnus",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Tietoja ei ole käytettävissä.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Salasanat eivät täsmää",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Sinulla ei ole määritettyjä verkkosivustoja.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Sivua ei löydetty.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "Kaikki sivuston tilastot poistetaan, mutta seurantakoodi pysyy muuttumattomana.",
+ "message.saved": "Tallennettu onnistuneesti.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Tämä on julkisesti jaettu URL sivustolle {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Seurantakoodi",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Vierailija maasta {country} selaimella {browser} laitteella {os} {device}"
+}
diff --git a/src/lang/fo-FO.json b/src/lang/fo-FO.json
new file mode 100644
index 0000000..6fca425
--- /dev/null
+++ b/src/lang/fo-FO.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Aðgangskoda",
+ "label.actions": "Gerðir",
+ "label.activity": "Activity log",
+ "label.add": "Legg afturat",
+ "label.add-board": "Legg borð afturat",
+ "label.add-description": "Legg lýsing afturat",
+ "label.add-member": "Legg lim afturat",
+ "label.add-step": "Legg stig afturat",
+ "label.add-website": "Legg heimasíðu afturat",
+ "label.admin": "Fyrisitari",
+ "label.affiliate": "Samband",
+ "label.after": "Eftir",
+ "label.all": "Alt",
+ "label.all-time": "Allur tíðin",
+ "label.analytics": "Greining",
+ "label.apply": "Nýt",
+ "label.attribution": "Áseting",
+ "label.attribution-description": "Síggj hvussu brúkarar samskifta við marknaðarføringina og hvat førir til umvendingar.",
+ "label.average": "Miðal",
+ "label.back": "Aftur",
+ "label.before": "Áðrenn",
+ "label.behavior": "Atferð",
+ "label.boards": "Borð",
+ "label.bounce-rate": "Bounce prosenttal",
+ "label.breakdown": "Sundurgreining",
+ "label.browser": "Kagi",
+ "label.browsers": "Kagar",
+ "label.campaigns": "Herferðir",
+ "label.cancel": "Strika",
+ "label.change-password": "Skift loyniorð",
+ "label.channels": "Rásir",
+ "label.cities": "Býir",
+ "label.city": "Býur",
+ "label.clear-all": "Tøm alt",
+ "label.cohort": "Bólkur",
+ "label.compare": "Samanber",
+ "label.compare-dates": "Samanber dato",
+ "label.confirm": "Staðfest",
+ "label.confirm-password": "Vátta loyniorð",
+ "label.contains": "Inniheldur",
+ "label.content": "Innihald",
+ "label.continue": "Halt fram",
+ "label.conversion": "Umvending",
+ "label.conversion-rate": "Umvendingarprosent",
+ "label.conversion-step": "Umvendingarstigur",
+ "label.count": "Tal",
+ "label.countries": "Lond",
+ "label.country": "Land",
+ "label.create": "Stovna",
+ "label.create-report": "Stovna frágreiðing",
+ "label.create-team": "Stovna lið",
+ "label.create-user": "Stovna brúkara",
+ "label.created": "Stovnaður",
+ "label.created-by": "Stovnaður av",
+ "label.currency": "Gjaldoyra",
+ "label.current": "Núverandi",
+ "label.current-password": "Núverandi loyniorð",
+ "label.custom-range": "Tillaga spenni",
+ "label.dashboard": "Yvirlitsskíggi",
+ "label.data": "Dáta",
+ "label.date": "Dato",
+ "label.date-range": "Vel dato",
+ "label.day": "Dagur",
+ "label.default-date-range": "Forsett dato",
+ "label.delete": "Sletta",
+ "label.delete-report": "Strika frágreiðing",
+ "label.delete-team": "Strika lið",
+ "label.delete-user": "Strika brúkara",
+ "label.delete-website": "Sletta heimasíðu",
+ "label.description": "Lýsing",
+ "label.desktop": "Borðtelda",
+ "label.details": "Nærri upplýsingar",
+ "label.device": "Tól",
+ "label.devices": "Tóleindir",
+ "label.direct": "Beinleiðis",
+ "label.dismiss": "Lat fara",
+ "label.distinct-id": "Sermerkt ID",
+ "label.does-not-contain": "Inniheldur ikki",
+ "label.does-not-include": "Er ikki við",
+ "label.doest-not-exist": "Er ikki til",
+ "label.domain": "Økisnavn",
+ "label.dropoff": "Dropoff",
+ "label.edit": "Ger broyting",
+ "label.edit-dashboard": "Ritstjórna yvirlitsskíggja",
+ "label.edit-member": "Ritstjórna lim",
+ "label.email": "Teldupostur",
+ "label.enable-share-url": "Virkja deili leinki",
+ "label.end-step": "Endastigur",
+ "label.entry": "Inngangs URL",
+ "label.event": "Tiltak",
+ "label.event-data": "Tiltaksdata",
+ "label.event-name": "Tiltaksnavn",
+ "label.events": "Hendingar/tiltøk",
+ "label.exists": "Er til",
+ "label.exit": "Útgangs URL",
+ "label.false": "Falskt",
+ "label.field": "Øki",
+ "label.fields": "Øki",
+ "label.filter": "Sía",
+ "label.filter-combined": "Samansett",
+ "label.filter-raw": "Óviðgjørt",
+ "label.filters": "Síur",
+ "label.first-click": "Fyrsta trýst",
+ "label.first-seen": "Fyrst sæddur",
+ "label.funnel": "Traktari",
+ "label.funnel-description": "Fá yvirlit yvir umvendingar og fráfall hjá brúkarum.",
+ "label.funnels": "Traktarar",
+ "label.goal": "Mál",
+ "label.goals": "Mál",
+ "label.goals-description": "Fylg við málum fyri síðuvísingar og tiltøk.",
+ "label.greater-than": "Størri enn",
+ "label.greater-than-equals": "Størri ella javnt",
+ "label.grouped": "Bólkað",
+ "label.hostname": "Vertnavn",
+ "label.includes": "Inniheldur",
+ "label.insight": "Innlit",
+ "label.insights": "Innlit",
+ "label.insights-description": "Fá meira innlit í tínar dátur við at brúka bólkar og síur.",
+ "label.is": "Er",
+ "label.is-false": "Er falskt",
+ "label.is-not": "Er ikki",
+ "label.is-not-set": "Er ikki sett",
+ "label.is-set": "Er sett",
+ "label.is-true": "Er satt",
+ "label.join": "Luttak",
+ "label.join-team": "Luttak í liði",
+ "label.journey": "Ferð",
+ "label.journey-description": "Fá yvirlit yvir hvussu brúkarar ferðast á heimasíðuni.",
+ "label.journeys": "Ferðir",
+ "label.language": "Mál",
+ "label.languages": "Mál",
+ "label.laptop": "Fartelda",
+ "label.last-click": "Seinasta trýst",
+ "label.last-days": "Seinastu {x} dagarnar",
+ "label.last-hours": "Seinastu {x} tímarnar",
+ "label.last-months": "Seinastu {x} mánaðirnar",
+ "label.last-seen": "Síðst sæddur",
+ "label.leave": "Far burtur",
+ "label.leave-team": "Far úr liði",
+ "label.less-than": "Minni enn",
+ "label.less-than-equals": "Minni ella javnt",
+ "label.links": "Leinkjur",
+ "label.login": "Rita inn",
+ "label.logout": "Rita út",
+ "label.manage": "Stýra",
+ "label.manager": "Stjóri",
+ "label.max": "Mest",
+ "label.maximize": "Víðka",
+ "label.medium": "Miðal",
+ "label.member": "Limur",
+ "label.members": "Limir",
+ "label.min": "Minst",
+ "label.mobile": "Telefon",
+ "label.model": "Model",
+ "label.more": "Meira",
+ "label.my-account": "Mín konto",
+ "label.my-websites": "Mínar heimasíður",
+ "label.name": "Navn",
+ "label.new-password": "Nýtt loyniorð",
+ "label.none": "Eingin",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organisk leiting",
+ "label.organic-shopping": "Organisk keyp",
+ "label.organic-social": "Organisk sosial miðla",
+ "label.organic-video": "Organisk video",
+ "label.os": "OS",
+ "label.other": "Annað",
+ "label.overview": "Yvirlit",
+ "label.owner": "Eigari",
+ "label.page": "Síða",
+ "label.page-of": "Síða {current} av {total}",
+ "label.page-views": "Opnaðar síðir",
+ "label.pageTitle": "Síðuheiti",
+ "label.pages": "Síðir",
+ "label.paid-ads": "Goldnar lýsingar",
+ "label.paid-search": "Goldin leiting",
+ "label.paid-shopping": "Goldið keyp",
+ "label.paid-social": "Goldin sosial miðla",
+ "label.paid-video": "Goldið video",
+ "label.password": "Loyniorð",
+ "label.path": "Leið",
+ "label.paths": "Leiðir",
+ "label.pixels": "Pikslur",
+ "label.powered-by": "Rikið av {name}",
+ "label.previous": "Fyrra",
+ "label.previous-period": "Fyrra tíðarskeið",
+ "label.previous-year": "Fyrra ár",
+ "label.profile": "Vangi",
+ "label.properties": "Eginleikar",
+ "label.property": "Eginleiki",
+ "label.queries": "Fyrispurningar",
+ "label.query": "Fyrispurningur",
+ "label.query-parameters": "Fyrispurningsparametrar",
+ "label.realtime": "Beinleiðis",
+ "label.referral": "Ávísing",
+ "label.referrer": "Ávísari",
+ "label.referrers": "Framsendingar",
+ "label.refresh": "Dagfør",
+ "label.regenerate": "Endurskapa",
+ "label.region": "Øki",
+ "label.regions": "Øki",
+ "label.remaining": "Eftir",
+ "label.remove": "Fjern",
+ "label.remove-member": "Fjern lim",
+ "label.reports": "Frágreiðingar",
+ "label.required": "Kravið",
+ "label.reset": "Nulstilla",
+ "label.reset-website": "Nulstilla heimasíðu",
+ "label.retention": "Hald",
+ "label.retention-description": "Mát hvussu ofta brúkarar koma aftur á tína síðu.",
+ "label.revenue": "Inntøka",
+ "label.revenue-description": "Fá yvirlit yvir inntøku yvir tíð.",
+ "label.role": "Leiklutur",
+ "label.run-query": "Koyr fyrispurning",
+ "label.save": "Goym",
+ "label.screens": "Skíggjar",
+ "label.search": "Leita",
+ "label.select": "Vel",
+ "label.select-date": "Vel dato",
+ "label.select-filter": "Vel síu",
+ "label.select-role": "Vel leiklut",
+ "label.select-website": "Vel heimasíðu",
+ "label.session": "Seta",
+ "label.session-data": "Setudáta",
+ "label.sessions": "Setur",
+ "label.settings": "Stillingar",
+ "label.share": "Deil",
+ "label.share-url": "Deil leinku",
+ "label.single-day": "Einkultur dagur",
+ "label.sms": "SMS",
+ "label.sources": "Keldur",
+ "label.start-step": "Byrjanarstigur",
+ "label.steps": "Stig",
+ "label.sum": "Samanlagt",
+ "label.tablet": "Teldil",
+ "label.tag": "Merki",
+ "label.tags": "Merki",
+ "label.team": "Lið",
+ "label.team-id": "Lið ID",
+ "label.team-manager": "Liðleiðari",
+ "label.team-member": "Liðlimur",
+ "label.team-name": "Liðnavn",
+ "label.team-owner": "Liðeigari",
+ "label.team-settings": "Liðstillingar",
+ "label.team-view-only": "Bert til at síggja lið",
+ "label.team-websites": "Lið heimasíður",
+ "label.teams": "Lið",
+ "label.terms": "Treytir",
+ "label.theme": "Evni",
+ "label.this-month": "Hendan mánan",
+ "label.this-week": "Hesa vikuna",
+ "label.this-year": "Hetta árið",
+ "label.timezone": "Tíðarsona",
+ "label.title": "Title",
+ "label.today": "Í dag",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Spori kota",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Einsýna vitjanir",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Ókent",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Brúkaranavn",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Vís frágreiðing",
+ "label.view-only": "View only",
+ "label.views": "Sýningar",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Miðal vitjurnartíð ",
+ "label.visitors": "Vitjandi",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Heimasíður",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} í løtuni {x, plural, one {vitjandi} other { vitjandi }}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Ert tú sikkur at tú ynskir at strika {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Øll data ið er knýtt at verður eisini strika.",
+ "message.error": "Okkurt bleiv gali.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Far til stillingar",
+ "message.incorrect-username-password": "Skeivt brúkaranavn/loyniorð.",
+ "message.invalid-domain": "Ógilt økisnavn",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Einki data tøk.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Loyniorðini eru ikki eins",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Tú hevur ongar heimasíður stillaða til.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Síðan bleiv ikki funnin.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "Goymt.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Hettar er tann almenna leinkan av {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Spori kota",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Vitjandi frá {country} brúkar {browser} á {os} {device}"
+}
diff --git a/src/lang/fr-FR.json b/src/lang/fr-FR.json
new file mode 100644
index 0000000..cd6a96b
--- /dev/null
+++ b/src/lang/fr-FR.json
@@ -0,0 +1,341 @@
+{
+ "label.access-code": "Code d'accès",
+ "label.actions": "Actions",
+ "label.activity": "Journal d'activité",
+ "label.add": "Ajouter",
+ "label.add-board": "Ajouter un tableau",
+ "label.add-description": "Ajouter une description",
+ "label.add-member": "Ajouter un membre",
+ "label.add-step": "Ajouter une étape",
+ "label.add-website": "Ajouter un site",
+ "label.admin": "Administrateur",
+ "label.affiliate": "Affiliation",
+ "label.after": "Après",
+ "label.all": "Tout",
+ "label.all-time": "Toutes les données",
+ "label.analytics": "Analytique",
+ "label.apply": "Appliquer",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "Découvrez comment les utilisateurs s'engagent avec votre marketing et ce qui génère des conversions.",
+ "label.average": "Moyenne",
+ "label.back": "Retour",
+ "label.before": "Avant",
+ "label.boards": "Tableaux",
+ "label.bounce-rate": "Taux de rebond",
+ "label.breakdown": "Répartition",
+ "label.browser": "Navigateur",
+ "label.browsers": "Navigateurs",
+ "label.campaigns": "Campagnes",
+ "label.cancel": "Annuler",
+ "label.change-password": "Changer le mot de passe",
+ "label.channels": "Canaux",
+ "label.cities": "Villes",
+ "label.city": "Ville",
+ "label.clear-all": "Réinitialiser",
+ "label.cohort": "Cohorte",
+ "label.compare": "Comparer",
+ "label.compare-dates": "Comparer les dates",
+ "label.confirm": "Confirmer",
+ "label.confirm-password": "Confirmation du mot de passe",
+ "label.contains": "Contient",
+ "label.content": "Contenu",
+ "label.continue": "Continuer",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Taux de conversion",
+ "label.conversion-step": "Étape de conversion",
+ "label.count": "Compte",
+ "label.countries": "Pays",
+ "label.country": "Pays",
+ "label.create": "Créer",
+ "label.create-report": "Créer un rapport",
+ "label.create-team": "Créer une équipe",
+ "label.create-user": "Créer un utilisateur",
+ "label.created": "Créé",
+ "label.created-by": "Créé par",
+ "label.currency": "Devise",
+ "label.current": "Actuel",
+ "label.current-password": "Mot de passe actuel",
+ "label.custom-range": "Période personnalisée",
+ "label.dashboard": "Tableau de bord",
+ "label.data": "Données",
+ "label.date": "Date",
+ "label.date-range": "Période",
+ "label.day": "Jour",
+ "label.default-date-range": "Période par défaut",
+ "label.delete": "Supprimer",
+ "label.delete-report": "Supprimer le rapport",
+ "label.delete-team": "Supprimer l'équipe",
+ "label.delete-user": "Supprimer l'utilisateur",
+ "label.delete-website": "Supprimer le site",
+ "label.description": "Description",
+ "label.desktop": "Ordinateur",
+ "label.details": "Détails",
+ "label.device": "Appareil",
+ "label.devices": "Appareils",
+ "label.direct": "Direct",
+ "label.dismiss": "Ignorer",
+ "label.distinct-id": "ID distinct",
+ "label.does-not-contain": "Ne contient pas",
+ "label.does-not-include": "N'inclut pas",
+ "label.doest-not-exist": "N'existe pas",
+ "label.domain": "Domaine",
+ "label.dropoff": "Abandons",
+ "label.edit": "Modifier",
+ "label.edit-dashboard": "Modifier le tableau de bord",
+ "label.edit-member": "Modifier le membre",
+ "label.email": "E-mail",
+ "label.enable-share-url": "Activer l'URL de partage",
+ "label.end-step": "Étape de fin",
+ "label.entry": "Chemin d'entrée",
+ "label.event": "Évènement",
+ "label.event-data": "Données d'évènements",
+ "label.event-name": "Nom de l'évènement",
+ "label.events": "Évènements",
+ "label.exists": "Existe",
+ "label.exit": "Chemin de sortie",
+ "label.false": "Faux",
+ "label.field": "Champ",
+ "label.fields": "Champs",
+ "label.filter": "Filtrer",
+ "label.filter-combined": "Combiné",
+ "label.filter-raw": "Brut",
+ "label.filters": "Filtres",
+ "label.first-click": "Premier clic",
+ "label.first-seen": "Vu pour la première fois",
+ "label.funnel": "Entonnoir",
+ "label.funnel-description": "Comprenez les taux de conversions et d'abandons des utilisateurs.",
+ "label.funnels": "Entonnoirs",
+ "label.goal": "Objectif",
+ "label.goals": "Objectifs",
+ "label.goals-description": "Suivez vos objectifs en matière de pages vues et d'événements.",
+ "label.greater-than": "Supérieur à",
+ "label.greater-than-equals": "Supérieur ou égal à",
+ "label.grouped": "Groupé",
+ "label.hostname": "Nom d'hôte",
+ "label.includes": "Inclut",
+ "label.insight": "Aperçu",
+ "label.insights": "Aperçus",
+ "label.insights-description": "Analysez précisément vos données en utilisant des segments et des filtres.",
+ "label.is": "Est",
+ "label.is-false": "Est faux",
+ "label.is-not": "N'est pas",
+ "label.is-not-set": "N'est pas défini",
+ "label.is-set": "Est défini",
+ "label.is-true": "Est vrai",
+ "label.join": "Rejoindre",
+ "label.join-team": "Rejoindre une équipe",
+ "label.journey": "Parcours",
+ "label.journey-description": "Comprennez comment les utilisateurs naviguent sur votre site.",
+ "label.journeys": "Parcours",
+ "label.language": "Langue",
+ "label.languages": "Langues",
+ "label.laptop": "Portable",
+ "label.last-click": "Dernier clic",
+ "label.last-days": "{x} derniers jours",
+ "label.last-hours": "{x} dernières heures",
+ "label.last-months": "{x} derniers mois",
+ "label.last-seen": "Vu pour la dernière fois",
+ "label.leave": "Quitter",
+ "label.leave-team": "Quitter l'équipe",
+ "label.less-than": "Inférieur à",
+ "label.less-than-equals": "Inférieur ou égal à",
+ "label.links": "Liens",
+ "label.login": "Connexion",
+ "label.logout": "Déconnexion",
+ "label.manage": "Gérer",
+ "label.manager": "Gestionnaire",
+ "label.max": "Max",
+ "label.maximize": "Développer",
+ "label.medium": "Moyen",
+ "label.member": "Membre",
+ "label.members": "Membres",
+ "label.min": "Min",
+ "label.mobile": "Téléphone",
+ "label.model": "Modèle",
+ "label.more": "Plus",
+ "label.my-account": "Mon compte",
+ "label.my-websites": "Mes sites",
+ "label.name": "Nom",
+ "label.new-password": "Nouveau mot de passe",
+ "label.none": "Aucun",
+ "label.number-of-records": "{x} {x, plural, one {enregistrement} other {enregistrements}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Recherche organique",
+ "label.organic-shopping": "Achat organique",
+ "label.organic-social": "Réseau social organique",
+ "label.organic-video": "Vidéo organique",
+ "label.os": "OS",
+ "label.other": "Autre",
+ "label.overview": "Vue d'ensemble",
+ "label.owner": "Propriétaire",
+ "label.page": "Page",
+ "label.page-of": "Page {current} sur {total}",
+ "label.page-views": "Pages vues",
+ "label.pageTitle": "Titre de page",
+ "label.pages": "Pages",
+ "label.paid-ads": "Publicités payantes",
+ "label.paid-search": "Recherche payante",
+ "label.paid-shopping": "Achat payant",
+ "label.paid-social": "Réseau social payant",
+ "label.paid-video": "Vidéo payante",
+ "label.password": "Mot de passe",
+ "label.path": "Chemin",
+ "label.paths": "Chemins",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Propulsé par {name}",
+ "label.previous": "Précédent",
+ "label.previous-period": "Période précédente",
+ "label.previous-year": "Année précédente",
+ "label.profile": "Profil",
+ "label.properties": "Propriétés",
+ "label.property": "Propriété",
+ "label.queries": "Requêtes",
+ "label.query": "Requête",
+ "label.query-parameters": "Paramètres de requête",
+ "label.realtime": "Temps réel",
+ "label.referral": "Référent",
+ "label.referrer": "Site référent",
+ "label.referrers": "Sites référents",
+ "label.refresh": "Rafraîchir",
+ "label.regenerate": "Régénérer",
+ "label.region": "Région",
+ "label.regions": "Régions",
+ "label.remaining": "Restant",
+ "label.remove": "Retirer",
+ "label.remove-member": "Retirer le membre",
+ "label.reports": "Rapports",
+ "label.required": "Requis",
+ "label.reset": "Réinitialiser",
+ "label.reset-website": "Réinitialiser les statistiques",
+ "label.retention": "Rétention",
+ "label.retention-description": "Mesurez l'attractivité de votre site en suivant la fréquence de retour des utilisateurs.",
+ "label.revenue": "Revenus",
+ "label.revenue-description": "Consultez vos revenus au fil du temps.",
+ "label.role": "Rôle",
+ "label.run-query": "Exécuter la requête",
+ "label.save": "Enregistrer",
+ "label.screens": "Écrans",
+ "label.search": "Rechercher",
+ "label.select": "Sélectionner",
+ "label.select-date": "Choisir une période",
+ "label.select-filter": "Sélectionner un filtre",
+ "label.select-role": "Choisir un rôle",
+ "label.select-website": "Choisir un site",
+ "label.session": "Session",
+ "label.session-data": "Données de session",
+ "label.sessions": "Sessions",
+ "label.settings": "Paramètres",
+ "label.share": "Partager",
+ "label.share-url": "URL de partage",
+ "label.single-day": "Journée",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Étape de départ",
+ "label.steps": "Étapes",
+ "label.sum": "Somme",
+ "label.tablet": "Tablette",
+ "label.tag": "Étiquette",
+ "label.tags": "Étiquettes",
+ "label.team": "Équipe",
+ "label.team-id": "ID d'équipe",
+ "label.team-manager": "Manager de l'équipe",
+ "label.team-member": "Membre de l'équipe",
+ "label.team-name": "Nom de l'équipe",
+ "label.team-owner": "Propriétaire de l'équipe",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Vue d'équipe uniquement",
+ "label.team-websites": "Sites d'équipes",
+ "label.teams": "Équipes",
+ "label.terms": "Mots clés",
+ "label.theme": "Thème",
+ "label.this-month": "Ce mois",
+ "label.this-week": "Cette semaine",
+ "label.this-year": "Cette année",
+ "label.timezone": "Fuseau horaire",
+ "label.title": "Titre",
+ "label.today": "Aujourd'hui",
+ "label.toggle-charts": "Afficher/Masquer les graphiques",
+ "label.total": "Total",
+ "label.total-records": "Nombre d'enregistrements",
+ "label.tracking-code": "Code de suivi",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transférer",
+ "label.transfer-website": "Transférer le site",
+ "label.true": "Vrai",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Visiteurs uniques",
+ "label.uniqueCustomers": "Clients uniques",
+ "label.unknown": "Inconnu",
+ "label.untitled": "Sans titre",
+ "label.update": "Modifier",
+ "label.user": "Utilisateur",
+ "label.username": "Nom d'utilisateur",
+ "label.users": "Utilisateurs",
+ "label.utm": "UTM",
+ "label.utm-description": "Suivez vos campagnes via les paramètres UTM.",
+ "label.value": "Valeur",
+ "label.view": "Voir",
+ "label.view-details": "Voir les détails",
+ "label.view-only": "Consultation",
+ "label.views": "Vues",
+ "label.views-per-visit": "Vues par visite",
+ "label.visit-duration": "Temps de visite",
+ "label.visitors": "Visiteurs",
+ "label.visits": "Visites",
+ "label.website": "Site",
+ "label.website-id": "ID de site",
+ "label.websites": "Sites",
+ "label.window": "Fenêtre",
+ "label.yesterday": "Hier",
+ "label.behavior": "Comportement",
+ "label.traffic": "Trafic",
+ "label.segments": "Segments",
+ "message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.",
+ "message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Donnée collectée",
+ "message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?",
+ "message.confirm-leave": "Êtes-vous sûr de vouloir quitter {target} ?",
+ "message.confirm-remove": "Êtes-vous sûr de vouloir retirer {target} ?",
+ "message.confirm-reset": "Êtes-vous sûr de vouloir réinitialiser les statistiques de {target} ?",
+ "message.delete-team-warning": "Supprimer une équipe supprimera aussi tous les sites de cette équipe.",
+ "message.delete-website-warning": "Toutes les données associées seront supprimées.",
+ "message.error": "Un problème est survenu.",
+ "message.event-log": "{event} sur {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Aller aux paramètres",
+ "message.incorrect-username-password": "Nom d'utilisateur/Mot de passe incorrect.",
+ "message.invalid-domain": "Domaine invalide",
+ "message.min-password-length": "Taille minimale de {n} caractères",
+ "message.new-version-available": "Une nouvelle version d'Umami {version} est disponible !",
+ "message.no-data-available": "Aucune donnée disponible.",
+ "message.no-event-data": "Aucune donnée d'événement disponible.",
+ "message.no-match-password": "Les mots de passe ne correspondent pas",
+ "message.no-results-found": "Aucun résultat n'a été trouvé.",
+ "message.no-team-websites": "Cette équipe n'a aucun site.",
+ "message.no-teams": "Vous n'avez pas créé d'équipe.",
+ "message.no-users": "Aucun utilisateur.",
+ "message.no-websites-configured": "Vous n'avez pas configuré de site.",
+ "message.not-found": "Non trouvé!",
+ "message.nothing-selected": "Rien n'est sélectionné.",
+ "message.page-not-found": "Page non trouvée.",
+ "message.reset-website": "Pour réinitialiser ce site, taper {confirmation} ci-dessous pour confirmer.",
+ "message.reset-website-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.",
+ "message.saved": "Enregistré.",
+ "message.sever-error": "Erreur serveur",
+ "message.share-url": "Les statistiques de votre site sont accessibles publiquement sur cette URL :",
+ "message.team-already-member": "Vous êtes déjà membre de cette équipe.",
+ "message.team-not-found": "Équipe non trouvée.",
+ "message.team-websites-info": "Les sites peuvent être vus par tout utilisateur dans l'équipe.",
+ "message.tracking-code": "Code de suivi",
+ "message.transfer-team-website-to-user": "Transférer ce site sur votre compte ?",
+ "message.transfer-user-website-to-team": "Choisir l'équipe à laquelle transférer ce site.",
+ "message.transfer-website": "Transférer la propriété du site sur votre compte ou à une autre équipe.",
+ "message.triggered-event": "Évènement déclenché",
+ "message.unauthorized": "Non authorisé!",
+ "message.user-deleted": "Utilisateur supprimé.",
+ "message.viewed-page": "Page vue",
+ "message.visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}"
+}
diff --git a/src/lang/ga-ES.json b/src/lang/ga-ES.json
new file mode 100644
index 0000000..2082600
--- /dev/null
+++ b/src/lang/ga-ES.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Código de acceso",
+ "label.actions": "Accións",
+ "label.activity": "Rexistro de actividade",
+ "label.add": "Engadir",
+ "label.add-board": "Engadir taboleiro",
+ "label.add-description": "Engadir descrición",
+ "label.add-member": "Engadir membro",
+ "label.add-step": "Engadir paso",
+ "label.add-website": "Engadir sitio web",
+ "label.admin": "Administrador/a",
+ "label.affiliate": "Afiliado",
+ "label.after": "Despois",
+ "label.all": "Todo",
+ "label.all-time": "Sempre",
+ "label.analytics": "Analíticas",
+ "label.apply": "Aplicar",
+ "label.attribution": "Atribución",
+ "label.attribution-description": "Vexa como os usuarios interactúan co seu márketing e que impulsa as conversións.",
+ "label.average": "Media",
+ "label.back": "Atrás",
+ "label.before": "Antes",
+ "label.boards": "Taboleiros",
+ "label.bounce-rate": "Proporción de rebote",
+ "label.breakdown": "Desglose",
+ "label.browser": "Navegador",
+ "label.browsers": "Navegadores",
+ "label.campaigns": "Campañas",
+ "label.cancel": "Cancelar",
+ "label.change-password": "Mudar contrasinal",
+ "label.channels": "Canles",
+ "label.cities": "Cidades",
+ "label.city": "Cidade",
+ "label.clear-all": "Limpar todo",
+ "label.cohort": "Cohorte",
+ "label.compare": "Comparar",
+ "label.compare-dates": "Comparar datas",
+ "label.confirm": "Confirmar",
+ "label.confirm-password": "Confirmar contrasinal",
+ "label.contains": "Contén",
+ "label.content": "Contido",
+ "label.continue": "Continuar",
+ "label.conversion": "Conversión",
+ "label.conversion-rate": "Taxa de conversión",
+ "label.conversion-step": "Paso de conversión",
+ "label.count": "Reconto",
+ "label.countries": "Países",
+ "label.country": "País",
+ "label.create": "Crear",
+ "label.create-report": "Crear informe",
+ "label.create-team": "Crear equipo",
+ "label.create-user": "Crear usuario",
+ "label.created": "Creado",
+ "label.created-by": "Creado por",
+ "label.currency": "Moeda",
+ "label.current": "Actual",
+ "label.current-password": "Contrasinal actual",
+ "label.custom-range": "Rango personalizado",
+ "label.dashboard": "Taboleiro",
+ "label.data": "Datos",
+ "label.date": "Data",
+ "label.date-range": "Rango temporal",
+ "label.day": "Día",
+ "label.default-date-range": "Rango temporal por defecto",
+ "label.delete": "Eliminar",
+ "label.delete-report": "Eliminar reporte",
+ "label.delete-team": "Eliminar equipo",
+ "label.delete-user": "Eliminar usuario",
+ "label.delete-website": "Eliminar sitio web",
+ "label.description": "Descripción",
+ "label.desktop": "Escritorio",
+ "label.details": "Detalles",
+ "label.device": "Dispositivo",
+ "label.devices": "Dispositivos",
+ "label.direct": "Directo",
+ "label.dismiss": "Desbotar",
+ "label.distinct-id": "ID distinto",
+ "label.does-not-contain": "Non contén",
+ "label.does-not-include": "Non inclúe",
+ "label.doest-not-exist": "Non existe",
+ "label.domain": "Dominio",
+ "label.dropoff": "Disminución",
+ "label.edit": "Editar",
+ "label.edit-dashboard": "Editar taboleiro",
+ "label.edit-member": "Editar membro",
+ "label.email": "Correo electrónico",
+ "label.enable-share-url": "Activar URL de compartición",
+ "label.end-step": "Paso final",
+ "label.entry": "URL de entrada",
+ "label.event": "Evento",
+ "label.event-data": "Datos do evento",
+ "label.event-name": "Nome do evento",
+ "label.events": "Eventos",
+ "label.exists": "Existe",
+ "label.exit": "URL de saída",
+ "label.false": "Falso",
+ "label.field": "Campo",
+ "label.fields": "Campos",
+ "label.filter": "Filtro",
+ "label.filter-combined": "Combinado",
+ "label.filter-raw": "Crú",
+ "label.filters": "Filtros",
+ "label.first-click": "Primeiro clic",
+ "label.first-seen": "Primeira visita",
+ "label.funnel": "Funil",
+ "label.funnel-description": "Entende a taxa de conversión e de abandono dos usuarios.",
+ "label.funnels": "Funís",
+ "label.goal": "Obxectivo",
+ "label.goals": "Obxectivos",
+ "label.goals-description": "Segue os teus obxectivos de visualizacións de páxinas e eventos.",
+ "label.greater-than": "Maior que",
+ "label.greater-than-equals": "Maior ou igual que",
+ "label.grouped": "Agrupado",
+ "label.hostname": "Nome do host",
+ "label.includes": "Inclúe",
+ "label.insight": "Información",
+ "label.insights": "Informacións",
+ "label.insights-description": "Afonda nos teus datos usando segmentos e filtros.",
+ "label.is": "É",
+ "label.is-false": "É falso",
+ "label.is-not": "Non é",
+ "label.is-not-set": "Non está establecido",
+ "label.is-set": "Está establecido",
+ "label.is-true": "É verdadeiro",
+ "label.join": "Unirse",
+ "label.join-team": "Unirse ao equipo",
+ "label.journey": "Traxectoria",
+ "label.journey-description": "Entende como os usuarios navegan polo teu sitio web.",
+ "label.journeys": "Traxectorias",
+ "label.language": "Idioma",
+ "label.languages": "Idiomas",
+ "label.laptop": "Portátil",
+ "label.last-click": "Último clic",
+ "label.last-days": "Últimos {x} días",
+ "label.last-hours": "Últimas {x} horas",
+ "label.last-months": "Últimos {x} meses",
+ "label.last-seen": "Última visita",
+ "label.leave": "Deixar",
+ "label.leave-team": "Deixar o equipo",
+ "label.less-than": "Menor que",
+ "label.less-than-equals": "Menor ou igual que",
+ "label.links": "Ligazóns",
+ "label.login": "Acceder",
+ "label.logout": "Pechar sesión",
+ "label.manage": "Xestionar",
+ "label.manager": "Xestor",
+ "label.max": "Max",
+ "label.maximize": "Expandir",
+ "label.medium": "Medio",
+ "label.member": "Membro",
+ "label.members": "Membros",
+ "label.min": "Min",
+ "label.mobile": "Móbil",
+ "label.model": "Modelo",
+ "label.more": "Máis",
+ "label.my-account": "A miña conta",
+ "label.my-websites": "Os meus sitios web",
+ "label.name": "Nome",
+ "label.new-password": "Novo contrasinal",
+ "label.none": "Ningún",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Busca orgánica",
+ "label.organic-shopping": "Compra orgánica",
+ "label.organic-social": "Social orgánico",
+ "label.organic-video": "Vídeo orgánico",
+ "label.os": "Sistema operativo",
+ "label.other": "Outro",
+ "label.overview": "Resumo",
+ "label.owner": "Propietario/a",
+ "label.page": "Páxina",
+ "label.page-of": "Páxina {current} de {total}",
+ "label.page-views": "Vistas de páxinas",
+ "label.pageTitle": "Título da páxina",
+ "label.pages": "Páxinas",
+ "label.paid-ads": "Anuncios de pago",
+ "label.paid-search": "Busca de pago",
+ "label.paid-shopping": "Compra de pago",
+ "label.paid-social": "Social de pago",
+ "label.paid-video": "Vídeo de pago",
+ "label.password": "Contrasinal",
+ "label.path": "Ruta",
+ "label.paths": "Rutas",
+ "label.pixels": "Píxeles",
+ "label.powered-by": "Funciona grazas a {name}",
+ "label.previous": "Anterior",
+ "label.previous-period": "Periodo anterior",
+ "label.previous-year": "Ano anterior",
+ "label.profile": "Perfil",
+ "label.properties": "Propiedades",
+ "label.property": "Propiedade",
+ "label.queries": "Peticións",
+ "label.query": "Petición",
+ "label.query-parameters": "Parámetros da petición",
+ "label.realtime": "Agora mesmo",
+ "label.referral": "Referencia",
+ "label.referrer": "Orixe",
+ "label.referrers": "Orixes",
+ "label.refresh": "Actualizar",
+ "label.regenerate": "Rexenerar",
+ "label.region": "Rexión",
+ "label.regions": "Rexións",
+ "label.remaining": "Restante",
+ "label.remove": "Eliminar",
+ "label.remove-member": "Eliminar membro",
+ "label.reports": "Reportes",
+ "label.required": "Requerido",
+ "label.reset": "Restablecer",
+ "label.reset-website": "Para restablecer este sitio web, escriba {confirmation} na caixa de texto de embaixo para confirmar.",
+ "label.retention": "Retención",
+ "label.retention-description": "Mide a fidelidade dos usuarios ao teu sitio web seguindo a frecuencia coa que volven.",
+ "label.revenue": "Ingresos",
+ "label.revenue-description": "Consulta os teus ingresos ao longo do tempo.",
+ "label.role": "Rol",
+ "label.run-query": "Executar petición",
+ "label.save": "Gardar",
+ "label.screens": "Pantallas",
+ "label.search": "Buscar",
+ "label.select": "Seleccionar",
+ "label.select-date": "Seleccionar data",
+ "label.select-filter": "Seleccionar filtro",
+ "label.select-role": "Seleccionar rol",
+ "label.select-website": "Seleccionar sitio web",
+ "label.session": "Sesión",
+ "label.session-data": "Datos da sesión",
+ "label.sessions": "Sesións",
+ "label.settings": "Axustes",
+ "label.share": "Compartir",
+ "label.share-url": "Compartir URL",
+ "label.single-day": "Un só día",
+ "label.sms": "SMS",
+ "label.sources": "Fontes",
+ "label.start-step": "Start Step",
+ "label.steps": "Pasos",
+ "label.sum": "Suma",
+ "label.tablet": "Tableta",
+ "label.tag": "Etiqueta",
+ "label.tags": "Etiquetas",
+ "label.team": "Equipo",
+ "label.team-id": "ID do equipo",
+ "label.team-manager": "Xestor do equipo",
+ "label.team-member": "Membro do equipo",
+ "label.team-name": "Nome do equipo",
+ "label.team-owner": "Propietario do equipo",
+ "label.team-settings": "Axustes do equipo",
+ "label.team-view-only": "Equipo de só lectura",
+ "label.team-websites": "Sitios web do equipo",
+ "label.teams": "Equipos",
+ "label.terms": "Termos",
+ "label.theme": "Decorado",
+ "label.this-month": "Este mes",
+ "label.this-week": "Esta semana",
+ "label.this-year": "Este ano",
+ "label.timezone": "Zona horaria",
+ "label.title": "Título",
+ "label.today": "Hoxe",
+ "label.toggle-charts": "Activación das gráficas",
+ "label.total": "Total",
+ "label.total-records": "Rexistros totais",
+ "label.tracking-code": "Código de seguemento",
+ "label.transactions": "Transaccións",
+ "label.transfer": "Transferir",
+ "label.transfer-website": "Transferir sitio web",
+ "label.true": "Verdadeiro",
+ "label.type": "Tipo",
+ "label.unique": "Único",
+ "label.unique-visitors": "Visitas únicas",
+ "label.uniqueCustomers": "Clientes únicos",
+ "label.unknown": "Descoñecido",
+ "label.untitled": "Sen título",
+ "label.update": "Actualizar",
+ "label.user": "Usuario",
+ "label.username": "Identificador",
+ "label.users": "Usuarios",
+ "label.utm": "UTM",
+ "label.utm-description": "Segue as túas campañas a través dos parámetros UTM.",
+ "label.value": "Valor",
+ "label.view": "Vista",
+ "label.view-details": "Ver detalles",
+ "label.view-only": "Só lectura",
+ "label.views": "Visualizacións",
+ "label.views-per-visit": "Visualizacións por visita",
+ "label.visit-duration": "Tempo medio de visita",
+ "label.visitors": "Visitantes",
+ "label.visits": "Visitas",
+ "label.website": "Sitio web",
+ "label.website-id": "ID do sitio web",
+ "label.websites": "Sitios web",
+ "label.window": "Ventá",
+ "label.yesterday": "Onte",
+ "label.behavior": "Comportamento",
+ "message.action-confirmation": "Escribe {confirmation} na caixa de embaixo para confirmar.",
+ "message.active-users": "{x} actual {x, plural, one {visitante} other {visitantes}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Datos recopilados",
+ "message.confirm-delete": "Estás seguro/a de que queres eliminar {target}?",
+ "message.confirm-leave": "Estás seguro/a de que queres deixar {target}?",
+ "message.confirm-remove": "Estás seguro/a de que queres eliminar {target}?",
+ "message.confirm-reset": "Estás seguro/a de querer restablecer as estatísticas de {target}?",
+ "message.delete-team-warning": "Eliminar un equipo tamén eliminará tódolos sitios web do equipo.",
+ "message.delete-website-warning": "Tamén serán borrados tódolos datos asociados.",
+ "message.error": "Houbo un fallo.",
+ "message.event-log": "{event} en {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Ir aos axustes",
+ "message.incorrect-username-password": "Credenciais incorrectas.",
+ "message.invalid-domain": "Dominio non válido",
+ "message.min-password-length": "Lonxitude mínima de {n} caracteres",
+ "message.new-version-available": "Unha nova versión de Umami {version} está dispoñible!",
+ "message.no-data-available": "Sen datos dispoñibles.",
+ "message.no-event-data": "Sen datos de eventos dispoñibles.",
+ "message.no-match-password": "Non concordan os contrasinais",
+ "message.no-results-found": "Non se atoparon resultados.",
+ "message.no-team-websites": "Este equipo non ten ningún sitio web.",
+ "message.no-teams": "Non creaches ningún equipo.",
+ "message.no-users": "Non hai usuarios.",
+ "message.no-websites-configured": "Non tes sitios web configurados.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Páxina non atopada.",
+ "message.reset-website": "Para restablecer este sitio web, escriba {confirmation} na caixa de embaixo para confirmar.",
+ "message.reset-website-warning": "Vanse eliminar tódalas estatísticas deste sitio web, pero o código de seguimento permanecerá sen cambios.",
+ "message.saved": "Gardouse correctamente.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Este é o URL da compartición pública de {target}.",
+ "message.team-already-member": "Xa es membro do equipo.",
+ "message.team-not-found": "Equipo non atopado.",
+ "message.team-websites-info": "Os sitios web poden ser vistos por calquera membro do equipo.",
+ "message.tracking-code": "Código de seguimento",
+ "message.transfer-team-website-to-user": "Transferir este sitio web á túa conta?",
+ "message.transfer-user-website-to-team": "Selecciona o equipo ao que transferir este sitio web.",
+ "message.transfer-website": "Transferir propiedade do sitio web á túa conta ou a outro equipo.",
+ "message.triggered-event": "Activou o evento",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Usuario eliminado.",
+ "message.viewed-page": "Páxina vista",
+ "message.visitor-log": "Visitante desde {country} usando {browser} en {os} {device}"
+}
diff --git a/src/lang/he-IL.json b/src/lang/he-IL.json
new file mode 100644
index 0000000..2d115c8
--- /dev/null
+++ b/src/lang/he-IL.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "קוד גישה",
+ "label.actions": "פעולות",
+ "label.activity": "יומן פעילות",
+ "label.add": "הוסף",
+ "label.add-board": "הוסף לוח",
+ "label.add-description": "הוסף תיאור",
+ "label.add-member": "הוסף חבר",
+ "label.add-step": "הוסף שלב",
+ "label.add-website": "הוספת אתר",
+ "label.admin": "מנהל",
+ "label.affiliate": "שותף",
+ "label.after": "אחרי",
+ "label.all": "הכל",
+ "label.all-time": "כל הזמנים",
+ "label.analytics": "אנליטיקה",
+ "label.apply": "החל",
+ "label.attribution": "שיוך",
+ "label.attribution-description": "צפה כיצד משתמשים מתקשרים עם השיווק שלך ומה מניע המרות.",
+ "label.average": "ממוצע",
+ "label.back": "חזרה",
+ "label.before": "לפני",
+ "label.boards": "לוחות",
+ "label.bounce-rate": "שיעור נטישה",
+ "label.breakdown": "פירוט",
+ "label.browser": "דפדפן",
+ "label.browsers": "דפדפנים",
+ "label.campaigns": "קמפיינים",
+ "label.cancel": "ביטול",
+ "label.change-password": "שינוי סיסמה",
+ "label.channels": "ערוצים",
+ "label.cities": "ערים",
+ "label.city": "עיר",
+ "label.clear-all": "נקה הכל",
+ "label.cohort": "קבוצה",
+ "label.compare": "השווה",
+ "label.compare-dates": "השווה תאריכים",
+ "label.confirm": "אשר",
+ "label.confirm-password": "אישור סיסמה",
+ "label.contains": "Contains",
+ "label.content": "תוכן",
+ "label.continue": "המשך",
+ "label.conversion": "המרה",
+ "label.conversion-rate": "שיעור המרה",
+ "label.conversion-step": "שלב המרה",
+ "label.count": "ספירה",
+ "label.countries": "מדינות",
+ "label.country": "מדינה",
+ "label.create": "צור",
+ "label.create-report": "צור דוח",
+ "label.create-team": "צור צוות",
+ "label.create-user": "צור משתמש",
+ "label.created": "נוצר",
+ "label.created-by": "נוצר על ידי",
+ "label.currency": "מטבע",
+ "label.current": "נוכחי",
+ "label.current-password": "סיסמה נוכחית",
+ "label.custom-range": "טווח מותאם",
+ "label.dashboard": "דשבורד",
+ "label.data": "נתונים",
+ "label.date": "תאריך",
+ "label.date-range": "טווח תאריכים",
+ "label.day": "יום",
+ "label.default-date-range": "טווח תאריכים בברירת מחדל",
+ "label.delete": "הסרה",
+ "label.delete-report": "מחק דוח",
+ "label.delete-team": "מחק צוות",
+ "label.delete-user": "מחק משתמש",
+ "label.delete-website": "הסרת אתר",
+ "label.description": "תיאור",
+ "label.desktop": "מחשב שולחני",
+ "label.details": "פרטים",
+ "label.device": "מכשיר",
+ "label.devices": "מכשירים",
+ "label.direct": "ישיר",
+ "label.dismiss": "שיחרור",
+ "label.distinct-id": "מזהה ייחודי",
+ "label.does-not-contain": "לא מכיל",
+ "label.does-not-include": "לא כולל",
+ "label.doest-not-exist": "לא קיים",
+ "label.domain": "דומיין",
+ "label.dropoff": "עזיבה",
+ "label.edit": "עריכה",
+ "label.edit-dashboard": "ערוך לוח מחוונים",
+ "label.edit-member": "ערוך חבר",
+ "label.email": "אימייל",
+ "label.enable-share-url": "הפעלת URL שיתוף",
+ "label.end-step": "שלב סיום",
+ "label.entry": "כתובת כניסה",
+ "label.event": "אירוע",
+ "label.event-data": "נתוני אירוע",
+ "label.event-name": "שם האירוע",
+ "label.events": "אירועים",
+ "label.exists": "קיים",
+ "label.exit": "כתובת יציאה",
+ "label.false": "שקר",
+ "label.field": "שדה",
+ "label.fields": "שדות",
+ "label.filter": "Filter",
+ "label.filter-combined": "משותף",
+ "label.filter-raw": "גולמי",
+ "label.filters": "מסננים",
+ "label.first-click": "קליק ראשון",
+ "label.first-seen": "נראה לראשונה",
+ "label.funnel": "משפך",
+ "label.funnel-description": "הבן את שיעור ההמרה והעזיבה של המשתמשים.",
+ "label.funnels": "משפכים",
+ "label.goal": "מטרה",
+ "label.goals": "מטרות",
+ "label.goals-description": "עקוב אחרי המטרות שלך לצפיות בדף ואירועים.",
+ "label.greater-than": "גדול מ-",
+ "label.greater-than-equals": "גדול או שווה ל-",
+ "label.grouped": "מקובץ",
+ "label.hostname": "שם מארח",
+ "label.includes": "כולל",
+ "label.insight": "תובנה",
+ "label.insights": "תובנות",
+ "label.insights-description": "צלול עמוק יותר לנתונים שלך באמצעות פילוחים ומסננים.",
+ "label.is": "הוא",
+ "label.is-false": "הוא שקר",
+ "label.is-not": "אינו",
+ "label.is-not-set": "לא הוגדר",
+ "label.is-set": "הוגדר",
+ "label.is-true": "הוא אמת",
+ "label.join": "הצטרף",
+ "label.join-team": "הצטרף לצוות",
+ "label.journey": "מסע",
+ "label.journey-description": "הבן כיצד משתמשים מנווטים באתר שלך.",
+ "label.journeys": "מסעות",
+ "label.language": "Language",
+ "label.languages": "Languages",
+ "label.laptop": "לפטופ",
+ "label.last-click": "קליק אחרון",
+ "label.last-days": "{x} ימים אחרונים",
+ "label.last-hours": "{x} שעות אחרונות",
+ "label.last-months": "{x} חודשים אחרונים",
+ "label.last-seen": "נראה לאחרונה",
+ "label.leave": "עזוב",
+ "label.leave-team": "עזוב צוות",
+ "label.less-than": "פחות מ-",
+ "label.less-than-equals": "פחות או שווה ל-",
+ "label.links": "קישורים",
+ "label.login": "התחברות",
+ "label.logout": "התנתקות",
+ "label.manage": "נהל",
+ "label.manager": "מנהל",
+ "label.max": "מקסימום",
+ "label.maximize": "הרחב",
+ "label.medium": "בינוני",
+ "label.member": "חבר",
+ "label.members": "חברים",
+ "label.min": "מינימום",
+ "label.mobile": "מובייל",
+ "label.model": "Model",
+ "label.more": "עוד",
+ "label.my-account": "החשבון שלי",
+ "label.my-websites": "האתרים שלי",
+ "label.name": "שם",
+ "label.new-password": "סיסמה חדשה",
+ "label.none": "ללא",
+ "label.number-of-records": "{x} {x, plural, one {רשומה} other {רשומות}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "חיפוש אורגני",
+ "label.organic-shopping": "קניות אורגניות",
+ "label.organic-social": "רשת חברתית אורגנית",
+ "label.organic-video": "וידאו אורגני",
+ "label.os": "OS",
+ "label.other": "אחר",
+ "label.overview": "סקירה כללית",
+ "label.owner": "בעלים",
+ "label.page": "דף",
+ "label.page-of": "דף {current} מתוך {total}",
+ "label.page-views": "צפיות בדפים",
+ "label.pageTitle": "Page title",
+ "label.pages": "דפים",
+ "label.paid-ads": "מודעות בתשלום",
+ "label.paid-search": "חיפוש בתשלום",
+ "label.paid-shopping": "קניות בתשלום",
+ "label.paid-social": "רשת חברתית בתשלום",
+ "label.paid-video": "וידאו בתשלום",
+ "label.password": "סיסמה",
+ "label.path": "נתיב",
+ "label.paths": "נתיבים",
+ "label.pixels": "פיקסלים",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "פרופיל",
+ "label.properties": "מאפיינים",
+ "label.property": "מאפיין",
+ "label.queries": "שאילתות",
+ "label.query": "שאילתה",
+ "label.query-parameters": "פרמטרי שאילתה",
+ "label.realtime": "זמן אמת",
+ "label.referral": "הפניה",
+ "label.referrer": "Referrer",
+ "label.referrers": "מפנים",
+ "label.refresh": "רענון",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "נותר",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "נדרש",
+ "label.reset": "איפוס",
+ "label.reset-website": "Reset statistics",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "הכנסה",
+ "label.revenue-description": "בדוק את ההכנסות שלך לאורך זמן.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "שמירה",
+ "label.screens": "מסכים",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "בחר מסנן",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "סשן",
+ "label.session-data": "נתוני סשן",
+ "label.sessions": "Sessions",
+ "label.settings": "הגדרות",
+ "label.share": "שתף",
+ "label.share-url": "שיתוף URL",
+ "label.single-day": "יום בודד",
+ "label.sms": "SMS",
+ "label.sources": "מקורות",
+ "label.start-step": "שלב התחלה",
+ "label.steps": "שלבים",
+ "label.sum": "Sum",
+ "label.tablet": "טאבלט",
+ "label.tag": "תגית",
+ "label.tags": "תגיות",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "הגדרות צוות",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "תנאים",
+ "label.theme": "Theme",
+ "label.this-month": "החודש",
+ "label.this-week": "השבוע",
+ "label.this-year": "השנה",
+ "label.timezone": "אזור זמן",
+ "label.title": "Title",
+ "label.today": "היום",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "קוד מעקב",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "מבקרים ייחודיים",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "לא ידוע",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "שם משתמש",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "פרטים נוספים",
+ "label.behavior": "התנהגות",
+ "label.view-only": "View only",
+ "label.views": "צפיות",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "זמן ביקור ממוצע",
+ "label.visitors": "מבקרים",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "אתרים",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} נוכחיים {x, plural, one {מבקר} other {מבקרים}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "האם באמת למחוק את {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "כל המידע המקושר יימחק",
+ "message.error": "משהו השתבש",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "להדרותג",
+ "message.incorrect-username-password": "שם משתמש או סיסמה לא נכונים",
+ "message.invalid-domain": "דומיין לא תקין",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "אין מידע זמין",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "סיסמאות לא תואמות",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "לא מוגדרים אתרים",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "דף לא נמצא",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "נשמר בהצלחה",
+ "message.sever-error": "Server error",
+ "message.share-url": "זהו URL ציבורי עבור {target}",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "מבקר ממדינת {country} משתמבש בדפדפן {browser} ב-{os} {device}"
+}
diff --git a/src/lang/hi-IN.json b/src/lang/hi-IN.json
new file mode 100644
index 0000000..54cac30
--- /dev/null
+++ b/src/lang/hi-IN.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "पहुंच कोड",
+ "label.actions": "कार्य",
+ "label.activity": "गतिविधि लॉग",
+ "label.add": "जोडो",
+ "label.add-board": "बोर्ड जोड़ें",
+ "label.add-description": "विवरण लिखें",
+ "label.add-member": "सदस्य जोड़ें",
+ "label.add-step": "चरण जोड़ें",
+ "label.add-website": "वेबसाइट",
+ "label.admin": "प्रशासक",
+ "label.affiliate": "संबद्ध",
+ "label.after": "बाद में",
+ "label.all": "सब",
+ "label.all-time": "सभी समय",
+ "label.analytics": "विश्लेषण",
+ "label.apply": "लागू करें",
+ "label.attribution": "अर्पण",
+ "label.attribution-description": "देखें कि उपयोगकर्ता आपके विपणन के साथ कैसे जुड़ते हैं और क्या रूपांतरण को प्रेरित करता है।",
+ "label.average": "औसत",
+ "label.back": "पीछे",
+ "label.before": "पहले",
+ "label.behavior": "व्यवहार",
+ "label.boards": "बोर्ड्स",
+ "label.bounce-rate": "उछाल दर",
+ "label.breakdown": "विभाजन",
+ "label.browser": "ब्राउज़र",
+ "label.browsers": "वेब ब्राउज़र",
+ "label.campaigns": "अभियान",
+ "label.cancel": "रद्द करें",
+ "label.change-password": "पासवर्ड बदलें",
+ "label.channels": "चैनल",
+ "label.cities": "शहर",
+ "label.city": "शहर",
+ "label.clear-all": "सभी साफ करें",
+ "label.cohort": "समूह",
+ "label.compare": "तुलना करें",
+ "label.compare-dates": "तिथियों की तुलना करें",
+ "label.confirm": "पुष्टि करें",
+ "label.confirm-password": "पासवर्ड की पुष्टि कीजिये",
+ "label.contains": "शामिल है",
+ "label.content": "सामग्री",
+ "label.continue": "जारी रखें",
+ "label.conversion": "रूपांतरण",
+ "label.conversion-rate": "रूपांतरण दर",
+ "label.conversion-step": "रूपांतरण चरण",
+ "label.count": "गिनती",
+ "label.countries": "देश",
+ "label.country": "देश",
+ "label.create": "बनाएँ",
+ "label.create-report": "रिपोर्ट बनाएं",
+ "label.create-team": "टीम बनाएं",
+ "label.create-user": "उपयोगकर्ता बनाएं",
+ "label.created": "बनाया गया",
+ "label.created-by": "द्वारा बनाया गया",
+ "label.currency": "मुद्रा",
+ "label.current": "वर्तमान",
+ "label.current-password": "वर्तमान पासवर्ड",
+ "label.custom-range": "कस्टम रेंज",
+ "label.dashboard": "नियंत्रण-पट्ट",
+ "label.data": "डेटा",
+ "label.date": "तिथि",
+ "label.date-range": "तिथि सीमा",
+ "label.day": "दिन",
+ "label.default-date-range": "डिफ़ॉल्ट तिथि सीमा",
+ "label.delete": "खाता हटाएं",
+ "label.delete-report": "रिपोर्ट हटाएं",
+ "label.delete-team": "टीम हटाएं",
+ "label.delete-user": "उपयोगकर्ता हटाएं",
+ "label.delete-website": "वेबसाइट हटाएं",
+ "label.description": "विवरण",
+ "label.desktop": "डेस्कटॉप",
+ "label.details": "विवरण",
+ "label.device": "डिवाइस",
+ "label.devices": "उपकरण",
+ "label.direct": "प्रत्यक्ष",
+ "label.dismiss": "खारिज कीजिये",
+ "label.distinct-id": "अद्वितीय आईडी",
+ "label.does-not-contain": "शामिल नहीं है",
+ "label.does-not-include": "शामिल नहीं है",
+ "label.doest-not-exist": "मौजूद नहीं है",
+ "label.domain": "डोमेन",
+ "label.dropoff": "Dropoff",
+ "label.edit": "संपादित करें",
+ "label.edit-dashboard": "डैशबोर्ड संपादित करें",
+ "label.edit-member": "सदस्य संपादित करें",
+ "label.email": "ईमेल",
+ "label.enable-share-url": "शेयर URL सक्षम करें",
+ "label.end-step": "अंतिम चरण",
+ "label.entry": "प्रवेश URL",
+ "label.event": "घटना",
+ "label.event-data": "घटना डेटा",
+ "label.event-name": "घटना नाम",
+ "label.events": "स्पर्धाएँ",
+ "label.exists": "मौजूद है",
+ "label.exit": "निकास URL",
+ "label.false": "गलत",
+ "label.field": "फ़ील्ड",
+ "label.fields": "फ़ील्ड्स",
+ "label.filter": "फ़िल्टर",
+ "label.filter-combined": "संयुक्त",
+ "label.filter-raw": "रॉ",
+ "label.filters": "फ़िल्टर",
+ "label.first-click": "पहला क्लिक",
+ "label.first-seen": "पहली बार देखा गया",
+ "label.funnel": "फनल",
+ "label.funnel-description": "उपयोगकर्ताओं की रूपांतरण और ड्रॉप-ऑफ दर को समझें।",
+ "label.funnels": "फनल्स",
+ "label.goal": "लक्ष्य",
+ "label.goals": "लक्ष्य",
+ "label.goals-description": "पृष्ठदृश्यों और घटनाओं के लिए अपने लक्ष्यों को ट्रैक करें।",
+ "label.greater-than": "से अधिक",
+ "label.greater-than-equals": "से अधिक या बराबर",
+ "label.grouped": "समूहित",
+ "label.hostname": "होस्टनाम",
+ "label.includes": "शामिल है",
+ "label.insight": "अंतर्दृष्टि",
+ "label.insights": "अंतर्दृष्टियाँ",
+ "label.insights-description": "सेगमेंट और फ़िल्टर का उपयोग करके अपने डेटा में गहराई से जाएं।",
+ "label.is": "है",
+ "label.is-false": "गलत है",
+ "label.is-not": "नहीं है",
+ "label.is-not-set": "सेट नहीं है",
+ "label.is-set": "सेट है",
+ "label.is-true": "सही है",
+ "label.join": "शामिल हों",
+ "label.join-team": "टीम में शामिल हों",
+ "label.journey": "यात्रा",
+ "label.journey-description": "समझें कि उपयोगकर्ता आपकी वेबसाइट पर कैसे नेविगेट करते हैं।",
+ "label.journeys": "यात्राएँ",
+ "label.language": "भाषा",
+ "label.languages": "भाषाएँ",
+ "label.laptop": "लैपटॉप",
+ "label.last-click": "अंतिम क्लिक",
+ "label.last-days": "पिछले {x} दिन",
+ "label.last-hours": "पिछले {x} घंटे",
+ "label.last-months": "पिछले {x} महीने",
+ "label.last-seen": "अंतिम बार देखा गया",
+ "label.leave": "छोड़ें",
+ "label.leave-team": "टीम छोड़ें",
+ "label.less-than": "से कम",
+ "label.less-than-equals": "से कम या बराबर",
+ "label.links": "लिंक",
+ "label.login": "लॉग इन",
+ "label.logout": "लॉग आउट",
+ "label.manage": "प्रबंधित करें",
+ "label.manager": "प्रबंधक",
+ "label.max": "अधिकतम",
+ "label.maximize": "विस्तार करें",
+ "label.medium": "मध्यम",
+ "label.member": "सदस्य",
+ "label.members": "सदस्यगण",
+ "label.min": "न्यूनतम",
+ "label.mobile": "मोबाइल फोन",
+ "label.model": "मॉडल",
+ "label.more": "और",
+ "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.online": "Online",
+ "label.organic-search": "ऑर्गेनिक खोज",
+ "label.organic-shopping": "ऑर्गेनिक खरीदारी",
+ "label.organic-social": "ऑर्गेनिक सोशल",
+ "label.organic-video": "ऑर्गेनिक वीडियो",
+ "label.os": "OS",
+ "label.other": "अन्य",
+ "label.overview": "सारांश",
+ "label.owner": "मालिक",
+ "label.page": "पृष्ठ",
+ "label.page-of": "पृष्ठ {current} का {total}",
+ "label.page-views": "पृष्ठ दृश्य",
+ "label.pageTitle": "पृष्ठ शीर्षक",
+ "label.pages": "पृष्ठों",
+ "label.paid-ads": "पेड विज्ञापन",
+ "label.paid-search": "पेड खोज",
+ "label.paid-shopping": "पेड खरीदारी",
+ "label.paid-social": "पेड सोशल",
+ "label.paid-video": "पेड वीडियो",
+ "label.password": "पासवर्ड",
+ "label.path": "पथ",
+ "label.paths": "पथ",
+ "label.pixels": "पिक्सेल",
+ "label.powered-by": "{name} द्वारा संचालित",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "प्रोफ़ाइल",
+ "label.properties": "गुण",
+ "label.property": "गुण",
+ "label.queries": "प्रश्न",
+ "label.query": "प्रश्न",
+ "label.query-parameters": "प्रश्न पैरामीटर",
+ "label.realtime": "वास्तव काल",
+ "label.referral": "संदर्भ",
+ "label.referrer": "संदर्भकर्ता",
+ "label.referrers": "सन्दर्भदाता",
+ "label.refresh": "रिफ्रेश",
+ "label.regenerate": "पुनः उत्पन्न करें",
+ "label.region": "क्षेत्र",
+ "label.regions": "क्षेत्र",
+ "label.remaining": "शेष",
+ "label.remove": "हटाएं",
+ "label.remove-member": "सदस्य हटाएं",
+ "label.reports": "रिपोर्ट्स",
+ "label.required": "अपेक्षित",
+ "label.reset": "रीसेट",
+ "label.reset-website": "आँकड़े रीसेट करें",
+ "label.retention": "पुनः आगमन",
+ "label.retention-description": "यह मापें कि उपयोगकर्ता कितनी बार आपकी वेबसाइट पर लौटते हैं।",
+ "label.revenue": "राजस्व",
+ "label.revenue-description": "समय के साथ अपने राजस्व को देखें।",
+ "label.role": "भूमिका",
+ "label.run-query": "प्रश्न चलाएँ",
+ "label.save": "सहेजें",
+ "label.screens": "स्क्रीन",
+ "label.search": "खोजें",
+ "label.select": "चुनें",
+ "label.select-date": "तिथि चुनें",
+ "label.select-filter": "फ़िल्टर चुनें",
+ "label.select-role": "भूमिका चुनें",
+ "label.select-website": "वेबसाइट चुनें",
+ "label.session": "सत्र",
+ "label.session-data": "सत्र डेटा",
+ "label.sessions": "सत्र",
+ "label.settings": "समायोजन",
+ "label.share": "साझा करें",
+ "label.share-url": "यूआरएल साझा करें",
+ "label.single-day": "एक दिन",
+ "label.sms": "SMS",
+ "label.sources": "स्रोत",
+ "label.start-step": "प्रारंभिक चरण",
+ "label.steps": "चरण",
+ "label.sum": "योग",
+ "label.tablet": "टैबलेट",
+ "label.tag": "टैग",
+ "label.tags": "टैग्स",
+ "label.team": "टीम",
+ "label.team-id": "टीम आईडी",
+ "label.team-manager": "टीम प्रबंधक",
+ "label.team-member": "टीम सदस्य",
+ "label.team-name": "टीम नाम",
+ "label.team-owner": "टीम मालिक",
+ "label.team-settings": "टीम सेटिंग्स",
+ "label.team-view-only": "केवल टीम देखें",
+ "label.team-websites": "टीम वेबसाइट्स",
+ "label.teams": "टीमें",
+ "label.terms": "शर्तें",
+ "label.theme": "थीम",
+ "label.this-month": "इस महीने",
+ "label.this-week": "इस सप्ताह",
+ "label.this-year": "इस साल",
+ "label.timezone": "समय क्षेत्र",
+ "label.title": "Title",
+ "label.today": "आज",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "ट्रैकिंग कोड",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "अद्वितीय आगंतुकों",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "अज्ञात",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "उपयोगकर्ता नाम",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "विवरण देखें",
+ "label.view-only": "View only",
+ "label.views": "दृश्य",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "औसत दृश्य समय",
+ "label.visitors": "आगंतुकों",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "वेबसाइटों",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} मौजूद {x, plural, one {आगंतुक} other {आगंतुकों}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "क्या आप वाकई में {target} हटाना चाहते हैं?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "सभी संबद्ध डेटा को भी हटा दिया जाएगा।",
+ "message.error": "कुछ गलत हो गया।",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "समायोजन में जाइए",
+ "message.incorrect-username-password": "ग़लत उपयोगकर्ता नाम / पासवर्ड।",
+ "message.invalid-domain": "अमान्य डोमेन",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "कोई डेटा उपलब्ध नहीं है।",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "पासवर्ड मेल नहीं खाते",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "आपके पास कोई वेबसाइट कॉन्फ़िगर नहीं है।",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "पृष्ठ नहीं मिला।",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "सफलतापूर्वक संचित कर लिया गया है।",
+ "message.sever-error": "Server error",
+ "message.share-url": "यह {target} के लिए सार्वजनिक रूप से साझा किया गया URL है।",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "{country} का आगंतुक, जो {browser} का उपयोग करता है, {os} यन्त्र पर"
+}
diff --git a/src/lang/hr-HR.json b/src/lang/hr-HR.json
new file mode 100644
index 0000000..141ad3f
--- /dev/null
+++ b/src/lang/hr-HR.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Pristupni kod",
+ "label.actions": "Akcije",
+ "label.activity": "Dnevnik aktivnosti",
+ "label.add": "Dodaj",
+ "label.add-board": "Dodaj ploču",
+ "label.add-description": "Dodaj opis",
+ "label.add-member": "Dodaj člana",
+ "label.add-step": "Dodaj korak",
+ "label.add-website": "Dodaj web stranicu",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partner",
+ "label.after": "Nakon",
+ "label.all": "Sve",
+ "label.all-time": "Svo vrijeme",
+ "label.analytics": "Analitika",
+ "label.apply": "Primijeni",
+ "label.attribution": "Atribucija",
+ "label.attribution-description": "Pogledajte kako korisnici komuniciraju s vašim marketingom i što dovodi do konverzija.",
+ "label.average": "Prosjek",
+ "label.back": "Natrag ",
+ "label.before": "Prije",
+ "label.behavior": "Ponašanje",
+ "label.boards": "Ploče",
+ "label.bounce-rate": "Stopa napuštanja",
+ "label.breakdown": "Raspad",
+ "label.browser": "Preglednik",
+ "label.browsers": "Preglednici",
+ "label.campaigns": "Kampanje",
+ "label.cancel": "Odustani",
+ "label.change-password": "Promijeni lozinku",
+ "label.channels": "Kanali",
+ "label.cities": "Gradovi",
+ "label.city": "Grad",
+ "label.clear-all": "Očisti sve",
+ "label.cohort": "Kohorta",
+ "label.compare": "Usporedi",
+ "label.compare-dates": "Usporedi datume",
+ "label.confirm": "Potvrdi",
+ "label.confirm-password": "Potvrdi lozinku",
+ "label.contains": "Contains",
+ "label.content": "Sadržaj",
+ "label.continue": "Nastavi",
+ "label.conversion": "Konverzija",
+ "label.conversion-rate": "Stopa konverzije",
+ "label.conversion-step": "Korak konverzije",
+ "label.count": "Broj",
+ "label.countries": "Countries",
+ "label.country": "Država",
+ "label.create": "Kreiraj",
+ "label.create-report": "Kreiraj izvještaj",
+ "label.create-team": "Kreiraj tim",
+ "label.create-user": "Kreiraj korisnika",
+ "label.created": "Kreirano",
+ "label.created-by": "Kreirao",
+ "label.currency": "Valuta",
+ "label.current": "Trenutno",
+ "label.current-password": "Trenutna lozinka",
+ "label.custom-range": "Prilagođeni raspon",
+ "label.dashboard": "Nadzorna ploča",
+ "label.data": "Podaci",
+ "label.date": "Datum",
+ "label.date-range": "Raspon datuma",
+ "label.day": "Dan",
+ "label.default-date-range": "Zadani datumski raspon",
+ "label.delete": "Obriši",
+ "label.delete-report": "Obriši izvještaj",
+ "label.delete-team": "Obriši tim",
+ "label.delete-user": "Obriši korisnika",
+ "label.delete-website": "Obriši web stranicu",
+ "label.description": "Opis",
+ "label.desktop": "Stolno računalo",
+ "label.details": "Detalji",
+ "label.device": "Uređaj",
+ "label.devices": "Uređaji",
+ "label.direct": "Direktno",
+ "label.dismiss": "Odbaci",
+ "label.distinct-id": "Jedinstveni ID",
+ "label.does-not-contain": "Ne sadrži",
+ "label.does-not-include": "Ne uključuje",
+ "label.doest-not-exist": "Ne postoji",
+ "label.domain": "Domena",
+ "label.dropoff": "Odlazak",
+ "label.edit": "Uredi",
+ "label.edit-dashboard": "Uredi nadzornu ploču",
+ "label.edit-member": "Uredi člana",
+ "label.email": "E-mail",
+ "label.enable-share-url": "Omogući dijeljenje poveznice",
+ "label.end-step": "Završni korak",
+ "label.entry": "Ulazni URL",
+ "label.event": "Događaj",
+ "label.event-data": "Podaci događaja",
+ "label.event-name": "Naziv događaja",
+ "label.events": "Events",
+ "label.exists": "Postoji",
+ "label.exit": "Izlazni URL",
+ "label.false": "Netočno",
+ "label.field": "Polje",
+ "label.fields": "Polja",
+ "label.filter": "Filter",
+ "label.filter-combined": "Combined",
+ "label.filter-raw": "Raw",
+ "label.filters": "Filteri",
+ "label.first-click": "Prvi klik",
+ "label.first-seen": "Prvi put viđeno",
+ "label.funnel": "Lijevak",
+ "label.funnel-description": "Razumite stopu konverzije i odlaska korisnika.",
+ "label.funnels": "Ljevci",
+ "label.goal": "Cilj",
+ "label.goals": "Ciljevi",
+ "label.goals-description": "Pratite svoje ciljeve za prikaze stranica i događaje.",
+ "label.greater-than": "Veće od",
+ "label.greater-than-equals": "Veće ili jednako",
+ "label.grouped": "Grupirano",
+ "label.hostname": "Naziv hosta",
+ "label.includes": "Uključuje",
+ "label.insight": "Uvid",
+ "label.insights": "Uvidi",
+ "label.insights-description": "Dublje analizirajte svoje podatke pomoću segmenata i filtera.",
+ "label.is": "Je",
+ "label.is-false": "Je netočno",
+ "label.is-not": "Nije",
+ "label.is-not-set": "Nije postavljeno",
+ "label.is-set": "Postavljeno",
+ "label.is-true": "Je točno",
+ "label.join": "Pridruži se",
+ "label.join-team": "Pridruži se timu",
+ "label.journey": "Putovanje",
+ "label.journey-description": "Razumite kako korisnici navigiraju vašom web stranicom.",
+ "label.journeys": "Putovanja",
+ "label.language": "Jezik",
+ "label.languages": "Languages",
+ "label.laptop": "Laptop",
+ "label.last-click": "Zadnji klik",
+ "label.last-days": "Zadnjih {x} dana",
+ "label.last-hours": "Zadnjih {x} sati",
+ "label.last-months": "Zadnjih {x} mjeseci",
+ "label.last-seen": "Zadnji put viđeno",
+ "label.leave": "Napusti",
+ "label.leave-team": "Napusti tim",
+ "label.less-than": "Manje od",
+ "label.less-than-equals": "Manje ili jednako",
+ "label.links": "Poveznice",
+ "label.login": "Prijava",
+ "label.logout": "Odjava",
+ "label.manage": "Upravljaj",
+ "label.manager": "Upravitelj",
+ "label.max": "Maksimum",
+ "label.maximize": "Proširi",
+ "label.medium": "Srednje",
+ "label.member": "Član",
+ "label.members": "Članovi",
+ "label.min": "Minimum",
+ "label.mobile": "Mobile",
+ "label.model": "Model",
+ "label.more": "Više",
+ "label.my-account": "Moj račun",
+ "label.my-websites": "Moje web stranice",
+ "label.name": "Ime",
+ "label.new-password": "Nova lozinka",
+ "label.none": "Nijedan",
+ "label.number-of-records": "{x} {x, plural, one {zapis} other {zapisa}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organsko pretraživanje",
+ "label.organic-shopping": "Organska kupovina",
+ "label.organic-social": "Organska društvena mreža",
+ "label.organic-video": "Organski videozapis",
+ "label.os": "OS",
+ "label.other": "Ostalo",
+ "label.overview": "Pregled",
+ "label.owner": "Vlasnik",
+ "label.page": "Stranica",
+ "label.page-of": "Stranica {current} od {total}",
+ "label.page-views": "Page views",
+ "label.pageTitle": "Page title",
+ "label.pages": "Pages",
+ "label.paid-ads": "Plaćeni oglasi",
+ "label.paid-search": "Plaćeno pretraživanje",
+ "label.paid-shopping": "Plaćena kupovina",
+ "label.paid-social": "Plaćena društvena mreža",
+ "label.paid-video": "Plaćeni videozapis",
+ "label.password": "Lozinka",
+ "label.path": "Putanja",
+ "label.paths": "Putanje",
+ "label.pixels": "Pikseli",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profil",
+ "label.properties": "Svojstva",
+ "label.property": "Svojstvo",
+ "label.queries": "Upiti",
+ "label.query": "Upit",
+ "label.query-parameters": "Parametri upita",
+ "label.realtime": "Stvarno vrijeme",
+ "label.referral": "Preporuka",
+ "label.referrer": "Referrer",
+ "label.referrers": "Referrers",
+ "label.refresh": "Osvježi",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Preostalo",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Potrebna",
+ "label.reset": "Resetirati",
+ "label.reset-website": "Resetirati web stranicu",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Prihod",
+ "label.revenue-description": "Pogledajte svoj prihod tijekom vremena.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Spremi",
+ "label.screens": "Ekrani",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Odaberi filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Sesija",
+ "label.session-data": "Podaci sesije",
+ "label.sessions": "Sessions",
+ "label.settings": "Postavke",
+ "label.share": "Podijeli",
+ "label.share-url": "Podijeli poveznicu",
+ "label.single-day": "Jedan dan",
+ "label.sms": "SMS",
+ "label.sources": "Izvori",
+ "label.start-step": "Početni korak",
+ "label.steps": "Koraci",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Oznaka",
+ "label.tags": "Oznake",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Postavke tima",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Pojmovi",
+ "label.theme": "Tema",
+ "label.this-month": "Ovaj mjesec",
+ "label.this-week": "Ovaj tjedan",
+ "label.this-year": "Ova godina",
+ "label.timezone": "Vremenska zona",
+ "label.title": "Title",
+ "label.today": "Danas",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Kod za praćenje",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Unique visitors",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Nepoznato",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Korisničko ime",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Pogledaj detalje",
+ "label.view-only": "View only",
+ "label.views": "Views",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Visit duration",
+ "label.visitors": "Visitors",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Web stranice",
+ "label.window": "Window",
+ "label.yesterday": "Jučer",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} Trenutno {x, plural, one {posjetitelj} other {posjetitelja}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Jeste li sigurni da želite obrisati {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Jeste li sigurni da želite resetirati {target}'s statistiku?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "All website data will be deleted.",
+ "message.error": "Something went wrong.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Idi u postavke",
+ "message.incorrect-username-password": "Neispravno korisničke ime/lozinka.",
+ "message.invalid-domain": "Invalid domain. Do not include http/https.",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Nema dostupnih podataka.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Passwords do not match.",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "You do not have any websites configured.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Stranica nije pronađena.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your settings will remain intact.",
+ "message.saved": "Saved.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Ovo je javno dijeljena poveznica za {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "To track stats for this website, place the following code in the <head>...</head> section of your HTML.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}"
+}
diff --git a/src/lang/hu-HU.json b/src/lang/hu-HU.json
new file mode 100644
index 0000000..1666b7a
--- /dev/null
+++ b/src/lang/hu-HU.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Hozzáférési kód",
+ "label.actions": "Műveletek",
+ "label.activity": "Tevékenységnapló",
+ "label.add": "Hozzáadás",
+ "label.add-board": "Tábla hozzáadása",
+ "label.add-description": "Leírás hozzáadása",
+ "label.add-member": "Tag hozzáadása",
+ "label.add-step": "Lépés hozzáadása",
+ "label.add-website": "Weboldal hozzáadása",
+ "label.admin": "Adminisztrátor",
+ "label.affiliate": "Partner",
+ "label.after": "Után",
+ "label.all": "Összes",
+ "label.all-time": "Minden időszak",
+ "label.analytics": "Analitika",
+ "label.apply": "Alkalmaz",
+ "label.attribution": "Attribúció",
+ "label.attribution-description": "Nézze meg, hogyan lépnek kapcsolatba a felhasználók a marketingjével, és mi vezet konverzióhoz.",
+ "label.average": "Átlag",
+ "label.back": "Vissza",
+ "label.before": "Előtt",
+ "label.behavior": "Viselkedés",
+ "label.boards": "Táblák",
+ "label.bounce-rate": "Visszafordulási arány",
+ "label.breakdown": "Bontás",
+ "label.browser": "Böngésző",
+ "label.browsers": "Böngészők",
+ "label.campaigns": "Kampányok",
+ "label.cancel": "Mégsem",
+ "label.change-password": "Jelszó módosítása",
+ "label.channels": "Csatornák",
+ "label.cities": "Városok",
+ "label.city": "Város",
+ "label.clear-all": "Összes törlése",
+ "label.cohort": "Kohorsz",
+ "label.compare": "Összehasonlít",
+ "label.compare-dates": "Dátumok összehasonlítása",
+ "label.confirm": "Megerősít",
+ "label.confirm-password": "Jelszó megerősítése",
+ "label.contains": "Contains",
+ "label.content": "Tartalom",
+ "label.continue": "Folytatás",
+ "label.conversion": "Konverzió",
+ "label.conversion-rate": "Konverziós arány",
+ "label.conversion-step": "Konverziós lépés",
+ "label.count": "Darabszám",
+ "label.countries": "Országok",
+ "label.country": "Ország",
+ "label.create": "Létrehozás",
+ "label.create-report": "Jelentés létrehozása",
+ "label.create-team": "Csapat létrehozása",
+ "label.create-user": "Felhasználó létrehozása",
+ "label.created": "Létrehozva",
+ "label.created-by": "Létrehozta",
+ "label.currency": "Pénznem",
+ "label.current": "Jelenlegi",
+ "label.current-password": "Jelenlegi jelszó",
+ "label.custom-range": "Egyedi tartomány",
+ "label.dashboard": "Áttekintés",
+ "label.data": "Adat",
+ "label.date": "Dátum",
+ "label.date-range": "Időintervallum",
+ "label.day": "Nap",
+ "label.default-date-range": "Alapértelmezett időintervallum",
+ "label.delete": "Eltávolítás",
+ "label.delete-report": "Jelentés törlése",
+ "label.delete-team": "Csapat törlése",
+ "label.delete-user": "Felhasználó törlése",
+ "label.delete-website": "Weboldal eltávolítása",
+ "label.description": "Leírás",
+ "label.desktop": "Asztali számítógép",
+ "label.details": "Részletek",
+ "label.device": "Eszköz",
+ "label.devices": "Eszközök",
+ "label.direct": "Közvetlen",
+ "label.dismiss": "Mellőzés",
+ "label.distinct-id": "Egyedi azonosító",
+ "label.does-not-contain": "Nem tartalmazza",
+ "label.does-not-include": "Nem tartalmazza",
+ "label.doest-not-exist": "Nem létezik",
+ "label.domain": "Domain",
+ "label.dropoff": "Lemorzsolódás",
+ "label.edit": "Módosítás",
+ "label.edit-dashboard": "Irányítópult szerkesztése",
+ "label.edit-member": "Tag szerkesztése",
+ "label.email": "E-mail",
+ "label.enable-share-url": "URL-megosztás engedélyezése",
+ "label.end-step": "Befejező lépés",
+ "label.entry": "Belépési URL",
+ "label.event": "Esemény",
+ "label.event-data": "Eseményadatok",
+ "label.event-name": "Esemény neve",
+ "label.events": "Események",
+ "label.exists": "Létezik",
+ "label.exit": "Kilépési URL",
+ "label.false": "Hamis",
+ "label.field": "Mező",
+ "label.fields": "Mezők",
+ "label.filter": "Filter",
+ "label.filter-combined": "Összevont",
+ "label.filter-raw": "Nyers",
+ "label.filters": "Szűrők",
+ "label.first-click": "Első kattintás",
+ "label.first-seen": "Első megtekintés",
+ "label.funnel": "Tölcsér",
+ "label.funnel-description": "Értse meg a felhasználók konverziós és lemorzsolódási arányát.",
+ "label.funnels": "Tölcsérek",
+ "label.goal": "Cél",
+ "label.goals": "Célok",
+ "label.goals-description": "Kövesse nyomon a céljait oldalmegtekintések és események alapján.",
+ "label.greater-than": "Nagyobb mint",
+ "label.greater-than-equals": "Nagyobb vagy egyenlő",
+ "label.grouped": "Csoportosítva",
+ "label.hostname": "Hosztnév",
+ "label.includes": "Tartalmazza",
+ "label.insight": "Betekintés",
+ "label.insights": "Betekintések",
+ "label.insights-description": "Merüljön el mélyebben az adataiban szegmensek és szűrők használatával.",
+ "label.is": "Az",
+ "label.is-false": "Hamis",
+ "label.is-not": "Nem az",
+ "label.is-not-set": "Nincs beállítva",
+ "label.is-set": "Beállítva",
+ "label.is-true": "Igaz",
+ "label.join": "Csatlakozás",
+ "label.join-team": "Csatlakozás a csapathoz",
+ "label.journey": "Út",
+ "label.journey-description": "Értse meg, hogyan navigálnak a felhasználók a weboldalán.",
+ "label.journeys": "Utak",
+ "label.language": "Language",
+ "label.languages": "Languages",
+ "label.laptop": "Laptop",
+ "label.last-click": "Utolsó kattintás",
+ "label.last-days": "Legutóbbi {x} nap",
+ "label.last-hours": "Legutóbbi {x} óra",
+ "label.last-months": "Utolsó {x} hónap",
+ "label.last-seen": "Utoljára látva",
+ "label.leave": "Kilépés",
+ "label.leave-team": "Csapat elhagyása",
+ "label.less-than": "Kevesebb mint",
+ "label.less-than-equals": "Kevesebb vagy egyenlő",
+ "label.links": "Linkek",
+ "label.login": "Bejelentkezés",
+ "label.logout": "Kijelentkezés",
+ "label.manage": "Kezelés",
+ "label.manager": "Menedzser",
+ "label.max": "Maximum",
+ "label.maximize": "Kibontás",
+ "label.medium": "Közepes",
+ "label.member": "Tag",
+ "label.members": "Tagok",
+ "label.min": "Minimum",
+ "label.mobile": "Telefon",
+ "label.model": "Model",
+ "label.more": "Bővebben",
+ "label.my-account": "Saját fiók",
+ "label.my-websites": "Saját weboldalak",
+ "label.name": "Név",
+ "label.new-password": "Új jelszó",
+ "label.none": "Nincs",
+ "label.number-of-records": "{x} {x, plural, one {rekord} other {rekord}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organikus keresés",
+ "label.organic-shopping": "Organikus vásárlás",
+ "label.organic-social": "Organikus közösségi",
+ "label.organic-video": "Organikus videó",
+ "label.os": "OS",
+ "label.other": "Egyéb",
+ "label.overview": "Áttekintés",
+ "label.owner": "Tulajdonos",
+ "label.page": "Oldal",
+ "label.page-of": "Oldal {current} / {total}",
+ "label.page-views": "Oldalmegtekintések",
+ "label.pageTitle": "Page title",
+ "label.pages": "Oldalak",
+ "label.paid-ads": "Fizetett hirdetések",
+ "label.paid-search": "Fizetett keresés",
+ "label.paid-shopping": "Fizetett vásárlás",
+ "label.paid-social": "Fizetett közösségi",
+ "label.paid-video": "Fizetett videó",
+ "label.password": "Jelszó",
+ "label.path": "Útvonal",
+ "label.paths": "Útvonalak",
+ "label.pixels": "Pixelek",
+ "label.powered-by": "Működteti az {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profil",
+ "label.properties": "Tulajdonságok",
+ "label.property": "Tulajdonság",
+ "label.queries": "Lekérdezések",
+ "label.query": "Lekérdezés",
+ "label.query-parameters": "Lekérdezési paraméterek",
+ "label.realtime": "Valós idejű",
+ "label.referral": "Hivatkozás",
+ "label.referrer": "Referrer",
+ "label.referrers": "Hivatkozók",
+ "label.refresh": "Frissítés",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Hátralévő",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Kötelező",
+ "label.reset": "Visszaállítás",
+ "label.reset-website": "Reset statistics",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Bevétel",
+ "label.revenue-description": "Tekintse meg bevételeit az idő múlásával.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Mentés",
+ "label.screens": "Képernyők",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Szűrő kiválasztása",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Munkamenet",
+ "label.session-data": "Munkamenet adatai",
+ "label.sessions": "Sessions",
+ "label.settings": "Beállítások",
+ "label.share": "Megosztás",
+ "label.share-url": "URL megosztása",
+ "label.single-day": "Egy nap",
+ "label.sms": "SMS",
+ "label.sources": "Források",
+ "label.start-step": "Kezdő lépés",
+ "label.steps": "Lépések",
+ "label.sum": "Sum",
+ "label.tablet": "Táblagép",
+ "label.tag": "Címke",
+ "label.tags": "Címkék",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Csapat beállításai",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Kifejezések",
+ "label.theme": "Theme",
+ "label.this-month": "Ezen hónap",
+ "label.this-week": "Ezen hét",
+ "label.this-year": "Ezen év",
+ "label.timezone": "Időzóna",
+ "label.title": "Title",
+ "label.today": "Ma",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Követési kód",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Egyedi látogatók",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Ismeretlen",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Felhasználónév",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Részletek",
+ "label.view-only": "View only",
+ "label.views": "Megtekintések",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Átlagos látogatási idő",
+ "label.visitors": "Látogatók",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Weboldalak",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} {x, plural, one {látogató} other {latógató}} jelenleg",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Biztos, hogy törölni szeretnéd {target} elemet?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Minden társított adat törlésre kerül.",
+ "message.error": "Valami baj történt.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Tovább a beállításokhoz",
+ "message.incorrect-username-password": "Érvénytelen felhasználónév/jelszó.",
+ "message.invalid-domain": "Érvénytelen domain",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Nincs rendelkezésre álló adat.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "A jelszavak nem egyeznek",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Még nem állítottál be egyetlen weboldalt sem.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Oldal nem található.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "Sikeres mentés.",
+ "message.sever-error": "Server error",
+ "message.share-url": "{target} nyilvánosan megosztott URL címe.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Követési kód",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Látógató {country} területéről, {os} {device} eszközön, {browser} böngészőből."
+}
diff --git a/src/lang/id-ID.json b/src/lang/id-ID.json
new file mode 100644
index 0000000..30a64b6
--- /dev/null
+++ b/src/lang/id-ID.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Kode akses",
+ "label.actions": "Aksi",
+ "label.activity": "Catatan aktivitas",
+ "label.add": "Tambah",
+ "label.add-board": "Tambah papan",
+ "label.add-description": "Tambah deskripsi",
+ "label.add-member": "Tambah anggota",
+ "label.add-step": "Tambah langkah",
+ "label.add-website": "Tambah situs web",
+ "label.admin": "Pengelola",
+ "label.affiliate": "Afiliasi",
+ "label.after": "Setelah",
+ "label.all": "Semua",
+ "label.all-time": "Semua waktu",
+ "label.analytics": "Analitik",
+ "label.apply": "Terapkan",
+ "label.attribution": "Atribusi",
+ "label.attribution-description": "Lihat bagaimana pengguna berinteraksi dengan pemasaran Anda dan apa yang mendorong konversi.",
+ "label.average": "Rata-rata",
+ "label.back": "Kembali",
+ "label.before": "Sebelum",
+ "label.behavior": "Perilaku",
+ "label.boards": "Papan",
+ "label.bounce-rate": "Rasio pentalan",
+ "label.breakdown": "Rincian",
+ "label.browser": "Peramban",
+ "label.browsers": "Peramban",
+ "label.campaigns": "Kampanye",
+ "label.cancel": "Batal",
+ "label.change-password": "Ganti kata sandi",
+ "label.channels": "Saluran",
+ "label.cities": "Kota",
+ "label.city": "Kota",
+ "label.clear-all": "Hapus semua",
+ "label.cohort": "Kelompok",
+ "label.compare": "Bandingkan",
+ "label.compare-dates": "Bandingkan tanggal",
+ "label.confirm": "Konfirmasi",
+ "label.confirm-password": "Konfirmasi kata sandi",
+ "label.contains": "Mengandung",
+ "label.content": "Konten",
+ "label.continue": "Lanjutkan",
+ "label.conversion": "Konversi",
+ "label.conversion-rate": "Tingkat konversi",
+ "label.conversion-step": "Langkah konversi",
+ "label.count": "Jumlah",
+ "label.countries": "Negara",
+ "label.country": "Negara",
+ "label.create": "Buat",
+ "label.create-report": "Buat laporan",
+ "label.create-team": "Buat tim",
+ "label.create-user": "Buat pengguna",
+ "label.created": "Dibuat",
+ "label.created-by": "Dibuat oleh",
+ "label.currency": "Mata uang",
+ "label.current": "Saat ini",
+ "label.current-password": "Kata sandi sekarang",
+ "label.custom-range": "Rentang khusus",
+ "label.dashboard": "Dasbor",
+ "label.data": "Data",
+ "label.date": "Tanggal",
+ "label.date-range": "Rentang tanggal",
+ "label.day": "Hari",
+ "label.default-date-range": "Rentang tanggal bawaan",
+ "label.delete": "Hapus",
+ "label.delete-report": "Hapus laporan",
+ "label.delete-team": "Hapus tim",
+ "label.delete-user": "Hapus pengguna",
+ "label.delete-website": "Hapus situs web",
+ "label.description": "Deskripsi",
+ "label.desktop": "Desktop",
+ "label.details": "Detail",
+ "label.device": "Perangkat",
+ "label.devices": "Perangkat",
+ "label.direct": "Langsung",
+ "label.dismiss": "Tutup",
+ "label.distinct-id": "ID unik",
+ "label.does-not-contain": "Tidak mengandung",
+ "label.does-not-include": "Tidak termasuk",
+ "label.doest-not-exist": "Tidak ada",
+ "label.domain": "Domain",
+ "label.dropoff": "Penurunan",
+ "label.edit": "Sunting",
+ "label.edit-dashboard": "Sunting dasbor",
+ "label.edit-member": "Sunting anggota",
+ "label.email": "Email",
+ "label.enable-share-url": "Aktifkan URL berbagi",
+ "label.end-step": "Langkah akhir",
+ "label.entry": "URL masuk",
+ "label.event": "Peristiwa",
+ "label.event-data": "Data peristiwa",
+ "label.event-name": "Nama peristiwa",
+ "label.events": "Peristiwa",
+ "label.exists": "Ada",
+ "label.exit": "Exit URL",
+ "label.false": "Salah",
+ "label.field": "Kolom",
+ "label.fields": "Kolom",
+ "label.filter": "Filter",
+ "label.filter-combined": "Gabungan",
+ "label.filter-raw": "Mentah",
+ "label.filters": "Filters",
+ "label.first-click": "Klik pertama",
+ "label.first-seen": "Pertama kali dilihat",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Pahami tingkat konversi dan penurunan pengguna.",
+ "label.funnels": "Corong",
+ "label.goal": "Tujuan",
+ "label.goals": "Tujuan",
+ "label.goals-description": "Lacak tujuan Anda untuk tampilan halaman dan peristiwa.",
+ "label.greater-than": "Lebih dari",
+ "label.greater-than-equals": "Lebih dari atau sama dengan",
+ "label.grouped": "Dikelompokkan",
+ "label.hostname": "Nama host",
+ "label.includes": "Termasuk",
+ "label.insight": "Wawasan",
+ "label.insights": "Wawasan",
+ "label.insights-description": "Jelajahi data Anda lebih dalam dengan menggunakan segmen dan filter.",
+ "label.is": "Adalah",
+ "label.is-false": "Salah",
+ "label.is-not": "Bukan",
+ "label.is-not-set": "Tidak diatur",
+ "label.is-set": "Diatur",
+ "label.is-true": "Benar",
+ "label.join": "Gabung",
+ "label.join-team": "Gabung tim",
+ "label.journey": "Perjalanan",
+ "label.journey-description": "Pahami bagaimana pengguna menavigasi situs web Anda.",
+ "label.journeys": "Perjalanan",
+ "label.language": "Bahasa",
+ "label.languages": "Bahasa",
+ "label.laptop": "Laptop",
+ "label.last-click": "Klik terakhir",
+ "label.last-days": "{x} hari terakhir",
+ "label.last-hours": "{x} jam terakhir",
+ "label.last-months": "{x} bulan terakhir",
+ "label.last-seen": "Terakhir kali dilihat",
+ "label.leave": "Keluar",
+ "label.leave-team": "Keluar dari tim",
+ "label.less-than": "Kurang dari",
+ "label.less-than-equals": "Kurang dari atau sama dengan",
+ "label.links": "Tautan",
+ "label.login": "Masuk",
+ "label.logout": "Keluar",
+ "label.manage": "Kelola",
+ "label.manager": "Manajer",
+ "label.max": "Maksimum",
+ "label.maximize": "Perluas",
+ "label.medium": "Sedang",
+ "label.member": "Anggota",
+ "label.members": "Anggota",
+ "label.min": "Minimum",
+ "label.mobile": "Ponsel",
+ "label.model": "Model",
+ "label.more": "Lebih banyak",
+ "label.my-account": "Akun saya",
+ "label.my-websites": "Situs web saya",
+ "label.name": "Nama",
+ "label.new-password": "Kata sandi baru",
+ "label.none": "Tidak ada",
+ "label.number-of-records": "{x} {x, plural, one {catatan} other {catatan}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Pencarian organik",
+ "label.organic-shopping": "Belanja organik",
+ "label.organic-social": "Sosial organik",
+ "label.organic-video": "Video organik",
+ "label.os": "OS",
+ "label.other": "Lainnya",
+ "label.overview": "Tinjauan umum",
+ "label.owner": "Pemilik",
+ "label.page": "Halaman",
+ "label.page-of": "Halaman {current} dari {total}",
+ "label.page-views": "Tampilan halaman",
+ "label.pageTitle": "Judul halaman",
+ "label.pages": "Halaman",
+ "label.paid-ads": "Iklan berbayar",
+ "label.paid-search": "Pencarian berbayar",
+ "label.paid-shopping": "Belanja berbayar",
+ "label.paid-social": "Sosial berbayar",
+ "label.paid-video": "Video berbayar",
+ "label.password": "Kata sandi",
+ "label.path": "Jalur",
+ "label.paths": "Jalur",
+ "label.pixels": "Piksel",
+ "label.powered-by": "Didukung oleh {name}",
+ "label.previous": "Sebelumnya",
+ "label.previous-period": "Periode sebelumnya",
+ "label.previous-year": "Tahun lalu",
+ "label.profile": "Profil",
+ "label.properties": "Properti",
+ "label.property": "Properti",
+ "label.queries": "Kueri",
+ "label.query": "Kueri",
+ "label.query-parameters": "Parameter kueri",
+ "label.realtime": "Waktu nyata",
+ "label.referral": "Rujukan",
+ "label.referrer": "Perujuk",
+ "label.referrers": "Perujuk",
+ "label.refresh": "Segarkan",
+ "label.regenerate": "Buat ulang",
+ "label.region": "Wilayah",
+ "label.regions": "Wilayah",
+ "label.remaining": "Tersisa",
+ "label.remove": "Hapus",
+ "label.remove-member": "Hapus anggota",
+ "label.reports": "Laporan",
+ "label.required": "Wajib",
+ "label.reset": "Atur ulang",
+ "label.reset-website": "Atur ulang statistik",
+ "label.retention": "Retensi",
+ "label.retention-description": "Ukur daya tarik situs web Anda dengan melacak seberapa sering pengguna kembali.",
+ "label.revenue": "Pendapatan",
+ "label.revenue-description": "Lihat pendapatan Anda seiring waktu.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Simpan",
+ "label.screens": "Layar",
+ "label.search": "Cari",
+ "label.select": "Pilih",
+ "label.select-date": "Pilih tanggal",
+ "label.select-filter": "Pilih filter",
+ "label.select-role": "Pilih role",
+ "label.select-website": "Pilih situs web",
+ "label.session": "Sesi",
+ "label.session-data": "Data sesi",
+ "label.sessions": "Sesi",
+ "label.settings": "Pengaturan",
+ "label.share": "Bagikan",
+ "label.share-url": "Bagikan URL",
+ "label.single-day": "Sehari",
+ "label.sms": "SMS",
+ "label.sources": "Sumber",
+ "label.start-step": "Langkah awal",
+ "label.steps": "Langkah",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Tag",
+ "label.team": "Tim",
+ "label.team-id": "ID tim",
+ "label.team-manager": "Pengelola tim",
+ "label.team-member": "Anggota tim",
+ "label.team-name": "Nama tim",
+ "label.team-owner": "Pemilik tim",
+ "label.team-settings": "Pengaturan tim",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Situs web tim",
+ "label.teams": "Tim",
+ "label.terms": "Ketentuan",
+ "label.theme": "Tema",
+ "label.this-month": "Bulan ini",
+ "label.this-week": "Minggu ini",
+ "label.this-year": "Tahun ini",
+ "label.timezone": "Zona waktu",
+ "label.title": "Judul",
+ "label.today": "Hari ini",
+ "label.toggle-charts": "Buka grafik",
+ "label.total": "Total",
+ "label.total-records": "Total baris",
+ "label.tracking-code": "Kode lacak",
+ "label.transactions": "Transaksi",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer situs web",
+ "label.true": "Benar",
+ "label.type": "Tipe",
+ "label.unique": "Unik",
+ "label.unique-visitors": "Pengunjung unik",
+ "label.uniqueCustomers": "Kustomer unik",
+ "label.unknown": "Tidak diketahui",
+ "label.untitled": "Tanpa judul",
+ "label.update": "Perbarui",
+ "label.user": "Pengguna",
+ "label.username": "Nama pengguna",
+ "label.users": "Pengguna",
+ "label.utm": "UTM",
+ "label.utm-description": "Lacak kampanye Anda melalui parameter UTM.",
+ "label.value": "Nilai",
+ "label.view": "Lihat",
+ "label.view-details": "Lihat Detil",
+ "label.view-only": "Hanya melihat",
+ "label.views": "Tampilan",
+ "label.views-per-visit": "Tampilan per kunjungan",
+ "label.visit-duration": "Waktu kunjungan rata-rata",
+ "label.visitors": "Pengunjung",
+ "label.visits": "Kunjungan",
+ "label.website": "Situs web",
+ "label.website-id": "ID situs web",
+ "label.websites": "Situs web",
+ "label.window": "Window",
+ "label.yesterday": "Kemarin",
+ "message.action-confirmation": "Ketik {confirmation} pada kotak di bawah untuk mengonfirmasi.",
+ "message.active-users": "{x} pengunjung saat ini",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Data dikumpulkan",
+ "message.confirm-delete": "Apakah kamu yakin ingin menghapus {target}?",
+ "message.confirm-leave": "Apakah Anda yakin ingin meninggalkan {target}?",
+ "message.confirm-remove": "Apakah Anda yakin ingin menghapus {target}?",
+ "message.confirm-reset": "Anda yakin ingin mengatur ulang statistik {target}?",
+ "message.delete-team-warning": "Menghapus tim juga akan menghapus semua situs web yang terkait.",
+ "message.delete-website-warning": "Semua data terkait juga akan dihapus.",
+ "message.error": "Ada yang salah.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Pergi ke pengaturan",
+ "message.incorrect-username-password": "Nama pengguna/kata sandi salah.",
+ "message.invalid-domain": "Domain tidak valid",
+ "message.min-password-length": "Minimal {n} karakter",
+ "message.new-version-available": "Versi baru dari Umami {version} telah tersedia!",
+ "message.no-data-available": "Tidak ada data.",
+ "message.no-event-data": "Tidak ada data peristiwa",
+ "message.no-match-password": "Kata sandi tidak cocok",
+ "message.no-results-found": "Tidak ada hasil yang ditemukan.",
+ "message.no-team-websites": "Tim ini tidak memiliki situs web.",
+ "message.no-teams": "Anda belum membuat tim.",
+ "message.no-users": "Tidak ada pengguna.",
+ "message.no-websites-configured": "Anda tidak memiliki situs web yang dikonfigurasi.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Halaman tidak ditemukan.",
+ "message.reset-website": "Untuk mengatur ulang situs web ini, ketik {confirmation} pada kotak di bawah untuk mengonfirmasi.",
+ "message.reset-website-warning": "Semua statistik pada situs web ini akan dihapus, tetapi kode lacak akan tetap terpasang",
+ "message.saved": "Berhasil disimpan.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Ini adalah URL yang dibagikan secara publik untuk {target}.",
+ "message.team-already-member": "Anda sudah menjadi anggota tim ini.",
+ "message.team-not-found": "Tim tidak ditemukan.",
+ "message.team-websites-info": "Situs web dapat dilihat oleh semua anggota tim.",
+ "message.tracking-code": "Kode lacak",
+ "message.transfer-team-website-to-user": "Transfer situs web ini ke akun Anda?",
+ "message.transfer-user-website-to-team": "Pilih tim tujuan untuk mentransfer situs web ini.",
+ "message.transfer-website": "Transfer kepemilikan situs web ke akun Anda atau tim lain",
+ "message.triggered-event": "Peristiwa terjadi",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Pengguna telah dihapus.",
+ "message.viewed-page": "Halaman dilihat",
+ "message.visitor-log": "Pengunjung dari {country} dengan {browser} di {device} {os}"
+}
diff --git a/src/lang/it-IT.json b/src/lang/it-IT.json
new file mode 100644
index 0000000..40cb5ec
--- /dev/null
+++ b/src/lang/it-IT.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Codice di accesso",
+ "label.actions": "Azioni",
+ "label.activity": "Registro attività",
+ "label.add": "Aggiungi",
+ "label.add-board": "Aggiungi bacheca",
+ "label.add-description": "Aggiungi descrizione",
+ "label.add-member": "Aggiungi membro",
+ "label.add-step": "Aggiungi passaggio",
+ "label.add-website": "Aggiungi sito",
+ "label.admin": "Amministratore",
+ "label.affiliate": "Affiliato",
+ "label.after": "Dopo",
+ "label.all": "Tutto",
+ "label.all-time": "Sempre",
+ "label.analytics": "Analitica",
+ "label.apply": "Applica",
+ "label.attribution": "Attribuzione",
+ "label.attribution-description": "Scopri come gli utenti interagiscono con il tuo marketing e cosa genera conversioni.",
+ "label.average": "Media",
+ "label.back": "Indietro",
+ "label.before": "Prima",
+ "label.behavior": "Comportamento",
+ "label.boards": "Bacheche",
+ "label.bounce-rate": "Frequenza di rimbalzo",
+ "label.breakdown": "Dettaglio",
+ "label.browser": "Browser",
+ "label.browsers": "Browser",
+ "label.campaigns": "Campagne",
+ "label.cancel": "Annulla",
+ "label.change-password": "Modifica password",
+ "label.channels": "Canali",
+ "label.cities": "Città",
+ "label.city": "Città",
+ "label.clear-all": "Cancella tutto",
+ "label.cohort": "Coorte",
+ "label.compare": "Confronta",
+ "label.compare-dates": "Confronta date",
+ "label.confirm": "Conferma",
+ "label.confirm-password": "Conferma password",
+ "label.contains": "Contains",
+ "label.content": "Contenuto",
+ "label.continue": "Continua",
+ "label.conversion": "Conversione",
+ "label.conversion-rate": "Tasso di conversione",
+ "label.conversion-step": "Passaggio di conversione",
+ "label.count": "Conteggio",
+ "label.countries": "Nazioni",
+ "label.country": "Paese",
+ "label.create": "Crea",
+ "label.create-report": "Crea rapporto",
+ "label.create-team": "Crea team",
+ "label.create-user": "Crea utente",
+ "label.created": "Creato",
+ "label.created-by": "Creato da",
+ "label.currency": "Valuta",
+ "label.current": "Attuale",
+ "label.current-password": "Password attuale",
+ "label.custom-range": "Personalizzato",
+ "label.dashboard": "Pannello di Controllo",
+ "label.data": "Dati",
+ "label.date": "Data",
+ "label.date-range": "Periodo",
+ "label.day": "Giorno",
+ "label.default-date-range": "Periodo standard",
+ "label.delete": "Elimina",
+ "label.delete-report": "Elimina rapporto",
+ "label.delete-team": "Elimina team",
+ "label.delete-user": "Elimina utente",
+ "label.delete-website": "Elimina sito",
+ "label.description": "Descrizione",
+ "label.desktop": "Desktop",
+ "label.details": "Dettagli",
+ "label.device": "Dispositivo",
+ "label.devices": "Dispositivi",
+ "label.direct": "Diretto",
+ "label.dismiss": "Scarta",
+ "label.distinct-id": "ID distinto",
+ "label.does-not-contain": "Non contiene",
+ "label.does-not-include": "Non include",
+ "label.doest-not-exist": "Non esiste",
+ "label.domain": "Dominio",
+ "label.dropoff": "Abbandono",
+ "label.edit": "Modifica",
+ "label.edit-dashboard": "Modifica pannello di controllo",
+ "label.edit-member": "Modifica membro",
+ "label.email": "Email",
+ "label.enable-share-url": "Abilita URL di condivisione",
+ "label.end-step": "Passaggio finale",
+ "label.entry": "URL di ingresso",
+ "label.event": "Evento",
+ "label.event-data": "Dati evento",
+ "label.event-name": "Nome evento",
+ "label.events": "Eventi",
+ "label.exists": "Esiste",
+ "label.exit": "URL di uscita",
+ "label.false": "Falso",
+ "label.field": "Campo",
+ "label.fields": "Campi",
+ "label.filter": "Filter",
+ "label.filter-combined": "Aggregati",
+ "label.filter-raw": "Raw",
+ "label.filters": "Filtri",
+ "label.first-click": "Primo clic",
+ "label.first-seen": "Prima visualizzazione",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Comprendi il tasso di conversione e di abbandono degli utenti.",
+ "label.funnels": "Funnel",
+ "label.goal": "Obiettivo",
+ "label.goals": "Obiettivi",
+ "label.goals-description": "Tieni traccia dei tuoi obiettivi per visualizzazioni di pagina ed eventi.",
+ "label.greater-than": "Maggiore di",
+ "label.greater-than-equals": "Maggiore o uguale a",
+ "label.grouped": "Raggruppato",
+ "label.hostname": "Nome host",
+ "label.includes": "Include",
+ "label.insight": "Approfondimento",
+ "label.insights": "Approfondimenti",
+ "label.insights-description": "Analizza più a fondo i tuoi dati utilizzando segmenti e filtri.",
+ "label.is": "È",
+ "label.is-false": "È falso",
+ "label.is-not": "Non è",
+ "label.is-not-set": "Non impostato",
+ "label.is-set": "Impostato",
+ "label.is-true": "È vero",
+ "label.join": "Unisciti",
+ "label.join-team": "Unisciti al team",
+ "label.journey": "Percorso",
+ "label.journey-description": "Comprendi come gli utenti navigano nel tuo sito web.",
+ "label.journeys": "Percorsi",
+ "label.language": "Lingua",
+ "label.languages": "Lingue",
+ "label.laptop": "Portatile",
+ "label.last-click": "Ultimo clic",
+ "label.last-days": "Ultimi {x} giorni",
+ "label.last-hours": "Ultime {x} ore",
+ "label.last-months": "Ultimi {x} mesi",
+ "label.last-seen": "Ultima visualizzazione",
+ "label.leave": "Lascia",
+ "label.leave-team": "Lascia il team",
+ "label.less-than": "Meno di",
+ "label.less-than-equals": "Meno o uguale a",
+ "label.links": "Link",
+ "label.login": "Accedi",
+ "label.logout": "Esci",
+ "label.manage": "Gestisci",
+ "label.manager": "Manager",
+ "label.max": "Massimo",
+ "label.maximize": "Espandi",
+ "label.medium": "Medio",
+ "label.member": "Membro",
+ "label.members": "Membri",
+ "label.min": "Minimo",
+ "label.mobile": "Cellulare",
+ "label.model": "Model",
+ "label.more": "Dettagli",
+ "label.my-account": "Il mio account",
+ "label.my-websites": "I miei siti",
+ "label.name": "Nome",
+ "label.new-password": "Nuova password",
+ "label.none": "Nessuno",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Ricerca organica",
+ "label.organic-shopping": "Acquisto organico",
+ "label.organic-social": "Social organico",
+ "label.organic-video": "Video organico",
+ "label.os": "OS",
+ "label.other": "Altro",
+ "label.overview": "Overview",
+ "label.owner": "Proprietario",
+ "label.page": "Pagina",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "Visualizzazioni di pagina",
+ "label.pageTitle": "Page title",
+ "label.pages": "Pagine",
+ "label.paid-ads": "Annunci a pagamento",
+ "label.paid-search": "Ricerca a pagamento",
+ "label.paid-shopping": "Acquisto a pagamento",
+ "label.paid-social": "Social a pagamento",
+ "label.paid-video": "Video a pagamento",
+ "label.password": "Password",
+ "label.path": "Percorso",
+ "label.paths": "Percorsi",
+ "label.pixels": "Pixel",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profilo",
+ "label.properties": "Proprietà",
+ "label.property": "Proprietà",
+ "label.queries": "Query",
+ "label.query": "Query",
+ "label.query-parameters": "Parametri query",
+ "label.realtime": "Tempo reale",
+ "label.referral": "Referente",
+ "label.referrer": "Referrer",
+ "label.referrers": "Referrers",
+ "label.refresh": "Ricarica",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Rimanente",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Obbligatorio",
+ "label.reset": "Reset",
+ "label.reset-website": "Resetta le statistiche",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Ricavi",
+ "label.revenue-description": "Consulta i tuoi ricavi nel tempo.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Salva",
+ "label.screens": "Schermi",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Seleziona filtro",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Dati sessione",
+ "label.sessions": "Sessions",
+ "label.settings": "Impostazioni",
+ "label.share": "Condividi",
+ "label.share-url": "Condividi link",
+ "label.single-day": "Singolo giorno",
+ "label.sms": "SMS",
+ "label.sources": "Fonti",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Etichetta",
+ "label.tags": "Etichette",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Impostazioni team",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Termini",
+ "label.theme": "Tema",
+ "label.this-month": "Questo mese",
+ "label.this-week": "Questa settimana",
+ "label.this-year": "Quest'anno",
+ "label.timezone": "Fuso orario",
+ "label.title": "Title",
+ "label.today": "Oggi",
+ "label.toggle-charts": "Apri/Chiudi i grafici",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Codice di tracking",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Visitatori unici",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Sconosciuto",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Nome utente",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Vedi dettagli",
+ "label.view-only": "View only",
+ "label.views": "Visualizzazioni",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Tempo medio di visita",
+ "label.visitors": "Visitatori",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Siti web",
+ "label.window": "Window",
+ "label.yesterday": "Ieri",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} {x, plural, one {visitatore} other {visitatori}} online",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Sei sicuro di voler eliminare {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Sei sicuro di voler azzerare le statistiche di {target}?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Saranno eliminati anche tutti i dati associati.",
+ "message.error": "Si è verificato un errore.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Vai alle impostazioni",
+ "message.incorrect-username-password": "Username o password non corretti.",
+ "message.invalid-domain": "Dominio non valido",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Nessun dato disponibile.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Le password non corrispondono",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Non hai ancora configurato alcun sito.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Pagina non trovata",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "Tutte le statistiche verranno cancellate per questo sito, ma il tuo codice di tracciamento rimarrà invariato.",
+ "message.saved": "Salvato!",
+ "message.sever-error": "Server error",
+ "message.share-url": "Questo è l'URL di condivisione per {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Codice di tracking",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Utenti da {country} tramite {browser} su {os} {device}"
+}
diff --git a/src/lang/ja-JP.json b/src/lang/ja-JP.json
new file mode 100644
index 0000000..7d2bf40
--- /dev/null
+++ b/src/lang/ja-JP.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "アクセスコード",
+ "label.actions": "アクション",
+ "label.activity": "アクティビティログ",
+ "label.add": "追加",
+ "label.add-board": "ボードを追加",
+ "label.add-description": "説明を追加",
+ "label.add-member": "メンバーの追加",
+ "label.add-step": "ステップを追加",
+ "label.add-website": "Webサイトの追加",
+ "label.admin": "管理者",
+ "label.affiliate": "アフィリエイト",
+ "label.after": "直後",
+ "label.all": "すべて",
+ "label.all-time": "すべての時間帯",
+ "label.analytics": "アナリティクス",
+ "label.apply": "適用",
+ "label.attribution": "アトリビューション",
+ "label.attribution-description": "ユーザーがあなたのマーケティングにどのように関与し、何がコンバージョンを促進するかを確認します。",
+ "label.average": "平均",
+ "label.back": "戻る",
+ "label.before": "直前",
+ "label.behavior": "行動",
+ "label.boards": "ボード",
+ "label.bounce-rate": "直帰率",
+ "label.breakdown": "故障",
+ "label.browser": "ブラウザ",
+ "label.browsers": "ブラウザ",
+ "label.campaigns": "キャンペーン",
+ "label.cancel": "キャンセル",
+ "label.change-password": "パスワードの変更",
+ "label.channels": "チャンネル",
+ "label.cities": "都市",
+ "label.city": "都市",
+ "label.clear-all": "すべてクリア",
+ "label.cohort": "コホート",
+ "label.compare": "比較",
+ "label.compare-dates": "日付を比較",
+ "label.confirm": "確認",
+ "label.confirm-password": "パスワード(確認)",
+ "label.contains": "コンテンツ",
+ "label.content": "コンテンツ",
+ "label.continue": "続ける",
+ "label.conversion": "コンバージョン",
+ "label.conversion-rate": "コンバージョン率",
+ "label.conversion-step": "コンバージョンステップ",
+ "label.count": "回数",
+ "label.countries": "国名",
+ "label.country": "国",
+ "label.create": "作成",
+ "label.create-report": "レポートの作成",
+ "label.create-team": "チームの作成",
+ "label.create-user": "ユーザーの作成",
+ "label.created": "作成されました",
+ "label.created-by": "作成者",
+ "label.currency": "通貨",
+ "label.current": "現在",
+ "label.current-password": "現在のパスワード",
+ "label.custom-range": "範囲指定",
+ "label.dashboard": "ダッシュボード",
+ "label.data": "データ",
+ "label.date": "日付",
+ "label.date-range": "期間",
+ "label.day": "日",
+ "label.default-date-range": "デフォルトの期間",
+ "label.delete": "削除",
+ "label.delete-report": "レポートの削除",
+ "label.delete-team": "チームの削除",
+ "label.delete-user": "ユーザーの削除",
+ "label.delete-website": "Webサイトの削除",
+ "label.description": "説明",
+ "label.desktop": "デスクトップ",
+ "label.details": "詳細情報",
+ "label.device": "デバイス",
+ "label.devices": "デバイス",
+ "label.direct": "ダイレクト",
+ "label.dismiss": "却下",
+ "label.distinct-id": "識別ID",
+ "label.does-not-contain": "を含まない",
+ "label.does-not-include": "含まない",
+ "label.doest-not-exist": "存在しない",
+ "label.domain": "ドメイン",
+ "label.dropoff": "切り捨て",
+ "label.edit": "編集",
+ "label.edit-dashboard": "ダッシュボードの編集",
+ "label.edit-member": "メンバーの編集",
+ "label.email": "メール",
+ "label.enable-share-url": "共有URLを有効にする",
+ "label.end-step": "最終ステップ",
+ "label.entry": "訪問時のURL",
+ "label.event": "イベント",
+ "label.event-data": "イベントデータ",
+ "label.event-name": "イベント名",
+ "label.events": "イベント",
+ "label.exists": "存在する",
+ "label.exit": "退出時のURL",
+ "label.false": "偽",
+ "label.field": "フィールド",
+ "label.fields": "フィールド",
+ "label.filter": "フィルター",
+ "label.filter-combined": "結合",
+ "label.filter-raw": "RAW",
+ "label.filters": "フィルター",
+ "label.first-click": "最初のクリック",
+ "label.first-seen": "初回ログイン",
+ "label.funnel": "ファネル",
+ "label.funnel-description": "ユーザーのコンバージョン率と離脱率を分析します。",
+ "label.funnels": "ファネル",
+ "label.goal": "目標",
+ "label.goals": "目標",
+ "label.goals-description": "ページビューとイベントの目標を追跡します。",
+ "label.greater-than": "超過",
+ "label.greater-than-equals": "以上",
+ "label.grouped": "グループ化",
+ "label.hostname": "ホスト名",
+ "label.includes": "含む",
+ "label.insight": "インサイト",
+ "label.insights": "インサイト",
+ "label.insights-description": "セグメントとフィルタを使用して、データをさらに詳しく分析します。",
+ "label.is": "に等しい",
+ "label.is-false": "偽である",
+ "label.is-not": "に等しくない",
+ "label.is-not-set": "未設定",
+ "label.is-set": "設定済み",
+ "label.is-true": "真である",
+ "label.join": "参加",
+ "label.join-team": "チームに参加",
+ "label.journey": "ジャーニー",
+ "label.journey-description": "ユーザーがWebサイト内をどのように移動するかを把握します。",
+ "label.journeys": "ジャーニー",
+ "label.language": "言語",
+ "label.languages": "言語",
+ "label.laptop": "ノートPC",
+ "label.last-click": "最後のクリック",
+ "label.last-days": "過去{x}日間",
+ "label.last-hours": "過去{x}時間",
+ "label.last-months": "過去{x}月間",
+ "label.last-seen": "最終ログイン",
+ "label.leave": "離脱",
+ "label.leave-team": "チームを離脱",
+ "label.less-than": "未満",
+ "label.less-than-equals": "以下",
+ "label.links": "リンク",
+ "label.login": "ログイン",
+ "label.logout": "ログアウト",
+ "label.manage": "管理",
+ "label.manager": "管理者",
+ "label.max": "最大",
+ "label.maximize": "展開",
+ "label.medium": "メディア",
+ "label.member": "メンバー",
+ "label.members": "メンバー",
+ "label.min": "最小",
+ "label.mobile": "携帯電話",
+ "label.model": "モデル",
+ "label.more": "もっと見る",
+ "label.my-account": "マイアカウント",
+ "label.my-websites": "マイWebサイト",
+ "label.name": "名前",
+ "label.new-password": "新しいパスワード",
+ "label.none": "なし",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "オーガニック検索",
+ "label.organic-shopping": "オーガニックショッピング",
+ "label.organic-social": "オーガニックソーシャル",
+ "label.organic-video": "オーガニックビデオ",
+ "label.os": "OS",
+ "label.other": "その他",
+ "label.overview": "概要",
+ "label.owner": "所有者",
+ "label.page": "ページ",
+ "label.page-of": "ページ {current}/{total}",
+ "label.page-views": "閲覧数",
+ "label.pageTitle": "ページタイトル",
+ "label.pages": "ページ",
+ "label.paid-ads": "有料広告",
+ "label.paid-search": "有料検索",
+ "label.paid-shopping": "有料ショッピング",
+ "label.paid-social": "有料ソーシャル",
+ "label.paid-video": "有料ビデオ",
+ "label.password": "パスワード",
+ "label.path": "パス",
+ "label.paths": "パス",
+ "label.pixels": "ピクセル",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "以前",
+ "label.previous-period": "前期",
+ "label.previous-year": "前年",
+ "label.profile": "プロフィール",
+ "label.properties": "プロパティ",
+ "label.property": "プロパティ",
+ "label.queries": "クエリ",
+ "label.query": "クエリ",
+ "label.query-parameters": "クエリパラメーター",
+ "label.realtime": "リアルタイム",
+ "label.referral": "Referral",
+ "label.referrer": "リファラー",
+ "label.referrers": "リファラー",
+ "label.refresh": "更新",
+ "label.regenerate": "再生成",
+ "label.region": "地域",
+ "label.regions": "地域",
+ "label.remaining": "残り",
+ "label.remove": "削除",
+ "label.remove-member": "メンバーの削除",
+ "label.reports": "レポート",
+ "label.required": "必須",
+ "label.reset": "リセット",
+ "label.reset-website": "Webサイトをリセットする",
+ "label.retention": "リテンション",
+ "label.retention-description": "ユーザーの再訪問回数を記録して、Webサイトのリテンション率を計測します。",
+ "label.revenue": "レベニュー",
+ "label.revenue-description": "時間あたりの売上高を確認します。",
+ "label.role": "ロール",
+ "label.run-query": "クエリ実行",
+ "label.save": "保存",
+ "label.screens": "画面サイズ",
+ "label.search": "検索",
+ "label.select": "選択",
+ "label.select-date": "日付を選択",
+ "label.select-filter": "フィルターを選択",
+ "label.select-role": "ロールを選択",
+ "label.select-website": "Webサイトを選択",
+ "label.session": "セッション",
+ "label.session-data": "セッションデータ",
+ "label.sessions": "セッション",
+ "label.settings": "設定",
+ "label.share": "共有",
+ "label.share-url": "共有URL",
+ "label.single-day": "一日",
+ "label.sms": "SMS",
+ "label.sources": "ソース",
+ "label.start-step": "最初のステップ",
+ "label.steps": "ステップ",
+ "label.sum": "合計",
+ "label.tablet": "タブレット",
+ "label.tag": "タグ",
+ "label.tags": "タグ",
+ "label.team": "チーム",
+ "label.team-id": "チームID",
+ "label.team-manager": "チーム管理者",
+ "label.team-member": "チームメンバー",
+ "label.team-name": "チーム名",
+ "label.team-owner": "チームオーナー",
+ "label.team-settings": "チーム設定",
+ "label.team-view-only": "チーム表示のみ",
+ "label.team-websites": "チームのWebサイト",
+ "label.teams": "チーム",
+ "label.terms": "利用規約",
+ "label.theme": "テーマ",
+ "label.this-month": "今月",
+ "label.this-week": "今週",
+ "label.this-year": "今年",
+ "label.timezone": "タイムゾーン",
+ "label.title": "タイトル",
+ "label.today": "今日",
+ "label.toggle-charts": "グラフを切り替える",
+ "label.total": "累計",
+ "label.total-records": "総記録数",
+ "label.tracking-code": "トラッキングコード",
+ "label.transactions": "トランザクション",
+ "label.transfer": "移管",
+ "label.transfer-website": "Webサイトの移管",
+ "label.true": "真",
+ "label.type": "種別",
+ "label.unique": "ユニーク",
+ "label.unique-visitors": "ユニーク訪問者数",
+ "label.uniqueCustomers": "ユニーク顧客数",
+ "label.unknown": "不明",
+ "label.untitled": "無題",
+ "label.update": "更新",
+ "label.user": "ユーザー",
+ "label.username": "ユーザー名",
+ "label.users": "ユーザー",
+ "label.utm": "UTM",
+ "label.utm-description": "UTMパラメーターを使用してキャンペーンを追跡します。",
+ "label.value": "値",
+ "label.view": "表示",
+ "label.view-details": "詳細を表示",
+ "label.view-only": "表示のみ",
+ "label.views": "表示",
+ "label.views-per-visit": "訪問あたりの閲覧数",
+ "label.visit-duration": "平均滞在時間",
+ "label.visitors": "訪問者",
+ "label.visits": "訪問数",
+ "label.website": "Webサイト",
+ "label.website-id": "WebサイトID",
+ "label.websites": "Webサイト",
+ "label.window": "ウィンドウ",
+ "label.yesterday": "昨日",
+ "message.action-confirmation": "承認する場合は、下のフォームに「{confirmation}」と入力してください。",
+ "message.active-users": "{x} {x, plural, one {アクティブな訪問者} other {アクティブな訪問者}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "収集されたデータ",
+ "message.confirm-delete": "{target}を削除してもよろしいですか?",
+ "message.confirm-leave": "{target}から離脱してもよろしいですか?",
+ "message.confirm-remove": "{target}を削除してもよろしいですか?",
+ "message.confirm-reset": "{target}をリセットしてもよろしいですか?",
+ "message.delete-team-warning": "チームを削除すると、そのチームが管理しているWebサイトもすべて削除されます。",
+ "message.delete-website-warning": "Webサイトのデータがすべて削除されます。",
+ "message.error": "未知のエラーが発生しました。",
+ "message.event-log": "{url}の{event}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "設定に移動する",
+ "message.incorrect-username-password": "ユーザー名またはパスワードが間違っています。",
+ "message.invalid-domain": "無効なドメインです。http/httpsを含めないでください。",
+ "message.min-password-length": "最小文字数は{n}文字です",
+ "message.new-version-available": "Umamiの新しいバージョン{version}が利用可能です!",
+ "message.no-data-available": "データがありません。",
+ "message.no-event-data": "イベントデータがありません。",
+ "message.no-match-password": "パスワードが一致しません。",
+ "message.no-results-found": "結果が見つかりません。",
+ "message.no-team-websites": "このチームにはWebサイトがありません。",
+ "message.no-teams": "チームを作成していません。",
+ "message.no-users": "ユーザーが存在しません。",
+ "message.no-websites-configured": "Webサイトが設定されていません。",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "ページが見つかりません",
+ "message.reset-website": "このWebサイトをリセットするには、下のフォームに「{confirmation}」と入力してください。",
+ "message.reset-website-warning": "このWebサイトの統計情報はすべて削除されますが、設定はそのまま残ります。",
+ "message.saved": "保存されました。",
+ "message.sever-error": "Server error",
+ "message.share-url": "あなたのWebサイトの統計情報は次のURLで公開されています:",
+ "message.team-already-member": "あなたはすでにチームのメンバーです。",
+ "message.team-not-found": "チームが見つかりません。",
+ "message.team-websites-info": "Webサイトはチーム内の誰でも見ることができます。",
+ "message.tracking-code": "このWebサイトの統計情報を追跡するには、HTMLの<head>...</head>セクションに以下のコードを記述します。",
+ "message.transfer-team-website-to-user": "このWebサイトをあなたのアカウントに移管しますか?",
+ "message.transfer-user-website-to-team": "このWebサイトを移管するチームを選択してください。",
+ "message.transfer-website": "Webサイトの所有権を自分のアカウントまたは別のチームへ移管します。",
+ "message.triggered-event": "トリガーされたイベント",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "ユーザーが削除されました。",
+ "message.viewed-page": "閲覧されたページ",
+ "message.visitor-log": "{os}({device})で{browser}を使用している{country}からの訪問者"
+}
diff --git a/src/lang/km-KH.json b/src/lang/km-KH.json
new file mode 100644
index 0000000..087e24d
--- /dev/null
+++ b/src/lang/km-KH.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "កូដចូលប្រើ",
+ "label.actions": "សកម្មភាព",
+ "label.activity": "កំណត់ហេតុ​សកម្មភាព",
+ "label.add": "បង្កើតបន្ថែម",
+ "label.add-board": "បន្ថែមក្តារ",
+ "label.add-description": "បន្ថែមពិពណ៌នា",
+ "label.add-member": "បន្ថែមសមាជិក",
+ "label.add-step": "បន្ថែមជំហាន",
+ "label.add-website": "បន្ថែមគេហទំព័រ",
+ "label.admin": "អ្នកគ្រប់គ្រង",
+ "label.affiliate": "ដៃគូ",
+ "label.after": "បន្ទាប់",
+ "label.all": "ទាំងអស់",
+ "label.all-time": "គ្រប់ពេល",
+ "label.analytics": "វិភាគ",
+ "label.apply": "អនុវត្ត",
+ "label.attribution": "ការបញ្ជាក់",
+ "label.attribution-description": "មើលថាប្រើប្រាស់របស់អ្នកធ្វើអ្វីជាមួយទីផ្សាររបស់អ្នក និងអ្វីជាហេតុបណ្តាលឲ្យមានការបម្លែង។",
+ "label.average": "ជាមធ្យម",
+ "label.back": "ថយក្រោយ",
+ "label.before": "មុន",
+ "label.behavior": "អាកប្បកិរិយា",
+ "label.boards": "ក្តារ",
+ "label.bounce-rate": "ចំនួនវិលត្រឡប់",
+ "label.breakdown": "បំបែកលម្អិត",
+ "label.browser": "កម្មវិធីរុករក",
+ "label.browsers": "កម្មវិធី",
+ "label.campaigns": "យុទ្ធនាការ",
+ "label.cancel": "បោះបង់",
+ "label.change-password": "ផ្លាស់ប្តូរពាក្យសម្ងាត់",
+ "label.channels": "ឆានែល",
+ "label.cities": "ទីក្រុង",
+ "label.city": "ទីក្រុង",
+ "label.clear-all": "លុបចេញទាំងអស់",
+ "label.cohort": "ក្រុម",
+ "label.compare": "ប្រៀបធៀប",
+ "label.compare-dates": "ប្រៀបធៀបទិន្នន័យថ្ងៃខែ",
+ "label.confirm": "បញ្ជាក់",
+ "label.confirm-password": "បញ្ជាក់ពាក្យសម្ងាត់",
+ "label.contains": "មាន",
+ "label.content": "មាតិកា",
+ "label.continue": "បន្ត",
+ "label.conversion": "ការបម្លែង",
+ "label.conversion-rate": "អត្រាបម្លែង",
+ "label.conversion-step": "ជំហានបម្លែង",
+ "label.count": "ចំនួន",
+ "label.countries": "ប្រទេស",
+ "label.country": "ប្រទេស",
+ "label.create": "បង្កើត",
+ "label.create-report": "បង្កើតរបាយការណ៍",
+ "label.create-team": "បង្កើតក្រុម",
+ "label.create-user": "បង្កើតអ្នកប្រើប្រាស់",
+ "label.created": "បង្កើតនៅ",
+ "label.created-by": "បង្កើតដោយ",
+ "label.currency": "រូបិយប័ណ្ណ",
+ "label.current": "បច្ចុប្បន្ន",
+ "label.current-password": "ពាក្យសម្ងាត់បច្ចុប្បន្ន",
+ "label.custom-range": "កំណត់ដោយខ្លួនឯង",
+ "label.dashboard": "ផ្ទាំងគ្រប់គ្រង",
+ "label.data": "ទិន្នន័យ",
+ "label.date": "កាលបរិច្ឆេទ",
+ "label.date-range": "ចន្លោះកាលបរិច្ឆេទ",
+ "label.day": "ថ្ងៃ",
+ "label.default-date-range": "ចន្លោះកាលបរិច្ឆេទដើម",
+ "label.delete": "លុប",
+ "label.delete-report": "លុបរបាយការណ៍",
+ "label.delete-team": "លុបក្រុម",
+ "label.delete-user": "លុបអ្នកប្រើប្រាស់",
+ "label.delete-website": "លុបគេហទំព័រ",
+ "label.description": "ការពិពណ៌នា",
+ "label.desktop": "កុំព្យូទ័រលើតុ",
+ "label.details": "ព័ត៌មានលម្អិត",
+ "label.device": "ឧបករណ៍",
+ "label.devices": "ឧបករណ៍",
+ "label.direct": "ផ្ទាល់",
+ "label.dismiss": "រំសាយ",
+ "label.distinct-id": "លេខសម្គាល់ពិសេស",
+ "label.does-not-contain": "មិនមាន",
+ "label.does-not-include": "មិនរួមបញ្ចូល",
+ "label.doest-not-exist": "មិនមានទេ",
+ "label.domain": "Domain",
+ "label.dropoff": "Dropoff",
+ "label.edit": "កែប្រែ",
+ "label.edit-dashboard": "កែផ្ទាំងគ្រប់គ្រង",
+ "label.edit-member": "កែព័ត៌មានសមាជិក",
+ "label.email": "Email",
+ "label.enable-share-url": "បើកការចែករំលែក URL",
+ "label.end-step": "បញ្ចប់ជំហាន",
+ "label.entry": "URL ចូល",
+ "label.event": "ព្រឹត្តិការណ៍",
+ "label.event-data": "ទិន្នន័យព្រឹត្តិការណ៍",
+ "label.event-name": "ឈ្មោះព្រឹត្តិការណ៍",
+ "label.events": "ព្រឹត្តិការណ៍",
+ "label.exists": "មាន",
+ "label.exit": "URL ចេញ",
+ "label.false": "មិនពិត",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "ចម្រោះ",
+ "label.filter-combined": "រួមបញ្ចូលគ្នា",
+ "label.filter-raw": "ដើម",
+ "label.filters": "ចម្រោះ",
+ "label.first-click": "ចុចដំបូង",
+ "label.first-seen": "First seen",
+ "label.funnel": "ផ្លូវបង្ហាញ",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "ផ្លូវបង្ហាញ",
+ "label.goal": "គោលដៅ",
+ "label.goals": "គោលដៅ",
+ "label.goals-description": "តាមដានគោលដៅរបស់អ្នកសម្រាប់ pageviews និង events។",
+ "label.greater-than": "ធំជាង",
+ "label.greater-than-equals": "ធំជាងឬស្មើ",
+ "label.grouped": "បានដាក់ជាក្រុម",
+ "label.hostname": "ឈ្មោះម៉ាស៊ីន",
+ "label.includes": "រួមបញ្ចូល",
+ "label.insight": "ការយល់ដឹង",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "គឺ",
+ "label.is-false": "មិនពិត",
+ "label.is-not": "មិនមែន",
+ "label.is-not-set": "មិនបានកំណត់",
+ "label.is-set": "បានកំណត់",
+ "label.is-true": "ពិត",
+ "label.join": "ចូលរួម",
+ "label.join-team": "ចូលក្រុម",
+ "label.journey": "​ដំណើរ",
+ "label.journey-description": "ស្វែងយល់ពីការប្រើប្រាស់គេហទំព័ររបស់អតិថិជនអ្នក។",
+ "label.journeys": "ដំណើរ",
+ "label.language": "ភាសា",
+ "label.languages": "ភាសា",
+ "label.laptop": "កុំព្យូទ័រយួរដៃ",
+ "label.last-click": "ចុចចុងក្រោយ",
+ "label.last-days": "{x} ថ្ងៃចុងក្រោយ",
+ "label.last-hours": "{x} ម៉ោងចុងក្រោយ",
+ "label.last-months": "{x} ខែចុងក្រោយ",
+ "label.last-seen": "Last seen",
+ "label.leave": "ចាកចេញ",
+ "label.leave-team": "ចេញពីក្រុម",
+ "label.less-than": "តិច​ជាង",
+ "label.less-than-equals": "តិចជាង ឬស្មើ",
+ "label.links": "តំណភ្ជាប់",
+ "label.login": "Login",
+ "label.logout": "Logout",
+ "label.manage": "គ្រប់គ្រង",
+ "label.manager": "អ្នកគ្រប់គ្រង",
+ "label.max": "Max",
+ "label.maximize": "ពង្រីក",
+ "label.medium": "មធ្យម",
+ "label.member": "សមាជិក",
+ "label.members": "សមាជិក",
+ "label.min": "Min",
+ "label.mobile": "ទូរស័ព្ទចល័ត",
+ "label.model": "ម៉ូដែល",
+ "label.more": "បន្ថែម",
+ "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.online": "Online",
+ "label.organic-search": "ស្វែងរកធម្មជាតិ",
+ "label.organic-shopping": "ការទិញធម្មជាតិ",
+ "label.organic-social": "សង្គមធម្មជាតិ",
+ "label.organic-video": "វីដេអូធម្មជាតិ",
+ "label.os": "OS",
+ "label.other": "ផ្សេងទៀត",
+ "label.overview": "ទិដ្ឋភាពរួម",
+ "label.owner": "ម្ចាស់",
+ "label.page": "ទំព័រ",
+ "label.page-of": "ទំព័រទី {current} នៃ {total}",
+ "label.page-views": "អ្នកមើលទំព័រ",
+ "label.pageTitle": "ចំណងជើងទំព័រ",
+ "label.pages": "ទំព័រ",
+ "label.paid-ads": "ផ្សាយពាណិជ្ជកម្មបង់ប្រាក់",
+ "label.paid-search": "ស្វែងរកបង់ប្រាក់",
+ "label.paid-shopping": "ទិញបង់ប្រាក់",
+ "label.paid-social": "សង្គមបង់ប្រាក់",
+ "label.paid-video": "វីដេអូបង់ប្រាក់",
+ "label.password": "ពាក្យសម្ងាត់​",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "ភីកសែល",
+ "label.powered-by": "ដំណើរការដោយ {name}",
+ "label.previous": "មុន",
+ "label.previous-period": "មួយរយៈពេលមុន",
+ "label.previous-year": "ឆ្នាំ​មុន",
+ "label.profile": "គណនី",
+ "label.properties": "លក្ខណៈពិសេស",
+ "label.property": "លក្ខណៈពិសេស",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "ប៉ារ៉ាម៉ែត្រ Query",
+ "label.realtime": "ឥលូវនេះ",
+ "label.referral": "ការបញ្ជូន",
+ "label.referrer": "អ្នកណែនាំ",
+ "label.referrers": "អ្នកណែនាំ",
+ "label.refresh": "ផ្ទុកឡើងវិញ",
+ "label.regenerate": "Regenerate",
+ "label.region": "តំបន់",
+ "label.regions": "តំបន់",
+ "label.remaining": "នៅសល់",
+ "label.remove": "លុប",
+ "label.remove-member": "លុបសមាជិកក្រុម",
+ "label.reports": "របាយការណ៍",
+ "label.required": "ទាមទារ",
+ "label.reset": "កែសម្រួល",
+ "label.reset-website": "ដើម្បីកែគេហទំព័រនេះឡើងវិញ សូមសរសេរ {confirmation} នៅក្នុងប្រអប់ខាងក្រោមដើម្បីបញ្ជាក់។",
+ "label.retention": "ការរក្សាទុក",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "មុខងារ",
+ "label.run-query": "Run query",
+ "label.save": "រក្សាទុក",
+ "label.screens": "ប្រភេទឧបករណ៍",
+ "label.search": "ស្វែងរក",
+ "label.select": "ជ្រើសរើស",
+ "label.select-date": "ជ្រើសរើសកាលបរិច្ឆេទ",
+ "label.select-filter": "ជ្រើសរើសតម្រង",
+ "label.select-role": "ជ្រើសរើសមុខងារ",
+ "label.select-website": "ជ្រើសរើសគេហទំព័រ",
+ "label.session": "Session",
+ "label.session-data": "ទិន្នន័យសម័យ",
+ "label.sessions": "Sessions",
+ "label.settings": "ការកំណត់",
+ "label.share": "ចែករំលែក",
+ "label.share-url": "ចែករំលែក URL",
+ "label.single-day": "ថ្ងៃតែមួយ",
+ "label.sms": "SMS",
+ "label.sources": "ប្រភព",
+ "label.start-step": "ជំហានចាប់ផ្តើម",
+ "label.steps": "ជំហាន",
+ "label.sum": "Sum",
+ "label.tablet": "ថេប្លេត",
+ "label.tag": "ស្លាក",
+ "label.tags": "ស្លាក",
+ "label.team": "ក្រុម",
+ "label.team-id": "ID ក្រុម",
+ "label.team-manager": "អ្នកគ្រប់គ្រងក្រុម",
+ "label.team-member": "សមាជិកក្រុម",
+ "label.team-name": "ឈ្មោះក្រុម",
+ "label.team-owner": "ម្ចាស់ក្រុម",
+ "label.team-settings": "ការកំណត់ក្រុម",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "គេហទំព័ររបស់ក្រុម",
+ "label.teams": "ក្រុម",
+ "label.terms": "លក្ខខណ្ឌ",
+ "label.theme": "រូបរាង",
+ "label.this-month": "ខែនេះ",
+ "label.this-week": "ស​ប្តា​ហ៍​នេះ",
+ "label.this-year": "ឆ្នាំ​នេះ",
+ "label.timezone": "តំបន់ម៉ោង",
+ "label.title": "ចំណងជើង",
+ "label.today": "ថ្ងៃនេះ",
+ "label.toggle-charts": "បិទ/បើកតារាង",
+ "label.total": "សរុប",
+ "label.total-records": "កំណត់ត្រាសរុប",
+ "label.tracking-code": "លេខកូដតាមដាន",
+ "label.transactions": "Transactions",
+ "label.transfer": "ការផ្ទេរ",
+ "label.transfer-website": "ការផ្ទេរគេហទំព័រ",
+ "label.true": "ពិត",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "អ្នកចូលមើលម្នាក់ៗ",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "មិនស្គាល់",
+ "label.untitled": "គ្មានចំណងជើង",
+ "label.update": "Update",
+ "label.user": "អ្នកប្រើប្រាស់",
+ "label.username": "ឈ្មោះ​អ្នកប្រើប្រាស់",
+ "label.users": "អ្នកប្រើប្រាស់",
+ "label.utm": "UTM",
+ "label.utm-description": "តាមដានយុទ្ធនាការរបស់អ្នកតាមរយៈប៉ារ៉ាម៉ែត្រ UTM។",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "មើលព័ត៌មានលម្អិត",
+ "label.view-only": "បានតែមើលប៉ុណ្ណោះ",
+ "label.views": "អ្នកចូលមើល",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "រយៈពេលទស្សនា",
+ "label.visitors": "អ្នកទស្សនា",
+ "label.visits": "ទស្សនា",
+ "label.website": "គេហទំព័រ",
+ "label.website-id": "ID គេហទំព័រ",
+ "label.websites": "គេហទំព័រ",
+ "label.window": "Window",
+ "label.yesterday": "ម្សិលមិញ",
+ "message.action-confirmation": "សសេរ {confirmation} នៅក្នុងប្រអប់ខាងក្រោមដើម្បីបញ្ជាក់។",
+ "message.active-users": "មានអ្នកមើល {x} នាក់ ឥលូវនេះ",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "ទិន្នន័យដែលបានប្រមូលទុក",
+ "message.confirm-delete": "តើអ្នកប្រាកដថាចង់លុប {target} ទេ?",
+ "message.confirm-leave": "តើអ្នកប្រាកដថាចង់ចាកចេញ {target} ទេ?",
+ "message.confirm-remove": "តើអ្នកប្រាកដថាចង់លុប {target} ទេ?",
+ "message.confirm-reset": "តើអ្នកប្រាកដថាចង់កំណត់ស្ថិតិរបស់ {target} ឡើងវិញទេ?",
+ "message.delete-team-warning": "ពេលលុបក្រុម គេហទំព័ររបស់ក្រុមក៏នឹងត្រូវលប់ចោលទាំងអស់ផងដែរ។",
+ "message.delete-website-warning": "ទិន្នន័យរបស់គេហទំព័រទាំងអស់នឹងត្រូវលុបចោល។",
+ "message.error": "មាន​អ្វីមួយ​មិន​ប្រក្រតី។",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "ការកំណត់",
+ "message.incorrect-username-password": "ឈ្មោះអ្នកប្រើឬពាក្យសម្ងាត់មិនត្រឹមត្រូវ។",
+ "message.invalid-domain": "Domain មិន​ត្រឹមត្រូវ",
+ "message.min-password-length": "តិចបំផុតដែលមានអក្សរ {n} តួអក្សរ",
+ "message.new-version-available": "Version ថ្មីនៃ Umami {version} អាចប្រើប្រាស់បានហើយ!",
+ "message.no-data-available": "មិនមានទិន្នន័យ។",
+ "message.no-event-data": "មិនមានទិន្នន័យព្រឹត្តិការណ៍ទេ។",
+ "message.no-match-password": "ពាក្យសម្ងាត់មិនត្រូវគ្នាទេ។",
+ "message.no-results-found": "មិនមានលទ្ធផល។",
+ "message.no-team-websites": "ក្រុមនេះមិនមានគេហទំព័រទេ។",
+ "message.no-teams": "អ្នកមិនទាន់បានបង្កើតក្រុមណាមួយទេ។",
+ "message.no-users": "មិនមានអ្នកប្រើប្រាស់ទេ។",
+ "message.no-websites-configured": "អ្នកមិនទាន់បានដាក់គេហទំព័រណាមួយចូលទេ។",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "រកមិនឃើញទំព័រ។",
+ "message.reset-website": "ដើម្បីកែគេហទំព័រនេះឡើងវិញ សូមសរសេរ {confirmation} នៅក្នុងប្រអប់ខាងក្រោមដើម្បីបញ្ជាក់។",
+ "message.reset-website-warning": "ស្ថិតិទាំងអស់សម្រាប់គេហទំព័រនេះនឹងត្រូវបានលុប ប៉ុន្តែកូដតាមដានរបស់អ្នកនឹងនៅដដែល។",
+ "message.saved": "រក្សាទុកដោយជោគជ័យ។",
+ "message.sever-error": "Server error",
+ "message.share-url": "នេះគឺជា URL ដែលអាចចែករំលែកជាសាធារណៈបានសម្រាប់ {target}។",
+ "message.team-already-member": "អ្នកគឺជាសមាជិកនៃក្រុមរួចហើយ។",
+ "message.team-not-found": "រកក្រុមមិនឃើញទេ។",
+ "message.team-websites-info": "គេហទំព័រនេះអាចមើលបានតែសមាជិកក្រុមតែប៉ុណ្ណោះ",
+ "message.tracking-code": "ដើម្បីតាមដានស្ថិតិសម្រាប់គេហទំព័រអ្នក សូមដាក់កូដខាងក្រោមទៅក្នុងផ្នែក <head>...</head> នៃ HTML របស់អ្នក។",
+ "message.transfer-team-website-to-user": "ផ្ទេរគេហទំព័រនេះទៅគណនីរបស់អ្នក។?",
+ "message.transfer-user-website-to-team": "ជ្រើសក្រុមដែរត្រូវផ្ទេរគេហទំព័រនេះទៅ។",
+ "message.transfer-website": "ផ្ទេរកម្មសិទ្ធិគេហទំព័រទៅគណនីរបស់អ្នក ឬក្រុមផ្សេងទៀត។",
+ "message.triggered-event": "Triggered event",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "អ្នកប្រើប្រាស់ត្រូវបានលុបចោល។",
+ "message.viewed-page": "ទំព័រដែលបានមើល",
+ "message.visitor-log": "អ្នកមើលពីប្រទេស {country} ប្រើប្រាស់កម្មវិធី {browser} លើឧបករណ៍ {os} {device}"
+}
diff --git a/src/lang/ko-KR.json b/src/lang/ko-KR.json
new file mode 100644
index 0000000..977eea4
--- /dev/null
+++ b/src/lang/ko-KR.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "액세스 코드",
+ "label.actions": "동작",
+ "label.activity": "활동",
+ "label.add": "추가",
+ "label.add-board": "보드 추가",
+ "label.add-description": "설명 추가",
+ "label.add-member": "멤버 추가",
+ "label.add-step": "단계 추가",
+ "label.add-website": "웹사이트 추가",
+ "label.admin": "관리자",
+ "label.affiliate": "제휴사",
+ "label.after": "이후",
+ "label.all": "전체",
+ "label.all-time": "전체 시간",
+ "label.analytics": "분석",
+ "label.apply": "적용",
+ "label.attribution": "기여도",
+ "label.attribution-description": "사용자가 마케팅에 어떻게 반응하고 전환을 유도하는지 확인하세요.",
+ "label.average": "평균",
+ "label.back": "뒤로",
+ "label.before": "이전",
+ "label.behavior": "행동",
+ "label.boards": "보드",
+ "label.bounce-rate": "이탈률",
+ "label.breakdown": "세부 사항",
+ "label.browser": "브라우저",
+ "label.browsers": "브라우저",
+ "label.campaigns": "캠페인",
+ "label.cancel": "취소",
+ "label.change-password": "비밀번호 변경",
+ "label.channels": "채널",
+ "label.cities": "도시",
+ "label.city": "도시",
+ "label.clear-all": "모두 지우기",
+ "label.cohort": "코호트",
+ "label.compare": "비교",
+ "label.compare-dates": "날짜 비교",
+ "label.confirm": "확인",
+ "label.confirm-password": "비밀번호 확인",
+ "label.contains": "포함",
+ "label.content": "콘텐츠",
+ "label.continue": "계속",
+ "label.conversion": "전환",
+ "label.conversion-rate": "전환율",
+ "label.conversion-step": "전환 단계",
+ "label.count": "수",
+ "label.countries": "국가",
+ "label.country": "국가",
+ "label.create": "만들기",
+ "label.create-report": "보고서 만들기",
+ "label.create-team": "팀 만들기",
+ "label.create-user": "사용자 만들기",
+ "label.created": "생성됨",
+ "label.created-by": "작성자",
+ "label.currency": "통화",
+ "label.current": "현재",
+ "label.current-password": "현재 비밀번호",
+ "label.custom-range": "범위 지정",
+ "label.dashboard": "대시보드",
+ "label.data": "데이터",
+ "label.date": "날짜",
+ "label.date-range": "날짜 범위",
+ "label.day": "일",
+ "label.default-date-range": "기본 날짜 범위",
+ "label.delete": "삭제",
+ "label.delete-report": "보고서 삭제",
+ "label.delete-team": "팀 삭제",
+ "label.delete-user": "사용자 삭제",
+ "label.delete-website": "웹사이트 삭제",
+ "label.description": "설명",
+ "label.desktop": "데스크톱",
+ "label.details": "세부 정보",
+ "label.device": "기기",
+ "label.devices": "기기",
+ "label.direct": "직접",
+ "label.dismiss": "무시하기",
+ "label.distinct-id": "고유 ID",
+ "label.does-not-contain": "포함하지 않음",
+ "label.does-not-include": "포함하지 않음",
+ "label.doest-not-exist": "존재하지 않음",
+ "label.domain": "도메인",
+ "label.dropoff": "이탈",
+ "label.edit": "편집",
+ "label.edit-dashboard": "대시보드 편집",
+ "label.edit-member": "멤버 편집",
+ "label.email": "이메일",
+ "label.enable-share-url": "URL 공유 활성화",
+ "label.end-step": "마지막 단계",
+ "label.entry": "입장 URL",
+ "label.event": "이벤트",
+ "label.event-data": "이벤트 데이터",
+ "label.event-name": "이벤트 이름",
+ "label.events": "이벤트",
+ "label.exists": "존재함",
+ "label.exit": "퇴장 URL",
+ "label.false": "거짓",
+ "label.field": "필드",
+ "label.fields": "필드",
+ "label.filter": "필터",
+ "label.filter-combined": "합쳐 보기",
+ "label.filter-raw": "전체 보기",
+ "label.filters": "필터",
+ "label.first-click": "첫 클릭",
+ "label.first-seen": "첫 접속",
+ "label.funnel": "퍼널",
+ "label.funnel-description": "사용자 전환율 및 이탈률을 살펴보세요.",
+ "label.funnels": "퍼널",
+ "label.goal": "목표",
+ "label.goals": "목표",
+ "label.goals-description": "페이지 조회 및 이벤트 목표를 추적합니다.",
+ "label.greater-than": "이상",
+ "label.greater-than-equals": "이상",
+ "label.grouped": "그룹화됨",
+ "label.hostname": "호스트명",
+ "label.includes": "포함",
+ "label.insight": "인사이트",
+ "label.insights": "인사이트",
+ "label.insights-description": "세그먼트 및 필터를 사용하여 데이터를 더 자세히 살펴보세요.",
+ "label.is": "해당",
+ "label.is-false": "거짓임",
+ "label.is-not": "해당하지 않음",
+ "label.is-not-set": "설정되지 않음",
+ "label.is-set": "설정됨",
+ "label.is-true": "참임",
+ "label.join": "가입하기",
+ "label.join-team": "팀 가입하기",
+ "label.journey": "여정",
+ "label.journey-description": "사용자가 웹사이트를 탐색하는 경로를 살펴보세요.",
+ "label.journeys": "여정",
+ "label.language": "언어",
+ "label.languages": "언어",
+ "label.laptop": "노트북",
+ "label.last-click": "마지막 클릭",
+ "label.last-days": "지난 {x}일",
+ "label.last-hours": "지난 {x}시간",
+ "label.last-months": "지난 {x}개월",
+ "label.last-seen": "마지막 접속",
+ "label.leave": "떠나기",
+ "label.leave-team": "팀 떠나기",
+ "label.less-than": "미만",
+ "label.less-than-equals": "이하",
+ "label.links": "링크",
+ "label.login": "로그인",
+ "label.logout": "로그아웃",
+ "label.manage": "관리",
+ "label.manager": "관리자",
+ "label.max": "최대",
+ "label.maximize": "확장",
+ "label.medium": "미디엄",
+ "label.member": "멤버",
+ "label.members": "멤버",
+ "label.min": "최소",
+ "label.mobile": "모바일",
+ "label.model": "모델",
+ "label.more": "더 보기",
+ "label.my-account": "내 계정",
+ "label.my-websites": "내 웹사이트",
+ "label.name": "이름",
+ "label.new-password": "새 비밀번호",
+ "label.none": "없음",
+ "label.number-of-records": "{x}개 레코드",
+ "label.ok": "확인",
+ "label.online": "Online",
+ "label.organic-search": "자연 검색",
+ "label.organic-shopping": "자연 쇼핑",
+ "label.organic-social": "자연 소셜",
+ "label.organic-video": "자연 비디오",
+ "label.os": "운영 체제",
+ "label.other": "기타",
+ "label.overview": "개요",
+ "label.owner": "소유자",
+ "label.page": "페이지",
+ "label.page-of": "페이지 {current}/{total}",
+ "label.page-views": "페이지 조회",
+ "label.pageTitle": "페이지 제목",
+ "label.pages": "페이지",
+ "label.paid-ads": "유료 광고",
+ "label.paid-search": "유료 검색",
+ "label.paid-shopping": "유료 쇼핑",
+ "label.paid-social": "유료 소셜",
+ "label.paid-video": "유료 비디오",
+ "label.password": "비밀번호",
+ "label.path": "패스",
+ "label.paths": "패스",
+ "label.pixels": "픽셀",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "이전",
+ "label.previous-period": "이전 기간",
+ "label.previous-year": "이전 연도",
+ "label.profile": "프로필",
+ "label.properties": "속성",
+ "label.property": "속성",
+ "label.queries": "쿼리",
+ "label.query": "쿼리",
+ "label.query-parameters": "쿼리 매개 변수",
+ "label.realtime": "실시간",
+ "label.referral": "Referral",
+ "label.referrer": "리퍼러",
+ "label.referrers": "리퍼러",
+ "label.refresh": "새로 고침",
+ "label.regenerate": "다시 생성",
+ "label.region": "지역",
+ "label.regions": "지역",
+ "label.remaining": "남음",
+ "label.remove": "제거",
+ "label.remove-member": "멤버 제거",
+ "label.reports": "보고서",
+ "label.required": "필수",
+ "label.reset": "초기화",
+ "label.reset-website": "웹사이트 초기화",
+ "label.retention": "리텐션",
+ "label.retention-description": "사용자가 얼마나 자주 돌아오는지를 추적하여 웹사이트의 리텐션을 측정하세요.",
+ "label.revenue": "수익",
+ "label.revenue-description": "시간대별 수익을 살펴보세요.",
+ "label.role": "역할",
+ "label.run-query": "쿼리 실행",
+ "label.save": "저장",
+ "label.screens": "화면",
+ "label.search": "검색",
+ "label.select": "선택",
+ "label.select-date": "날짜 선택",
+ "label.select-filter": "필터 선택",
+ "label.select-role": "역할 선택",
+ "label.select-website": "웹사이트 선택",
+ "label.session": "세션",
+ "label.session-data": "세션 데이터",
+ "label.sessions": "세션",
+ "label.settings": "설정",
+ "label.share": "공유",
+ "label.share-url": "공유 URL",
+ "label.single-day": "하루",
+ "label.sms": "SMS",
+ "label.sources": "소스",
+ "label.start-step": "시작 단계",
+ "label.steps": "단계",
+ "label.sum": "합계",
+ "label.tablet": "태블릿",
+ "label.tag": "태그",
+ "label.tags": "태그",
+ "label.team": "팀",
+ "label.team-id": "팀 ID",
+ "label.team-manager": "팀 관리자",
+ "label.team-member": "팀 멤버",
+ "label.team-name": "팀 이름",
+ "label.team-owner": "팀 소유자",
+ "label.team-settings": "팀 설정",
+ "label.team-view-only": "팀 보기 전용",
+ "label.team-websites": "팀 웹사이트",
+ "label.teams": "팀",
+ "label.terms": "약관",
+ "label.theme": "테마",
+ "label.this-month": "이번 달",
+ "label.this-week": "이번 주",
+ "label.this-year": "올해",
+ "label.timezone": "표준 시간대",
+ "label.title": "제목",
+ "label.today": "오늘",
+ "label.toggle-charts": "차트 전환",
+ "label.total": "합계",
+ "label.total-records": "전체 레코드",
+ "label.tracking-code": "추적 코드",
+ "label.transactions": "거래",
+ "label.transfer": "전송",
+ "label.transfer-website": "웹사이트 전송",
+ "label.true": "참",
+ "label.type": "유형",
+ "label.unique": "고유",
+ "label.unique-visitors": "고유 방문자",
+ "label.uniqueCustomers": "고유 고객",
+ "label.unknown": "알 수 없음",
+ "label.untitled": "제목 없음",
+ "label.update": "업데이트",
+ "label.user": "사용자",
+ "label.username": "사용자 이름",
+ "label.users": "사용자",
+ "label.utm": "UTM",
+ "label.utm-description": "UTM 매개변수를 통해 캠페인을 추적하세요.",
+ "label.value": "값",
+ "label.view": "보기",
+ "label.view-details": "자세히 보기",
+ "label.view-only": "보기 전용",
+ "label.views": "조회",
+ "label.views-per-visit": "방문당 조회",
+ "label.visit-duration": "방문 시간",
+ "label.visitors": "방문자",
+ "label.visits": "방문",
+ "label.website": "웹사이트",
+ "label.website-id": "웹사이트 ID",
+ "label.websites": "웹사이트",
+ "label.window": "창",
+ "label.yesterday": "어제",
+ "message.action-confirmation": "확인을 위해 아래 상자에 {confirmation}을(를) 입력하세요.",
+ "message.active-users": "현재 방문자 {x}명",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "수집된 데이터",
+ "message.confirm-delete": "{target}을(를) 삭제하시겠습니까?",
+ "message.confirm-leave": "{target}을(를) 떠나시겠습니까?",
+ "message.confirm-remove": "{target}을(를) 제거하시겠습니까?",
+ "message.confirm-reset": "{target}을(를) 초기화하시겠습니까?",
+ "message.delete-team-warning": "팀을 삭제하면 팀에 등록된 모든 웹사이트도 삭제됩니다.",
+ "message.delete-website-warning": "관련된 모든 데이터가 삭제됩니다.",
+ "message.error": "문제가 발생했습니다.",
+ "message.event-log": "{event} - {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "설정으로 이동",
+ "message.incorrect-username-password": "사용자 이름 또는 비밀번호를 잘못 입력했습니다.",
+ "message.invalid-domain": "잘못된 도메인입니다. http/https를 포함하지 마세요.",
+ "message.min-password-length": "최소 {n}자여야 합니다",
+ "message.new-version-available": "Umami의 새 버전 {version}을(를) 사용할 수 있습니다!",
+ "message.no-data-available": "사용할 수 있는 데이터가 없습니다.",
+ "message.no-event-data": "사용할 수 있는 이벤트 데이터가 없습니다.",
+ "message.no-match-password": "비밀번호가 일치하지 않습니다.",
+ "message.no-results-found": "결과를 찾을 수 없습니다.",
+ "message.no-team-websites": "팀에 웹사이트가 없습니다.",
+ "message.no-teams": "만든 팀이 없습니다.",
+ "message.no-users": "사용자가 없습니다.",
+ "message.no-websites-configured": "설정된 웹사이트가 없습니다.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "페이지를 찾을 수 없음",
+ "message.reset-website": "이 웹사이트를 초기화하려면 아래 상자에 {confirmation}을(를) 입력하세요.",
+ "message.reset-website-warning": "이 웹사이트의 모든 통계가 삭제되지만 설정은 그대로 유지됩니다.",
+ "message.saved": "저장했습니다.",
+ "message.sever-error": "Server error",
+ "message.share-url": "아래 링크를 통해 웹사이트의 통계를 누구나 볼 수 있습니다.",
+ "message.team-already-member": "이미 팀 멤버입니다.",
+ "message.team-not-found": "팀을 찾을 수 없습니다.",
+ "message.team-websites-info": "웹사이트는 팀 멤버 누구나 볼 수 있습니다.",
+ "message.tracking-code": "이 웹사이트의 통계를 추적하려면 다음 코드를 HTML의 <head>...</head> 부분에 추가하세요.",
+ "message.transfer-team-website-to-user": "이 웹사이트를 당신의 계정으로 전송하시겠습니까?",
+ "message.transfer-user-website-to-team": "이 웹사이트를 전송받을 팀을 선택하세요.",
+ "message.transfer-website": "웹사이트 소유권을 계정이나 다른 팀으로 전송합니다.",
+ "message.triggered-event": "트리거된 이벤트",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "사용자를 삭제했습니다.",
+ "message.viewed-page": "조회한 페이지",
+ "message.visitor-log": "{os} {device}에서 {browser}을(를) 사용하는 {country}의 방문자"
+}
diff --git a/src/lang/lt-LT.json b/src/lang/lt-LT.json
new file mode 100644
index 0000000..772fa34
--- /dev/null
+++ b/src/lang/lt-LT.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Prieigos kodas",
+ "label.actions": "Veiksmai",
+ "label.activity": "Veiklos žurnalas",
+ "label.add": "Pridėti",
+ "label.add-board": "Pridėti lentą",
+ "label.add-description": "Pridėti aprašymą",
+ "label.add-member": "Pridėti narį",
+ "label.add-step": "Pridėti žingsnį",
+ "label.add-website": "Pridėti svetainę",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partneris",
+ "label.after": "Po",
+ "label.all": "Visi",
+ "label.all-time": "Visas laikotarpis",
+ "label.analytics": "Analitika",
+ "label.apply": "Taikyti",
+ "label.attribution": "Priskyrimas",
+ "label.attribution-description": "Žiūrėkite, kaip naudotojai įsitraukia į jūsų rinkodarą ir kas lemia konversijas.",
+ "label.average": "Vidurkis",
+ "label.back": "Atgal",
+ "label.before": "Prieš",
+ "label.behavior": "Elgsena",
+ "label.boards": "Lentos",
+ "label.bounce-rate": "Atmetimo rodiklis",
+ "label.breakdown": "Išskaidymas",
+ "label.browser": "Naršyklė",
+ "label.browsers": "Naršyklės",
+ "label.campaigns": "Kampanijos",
+ "label.cancel": "Atšaukti",
+ "label.change-password": "Pakeisti slaptažodį",
+ "label.channels": "Kanalai",
+ "label.cities": "Miestai",
+ "label.city": "Miestas",
+ "label.clear-all": "Išvalyti visus",
+ "label.cohort": "Kohorta",
+ "label.compare": "Palyginti",
+ "label.compare-dates": "Palyginti datas",
+ "label.confirm": "Patvirtinti",
+ "label.confirm-password": "Patvirtinti slaptažodį",
+ "label.contains": "Turi",
+ "label.content": "Turinys",
+ "label.continue": "Tęsti",
+ "label.conversion": "Konversija",
+ "label.conversion-rate": "Konversijos rodiklis",
+ "label.conversion-step": "Konversijos žingsnis",
+ "label.count": "Skaičius",
+ "label.countries": "Šalys",
+ "label.country": "Šalis",
+ "label.create": "Sukurti",
+ "label.create-report": "Kurti ataskaitą",
+ "label.create-team": "Sukurti komandą",
+ "label.create-user": "Sukurti vartotoją",
+ "label.created": "Sukurta",
+ "label.created-by": "Sukūrė",
+ "label.currency": "Valiuta",
+ "label.current": "Dabartinis",
+ "label.current-password": "Dabartinis slaptažodis",
+ "label.custom-range": "Pasirinktinis intervalas",
+ "label.dashboard": "Švieslentė",
+ "label.data": "Duomenys",
+ "label.date": "Data",
+ "label.date-range": "Laikotarpis",
+ "label.day": "Diena",
+ "label.default-date-range": "Numatytasis laikotarpis",
+ "label.delete": "Ištrinti",
+ "label.delete-report": "Ištrinti ataskaitą",
+ "label.delete-team": "Ištrinti komandą",
+ "label.delete-user": "Ištrinti vartotoją",
+ "label.delete-website": "Ištrinti svetainę",
+ "label.description": "Aprašymas",
+ "label.desktop": "Stalinis kompiuteris",
+ "label.details": "Detalės",
+ "label.device": "Įrenginys",
+ "label.devices": "Įrenginiai",
+ "label.direct": "Tiesioginis",
+ "label.dismiss": "Gerai",
+ "label.distinct-id": "Unikalus ID",
+ "label.does-not-contain": "Neturi",
+ "label.does-not-include": "Neįtraukia",
+ "label.doest-not-exist": "Neegzistuoja",
+ "label.domain": "Domenas",
+ "label.dropoff": "Atsitraukimas",
+ "label.edit": "Redaguoti",
+ "label.edit-dashboard": "Redaguoti švieslentę",
+ "label.edit-member": "Redaguoti narį",
+ "label.email": "El. paštas",
+ "label.enable-share-url": "Įjungti bendrinimą su nuoroda",
+ "label.end-step": "Paskutinis žingsnis",
+ "label.entry": "Įėjimo URL",
+ "label.event": "Įvykis",
+ "label.event-data": "Įvykių duomenys",
+ "label.event-name": "Įvykio pavadinimas",
+ "label.events": "Įvykiai",
+ "label.exists": "Egzistuoja",
+ "label.exit": "Išėjimo URL",
+ "label.false": "Netiesa",
+ "label.field": "Laukelis",
+ "label.fields": "Laukeliai",
+ "label.filter": "Filtruoti",
+ "label.filter-combined": "Kombinuoti",
+ "label.filter-raw": "Neapdoroti",
+ "label.filters": "Filtrai",
+ "label.first-click": "Pirmas paspaudimas",
+ "label.first-seen": "Pirmą kartą matyta",
+ "label.funnel": "Piltuvas",
+ "label.funnel-description": "Supraskite naudotojų konversijos ir atsitraukimo rodiklius.",
+ "label.funnels": "Piltuvai",
+ "label.goal": "Tikslas",
+ "label.goals": "Tikslai",
+ "label.goals-description": "Sekite savo tikslus puslapių peržiūroms ir įvykiams.",
+ "label.greater-than": "Daugiau nei",
+ "label.greater-than-equals": "Daugiau arba lygu",
+ "label.grouped": "Grupuota",
+ "label.hostname": "Pagrindinis kompiuteris",
+ "label.includes": "Įtraukia",
+ "label.insight": "Įžvalga",
+ "label.insights": "Įžvalgos",
+ "label.insights-description": "Pasinerkite giliau į savo duomenis naudodami segmentus ir filtrus.",
+ "label.is": "Yra",
+ "label.is-false": "Yra netiesa",
+ "label.is-not": "Nėra",
+ "label.is-not-set": "Nenurodyta",
+ "label.is-set": "Nustatyta",
+ "label.is-true": "Yra tiesa",
+ "label.join": "Prisijungti",
+ "label.join-team": "Prisijungti į komandą",
+ "label.journey": "Kelionė",
+ "label.journey-description": "Sužinokite, kaip naudotojai naršo jūsų svetainėje.",
+ "label.journeys": "Kelionės",
+ "label.language": "Kalba",
+ "label.languages": "Kalbos",
+ "label.laptop": "Nešiojamas kompiuteris",
+ "label.last-click": "Paskutinis paspaudimas",
+ "label.last-days": "{x, plural, =0 {Paskutinės # dienų} zero {Paskutinės # dienų} one {Paskutinė diena} other {Paskutinės # dienos}}",
+ "label.last-hours": "{x, plural, =0 {Paskutinės # valandų} zero {Paskutinės # valandų} one {Paskutinė # valanda} other {Paskutinės # valandos}}",
+ "label.last-months": "Paskutiniai {x} mėnesiai",
+ "label.last-seen": "Paskutinį kartą matyta",
+ "label.leave": "Išeiti",
+ "label.leave-team": "Išeiti iš komandos",
+ "label.less-than": "Mažiau nei",
+ "label.less-than-equals": "Mažiau arba lygu",
+ "label.links": "Nuorodos",
+ "label.login": "Prisijungti",
+ "label.logout": "Atsijungti",
+ "label.manage": "Tvarkyti",
+ "label.manager": "Vadovas",
+ "label.max": "Maksimumas",
+ "label.maximize": "Išplėsti",
+ "label.medium": "Vidutinis",
+ "label.member": "Narys",
+ "label.members": "Nariai",
+ "label.min": "Minimumas",
+ "label.mobile": "Mobilusis",
+ "label.model": "Modelis",
+ "label.more": "Daugiau",
+ "label.my-account": "Mano paskyra",
+ "label.my-websites": "Mano svetainės",
+ "label.name": "Pavadinimas",
+ "label.new-password": "Naujas slaptažodis",
+ "label.none": "Nėra",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organinė paieška",
+ "label.organic-shopping": "Organinis apsipirkimas",
+ "label.organic-social": "Organinis socialinis",
+ "label.organic-video": "Organinis vaizdo įrašas",
+ "label.os": "Operacinės sistemos",
+ "label.other": "Kita",
+ "label.overview": "Apžvalga",
+ "label.owner": "Savininkas",
+ "label.page": "Puslapis",
+ "label.page-of": "Puslapis {current} iš {total}",
+ "label.page-views": "Puslapių peržiūros",
+ "label.pageTitle": "Puslapio pavadinimas",
+ "label.pages": "Puslapiai",
+ "label.paid-ads": "Mokama reklama",
+ "label.paid-search": "Mokama paieška",
+ "label.paid-shopping": "Mokamas apsipirkimas",
+ "label.paid-social": "Mokamas socialinis",
+ "label.paid-video": "Mokamas vaizdo įrašas",
+ "label.password": "Slaptažodis",
+ "label.path": "Kelias",
+ "label.paths": "Keliai",
+ "label.pixels": "Pikseliai",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "Ankstesnis",
+ "label.previous-period": "Ankstesnis laikotarpis",
+ "label.previous-year": "Ankstesni metai",
+ "label.profile": "Profilis",
+ "label.properties": "Savybės",
+ "label.property": "Savybė",
+ "label.queries": "Užklausos",
+ "label.query": "Užklausa",
+ "label.query-parameters": "Užklausų parametrai",
+ "label.realtime": "Realiuoju laiku",
+ "label.referral": "Persiuntimas",
+ "label.referrer": "Persiuntėjas",
+ "label.referrers": "Persiuntėjai",
+ "label.refresh": "Atnaujinti",
+ "label.regenerate": "Sugeneruoti iš naujo",
+ "label.region": "Regionas",
+ "label.regions": "Regionai",
+ "label.remaining": "Likę",
+ "label.remove": "Pašalinti",
+ "label.remove-member": "Pašalinti narį",
+ "label.reports": "Ataskaitos",
+ "label.required": "Reikalinga",
+ "label.reset": "Atstatyti",
+ "label.reset-website": "Atstatyti statistikos duomenis",
+ "label.retention": "Išlaikymas",
+ "label.retention-description": "Išmatuokite, kaip dažnai naudotojai grįžta į jūsų svetainę.",
+ "label.revenue": "Pajamos",
+ "label.revenue-description": "Peržiūrėkite savo pajamas laikui bėgant.",
+ "label.role": "Vaidmuo",
+ "label.run-query": "Vykdyti užklausą",
+ "label.save": "Išsaugoti",
+ "label.screens": "Ekranai",
+ "label.search": "Ieškoti",
+ "label.select": "Pasirinkti",
+ "label.select-date": "Pasirinkti laikotarpį",
+ "label.select-filter": "Pasirinkti filtrą",
+ "label.select-role": "Pasirinkti rolę",
+ "label.select-website": "Pasirinkti svetainę",
+ "label.session": "Sesija",
+ "label.session-data": "Sesijos duomenys",
+ "label.sessions": "Sesijos",
+ "label.settings": "Nustatymai",
+ "label.share": "Dalintis",
+ "label.share-url": "Pasidalinti nuoroda",
+ "label.single-day": "Viena diena",
+ "label.sms": "SMS",
+ "label.sources": "Šaltiniai",
+ "label.start-step": "Pradžios žingsnis",
+ "label.steps": "Žingsniai",
+ "label.sum": "Suma",
+ "label.tablet": "Planšetė",
+ "label.tag": "Žyma",
+ "label.tags": "Žymos",
+ "label.team": "Komanda",
+ "label.team-id": "Komandos ID",
+ "label.team-manager": "Komandos vadovas",
+ "label.team-member": "Komandos narys",
+ "label.team-name": "Komandos pavadinimas",
+ "label.team-owner": "Komandos savininkas",
+ "label.team-settings": "Komandos nustatymai",
+ "label.team-view-only": "Tik peržiūra",
+ "label.team-websites": "Komandos svetainės",
+ "label.teams": "Komandos",
+ "label.terms": "Sąlygos",
+ "label.theme": "Spalvų tema",
+ "label.this-month": "Šis mėnuo",
+ "label.this-week": "Ši savaitė",
+ "label.this-year": "Šie metai",
+ "label.timezone": "Laiko zona",
+ "label.title": "Pavadinimas",
+ "label.today": "Šiandien",
+ "label.toggle-charts": "Rodyti / slėpti grafikus",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Sekimo kodas",
+ "label.transactions": "Transactions",
+ "label.transfer": "Perleisti",
+ "label.transfer-website": "Perleisti svetainę",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Unikalūs lankytojai",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Nežinoma",
+ "label.untitled": "Be pavadinimo",
+ "label.update": "Update",
+ "label.user": "Vartotojas",
+ "label.username": "Vartotojo vardas",
+ "label.users": "Vartotojai",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "Atidaryti",
+ "label.view-details": "Peržiūrėti detaliau",
+ "label.view-only": "Tik peržiūrėti",
+ "label.views": "Peržiūros",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Vidutinė vizito trukmė",
+ "label.visitors": "Lankytojai",
+ "label.visits": "Visits",
+ "label.website": "Svetainė",
+ "label.website-id": "Svetainės ID",
+ "label.websites": "Svetainės",
+ "label.window": "Window",
+ "label.yesterday": "Vakar",
+ "message.action-confirmation": "Įrašykite {confirmation} žemiau, kad patvirtintumėte.",
+ "message.active-users": "{x, plural, =0 {# aktyvių vartotojų} zero {# aktyvių vartotojų} one {# aktyvus vartotojas} other {# aktyvūs vartotojai}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Ar esate tikri, jog norite ištrinti svetainę {target}?",
+ "message.confirm-leave": "Ar esate tikri, jog norite palikti {target}?",
+ "message.confirm-remove": "Ar esate tikri, jog norite ištrinti {target}?",
+ "message.confirm-reset": "Are esate tikri, jog norite atstatyti svetainės {target} statistikos duomenis?",
+ "message.delete-team-warning": "Ištrinant komandą bus ištrintos ir visos komandos svetainės.",
+ "message.delete-website-warning": "Visi susiję duomenys taip pat bus ištrinti.",
+ "message.error": "Kažkas įvyko ne taip.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Eiti į nustatymus",
+ "message.incorrect-username-password": "Neteisingas vartotojo vardas/slaptažodis.",
+ "message.invalid-domain": "Klaidingas domenas",
+ "message.min-password-length": "Reikia bent {n} simbolių",
+ "message.new-version-available": "Išleista nauja 'Umami' {version} versija!",
+ "message.no-data-available": "Nėra jokių duomenų.",
+ "message.no-event-data": "Jokių duomenų apie įvykius nėra.",
+ "message.no-match-password": "Slaptažodžiai nesutampa",
+ "message.no-results-found": "Jokių rezultatų nerasta.",
+ "message.no-team-websites": "Ši komanda neturi jokių svetainių.",
+ "message.no-teams": "Jūs nesate sukūrę jokių komandų.",
+ "message.no-users": "Nėra jokių vartotojų.",
+ "message.no-websites-configured": "Jūs nesate susikonfiguravę jokių svetainių.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Puslapis nerastas.",
+ "message.reset-website": "Kad atstatyti šią svetainę, įrašykite {confirmation} žemiau, kad patvirtintumėte.",
+ "message.reset-website-warning": "Visi šios svetainės statistikos duomenys bus ištrinti, bet sekimo kodas išliks nepaliestas.",
+ "message.saved": "Sėkmingai išsaugota.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Tai yra viešai prieinama {target} nuoroda (URL).",
+ "message.team-already-member": "Jūs jau esate šios komandos narys.",
+ "message.team-not-found": "Komanda nerasta.",
+ "message.team-websites-info": "Svetaines gali peržiūrėti bet kas iš šios komandos.",
+ "message.tracking-code": "Sekimo kodas",
+ "message.transfer-team-website-to-user": "Perduoti šią svetainę į jūsų paskyrą?",
+ "message.transfer-user-website-to-team": "Pasirinkite komandą, kuriai norite perduoti šią svetainę.",
+ "message.transfer-website": "Perduoti svetainės nuosavybę į savo paskyrą arba kitą komandą.",
+ "message.triggered-event": "Triggered event",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Vartotojas ištrintas.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Lankytojas iš {country}, naudojantis {browser} sistemoje {os} {device}"
+}
diff --git a/src/lang/mn-MN.json b/src/lang/mn-MN.json
new file mode 100644
index 0000000..e9c649d
--- /dev/null
+++ b/src/lang/mn-MN.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Хандалтын код",
+ "label.actions": "Үйлдлүүд",
+ "label.activity": "Үйл ажиллагааны бүртгэл",
+ "label.add": "Нэмэх",
+ "label.add-board": "Самбар нэмэх",
+ "label.add-description": "Тайлбар нэмэх",
+ "label.add-member": "Гишүүн нэмэх",
+ "label.add-step": "Алхам нэмэх",
+ "label.add-website": "Веб нэмэх",
+ "label.admin": "Админ",
+ "label.affiliate": "Харьяа",
+ "label.after": "Хойно",
+ "label.all": "Бүх",
+ "label.all-time": "Бүх цаг үеийн",
+ "label.analytics": "Аналитик",
+ "label.apply": "Хэрэглэх",
+ "label.attribution": "Холбогдол",
+ "label.attribution-description": "Хэрэглэгчид таны маркетингт хэрхэн оролцож, ямар зүйлс хөрвүүлэлтэд нөлөөлж байгааг хараарай.",
+ "label.average": "Дундаж",
+ "label.back": "Буцах",
+ "label.before": "Өмнө",
+ "label.behavior": "Зан төлөв",
+ "label.boards": "Самбарууд",
+ "label.bounce-rate": "Нэг хуудас үзээд гарсан",
+ "label.breakdown": "Задаргаа",
+ "label.browser": "Хөтөч",
+ "label.browsers": "Хөтөч",
+ "label.campaigns": "Аянууд",
+ "label.cancel": "Цуцлах",
+ "label.change-password": "Нууц үг солих",
+ "label.channels": "Суваг",
+ "label.cities": "Хотууд",
+ "label.city": "Хот",
+ "label.clear-all": "Бүгдийг арилгах",
+ "label.cohort": "Бүлэг",
+ "label.compare": "Харьцуулах",
+ "label.compare-dates": "Огноо харьцуулах",
+ "label.confirm": "Батлах",
+ "label.confirm-password": "Шинэ нууц үгээ давтах",
+ "label.contains": "Агуулах",
+ "label.content": "Агуулга",
+ "label.continue": "Үргэлжлүүлэх",
+ "label.conversion": "Хөрвүүлэлт",
+ "label.conversion-rate": "Хөрвүүлэлтийн хувь",
+ "label.conversion-step": "Хөрвүүлэлтийн алхам",
+ "label.count": "Тоо",
+ "label.countries": "Улс",
+ "label.country": "Улс",
+ "label.create": "Үүсгэх",
+ "label.create-report": "Тайлан үүсгэх",
+ "label.create-team": "Баг үүсгэх",
+ "label.create-user": "Хэрэглэгч үүсгэх",
+ "label.created": "Үүсгэсэн",
+ "label.created-by": "Үүсгэсэн",
+ "label.currency": "Валют",
+ "label.current": "Одоогийн",
+ "label.current-password": "Ашиглаж буй нууц үг",
+ "label.custom-range": "Дурын хугацаа",
+ "label.dashboard": "Хянах самбар",
+ "label.data": "Өгөгдөл",
+ "label.date": "Огноо",
+ "label.date-range": "Хугацааны муж",
+ "label.day": "Өдөр",
+ "label.default-date-range": "Өгөгдмөл хугацааны муж",
+ "label.delete": "Устгах",
+ "label.delete-report": "Тайлан устгах",
+ "label.delete-team": "Баг устгах",
+ "label.delete-user": "Хэрэглэгч устгах",
+ "label.delete-website": "Веб устгах",
+ "label.description": "Тайлбар",
+ "label.desktop": "Суурин компьютер",
+ "label.details": "Мэдээлэл",
+ "label.device": "Төхөөрөмж",
+ "label.devices": "Төхөөрөмж",
+ "label.direct": "Шууд",
+ "label.dismiss": "Үл хэрэгсэх",
+ "label.distinct-id": "Ялгаатай ID",
+ "label.does-not-contain": "Агуулахгүй",
+ "label.does-not-include": "Агуулаагүй",
+ "label.doest-not-exist": "Байхгүй",
+ "label.domain": "Домэйн",
+ "label.dropoff": "Уналт",
+ "label.edit": "Засах",
+ "label.edit-dashboard": "Хянах самбар засах",
+ "label.edit-member": "Гишүүн засах",
+ "label.email": "Имэйл",
+ "label.enable-share-url": "Хуваалцах холбоос идэвхжүүлэх",
+ "label.end-step": "Төгсгөлийн алхам",
+ "label.entry": "Орох зам",
+ "label.event": "Үйлдэл",
+ "label.event-data": "Үйлдлийн өгөгдөл",
+ "label.event-name": "Үйлдлийн нэр",
+ "label.events": "Үйлдэл",
+ "label.exists": "Байгаа",
+ "label.exit": "Гарах зам",
+ "label.false": "Худал",
+ "label.field": "Талбар",
+ "label.fields": "Талбар",
+ "label.filter": "Шүүлтүүр",
+ "label.filter-combined": "Нэгтгэсэн",
+ "label.filter-raw": "Түүхий",
+ "label.filters": "Шүүлтүүр",
+ "label.first-click": "Эхний даралт",
+ "label.first-seen": "Анх харсан",
+ "label.funnel": "Цутгал",
+ "label.funnel-description": "Хэрэглэгчдийн шилжилт, уналтын хэмжээг шинжлэх.",
+ "label.funnels": "Цутгалууд",
+ "label.goal": "Зорилго",
+ "label.goals": "Зорилго",
+ "label.goals-description": "Хуудас үзсэн болон үйлдлийн зорилгыг мөрдөх.",
+ "label.greater-than": "Их",
+ "label.greater-than-equals": "Их буюу тэнцүү",
+ "label.grouped": "Бүлэглэсэн",
+ "label.hostname": "Хост нэр",
+ "label.includes": "Агуулсан",
+ "label.insight": "Ойлголт",
+ "label.insights": "Шинжлэх",
+ "label.insights-description": "Өгөгдлөө хэсэгчлэн хуваах, шүүх байдлаар задлан шинжлэх.",
+ "label.is": "Бол",
+ "label.is-false": "Худал байна",
+ "label.is-not": "Биш",
+ "label.is-not-set": "Утга оноогоогүй",
+ "label.is-set": "Утга оноосон",
+ "label.is-true": "Үнэн байна",
+ "label.join": "Нэгдэх",
+ "label.join-team": "Багт нэгдэх",
+ "label.journey": "Аялал",
+ "label.journey-description": "Хэрэглэгчид таны цахим хуудсаар хэрхэн шилжиж явсныг шинжлэх.",
+ "label.journeys": "Аялалууд",
+ "label.language": "Хэл",
+ "label.languages": "Хэл",
+ "label.laptop": "Зөөврийн компьютер",
+ "label.last-click": "Сүүлийн даралт",
+ "label.last-days": "Сүүлийн {x} хоног",
+ "label.last-hours": "Сүүлийн {x} цаг",
+ "label.last-months": "Сүүлийн {x} сар",
+ "label.last-seen": "Сүүлд харагдсан",
+ "label.leave": "Гарах",
+ "label.leave-team": "Багаас гарах",
+ "label.less-than": "Бага",
+ "label.less-than-equals": "Бага буюу тэнцүү",
+ "label.links": "Холбоосууд",
+ "label.login": "Нэвтрэх",
+ "label.logout": "Гарах",
+ "label.manage": "Удирдах",
+ "label.manager": "Удирдагч",
+ "label.max": "Max",
+ "label.maximize": "Өргөтгөх",
+ "label.medium": "Дунд",
+ "label.member": "Гишүүн",
+ "label.members": "Гишүүд",
+ "label.min": "Min",
+ "label.mobile": "Утас",
+ "label.model": "Загвар",
+ "label.more": "Цааш",
+ "label.my-account": "Миний бүртгэл",
+ "label.my-websites": "Миний вебүүд",
+ "label.name": "Нэр",
+ "label.new-password": "Шинэ нууц үг",
+ "label.none": "Байхгүй",
+ "label.number-of-records": "{x} {x, plural, one {бичлэг} other {бичлэг}}",
+ "label.ok": "ЗА",
+ "label.online": "Online",
+ "label.organic-search": "Байгалийн хайлт",
+ "label.organic-shopping": "Байгалийн дэлгүүр",
+ "label.organic-social": "Байгалийн сошиал",
+ "label.organic-video": "Байгалийн видео",
+ "label.os": "OS",
+ "label.other": "Бусад",
+ "label.overview": "Тойм",
+ "label.owner": "Эзэмшигч",
+ "label.page": "Хуудас",
+ "label.page-of": "Хуудас {total}-с {current}",
+ "label.page-views": "Хуудас үзсэн",
+ "label.pageTitle": "Хуудасны гарчиг",
+ "label.pages": "Хуудас",
+ "label.paid-ads": "Төлбөртэй зар",
+ "label.paid-search": "Төлбөртэй хайлт",
+ "label.paid-shopping": "Төлбөртэй дэлгүүр",
+ "label.paid-social": "Төлбөртэй сошиал",
+ "label.paid-video": "Төлбөртэй видео",
+ "label.password": "Нууц үг",
+ "label.path": "Зам",
+ "label.paths": "Зам",
+ "label.pixels": "Пиксел",
+ "label.powered-by": "{name} дээр суурилсан",
+ "label.previous": "Өмнөх",
+ "label.previous-period": "Өмнөх үе",
+ "label.previous-year": "Өмнөх жил",
+ "label.profile": "Бүртгэл",
+ "label.properties": "Шинж чанар",
+ "label.property": "Шинж чанар",
+ "label.queries": "Query-нүүд",
+ "label.query": "Query",
+ "label.query-parameters": "Query параметр",
+ "label.realtime": "Яг одоо",
+ "label.referral": "Referral",
+ "label.referrer": "Чиглүүлэгч",
+ "label.referrers": "Чиглүүлэгч",
+ "label.refresh": "Сэргээх",
+ "label.regenerate": "Дахин үүсгэх",
+ "label.region": "Бүс",
+ "label.regions": "Бүсүүд",
+ "label.remaining": "Үлдсэн",
+ "label.remove": "Устгах",
+ "label.remove-member": "Гишүүн хасах",
+ "label.reports": "Тайлан",
+ "label.required": "Шаардлагатай",
+ "label.reset": "Дахин эхлүүлэх",
+ "label.reset-website": "Тоон үзүүлэлтийг дахин эхлүүлэх",
+ "label.retention": "Барилт",
+ "label.retention-description": "Хэрэглэгчид таны веб рүү дахин хандах буюу хэрэглэгчдээ хэр тогтоож буйг хэмжих.",
+ "label.revenue": "Орлого",
+ "label.revenue-description": "Цаг хугацааны туршид орлогын өөрчлөлтийг харах.",
+ "label.role": "Эрх",
+ "label.run-query": "Query ажиллуулах",
+ "label.save": "Хадгалах",
+ "label.screens": "Дэлгэц",
+ "label.search": "Хайх",
+ "label.select": "Сонгох",
+ "label.select-date": "Огноо сонгох",
+ "label.select-filter": "Шүүлтүүр сонгох",
+ "label.select-role": "Select role",
+ "label.select-website": "Веб сонгох",
+ "label.session": "Session",
+ "label.session-data": "Сессийн өгөгдөл",
+ "label.sessions": "Sessions",
+ "label.settings": "Тохиргоо",
+ "label.share": "Хуваалцах",
+ "label.share-url": "Хуваалцах холбоос",
+ "label.single-day": "Нэг өдөр",
+ "label.sms": "SMS",
+ "label.sources": "Эх сурвалжууд",
+ "label.start-step": "Эхлэх алхам",
+ "label.steps": "Алхам",
+ "label.sum": "Нийлбэр",
+ "label.tablet": "Таблет",
+ "label.tag": "Таг",
+ "label.tags": "Тагууд",
+ "label.team": "Баг",
+ "label.team-id": "Багийн ID",
+ "label.team-manager": "Багийн удирдагч",
+ "label.team-member": "Багийн гишүүн",
+ "label.team-name": "Багийн нэр",
+ "label.team-owner": "Багийн эзэмшигч",
+ "label.team-settings": "Багийн тохиргоо",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Багийн вебүүд",
+ "label.teams": "Багууд",
+ "label.terms": "Нөхцөл",
+ "label.theme": "Загвар",
+ "label.this-month": "Энэ сар",
+ "label.this-week": "Энэ долоо хоног",
+ "label.this-year": "Энэ жил",
+ "label.timezone": "Цагийн бүс",
+ "label.title": "Гарчиг",
+ "label.today": "Өнөөдөр",
+ "label.toggle-charts": "Графикийг харуулах/нуух",
+ "label.total": "Нийт",
+ "label.total-records": "Нийт мөрийн тоо",
+ "label.tracking-code": "Мөрдөх код",
+ "label.transactions": "Transactions",
+ "label.transfer": "Шилжүүлэх",
+ "label.transfer-website": "Вебийг шилжүүлэх",
+ "label.true": "Үнэн",
+ "label.type": "Төрөл",
+ "label.unique": "Давхардаагүй",
+ "label.unique-visitors": "Зочин",
+ "label.uniqueCustomers": "Давтагдаагүй зочин",
+ "label.unknown": "Тодорхойгүй",
+ "label.untitled": "Гарчиггүй",
+ "label.update": "Шинэчлэх",
+ "label.user": "Хэрэглэгч",
+ "label.username": "Хэрэглэгчийн нэр",
+ "label.users": "Хэрэглэгчид",
+ "label.utm": "UTM",
+ "label.utm-description": "UTM параметраар кампанит ажлаа мөрдөх.",
+ "label.value": "Утга",
+ "label.view": "Харах",
+ "label.view-details": "Дэлгэрүүлж харах",
+ "label.view-only": "Зөвхөн үзэх",
+ "label.views": "Үзсэн",
+ "label.views-per-visit": "Зочдын хуудас үзсэн тоо",
+ "label.visit-duration": "Зочилсон дундаж хугацаа",
+ "label.visitors": "Зочин",
+ "label.visits": "Зочилсон",
+ "label.website": "Веб",
+ "label.website-id": "Вебийн ID",
+ "label.websites": "Вебүүд",
+ "label.window": "Цонх",
+ "label.yesterday": "Өчигдөр",
+ "message.action-confirmation": "Доорх хэсэгт {confirmation} гэж бичин баталгаажуулна уу.",
+ "message.active-users": "одоо {x} {x, plural, one {зочин} other {зочин}} байна",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Цуглуулсан өгөгдөл",
+ "message.confirm-delete": "Та {target}-г устгахдаа итгэлтэй байна уу?",
+ "message.confirm-leave": "Та {target}-с гарахдаа итгэлтэй байна уу?",
+ "message.confirm-remove": "Та {target}-г устгахдаа итгэлтэй байна уу?",
+ "message.confirm-reset": "Та {target}-н тоон үзүүлэлтүүдийг устгахдаа итгэлтэй байна уу?",
+ "message.delete-team-warning": "Баг устгах нь мөн түүнд харъяалагдах вебүүдийг устгах болно.",
+ "message.delete-website-warning": "Энэ вебтэй холбоотой бүх өгөгдөл устах болно.",
+ "message.error": "Ямар нэг зүйл буруу боллоо.",
+ "message.event-log": "{url}-д {event}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Тохиргоо руу очих",
+ "message.incorrect-username-password": "Буруу хэрэглэгчийн нэр/нууц үг.",
+ "message.invalid-domain": "Буруу домэйн",
+ "message.min-password-length": "Хамгийн багадаа {n} тэмдэгт",
+ "message.new-version-available": "Umami-н шинэ хувилбар {version} гарсан байна!",
+ "message.no-data-available": "Өгөгдөл алга.",
+ "message.no-event-data": "Үйлдлийн өгөгдөл алга.",
+ "message.no-match-password": "Нууц үг тохирохгүй байна.",
+ "message.no-results-found": "Ямар ч үр дүн олдсонгүй.",
+ "message.no-team-websites": "Энэ багт ямар ч веб алга.",
+ "message.no-teams": "Та ямар ч баг үүсгээгүй байна.",
+ "message.no-users": "Хэрэглэгч байхгүй байна.",
+ "message.no-websites-configured": "Та ямар нэгэн веб тохируулаагүй байна.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Хуудас олдсонгүй.",
+ "message.reset-website": "Тоон үзүүлэлтийг дахин эхлүүлэхийн тулд доорх хэсэгт {confirmation} гэж бичиж, баталгаажуулна уу.",
+ "message.reset-website-warning": "Энэ вебийн бүх тоон үзүүлэлтүүдийг устгах болно. Гэхдээ мөрдөх код хэвээрээ үлдэнэ.",
+ "message.saved": "Хадгалсан.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Таны вебийн тоон үзүүлэлтүүд доорх URL дээр нийтэд харагдах болно:",
+ "message.team-already-member": "Та аль хэдийн энэ багийн гишүүн болсон байна.",
+ "message.team-not-found": "Баг олдсонгүй.",
+ "message.team-websites-info": "Вебийг багийн бүх гишүүд үзэж болно.",
+ "message.tracking-code": "Энэ вебийн хандалтуудыг мөрдөхийн тулд доорх кодыг HTML-нхээ <head>...</head> хэсэгт байрлуулна уу.",
+ "message.transfer-team-website-to-user": "Энэ вебийг өөрийн бүртгэл рүү шилжүүлэх үү?",
+ "message.transfer-user-website-to-team": "Энэ вебийг шилжүүлж авах багийг сонгоно уу.",
+ "message.transfer-website": "Энэ вебийг өөрийн бүртгэл рүү эсвэл багт шилжүүлж авах.",
+ "message.triggered-event": "Өдөөсөн үйлдэл",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Хэрэглэгч устсан.",
+ "message.viewed-page": "Үзсэн хуудас",
+ "message.visitor-log": "{country} улсаас {os} {device} дээр {browser} хөтөч ашиглан орсон"
+}
diff --git a/src/lang/ms-MY.json b/src/lang/ms-MY.json
new file mode 100644
index 0000000..32abd08
--- /dev/null
+++ b/src/lang/ms-MY.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Access code",
+ "label.actions": "Aksi",
+ "label.activity": "Activity log",
+ "label.add": "Add",
+ "label.add-board": "Add board",
+ "label.add-description": "Add description",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "Tambah laman web",
+ "label.admin": "Pentadbir",
+ "label.affiliate": "Affiliate",
+ "label.after": "After",
+ "label.all": "Semua",
+ "label.all-time": "All time",
+ "label.analytics": "Analytics",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "Average",
+ "label.back": "Kembali",
+ "label.before": "Before",
+ "label.behavior": "Behavior",
+ "label.boards": "Boards",
+ "label.bounce-rate": "Kadar lantunan",
+ "label.breakdown": "Breakdown",
+ "label.browser": "Browser",
+ "label.browsers": "Pelayar web",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "Batal",
+ "label.change-password": "Tukar kata laluan",
+ "label.channels": "Channels",
+ "label.cities": "Cities",
+ "label.city": "City",
+ "label.clear-all": "Clear all",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "Sahkan kata laluan",
+ "label.contains": "Contains",
+ "label.content": "Content",
+ "label.continue": "Continue",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "Negara",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "Create report",
+ "label.create-team": "Create team",
+ "label.create-user": "Create user",
+ "label.created": "Created",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "Kata laluan semasa",
+ "label.custom-range": "Julat khas",
+ "label.dashboard": "Papan pemuka",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "Julat tarikh",
+ "label.day": "Day",
+ "label.default-date-range": "Julat tarikh lalai",
+ "label.delete": "Padam",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Delete team",
+ "label.delete-user": "Delete user",
+ "label.delete-website": "Padam laman web",
+ "label.description": "Description",
+ "label.desktop": "Desktop",
+ "label.details": "Details",
+ "label.device": "Device",
+ "label.devices": "Peranti",
+ "label.direct": "Direct",
+ "label.dismiss": "Ketepikan",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Does not contain",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "Domain",
+ "label.dropoff": "Dropoff",
+ "label.edit": "Edit",
+ "label.edit-dashboard": "Edit dashboard",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "Aktifkan url berkongsi",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Event",
+ "label.event-data": "Event data",
+ "label.event-name": "Event name",
+ "label.events": "Peristiwa",
+ "label.exists": "Exists",
+ "label.exit": "Exit URL",
+ "label.false": "False",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "Filter",
+ "label.filter-combined": "Digabungkan",
+ "label.filter-raw": "Mentah",
+ "label.filters": "Filters",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Greater than",
+ "label.greater-than-equals": "Greater than or equals",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "Join",
+ "label.join-team": "Join team",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "Language",
+ "label.languages": "Languages",
+ "label.laptop": "Laptop",
+ "label.last-click": "Last click",
+ "label.last-days": "{x} hari lepas",
+ "label.last-hours": "{x} jam lepas",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "Leave",
+ "label.leave-team": "Leave team",
+ "label.less-than": "Less than",
+ "label.less-than-equals": "Less than or equals",
+ "label.links": "Links",
+ "label.login": "Log masuk",
+ "label.logout": "Log keluar",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "Members",
+ "label.min": "Min",
+ "label.mobile": "Telefon bimbit",
+ "label.model": "Model",
+ "label.more": "Lebih banyak lagi",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "Nama",
+ "label.new-password": "Kata laluan baru",
+ "label.none": "None",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "OS",
+ "label.other": "Other",
+ "label.overview": "Overview",
+ "label.owner": "Owner",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "Paparan halaman",
+ "label.pageTitle": "Page title",
+ "label.pages": "Halaman",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "Kata laluan",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Disediakan oleh {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "Profil",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "Siaran langsung",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "Perujuk",
+ "label.refresh": "Muat semula",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Remaining",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Diperlukan",
+ "label.reset": "Tetapkan semula",
+ "label.reset-website": "Reset statistics",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Simpan",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "Sessions",
+ "label.settings": "Tetapan",
+ "label.share": "Share",
+ "label.share-url": "Kongsikan URL",
+ "label.single-day": "Satu hari",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Terms",
+ "label.theme": "Theme",
+ "label.this-month": "Bulan ini",
+ "label.this-week": "Minggu ini",
+ "label.this-year": "Tahun ini",
+ "label.timezone": "Zon masa",
+ "label.title": "Title",
+ "label.today": "Hari ini",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Kod penjejakan",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Pelawat unik",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Tidak diketahui",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Nama pengguna",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Lihat butiran",
+ "label.view-only": "View only",
+ "label.views": "Lawatan",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Purata tempoh masa lawatan",
+ "label.visitors": "Pelawat",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Laman web",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} semasa {x, plural, one {pelawat} other {pelawat}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Pastikah anda ingin memadam {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Semua data yang berkaitan juga akan dihapuskan.",
+ "message.error": "Ada yang tidak kena.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Pergi ke tetapan",
+ "message.incorrect-username-password": "Pengguna/kata laluan tidak betul.",
+ "message.invalid-domain": "Domain tidak sah",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Tiada data yang boleh didapati.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Kata laluan tidak sepadan",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Anda tidak ada sebarang laman web yang telah dikonfigurasikan.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Halaman tidak dijumpai.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "Berjaya disimpan.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Ini adalah URL berkongsi untuk {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Kod penjejakan",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Pelawat dari {country} mengguna {browser} pada {os} {device}"
+}
diff --git a/src/lang/my-MM.json b/src/lang/my-MM.json
new file mode 100644
index 0000000..156b0c2
--- /dev/null
+++ b/src/lang/my-MM.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "ဝင်ခွင့်ကုဒ်",
+ "label.actions": "လုပ်ဆောင်ချက်များ",
+ "label.activity": "လုပ်ဆောင်ချက်စာရင်း",
+ "label.add": "ထပ်ထည့်မည်",
+ "label.add-board": "Add board",
+ "label.add-description": "အကြောင်းအရာဖော်ပြချက် ထည့်မည်",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "ဝက်ဘ်ဆိုဒ်ထည့်မည်",
+ "label.admin": "အက်ဒမင်",
+ "label.affiliate": "Affiliate",
+ "label.after": "ပြီးနောက်",
+ "label.all": "အားလုံး",
+ "label.all-time": "အချိန်အစမှအခုထိ",
+ "label.analytics": "အန်နလစ်တစ်",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "ပျမ်းမျှ",
+ "label.back": "နောက်သို့",
+ "label.before": "မတိုင်မီ",
+ "label.behavior": "အပြုအမူ",
+ "label.boards": "Boards",
+ "label.bounce-rate": "Bounce နှုန်း",
+ "label.breakdown": "ခွဲခြမ်းစိတ်ဖြာမှု",
+ "label.browser": "Browser",
+ "label.browsers": "ဝက်ဘ်ဘရောင်ဇာများ",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "မလုပ်တော့ပါ",
+ "label.change-password": "စကားဝှက် ပြောင်းမည်",
+ "label.channels": "Channels",
+ "label.cities": "မြို့များ",
+ "label.city": "City",
+ "label.clear-all": "အားလုံးကိုဖျက်မည်",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "အတည်ပြုသည်",
+ "label.confirm-password": "စကားဝှက်အတည်ပြုသည်",
+ "label.contains": "ပါဝင်သည်",
+ "label.content": "Content",
+ "label.continue": "ဆက်သွားမည်",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "နိုင်ငံများ",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "ရီပို့လုပ်မည်",
+ "label.create-team": "Team ပြုလုပ်မည်",
+ "label.create-user": "အသုံးပြုသူထည့်မည်",
+ "label.created": "ပြုလုပ်ပြီးသော",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "လက်ရှိစကားဝှက်",
+ "label.custom-range": "အချိန်အပိုင်းအခြားရွေးရန်",
+ "label.dashboard": "ဒက်ရှ်ဘုတ်",
+ "label.data": "ဒေတာ",
+ "label.date": "Date",
+ "label.date-range": "ရက်အပိုင်းအခြား",
+ "label.day": "Day",
+ "label.default-date-range": "ပုံသေ ရက်အပိုင်းအခြား",
+ "label.delete": "ဖျက်မည်",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Team ကိုဖျက်မည်",
+ "label.delete-user": "အသုံးပြုသူကိုဖျက်မည်",
+ "label.delete-website": "ဝက်ဘ်ဆိုဒ်ကိုဖျက်မည်",
+ "label.description": "ရှင်းပြချက်",
+ "label.desktop": "စားပွဲတင်ကွန်ပျူတာ",
+ "label.details": "အသေးစိတ်",
+ "label.device": "Device",
+ "label.devices": "အသုံးပြုသည့် ကိရိယာများ",
+ "label.direct": "Direct",
+ "label.dismiss": "ပိတ်ပါ",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "မပါဝင်ပါ",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "ဒိုမိန်း",
+ "label.dropoff": "Dropoff",
+ "label.edit": "ပြုပြင်မည်",
+ "label.edit-dashboard": "ဒက်ရှ်ဘုတ်ကို ပြုပြင်မည်",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "ဝေငှခြင်းကိုလင့်ကို ဖွင့်မည်",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "အဖြစ်အပျက်",
+ "label.event-data": "အဖြစ်အပျက် ဒေတာ",
+ "label.event-name": "Event name",
+ "label.events": "အဖြစ်အပျက်များ",
+ "label.exists": "Exists",
+ "label.exit": "Exit URL",
+ "label.false": "မှားသည်",
+ "label.field": "Field အမည်",
+ "label.fields": "Field အမည်များ",
+ "label.filter": "Filter",
+ "label.filter-combined": "ပေါင်းစပ်ပြီး",
+ "label.filter-raw": "အရှိအတိုင်း",
+ "label.filters": "Filter များ",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "ဖန်နယ်",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "ထက်ပို၍ကြီးသည်",
+ "label.greater-than-equals": "ထက်ပို၍ကြီးသည်သို့မဟုတ်တူသည်",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "အသေးစိတ်သိမြင်နိုင်ရန်",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "ဝင်မည်",
+ "label.join-team": "အသင်းဝင်မည်",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "ဘာသာစကား",
+ "label.languages": "ဘာသာစကားများ",
+ "label.laptop": "လက်တော့ပ်",
+ "label.last-click": "Last click",
+ "label.last-days": "လွန်ခဲ့သော {x} ရက်က",
+ "label.last-hours": "လွန်ခဲ့သော {x} နာရီက",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "ထွက်မည်",
+ "label.leave-team": "အသင်းမှထွက်မည်",
+ "label.less-than": "ထက်ပို၍ငယ်သည်",
+ "label.less-than-equals": "ထက်ပို၍ငယ်သည်သို့မဟုတ်တူသည်",
+ "label.links": "Links",
+ "label.login": "လော့ဂ်အင်",
+ "label.logout": "လော့ဂ်အောက်လုပ်မည်",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "အများဆုံး",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "အဖွဲ့ဝင်များ",
+ "label.min": "အနည်းဆုံး",
+ "label.mobile": "မိုဘိုင်း",
+ "label.model": "Model",
+ "label.more": "နောက်ထပ်",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "အမည်",
+ "label.new-password": "စကားဝှက်အသစ်",
+ "label.none": "မရှိပါ",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "ကွန်ပျူတာလည်ပတ်မှုစနစ်",
+ "label.other": "Other",
+ "label.overview": "အပေါ်ယံမြင်ကွင်း",
+ "label.owner": "ပိုင်ဆိုင်သူ",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "ဝင်ရောက်ကြည့်ရှုသူ",
+ "label.pageTitle": "Page title",
+ "label.pages": "စာမျက်နှာများ",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "စကားဝှက်",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "{name} ထောက်ပံ့သည်",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "ပရိုဖိုင်း",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries (ကွာရီများ)",
+ "label.query": "Query (ကွာရီ)",
+ "label.query-parameters": "Query parameters (ကွာရီပါရာမီတာများ)",
+ "label.realtime": "အချိန်နှင့်တပြေးညီ",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "ရည်ညွှန်းမှုများ",
+ "label.refresh": "Refresh လုပ်မည်",
+ "label.regenerate": "ပြန်ထုတ်မည်",
+ "label.region": "Region",
+ "label.regions": "ဒေသများ",
+ "label.remaining": "Remaining",
+ "label.remove": "ဖျက်မည်",
+ "label.remove-member": "Remove member",
+ "label.reports": "တင်ပြမှုများ",
+ "label.required": "လိုအပ်သည်",
+ "label.reset": "ပြန်စမည်",
+ "label.reset-website": "ဝက်ဘ်ဆိုဒ်ဒေတာကိုဖျက်မည်",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "အခန်းကဏ္ဍ",
+ "label.run-query": "Query ကိုလုပ်ဆောင်မည်",
+ "label.save": "သိမ်းဆည်းမည်",
+ "label.screens": "မြင်ကွင်းများ",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "ရက်ရွေးပါ",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "ဝဘက်ဘ်ဆိုဒ်ရွေးပါ",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "ဆက်ရှင်များ",
+ "label.settings": "ဆက်တင်များ",
+ "label.share": "Share",
+ "label.share-url": "URL ကိုရှဲမည်",
+ "label.single-day": "တစ်ရက်အတွင်း",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "ပေါင်းလဒ်",
+ "label.tablet": "တက်ဘလက်",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "အသင်း",
+ "label.team-id": "အသင်း အိုင်ဒီ",
+ "label.team-manager": "Team manager",
+ "label.team-member": "အသင်းဝင်",
+ "label.team-name": "Team name",
+ "label.team-owner": "အသင်းကိုပိုင်ဆိုင်သူ",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "အသင်းများ",
+ "label.terms": "Terms",
+ "label.theme": "Theme (အပြင်အဆင်)",
+ "label.this-month": "ယခုလ",
+ "label.this-week": "ယခုအပတ်",
+ "label.this-year": "ယခုနှစ်",
+ "label.timezone": "အချိန်ဇုန်",
+ "label.title": "ခေါင်းစဥ်",
+ "label.today": "ယနေ့",
+ "label.toggle-charts": "ဇယားများကို အဖွင့်အပိတ်လုပ်မည်",
+ "label.total": "စုစုပေါင်း",
+ "label.total-records": "မှတ်တမ်းစုစုပေါင်း",
+ "label.tracking-code": "ထရက်လုပ်သည့် ကုဒ်",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "မှန်သည်",
+ "label.type": "အမျိုးအစား",
+ "label.unique": "Unique",
+ "label.unique-visitors": "ဝင်ရောက်သူ (ထပ်ခြင်းမရှိ)",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "မသိသော",
+ "label.untitled": "ခေါင်းစဉ်မရှိ",
+ "label.update": "Update",
+ "label.user": "အသုံးပြုသူ",
+ "label.username": "အသုံးပြုသူအမည်",
+ "label.users": "အသုံးပြုသူများ",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "တန်ဖိုး",
+ "label.view": "ဝင်ရောက်ကြည့်ရှုမှု",
+ "label.view-details": "အသေးစိတ်ကို ကြည့်ရှုမည်",
+ "label.view-only": "ဝင်ရောက်ကြည့်ရှုမှုများသာ",
+ "label.views": "ဝင်ရောက်ကြည့်ရှုမှုများ",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "ဝဘက်ဘ်ဆိုဒ်တွင် ပျမ်းမျှကုန်ဆုံးချိန်",
+ "label.visitors": "ဝင်ရောက်ကြည့်ရှုသူများ",
+ "label.visits": "Visits",
+ "label.website": "ဝက်ဘ်ဆိုဒ်",
+ "label.website-id": "ဝက်ဘ်ဆိုဒ် အိုင်ဒီ",
+ "label.websites": "ဝက်ဘ်ဆိုဒ်များ",
+ "label.window": "ဝင်းဒိုး",
+ "label.yesterday": "မနေ့က",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} လက်ရှိအသုံးပြုနေသူ {x, plural, one {ယောက်} other {ယောက်}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "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.delete-website-warning": "ဝက်ဘ်ဆိုဒ် ဒေတာအကုန် ဖျက်မည်",
+ "message.error": "မှားယွင်းမှုတစ်ခု ရှိသွားပါသည်",
+ "message.event-log": "{url} တွင် {event}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "ဆက်တင်သို့ သွားရန်",
+ "message.incorrect-username-password": "အသုံးပြုသူအမည် သို့မဟုတ် စကားဝှက် မှားနေသည်",
+ "message.invalid-domain": "ဒိုမိန်း မမှန်ပါ http/https. မပါရပါ",
+ "message.min-password-length": "အနည်းဆုံး {n} character ရှိရမည်",
+ "message.new-version-available": "အူမာမီ {version} အသစ်ထွက်နေပါပြီ",
+ "message.no-data-available": "ဒေတာ မရှိပါ",
+ "message.no-event-data": "အဖြစ်အပျက်ဒေတာ မရှိပါ",
+ "message.no-match-password": "စကားဝှက် မှားနေသည်",
+ "message.no-results-found": "ရလဒ်မရှိပါ",
+ "message.no-team-websites": "ဤအသင်းတွင် ဝက်ဘ်ဆိုက်မရှိသေးပါ",
+ "message.no-teams": "အသင်း မပြုလုပ်ရသေးပါ",
+ "message.no-users": "အသုံးပြုသူ မရှိသေးပါ",
+ "message.no-websites-configured": "ဝက်ဘ်ဆိုဒ်တစ်ခုမှ မထည့်ရသေးပါ",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "ဤစာမျက်နှာသည် မရှိပါ",
+ "message.reset-website": "ဤ ဝက်ဘ်ဆိုဒ်ဒေတာကိုဖျက်၍ ပြန်စလုပ်ရန် အောက်တွင် {confirmation} ကို ရိုက်ထည့်ပေးပါ",
+ "message.reset-website-warning": "ဤဝက်ဘ်ဆိုဒ်က စာရင်းအချက်အလက်များကို ဖျက်မည်၊ ဆက်တင်ဒေတာများ မပါပါ",
+ "message.saved": "မှတ်သားပြီး",
+ "message.sever-error": "Server error",
+ "message.share-url": "သင့်ဝက်ဆိုဒ်ဘ်၏ စာရင်းအချက်အလက်များကို အောက်ပါ URL တွင် ဝင်ရောက်ကြည့်ရှုနိုင်သည်",
+ "message.team-already-member": "ဤအသင်းတွင် ဝင်ပြီးသားဖြစ်နေသည်",
+ "message.team-not-found": "အသင်း မရှိပါ",
+ "message.team-websites-info": "ဤဝက်ဘ်ဆိုဒ်များကို အသင်းထဲမှ လူတိုင်းဝင်ကြည့်နိုင်သည်",
+ "message.tracking-code": "ဤဝက်ဘ်ဆိုဒ်၏ ဒေတာကိုကောက်ခံရန် အောက်ပါ code ကို သင်၏ HTML တွင်ထည့်ပါ",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "အသုံးပြုသူ ဖျက်ပြီးပါပြီ",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "{country} မှ {browser} ဖြင့် {os} {device} တွင် ဝင်ရောက်ကြည့်ရှုသူ"
+}
diff --git a/src/lang/nb-NO.json b/src/lang/nb-NO.json
new file mode 100644
index 0000000..adb4468
--- /dev/null
+++ b/src/lang/nb-NO.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Tilgangskode",
+ "label.actions": "Handlinger",
+ "label.activity": "Aktivitetslogg",
+ "label.add": "Legg til",
+ "label.add-board": "Legg til tavle",
+ "label.add-description": "Legg til beskrivelse",
+ "label.add-member": "Legg til bruker",
+ "label.add-step": "Legg til steg",
+ "label.add-website": "Legg til nettsted",
+ "label.admin": "Administrator",
+ "label.affiliate": "Tilknyttet",
+ "label.after": "Etter",
+ "label.all": "Alle",
+ "label.all-time": "Noensinne",
+ "label.analytics": "Analyse",
+ "label.apply": "Bruk",
+ "label.attribution": "Attribusjon",
+ "label.attribution-description": "Se hvordan brukere engasjerer seg i markedsføringen din og hva som driver konverteringer.",
+ "label.average": "Gjennomsnnitt",
+ "label.back": "Tilbake",
+ "label.before": "Før",
+ "label.behavior": "Atferd",
+ "label.boards": "Tavler",
+ "label.bounce-rate": "Avvisningsfrekvens",
+ "label.breakdown": "Nedbrytning",
+ "label.browser": "Nettleser",
+ "label.browsers": "Nettlesere",
+ "label.campaigns": "Kampanjer",
+ "label.cancel": "Avvis",
+ "label.change-password": "Bytt passord",
+ "label.channels": "Kanaler",
+ "label.cities": "Byer",
+ "label.city": "By",
+ "label.clear-all": "Tøm alle",
+ "label.cohort": "Kohort",
+ "label.compare": "Sammenlign",
+ "label.compare-dates": "Sammenlign datoer",
+ "label.confirm": "Bekreft",
+ "label.confirm-password": "Godkjenn passord",
+ "label.contains": "Inneholder",
+ "label.content": "Innhold",
+ "label.continue": "Fortsett",
+ "label.conversion": "Konvertering",
+ "label.conversion-rate": "Konverteringsrate",
+ "label.conversion-step": "Konverteringssteg",
+ "label.count": "Antall",
+ "label.countries": "Land",
+ "label.country": "Land",
+ "label.create": "Opprett",
+ "label.create-report": "Opprett rapport",
+ "label.create-team": "Opprett team",
+ "label.create-user": "Opprett bruker",
+ "label.created": "Opprettet",
+ "label.created-by": "Opprettet av",
+ "label.currency": "Valuta",
+ "label.current": "Nåværende",
+ "label.current-password": "Nåværende passord",
+ "label.custom-range": "Egendefinert utvalg",
+ "label.dashboard": "Dashbord",
+ "label.data": "Data",
+ "label.date": "Dato",
+ "label.date-range": "Datointervall",
+ "label.day": "Dag",
+ "label.default-date-range": "Standard datoperiode",
+ "label.delete": "Slett",
+ "label.delete-report": "Slett rapport",
+ "label.delete-team": "Slett team",
+ "label.delete-user": "Slett bruker",
+ "label.delete-website": "Slett nettstedet",
+ "label.description": "Beskrivelse",
+ "label.desktop": "Stasjonær",
+ "label.details": "Detaljer",
+ "label.device": "Enhet",
+ "label.devices": "Enheter",
+ "label.direct": "Direkte",
+ "label.dismiss": "Avbryt",
+ "label.distinct-id": "Unik ID",
+ "label.does-not-contain": "Innholder ikke",
+ "label.does-not-include": "Inkluderer ikke",
+ "label.doest-not-exist": "Eksisterer ikke",
+ "label.domain": "Domene",
+ "label.dropoff": "Dropoff",
+ "label.edit": "Rediger",
+ "label.edit-dashboard": "Rediger dashboard",
+ "label.edit-member": "Rediger bruker",
+ "label.email": "Email",
+ "label.enable-share-url": "Aktiver delings-URL",
+ "label.end-step": "Avslutt steg",
+ "label.entry": "Inngangs-URL",
+ "label.event": "Hendelse",
+ "label.event-data": "Hendelsesdata",
+ "label.event-name": "Hendelsesnavn",
+ "label.events": "Hendelser",
+ "label.exists": "Eksisterer",
+ "label.exit": "Utgangs-URL",
+ "label.false": "Usant",
+ "label.field": "Felt",
+ "label.fields": "Felt",
+ "label.filter": "Filter",
+ "label.filter-combined": "Kombinert",
+ "label.filter-raw": "Rå",
+ "label.filters": "Filter",
+ "label.first-click": "Første klikk",
+ "label.first-seen": "Først sett",
+ "label.funnel": "Trakt",
+ "label.funnel-description": "Forstå konverteringen og drop-off frafallsfrekvens av brukere.",
+ "label.funnels": "Trakter",
+ "label.goal": "Mål",
+ "label.goals": "Mål",
+ "label.goals-description": "Spor dine mål for sidevisninger og hendelser.",
+ "label.greater-than": "Mer enn",
+ "label.greater-than-equals": "Mer enn eller lik",
+ "label.grouped": "Gruppert",
+ "label.hostname": "Vertsnavn",
+ "label.includes": "Inkluderer",
+ "label.insight": "Innsikt",
+ "label.insights": "Innsikt",
+ "label.insights-description": "Dykk dypere i din data ved bruk av segmentering og filtre.",
+ "label.is": "Er",
+ "label.is-false": "Er usant",
+ "label.is-not": "Er ikke",
+ "label.is-not-set": "Er ikke satt",
+ "label.is-set": "Er satt",
+ "label.is-true": "Er sant",
+ "label.join": "Bli med",
+ "label.join-team": "Bli med i teamet",
+ "label.journey": "Reise",
+ "label.journey-description": "Forstå hvordan brukerene navigerer gjennom din side.",
+ "label.journeys": "Reiser",
+ "label.language": "Språk",
+ "label.languages": "Språk",
+ "label.laptop": "Bærbar",
+ "label.last-click": "Siste klikk",
+ "label.last-days": "Siste {x} dager",
+ "label.last-hours": "Siste {x} timer",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Sist sett",
+ "label.leave": "Forlat",
+ "label.leave-team": "Forlat team",
+ "label.less-than": "Mindre enn",
+ "label.less-than-equals": "Mindre enn eller lik",
+ "label.links": "Lenker",
+ "label.login": "Logg inn",
+ "label.logout": "Logg ut",
+ "label.manage": "Administrer",
+ "label.manager": "Administrator",
+ "label.max": "Maks",
+ "label.maximize": "Utvid",
+ "label.medium": "Medium",
+ "label.member": "Bruker",
+ "label.members": "Brukere",
+ "label.min": "Min",
+ "label.mobile": "Mobiltelefon",
+ "label.model": "Modell",
+ "label.more": "Mer",
+ "label.my-account": "Min konto",
+ "label.my-websites": "Mine nettsider",
+ "label.name": "Navn",
+ "label.new-password": "Nytt passord",
+ "label.none": "Ingen",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organisk søk",
+ "label.organic-shopping": "Organisk handel",
+ "label.organic-social": "Organisk sosial",
+ "label.organic-video": "Organisk video",
+ "label.os": "OS",
+ "label.other": "Annet",
+ "label.overview": "Oversikt",
+ "label.owner": "Eier",
+ "label.page": "Side",
+ "label.page-of": "Side {current} av {total}",
+ "label.page-views": "Sidevisninger",
+ "label.pageTitle": "Sidetittel",
+ "label.pages": "Sider",
+ "label.paid-ads": "Betalte annonser",
+ "label.paid-search": "Betalt søk",
+ "label.paid-shopping": "Betalt handel",
+ "label.paid-social": "Betalt sosial",
+ "label.paid-video": "Betalt video",
+ "label.password": "Passord",
+ "label.path": "Sti",
+ "label.paths": "Stier",
+ "label.pixels": "Piksler",
+ "label.powered-by": "Drevet av {name}",
+ "label.previous": "Forrige",
+ "label.previous-period": "Forrige periode",
+ "label.previous-year": "Forrige år",
+ "label.profile": "Profil",
+ "label.properties": "Egenskaper",
+ "label.property": "Egenskap",
+ "label.queries": "Forspørsler",
+ "label.query": "Forespørsel",
+ "label.query-parameters": "Forespørsel parametere",
+ "label.realtime": "Sanntid",
+ "label.referral": "Referral",
+ "label.referrer": "Henviser",
+ "label.referrers": "Henvisere",
+ "label.refresh": "Oppdater",
+ "label.regenerate": "Regenerer",
+ "label.region": "Region",
+ "label.regions": "Regioner",
+ "label.remaining": "Gjenstår",
+ "label.remove": "Fjern",
+ "label.remove-member": "Fjern bruker",
+ "label.reports": "Rapporter",
+ "label.required": "Påkrevd",
+ "label.reset": "Nullstill",
+ "label.reset-website": "Nullstill statistikk",
+ "label.retention": "Retensjon",
+ "label.retention-description": "Mål nettstedets klebrighet ved å spore hvor ofte brukere kommer tilbake.",
+ "label.revenue": "Inntenker",
+ "label.revenue-description": "Se på inntektene dine over tid.",
+ "label.role": "Rolle",
+ "label.run-query": "Kjør spørring",
+ "label.save": "Lagre",
+ "label.screens": "Skjermer",
+ "label.search": "Søk",
+ "label.select": "Velg",
+ "label.select-date": "Velg dato",
+ "label.select-filter": "Velg filter",
+ "label.select-role": "Velg rolle",
+ "label.select-website": "Velg nettsted",
+ "label.session": "Økt",
+ "label.session-data": "Øktdata",
+ "label.sessions": "Økter",
+ "label.settings": "Innstillinger",
+ "label.share": "Del",
+ "label.share-url": "Del URL",
+ "label.single-day": "Enkeltdag",
+ "label.sms": "SMS",
+ "label.sources": "Kilder",
+ "label.start-step": "Starttrinn",
+ "label.steps": "Trinn",
+ "label.sum": "Sum",
+ "label.tablet": "Nettbrett",
+ "label.tag": "Tagg",
+ "label.tags": "Tagger",
+ "label.team": "Team",
+ "label.team-id": "Team-ID",
+ "label.team-manager": "Teamadministrator",
+ "label.team-member": "Teammedlem",
+ "label.team-name": "Teamnavn",
+ "label.team-owner": "Teameier",
+ "label.team-settings": "Teaminnstillinger",
+ "label.team-view-only": "Team (kun visning)",
+ "label.team-websites": "Team-nettsteder",
+ "label.teams": "Team",
+ "label.terms": "Vilkår",
+ "label.theme": "Tema",
+ "label.this-month": "Denne måneden",
+ "label.this-week": "Denne uka",
+ "label.this-year": "I år",
+ "label.timezone": "Tidssone",
+ "label.title": "Tittel",
+ "label.today": "I dag",
+ "label.toggle-charts": "Veksle grafer",
+ "label.total": "Totalt",
+ "label.total-records": "Totalt antall oppføringer",
+ "label.tracking-code": "Sporingskode",
+ "label.transactions": "Transaksjoner",
+ "label.transfer": "Overfør",
+ "label.transfer-website": "Overfør nettsted",
+ "label.true": "Sant",
+ "label.type": "Type",
+ "label.unique": "Unike",
+ "label.unique-visitors": "Unike besøkende",
+ "label.uniqueCustomers": "Unike kunder",
+ "label.unknown": "Ukjent",
+ "label.untitled": "Uten tittel",
+ "label.update": "Oppdater",
+ "label.user": "Bruker",
+ "label.username": "Brukernavn",
+ "label.users": "Brukere",
+ "label.utm": "UTM",
+ "label.utm-description": "Spor kampanjene dine via UTM-parametre.",
+ "label.value": "Verdi",
+ "label.view": "Vis",
+ "label.view-details": "Vis detaljer",
+ "label.view-only": "Kun visning",
+ "label.views": "Visninger",
+ "label.views-per-visit": "Visninger per besøk",
+ "label.visit-duration": "Gjennomsnittlig besøkstid",
+ "label.visitors": "Besøkende",
+ "label.visits": "Besøk",
+ "label.website": "Nettsted",
+ "label.website-id": "Nettsted-ID",
+ "label.websites": "Nettsteder",
+ "label.window": "Vindu",
+ "label.yesterday": "I går",
+ "message.action-confirmation": "Skriv {confirmation} i feltet nedenfor for å bekrefte.",
+ "message.active-users": "{x} {x, plural, one {besøkende} other {besøkende}} nå",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Innsamlede data",
+ "message.confirm-delete": "Er du sikker på at du vil slette {target}?",
+ "message.confirm-leave": "Er du sikker på at du vil forlate {target}?",
+ "message.confirm-remove": "Er du sikker på at du vil fjerne {target}?",
+ "message.confirm-reset": "Er du sikker på at du vil nullstille statistikken til {target}?",
+ "message.delete-team-warning": "Å slette et team vil også slette alle teamets nettsteder.",
+ "message.delete-website-warning": "Alle tilknyttede data vil også bli slettet.",
+ "message.error": "Noe gikk galt.",
+ "message.event-log": "{event} på {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Gå til innstillinger",
+ "message.incorrect-username-password": "Ugyldig brukernavn/passord.",
+ "message.invalid-domain": "Ugyldig domene",
+ "message.min-password-length": "Minimumslengde på {n} tegn",
+ "message.new-version-available": "En ny versjon av Umami {version} er tilgjengelig!",
+ "message.no-data-available": "Ingen data tilgjengelig.",
+ "message.no-event-data": "Ingen hendelsesdata er tilgjengelig.",
+ "message.no-match-password": "Passordene er ikke like",
+ "message.no-results-found": "Ingen resultater funnet.",
+ "message.no-team-websites": "Dette teamet har ingen nettsteder.",
+ "message.no-teams": "Du har ikke opprettet noen team.",
+ "message.no-users": "Ingen brukere.",
+ "message.no-websites-configured": "Du har ikke satt opp noen nettsteder.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Siden ble ikke funnet.",
+ "message.reset-website": "For å nullstille dette nettstedet, skriv {confirmation} i feltet nedenfor for å bekrefte.",
+ "message.reset-website-warning": "All statistikk for dette nettstedet vil bli slettet, men sporingskoden forblir uberørt.",
+ "message.saved": "Lagret!",
+ "message.sever-error": "Server error",
+ "message.share-url": "Dette er den offentlige delings-URL-en for {target}.",
+ "message.team-already-member": "Du er allerede medlem av teamet.",
+ "message.team-not-found": "Teamet ble ikke funnet.",
+ "message.team-websites-info": "Nettsteder kan vises av alle på teamet.",
+ "message.tracking-code": "Sporingskode",
+ "message.transfer-team-website-to-user": "Overfør dette nettstedet til kontoen din?",
+ "message.transfer-user-website-to-team": "Velg teamet du vil overføre dette nettstedet til.",
+ "message.transfer-website": "Overfør eierskapet til nettstedet til din konto eller et annet team.",
+ "message.triggered-event": "Utløst hendelse",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Bruker slettet.",
+ "message.viewed-page": "Vist side",
+ "message.visitor-log": "Besøkende fra {country} med {browser} på {os} {device}"
+}
diff --git a/src/lang/nl-NL.json b/src/lang/nl-NL.json
new file mode 100644
index 0000000..1ec5c02
--- /dev/null
+++ b/src/lang/nl-NL.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Toegangscode",
+ "label.actions": "Acties",
+ "label.activity": "Activiteiten logboek",
+ "label.add": "Toevoegen",
+ "label.add-board": "Bord toevoegen",
+ "label.add-description": "Omschrijving toevoegen",
+ "label.add-member": "Lid toevoegen",
+ "label.add-step": "Stap toevoegen",
+ "label.add-website": "Website koppelen",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partner",
+ "label.after": "Na",
+ "label.all": "Alles",
+ "label.all-time": "Onbeperkt",
+ "label.analytics": "Analyse",
+ "label.apply": "Toepassen",
+ "label.attribution": "Toewijzing",
+ "label.attribution-description": "Bekijk hoe gebruikers omgaan met je marketing en wat conversies stimuleert.",
+ "label.average": "Gemiddelde",
+ "label.back": "Terug",
+ "label.before": "Voor",
+ "label.behavior": "Gedrag",
+ "label.boards": "Borden",
+ "label.bounce-rate": "Bouncepercentage",
+ "label.breakdown": "Opsplitsen",
+ "label.browser": "Browser",
+ "label.browsers": "Browsers",
+ "label.campaigns": "Campagnes",
+ "label.cancel": "Annuleren",
+ "label.change-password": "Wachtwoord wijzigen",
+ "label.channels": "Kanalen",
+ "label.cities": "Steden",
+ "label.city": "Stad",
+ "label.clear-all": "Filters wissen",
+ "label.cohort": "Cohort",
+ "label.compare": "Vergelijken",
+ "label.compare-dates": "Datums vergelijken",
+ "label.confirm": "Bevestigen",
+ "label.confirm-password": "Wachtwoord bevestigen",
+ "label.contains": "Bevat",
+ "label.content": "Inhoud",
+ "label.continue": "Doorgaan",
+ "label.conversion": "Conversie",
+ "label.conversion-rate": "Conversieratio",
+ "label.conversion-step": "Conversiestap",
+ "label.count": "Aantal",
+ "label.countries": "Landen",
+ "label.country": "Land",
+ "label.create": "Aanmaken",
+ "label.create-report": "Rapport aanmaken",
+ "label.create-team": "Team aanmaken",
+ "label.create-user": "Gebruiker maken",
+ "label.created": "Gemaakt",
+ "label.created-by": "Gemaakt Door",
+ "label.currency": "Valuta",
+ "label.current": "Huidig",
+ "label.current-password": "Huidig wachtwoord",
+ "label.custom-range": "Aangepast bereik",
+ "label.dashboard": "Overzicht",
+ "label.data": "Gegevens",
+ "label.date": "Datum",
+ "label.date-range": "Datumbereik",
+ "label.day": "Dag",
+ "label.default-date-range": "Standaard bereik",
+ "label.delete": "Verwijderen",
+ "label.delete-report": "Rapport verwijderen",
+ "label.delete-team": "Team verwijderen",
+ "label.delete-user": "Gebruiker verwijderen",
+ "label.delete-website": "Website verwijderen",
+ "label.description": "Beschrijving",
+ "label.desktop": "Computer",
+ "label.details": "Informatie",
+ "label.device": "Apparaat",
+ "label.devices": "Apparaten",
+ "label.direct": "Direct",
+ "label.dismiss": "Negeren",
+ "label.distinct-id": "Uniek ID",
+ "label.does-not-contain": "Bevat geen",
+ "label.does-not-include": "Bevat niet",
+ "label.doest-not-exist": "Bestaat niet",
+ "label.domain": "Domein",
+ "label.dropoff": "Uitval",
+ "label.edit": "Bewerken",
+ "label.edit-dashboard": "Dashboard aanpassen",
+ "label.edit-member": "Gebruiker aanpassen",
+ "label.email": "Email",
+ "label.enable-share-url": "Sta delen via openbare URL toe",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Gebeurtenis",
+ "label.event-data": "Datum gebeurtenis",
+ "label.event-name": "Gebeurtenisnaam",
+ "label.events": "Gebeurtenissen",
+ "label.exists": "Bestaat",
+ "label.exit": "Exit URL",
+ "label.false": "Onwaar",
+ "label.field": "Veld",
+ "label.fields": "Velden",
+ "label.filter": "Filter",
+ "label.filter-combined": "Gecombineerd",
+ "label.filter-raw": "Ruw",
+ "label.filters": "Filters",
+ "label.first-click": "Eerste klik",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Ontdek de conversie- en uitvalpercentages van gebruikers.",
+ "label.funnels": "Trechters",
+ "label.goal": "Doel",
+ "label.goals": "Doelen",
+ "label.goals-description": "Volg je doelen voor paginaweergaven en gebeurtenissen.",
+ "label.greater-than": "Groter dan",
+ "label.greater-than-equals": "Groter of gelijk aan",
+ "label.grouped": "Gegroepeerd",
+ "label.hostname": "Hostnaam",
+ "label.includes": "Bevat",
+ "label.insight": "Inzicht",
+ "label.insights": "Inzichten",
+ "label.insights-description": "Verken je gegevens verder door segmenten en filters te gebruiken.",
+ "label.is": "Is",
+ "label.is-false": "Is onwaar",
+ "label.is-not": "Is niet",
+ "label.is-not-set": "Is niet ingesteld",
+ "label.is-set": "Is ingesteld",
+ "label.is-true": "Is waar",
+ "label.join": "Lid worden",
+ "label.join-team": "Word lid van een team",
+ "label.journey": "Reis",
+ "label.journey-description": "Begrijp hoe gebruikers door je website navigeren.",
+ "label.journeys": "Reizen",
+ "label.language": "Taal",
+ "label.languages": "Talen",
+ "label.laptop": "Laptop",
+ "label.last-click": "Laatste klik",
+ "label.last-days": "Laatste {x} dagen",
+ "label.last-hours": "Laatste {x} uur",
+ "label.last-months": "Laatste {x} maanden",
+ "label.last-seen": "Laatst gezien",
+ "label.leave": "Verlaten",
+ "label.leave-team": "Verlaat team",
+ "label.less-than": "Minder dan",
+ "label.less-than-equals": "Minder of gelijk aan",
+ "label.links": "Koppelingen",
+ "label.login": "Inloggen",
+ "label.logout": "Uitloggen",
+ "label.manage": "Beheren",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Uitvouwen",
+ "label.medium": "Medium",
+ "label.member": "Gebruiker",
+ "label.members": "Gebruikers",
+ "label.min": "Min",
+ "label.mobile": "Mobiel",
+ "label.model": "Model",
+ "label.more": "Toon meer",
+ "label.my-account": "Mijn profiel",
+ "label.my-websites": "Mijn websites",
+ "label.name": "Naam",
+ "label.new-password": "Nieuw wachtwoord",
+ "label.none": "Geen",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organisch zoeken",
+ "label.organic-shopping": "Organisch winkelen",
+ "label.organic-social": "Organisch sociaal",
+ "label.organic-video": "Organische video",
+ "label.os": "OS",
+ "label.other": "Overig",
+ "label.overview": "Overzicht",
+ "label.owner": "Eigenaar",
+ "label.page": "Pagina",
+ "label.page-of": "Pagina {current} van {total}",
+ "label.page-views": "Paginaweergaven",
+ "label.pageTitle": "Pagina titel",
+ "label.pages": "Pagina's",
+ "label.paid-ads": "Betaalde advertenties",
+ "label.paid-search": "Betaald zoeken",
+ "label.paid-shopping": "Betaald winkelen",
+ "label.paid-social": "Betaald sociaal",
+ "label.paid-video": "Betaalde video",
+ "label.password": "Wachtwoord",
+ "label.path": "Pad",
+ "label.paths": "Paden",
+ "label.pixels": "Pixels",
+ "label.powered-by": "mogelijk gemaakt door {name}",
+ "label.previous": "Vorige",
+ "label.previous-period": "Vorige periode",
+ "label.previous-year": "Vorig jaar",
+ "label.profile": "Profiel",
+ "label.properties": "Eigenschappen",
+ "label.property": "Eigenschap",
+ "label.queries": "Parameters",
+ "label.query": "Query",
+ "label.query-parameters": "URL-parameters",
+ "label.realtime": "Actueel",
+ "label.referral": "Verwijzing",
+ "label.referrer": "Referrer",
+ "label.referrers": "Verwijzers",
+ "label.refresh": "Vernieuwen",
+ "label.regenerate": "Opnieuw genereren",
+ "label.region": "Regio",
+ "label.regions": "Regio's",
+ "label.remaining": "Resterend",
+ "label.remove": "Verwijderen",
+ "label.remove-member": "Gebruiker verwijderen",
+ "label.reports": "Rapporten",
+ "label.required": "Verplicht",
+ "label.reset": "Opnieuw instellen",
+ "label.reset-website": "Statistieken opnieuw instellen",
+ "label.retention": "Retentie",
+ "label.retention-description": "Meet de retentie van je website door door bij te houden hoe vaak gebruikers terugkeren.",
+ "label.revenue": "Omzet",
+ "label.revenue-description": "Bekijk je omzet in de loop van de tijd.",
+ "label.role": "Gebruikersrol",
+ "label.run-query": "Query uitvoeren",
+ "label.save": "Opslaan",
+ "label.screens": "Schermen",
+ "label.search": "Zoeken",
+ "label.select": "Selecteer",
+ "label.select-date": "Datum selecteren",
+ "label.select-filter": "Filter selecteren",
+ "label.select-role": "Rol selecteren",
+ "label.select-website": "Website selecteren",
+ "label.session": "Sessie",
+ "label.session-data": "Sessiegegevens",
+ "label.sessions": "Sessies",
+ "label.settings": "Instellingen",
+ "label.share": "Delen",
+ "label.share-url": "URL delen",
+ "label.single-day": "Enkele dag",
+ "label.sms": "SMS",
+ "label.sources": "Bronnen",
+ "label.start-step": "Startstap",
+ "label.steps": "Stappen",
+ "label.sum": "Som",
+ "label.tablet": "Tablet",
+ "label.tag": "Label",
+ "label.tags": "Labels",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Teamleider",
+ "label.team-member": "Teamlid",
+ "label.team-name": "Teamnaam",
+ "label.team-owner": "Teameigenaar",
+ "label.team-settings": "Teaminstellingen",
+ "label.team-view-only": "Team alleen lezen",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Voorwaarden",
+ "label.theme": "Thema",
+ "label.this-month": "Deze maand",
+ "label.this-week": "Deze week",
+ "label.this-year": "Dit jaar",
+ "label.timezone": "Tijdzone",
+ "label.title": "Titel",
+ "label.today": "Vandaag",
+ "label.toggle-charts": "Grafieken tonen/verbergen",
+ "label.total": "Totaal",
+ "label.total-records": "Totaal records",
+ "label.tracking-code": "Volgcode",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "Waar",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Unieke bezoekers",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Onbekend",
+ "label.untitled": "Ongetiteld",
+ "label.update": "Update",
+ "label.user": "Gebruiker",
+ "label.username": "Gebruikersnaam",
+ "label.users": "Gebruikers",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Waarde",
+ "label.view": "Weergave",
+ "label.view-details": "Meer details",
+ "label.view-only": "Alleen inzien",
+ "label.views": "Weergaven",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Gemiddelde bezoektijd",
+ "label.visitors": "Bezoekers",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Websites",
+ "label.window": "Window",
+ "label.yesterday": "Gisteren",
+ "message.action-confirmation": "Typ {confirmation} in het veld hieronder om te bevestigen.",
+ "message.active-users": "{x} actieve {x, plural, one {bezoeker} other {bezoekers}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Weet je zeker dat je {target} wilt verwijderen?",
+ "message.confirm-leave": "Weet je zeker dat je {target} wilt verlaten?",
+ "message.confirm-remove": "Weet je zeker dat je {target} wilt verwijderen?",
+ "message.confirm-reset": "Weet je zeker dat je de statistieken van {target} opnieuw wilt instellen?",
+ "message.delete-team-warning": "Als een team wordt verwijderd, worden ook alle websites van dat team verwijderd.",
+ "message.delete-website-warning": "Alle verwante gegevens zullen ook verwijderd worden.",
+ "message.error": "Er is iets misgegaan.",
+ "message.event-log": "{event} op {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Naar instellingen",
+ "message.incorrect-username-password": "Incorrecte gebruikersnaam/wachtwoord.",
+ "message.invalid-domain": "Ongeldig domein",
+ "message.min-password-length": "Minimale lengte van {n} tekens",
+ "message.new-version-available": "Een nieuwe versie van Umami {version} is beschikbaar!",
+ "message.no-data-available": "Geen gegevens beschikbaar.",
+ "message.no-event-data": "Geen gegevens over de gebeurtenis beschikbaar.",
+ "message.no-match-password": "Wachtwoorden komen niet overeen",
+ "message.no-results-found": "Geen resultaten gevonden.",
+ "message.no-team-websites": "Er zijn geen websites gekoppeld aan dit team.",
+ "message.no-teams": "Er zijn nog geen teams aangemaakt.",
+ "message.no-users": "Er zijn geen gebruikers.",
+ "message.no-websites-configured": "Je hebt geen websites ingesteld.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Pagina niet gevonden.",
+ "message.reset-website": "Typ {confirmation} in het veld hieronder om te bevestigen dat je de website wilt resetten.",
+ "message.reset-website-warning": "Alle bijhorende statistieken van deze website worden verwijderd, maar jouw volgcode blijft gelden.",
+ "message.saved": "Opslaan succesvol.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Met deze URL kan {target} openbaar gedeeld worden.",
+ "message.team-already-member": "Je bent al lid van het team.",
+ "message.team-not-found": "Team niet gevonden.",
+ "message.team-websites-info": "Websites kunnen door iedereen in het team worden bekeken.",
+ "message.tracking-code": "Volgcode",
+ "message.transfer-team-website-to-user": "Deze website toevoegen aan je account?",
+ "message.transfer-user-website-to-team": "Selecteer het team om deze website aan toe te voegen.",
+ "message.transfer-website": "Draag het eigenaarschap van de website over naar jouw account, of een ander team.",
+ "message.triggered-event": "Getriggerde gebeurtenis",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Gebruiker verwijderd",
+ "message.viewed-page": "Bekeken pagina",
+ "message.visitor-log": "Bezoeker uit {country} met {browser} op een {os} {device}"
+}
diff --git a/src/lang/pl-PL.json b/src/lang/pl-PL.json
new file mode 100644
index 0000000..0c8b000
--- /dev/null
+++ b/src/lang/pl-PL.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Kod dostępu",
+ "label.actions": "Działania",
+ "label.activity": "Dziennik aktywności",
+ "label.add": "Dodaj",
+ "label.add-board": "Dodaj tablicę",
+ "label.add-description": "Dodaj opis",
+ "label.add-member": "Dodaj członka",
+ "label.add-step": "Dodaj krok",
+ "label.add-website": "Dodaj witrynę",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partner",
+ "label.after": "Po",
+ "label.all": "Wszystkie",
+ "label.all-time": "Cały czas",
+ "label.analytics": "Analityka",
+ "label.apply": "Zastosuj",
+ "label.attribution": "Atrybucja",
+ "label.attribution-description": "Zobacz, jak użytkownicy angażują się w Twoją reklamę i co napędza konwersje.",
+ "label.average": "Średnia",
+ "label.back": "Powrót",
+ "label.before": "Przed",
+ "label.behavior": "Zachowanie",
+ "label.boards": "Tablice",
+ "label.bounce-rate": "Współczynnik odrzuceń",
+ "label.breakdown": "Rozbicie",
+ "label.browser": "Przeglądarka",
+ "label.browsers": "Przeglądarki",
+ "label.campaigns": "Kampanie",
+ "label.cancel": "Anuluj",
+ "label.change-password": "Zmień hasło",
+ "label.channels": "Kanały",
+ "label.cities": "Miasta",
+ "label.city": "Miasto",
+ "label.clear-all": "Wyczyść wszystko",
+ "label.cohort": "Kohorta",
+ "label.compare": "Porównaj",
+ "label.compare-dates": "Porównaj daty",
+ "label.confirm": "Potwierdź",
+ "label.confirm-password": "Potwierdź hasło",
+ "label.contains": "Zawiera",
+ "label.content": "Treść",
+ "label.continue": "Kontynuuj",
+ "label.conversion": "Konwersja",
+ "label.conversion-rate": "Wskaźnik konwersji",
+ "label.conversion-step": "Etap konwersji",
+ "label.count": "Liczba",
+ "label.countries": "Kraje",
+ "label.country": "Państwo",
+ "label.create": "Utwórz",
+ "label.create-report": "Utwórz raport",
+ "label.create-team": "Utwórz zespół",
+ "label.create-user": "Utwórz użytkownika",
+ "label.created": "Utworzony",
+ "label.created-by": "Utworzony przez",
+ "label.currency": "Waluta",
+ "label.current": "Aktualny",
+ "label.current-password": "Aktualne hasło",
+ "label.custom-range": "Zakres niestandardowy",
+ "label.dashboard": "Panel",
+ "label.data": "Dane",
+ "label.date": "Data",
+ "label.date-range": "Zakres dat",
+ "label.day": "Dzień",
+ "label.default-date-range": "Domyślny zakres dat",
+ "label.delete": "Usuń",
+ "label.delete-report": "Usuń raport",
+ "label.delete-team": "Usuń zespół",
+ "label.delete-user": "Usuń użytkownika",
+ "label.delete-website": "Usuń witrynę",
+ "label.description": "Opis",
+ "label.desktop": "Komputer",
+ "label.details": "Szczegóły",
+ "label.device": "Urządzenie",
+ "label.devices": "Urządzenia",
+ "label.direct": "Bezpośredni",
+ "label.dismiss": "Odrzuć",
+ "label.distinct-id": "Unikalny ID",
+ "label.does-not-contain": "Nie zawiera",
+ "label.does-not-include": "Nie zawiera",
+ "label.doest-not-exist": "Nie istnieje",
+ "label.domain": "Domena",
+ "label.dropoff": "Odpływ",
+ "label.edit": "Edytuj",
+ "label.edit-dashboard": "Edytuj panel",
+ "label.edit-member": "Edytuj członka",
+ "label.email": "Email",
+ "label.enable-share-url": "Włącz udostępnianie adresu URL",
+ "label.end-step": "Krok końcowy",
+ "label.entry": "Entry URL",
+ "label.event": "Zdarzenie",
+ "label.event-data": "Dane zdarzenia",
+ "label.event-name": "Nazwa zdarzenia",
+ "label.events": "Zdarzenia",
+ "label.exists": "Istnieje",
+ "label.exit": "URL wyjściowy",
+ "label.false": "Fałsz",
+ "label.field": "Pole",
+ "label.fields": "Pola",
+ "label.filter": "Filtruj",
+ "label.filter-combined": "Połączone",
+ "label.filter-raw": "Surowe dane",
+ "label.filters": "Filtry",
+ "label.first-click": "Pierwsze kliknięcie",
+ "label.first-seen": "First seen",
+ "label.funnel": "Lejek",
+ "label.funnel-description": "Zrozum wskaźniki konwersji i odpływu użytkowników.",
+ "label.funnels": "Lejki",
+ "label.goal": "Cel",
+ "label.goals": "Cele",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Większe niż",
+ "label.greater-than-equals": "Większe niż lub równe",
+ "label.grouped": "Grupowane",
+ "label.hostname": "Nazwa hosta",
+ "label.includes": "Zawiera",
+ "label.insight": "Wgląd",
+ "label.insights": "Analiza",
+ "label.insights-description": "Poznaj lepiej swoje dane, korzystając z segmentów i filtrów.",
+ "label.is": "Równe",
+ "label.is-false": "Jest fałszem",
+ "label.is-not": "Nie jest równe",
+ "label.is-not-set": "Nieustawione",
+ "label.is-set": "Ustawione",
+ "label.is-true": "Jest prawdą",
+ "label.join": "Dołącz",
+ "label.join-team": "Dołącz do zespołu",
+ "label.journey": "Droga",
+ "label.journey-description": "Zrozum, w jaki sposób użytkownicy poruszają się po Twojej witrynie.",
+ "label.journeys": "Drogi",
+ "label.language": "Język",
+ "label.languages": "Języki",
+ "label.laptop": "Laptop",
+ "label.last-click": "Ostatnie kliknięcie",
+ "label.last-days": "Ostatnie {x} dni",
+ "label.last-hours": "Ostatnie {x} godzin",
+ "label.last-months": "Ostatnie {x} miesięcy",
+ "label.last-seen": "Ostatnio widziany",
+ "label.leave": "Opuść",
+ "label.leave-team": "Opuść zespół",
+ "label.less-than": "Mniejsze niż",
+ "label.less-than-equals": "Mniejsze niż lub równe",
+ "label.links": "Linki",
+ "label.login": "Zaloguj się",
+ "label.logout": "Wyloguj",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Maks",
+ "label.maximize": "Rozwiń",
+ "label.medium": "Medium",
+ "label.member": "Członek",
+ "label.members": "Członkowie",
+ "label.min": "Min",
+ "label.mobile": "Smartfon",
+ "label.model": "Model",
+ "label.more": "Więcej",
+ "label.my-account": "Moje konto",
+ "label.my-websites": "Moje witryny",
+ "label.name": "Nazwa",
+ "label.new-password": "Nowe hasło",
+ "label.none": "Brak",
+ "label.number-of-records": "{x} {x, plural, one {rekord} other {rekordy}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Wyszukiwanie organiczne",
+ "label.organic-shopping": "Zakupy organiczne",
+ "label.organic-social": "Organiczne social media",
+ "label.organic-video": "Organiczne wideo",
+ "label.os": "OS",
+ "label.other": "Inne",
+ "label.overview": "Przegląd",
+ "label.owner": "Właściciel",
+ "label.page": "Strona",
+ "label.page-of": "Strona {current} z {total}",
+ "label.page-views": "Wyświetlenia strony",
+ "label.pageTitle": "Tytuł strony",
+ "label.pages": "Strony",
+ "label.paid-ads": "Reklamy płatne",
+ "label.paid-search": "Płatne wyszukiwanie",
+ "label.paid-shopping": "Płatne zakupy",
+ "label.paid-social": "Płatne social media",
+ "label.paid-video": "Płatne wideo",
+ "label.password": "Hasło",
+ "label.path": "Ścieżka",
+ "label.paths": "Ścieżki",
+ "label.pixels": "Piksele",
+ "label.powered-by": "Obsługiwane przez {name}",
+ "label.previous": "Poprzedni",
+ "label.previous-period": "Poprzedni okres",
+ "label.previous-year": "Poprzedni rok",
+ "label.profile": "Profil",
+ "label.properties": "Właściwości",
+ "label.property": "Właściwość",
+ "label.queries": "Zapytania",
+ "label.query": "Zapytanie",
+ "label.query-parameters": "Parametry zapytania",
+ "label.realtime": "Czas rzeczywisty",
+ "label.referral": "Polecenie",
+ "label.referrer": "Źródło odsyłające",
+ "label.referrers": "Źródła odsyłające",
+ "label.refresh": "Odśwież",
+ "label.regenerate": "Wygeneruj ponownie",
+ "label.region": "Region",
+ "label.regions": "Regiony",
+ "label.remaining": "Pozostało",
+ "label.remove": "Usuń",
+ "label.remove-member": "Usuń członka",
+ "label.reports": "Raporty",
+ "label.required": "Wymagany",
+ "label.reset": "Zresetuj",
+ "label.reset-website": "Zresetuj statystyki",
+ "label.retention": "Retencja",
+ "label.retention-description": "Mierz przyciągającą siłę swojej strony internetowej, śledząc, jak często użytkownicy powracają.",
+ "label.revenue": "Przychód",
+ "label.revenue-description": "Sprawdź swoje przychody w czasie.",
+ "label.role": "Rola",
+ "label.run-query": "Uruchom zapytanie",
+ "label.save": "Zapisz",
+ "label.screens": "Ekrany",
+ "label.search": "Szukaj",
+ "label.select": "Wybierz",
+ "label.select-date": "Wybierz datę",
+ "label.select-filter": "Wybierz filtr",
+ "label.select-role": "Wybierz rolę",
+ "label.select-website": "Wybierz witrynę",
+ "label.session": "Sesja",
+ "label.session-data": "Dane sesji",
+ "label.sessions": "Sesje",
+ "label.settings": "Ustawienia",
+ "label.share": "Udostępnij",
+ "label.share-url": "Udostępnij adres URL",
+ "label.single-day": "W tym dniu",
+ "label.sms": "SMS",
+ "label.sources": "Źródła",
+ "label.start-step": "Krok startowy",
+ "label.steps": "Kroki",
+ "label.sum": "Suma",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Tagi",
+ "label.team": "Zespół",
+ "label.team-id": "ID zespołu",
+ "label.team-manager": "Menedżer zespołu",
+ "label.team-member": "Członek zespołu",
+ "label.team-name": "Nazwa zespołu",
+ "label.team-owner": "Właściciel zespołu",
+ "label.team-settings": "Ustawienia zespołu",
+ "label.team-view-only": "Tylko do odczytu dla zespołu",
+ "label.team-websites": "Witryny zespołu",
+ "label.teams": "Zespoły",
+ "label.terms": "Warunki",
+ "label.theme": "Motyw",
+ "label.this-month": "W tym miesiącu",
+ "label.this-week": "W tym tygodniu",
+ "label.this-year": "W tym roku",
+ "label.timezone": "Strefa czasowa",
+ "label.title": "Tytuł",
+ "label.today": "Dzisiaj",
+ "label.toggle-charts": "Przełącz wykresy",
+ "label.total": "W sumie",
+ "label.total-records": "Suma rekordów",
+ "label.tracking-code": "Kod śledzenia",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "Prawda",
+ "label.type": "Typ",
+ "label.unique": "Unikalne",
+ "label.unique-visitors": "Unikalni odwiedzający",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Nieznany",
+ "label.untitled": "Bez tytułu",
+ "label.update": "Aktualizuj",
+ "label.user": "Użytkownik",
+ "label.username": "Nazwa użytkownika",
+ "label.users": "Użytkownicy",
+ "label.utm": "UTM",
+ "label.utm-description": "Śledź swoje kampanie za pomocą parametrów UTM.",
+ "label.value": "Wartość",
+ "label.view": "Zobacz",
+ "label.view-details": "Pokaż szczegóły",
+ "label.view-only": "Tylko do odczytu",
+ "label.views": "Wyświetlenia",
+ "label.views-per-visit": "Widoków na wizytę",
+ "label.visit-duration": "Średni czas wizyty",
+ "label.visitors": "Odwiedzający",
+ "label.visits": "Wizyty",
+ "label.website": "Witryna",
+ "label.website-id": "ID witryny",
+ "label.websites": "Witryny",
+ "label.window": "Okno",
+ "label.yesterday": "Wczoraj",
+ "message.action-confirmation": "Wpisz {confirmation}, aby potwierdzić.",
+ "message.active-users": "{x} aktualnie {x, plural, one {odwiedzający} other {odwiedzających}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Zebrane dane",
+ "message.confirm-delete": "Czy na pewno chcesz usunąć {target}?",
+ "message.confirm-leave": "Czy na pewno chcesz opuścić {target}?",
+ "message.confirm-remove": "Czy na pewno chcesz usunąć {target}?",
+ "message.confirm-reset": "Czy na pewno chcesz zresetować statystyki {target}?",
+ "message.delete-team-warning": "Usunięcie zespołu usunie wszystkie jego witryny.",
+ "message.delete-website-warning": "Wszystkie powiązane dane również zostaną usunięte.",
+ "message.error": "Coś poszło nie tak.",
+ "message.event-log": "{event} na {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Przejdź do ustawień",
+ "message.incorrect-username-password": "Nieprawidłowa nazwa użytkownika lub hasło.",
+ "message.invalid-domain": "Nieprawidłowa witryna",
+ "message.min-password-length": "Minimalna długość {n} znaków",
+ "message.new-version-available": "Nowa wersja Umami {version} jest dostępna!",
+ "message.no-data-available": "Brak dostępnych danych.",
+ "message.no-event-data": "Brak dostępnych danych o zdarzeniach.",
+ "message.no-match-password": "Hasła się nie zgadzają",
+ "message.no-results-found": "Nie znaleziono wyników.",
+ "message.no-team-websites": "Ten zespół nie ma żadnych witryn internetowych.",
+ "message.no-teams": "Nie stworzyłeś żadnych zespołów.",
+ "message.no-users": "Nie ma żadnych użytkowników.",
+ "message.no-websites-configured": "Nie masz skonfigurowanych żadnych witryn internetowych.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Strona nie znaleziona.",
+ "message.reset-website": "Aby zresetować tę witrynę, wpisz {confirmation} w polu poniżej, aby potwierdzić.",
+ "message.reset-website-warning": "Wszystkie statystyki tej witryny zostaną usunięte, ale kod śledzenia pozostanie nienaruszony.",
+ "message.saved": "Zapisano pomyślnie.",
+ "message.sever-error": "Server error",
+ "message.share-url": "To jest publicznie udostępniany adres URL dla {target}.",
+ "message.team-already-member": "Jesteś już członkiem zespołu.",
+ "message.team-not-found": "Nie znaleziono zespołu.",
+ "message.team-websites-info": "Strony internetowe mogą być przeglądane przez każdego członka zespołu.",
+ "message.tracking-code": "Kod śledzenia",
+ "message.transfer-team-website-to-user": "Czy przenieść tę witrynę do Twoje konta?",
+ "message.transfer-user-website-to-team": "Wybierz zespół, do którego chcesz przenieść tę witrynę.",
+ "message.transfer-website": "Przenieś własność witryny na swoje konto lub do innego zespołu.",
+ "message.triggered-event": "Zdarzenie wyzwalające",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Użytkownik usunięty.",
+ "message.viewed-page": "Obejrzana strona",
+ "message.visitor-log": "Odwiedzający z {country} używa {browser} na {os} {device}"
+}
diff --git a/src/lang/pt-BR.json b/src/lang/pt-BR.json
new file mode 100644
index 0000000..c34c9ab
--- /dev/null
+++ b/src/lang/pt-BR.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Código de acesso",
+ "label.actions": "Ações do usuário",
+ "label.activity": "Registro de atividades",
+ "label.add": "Adicionar",
+ "label.add-board": "Adicionar quadro",
+ "label.add-description": "Adicionar descrição",
+ "label.add-member": "Adicionar membro",
+ "label.add-step": "Adicionar etapa",
+ "label.add-website": "Adicionar site",
+ "label.admin": "Administrador",
+ "label.affiliate": "Afiliado",
+ "label.after": "Depois",
+ "label.all": "Todos",
+ "label.all-time": "Todos os períodos",
+ "label.analytics": "Análise",
+ "label.apply": "Aplicar",
+ "label.attribution": "Atribuição",
+ "label.attribution-description": "Veja como os usuários interagem com seu marketing e o que impulsiona conversões.",
+ "label.average": "Média",
+ "label.back": "Voltar",
+ "label.before": "Antes",
+ "label.behavior": "Comportamento",
+ "label.boards": "Quadros",
+ "label.bounce-rate": "Taxa de rejeição",
+ "label.breakdown": "Detalhamento",
+ "label.browser": "Navegador",
+ "label.browsers": "Navegadores",
+ "label.campaigns": "Campanhas",
+ "label.cancel": "Cancelar",
+ "label.change-password": "Alterar senha",
+ "label.channels": "Canais",
+ "label.cities": "Cidades",
+ "label.city": "Cidade",
+ "label.clear-all": "Limpar tudo",
+ "label.cohort": "Cohorte",
+ "label.compare": "Comparar",
+ "label.compare-dates": "Comparar datas",
+ "label.confirm": "Confirmar",
+ "label.confirm-password": "Confirmar senha",
+ "label.contains": "Contém",
+ "label.content": "Conteúdo",
+ "label.continue": "Continuar",
+ "label.conversion": "Conversão",
+ "label.conversion-rate": "Taxa de conversão",
+ "label.conversion-step": "Etapa de conversão",
+ "label.count": "Contagem",
+ "label.countries": "Países",
+ "label.country": "País",
+ "label.create": "Criar",
+ "label.create-report": "Criar relatório",
+ "label.create-team": "Criar equipe",
+ "label.create-user": "Criar usuário",
+ "label.created": "Criado",
+ "label.created-by": "Criado por",
+ "label.currency": "Moeda",
+ "label.current": "Atual",
+ "label.current-password": "Senha atual",
+ "label.custom-range": "Período personalizado",
+ "label.dashboard": "Painel",
+ "label.data": "Dados",
+ "label.date": "Data",
+ "label.date-range": "Período",
+ "label.day": "Dia",
+ "label.default-date-range": "Período padrão",
+ "label.delete": "Excluir",
+ "label.delete-report": "Excluir relatório",
+ "label.delete-team": "Excluir equipe",
+ "label.delete-user": "Excluir usuário",
+ "label.delete-website": "Excluir site",
+ "label.description": "Descrição",
+ "label.desktop": "Computador",
+ "label.details": "Detalhes",
+ "label.device": "Dispositivo",
+ "label.devices": "Dispositivos",
+ "label.direct": "Direto",
+ "label.dismiss": "Fechar",
+ "label.distinct-id": "ID distinto",
+ "label.does-not-contain": "Não contém",
+ "label.does-not-include": "Não inclui",
+ "label.doest-not-exist": "Não existe",
+ "label.domain": "Domínio",
+ "label.dropoff": "Abandono",
+ "label.edit": "Editar",
+ "label.edit-dashboard": "Editar painel",
+ "label.edit-member": "Editar membro",
+ "label.email": "Email",
+ "label.enable-share-url": "Ativar link para compartilhar",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Evento",
+ "label.event-data": "Dados do evento",
+ "label.event-name": "Nome do evento",
+ "label.events": "Tipos de eventos",
+ "label.exists": "Existe",
+ "label.exit": "Exit URL",
+ "label.false": "Não",
+ "label.field": "Campo",
+ "label.fields": "Campos",
+ "label.filter": "Filtro",
+ "label.filter-combined": "Combinado",
+ "label.filter-raw": "Bruto",
+ "label.filters": "Filtros",
+ "label.first-click": "Primeiro clique",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funil",
+ "label.funnel-description": "Entenda a taxa de conversão e abandono dos seus usuários.",
+ "label.funnels": "Funis",
+ "label.goal": "Meta",
+ "label.goals": "Metas",
+ "label.goals-description": "Acompanhe suas metas para visualizações de página e eventos.",
+ "label.greater-than": "Maior que",
+ "label.greater-than-equals": "Maior ou igual a",
+ "label.grouped": "Agrupado",
+ "label.hostname": "Nome do host",
+ "label.includes": "Inclui",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Explore seus dados em mais detalhes usando filtros",
+ "label.is": "É igual a",
+ "label.is-false": "É falso",
+ "label.is-not": "Não é igual a",
+ "label.is-not-set": "Não definido",
+ "label.is-set": "Definido",
+ "label.is-true": "É verdadeiro",
+ "label.join": "Participar",
+ "label.join-team": "Participar da equipe",
+ "label.journey": "Jornada",
+ "label.journey-description": "Entenda como os usuários navegam pelo seu site.",
+ "label.journeys": "Jornadas",
+ "label.language": "Idioma",
+ "label.languages": "Idiomas",
+ "label.laptop": "Notebook",
+ "label.last-click": "Último clique",
+ "label.last-days": "Últimos {x} dias",
+ "label.last-hours": "Últimas {x} horas",
+ "label.last-months": "Últimos {x} meses",
+ "label.last-seen": "Última visualização",
+ "label.leave": "Sair",
+ "label.leave-team": "Sair da equipe",
+ "label.less-than": "Menor que",
+ "label.less-than-equals": "Menor ou igual a",
+ "label.links": "Links",
+ "label.login": "Entrar",
+ "label.logout": "Sair",
+ "label.manage": "Gerenciar",
+ "label.manager": "Manager",
+ "label.max": "Máximo",
+ "label.maximize": "Expandir",
+ "label.medium": "Médio",
+ "label.member": "Membro",
+ "label.members": "Membros",
+ "label.min": "Mínimo",
+ "label.mobile": "Celular",
+ "label.model": "Modelo",
+ "label.more": "Mais",
+ "label.my-account": "Minha conta",
+ "label.my-websites": "Meus sites",
+ "label.name": "Nome",
+ "label.new-password": "Nova senha",
+ "label.none": "Nenhum",
+ "label.number-of-records": "{x} {x, plural, one {registro} other {registros}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Busca orgânica",
+ "label.organic-shopping": "Compras orgânicas",
+ "label.organic-social": "Social orgânico",
+ "label.organic-video": "Vídeo orgânico",
+ "label.os": "Sistema operacional",
+ "label.other": "Outro",
+ "label.overview": "Visão geral",
+ "label.owner": "Proprietário",
+ "label.page": "Página",
+ "label.page-of": "Página {current} de {total}",
+ "label.page-views": "Visualizações de página",
+ "label.pageTitle": "Título",
+ "label.pages": "Páginas",
+ "label.paid-ads": "Anúncios pagos",
+ "label.paid-search": "Busca paga",
+ "label.paid-shopping": "Compras pagas",
+ "label.paid-social": "Social pago",
+ "label.paid-video": "Vídeo pago",
+ "label.password": "Senha",
+ "label.path": "Caminho",
+ "label.paths": "Caminhos",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Desenvolvido por {name}",
+ "label.previous": "Anterior",
+ "label.previous-period": "Período anterior",
+ "label.previous-year": "Ano anterior",
+ "label.profile": "Perfil",
+ "label.properties": "Propriedades",
+ "label.property": "Propriedade",
+ "label.queries": "Consultas",
+ "label.query": "Consulta",
+ "label.query-parameters": "Parâmetros da consulta",
+ "label.realtime": "Tempo real",
+ "label.referral": "Referência",
+ "label.referrer": "Referência",
+ "label.referrers": "Referências",
+ "label.refresh": "Atualizar",
+ "label.regenerate": "Gerar novamente",
+ "label.region": "Estado",
+ "label.regions": "Estados",
+ "label.remaining": "Restante",
+ "label.remove": "Remover",
+ "label.remove-member": "Remover membro",
+ "label.reports": "Relatórios",
+ "label.required": "Obrigatório",
+ "label.reset": "Redefinir",
+ "label.reset-website": "Redefinir dados",
+ "label.retention": "Retenção",
+ "label.retention-description": "Avalie a fidelidade dos seus usuários medindo a frequência com que eles retornam.",
+ "label.revenue": "Receita",
+ "label.revenue-description": "Veja sua receita ao longo do tempo.",
+ "label.role": "Função",
+ "label.run-query": "Executar consulta",
+ "label.save": "Salvar",
+ "label.screens": "Tamanhos de tela",
+ "label.search": "Pesquisar",
+ "label.select": "Selecionar",
+ "label.select-date": "Selecionar data",
+ "label.select-filter": "Selecionar filtro",
+ "label.select-role": "Selecionar função",
+ "label.select-website": "Selecionar site",
+ "label.session": "Sessão",
+ "label.session-data": "Dados da sessão",
+ "label.sessions": "Sessões",
+ "label.settings": "Configurações",
+ "label.share": "Compartilhar",
+ "label.share-url": "Link para compartilhar",
+ "label.single-day": "Apenas um dia",
+ "label.sms": "SMS",
+ "label.sources": "Fontes",
+ "label.start-step": "Start Step",
+ "label.steps": "Etapas",
+ "label.sum": "Soma",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Equipe",
+ "label.team-id": "ID da equipe",
+ "label.team-manager": "Gerente da equipe",
+ "label.team-member": "Membro da equipe",
+ "label.team-name": "Nome da equipe",
+ "label.team-owner": "Proprietário da equipe",
+ "label.team-settings": "Configurações da equipe",
+ "label.team-view-only": "Apenas visualização da equipe",
+ "label.team-websites": "Sites da equipe",
+ "label.teams": "Equipes",
+ "label.terms": "Termos",
+ "label.theme": "Tema",
+ "label.this-month": "Este mês",
+ "label.this-week": "Esta semana",
+ "label.this-year": "Este ano",
+ "label.timezone": "Fuso horário",
+ "label.title": "Título",
+ "label.today": "Hoje",
+ "label.toggle-charts": "Alternar gráficos",
+ "label.total": "Total",
+ "label.total-records": "Total de registros",
+ "label.tracking-code": "Código de rastreamento",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transferir",
+ "label.transfer-website": "Transferir site",
+ "label.true": "Sim",
+ "label.type": "Tipo",
+ "label.unique": "Únicos",
+ "label.unique-visitors": "Visitantes únicos",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Desconhecido",
+ "label.untitled": "Sem título",
+ "label.update": "Atualizar",
+ "label.user": "Usuário",
+ "label.username": "Nome de usuário",
+ "label.users": "Usuários",
+ "label.utm": "UTM",
+ "label.utm-description": "Acompanhe suas campanhas de publicidade através de parâmetros UTM.",
+ "label.value": "Valor",
+ "label.view": "Visualizar",
+ "label.view-details": "Ver mais",
+ "label.view-only": "Somente visualização",
+ "label.views": "Visualizações",
+ "label.views-per-visit": "Visualizações por visita",
+ "label.visit-duration": "Tempo médio de visita",
+ "label.visitors": "Visitantes",
+ "label.visits": "Visitas",
+ "label.website": "Site",
+ "label.website-id": "ID do site",
+ "label.websites": "Sites",
+ "label.window": "Janela",
+ "label.yesterday": "Ontem",
+ "message.action-confirmation": "Digite {confirmation} na caixa abaixo para confirmar.",
+ "message.active-users": " Atualmente {x} usuários ativos",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Tem certeza de que deseja excluir {target}?",
+ "message.confirm-leave": "Tem certeza de que deseja sair de {target}?",
+ "message.confirm-remove": "Tem certeza que deseja remover {target}?",
+ "message.confirm-reset": "Tem certeza que deseja redefinir os dados de {target}?",
+ "message.delete-team-warning": "Excluir a equipe também excluirá todos os sites da equipe.",
+ "message.delete-website-warning": "Todos os dados relacionados serão excluídos.",
+ "message.error": "Ocorreu um erro.",
+ "message.event-log": "{event} em {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Ir para as configurações",
+ "message.incorrect-username-password": "Nome de usuário ou senha incorretos.",
+ "message.invalid-domain": "Domínio inválido",
+ "message.min-password-length": "A senha deve ter no mínimo {n} caracteres",
+ "message.new-version-available": "Uma nova versão {version} do Umami está disponível!",
+ "message.no-data-available": "Não há dados disponíveis.",
+ "message.no-event-data": "Não há eventos disponíveis.",
+ "message.no-match-password": "As senhas não coincidem.",
+ "message.no-results-found": "Nenhum resultado encontrado.",
+ "message.no-team-websites": "Esta equipe não possui sites.",
+ "message.no-teams": "Você ainda não criou nenhuma equipe.",
+ "message.no-users": "Não há usuários.",
+ "message.no-websites-configured": "Você ainda não configurou nenhum site.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Página não encontrada.",
+ "message.reset-website": "Se você tiver certeza de que deseja redefinir este site, digite {confirmation} na caixa de entrada abaixo para confirmar.",
+ "message.reset-website-warning": "Todos os dados estatísticos deste site serão excluídos, mas seu código de rastreamento permanecerá o mesmo.",
+ "message.saved": "Salvo com sucesso.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Este é o link para compartilhar {target}.",
+ "message.team-already-member": "Você já é membro desta equipe.",
+ "message.team-not-found": "Equipe não encontrada.",
+ "message.team-websites-info": "Qualquer membro da equipe pode visualizar os sites.",
+ "message.tracking-code": "Código de rastreamento",
+ "message.transfer-team-website-to-user": "Transferir este site para sua conta?",
+ "message.transfer-user-website-to-team": "Selecione para qual equipe deseja transferir este site.",
+ "message.transfer-website": "Transfira a propriedade do site para sua conta ou para outra equipe.",
+ "message.triggered-event": "Evento disparado",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Usuário excluído.",
+ "message.viewed-page": "Página visualizada",
+ "message.visitor-log": "Visitante de {country} usando o navegador {browser} em um {device} com sistema operacional {os}."
+}
diff --git a/src/lang/pt-PT.json b/src/lang/pt-PT.json
new file mode 100644
index 0000000..86734cb
--- /dev/null
+++ b/src/lang/pt-PT.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Código de acesso",
+ "label.actions": "Ações",
+ "label.activity": "Registo de atividade",
+ "label.add": "Adicionar",
+ "label.add-board": "Adicionar quadro",
+ "label.add-description": "Adicionar descrição",
+ "label.add-member": "Adicionar membro",
+ "label.add-step": "Adicionar passo",
+ "label.add-website": "Adicionar website",
+ "label.admin": "Administrador",
+ "label.affiliate": "Afiliado",
+ "label.after": "Depois",
+ "label.all": "Todos",
+ "label.all-time": "Todo o tempo",
+ "label.analytics": "Análise",
+ "label.apply": "Aplicar",
+ "label.attribution": "Atribuição",
+ "label.attribution-description": "Veja como os utilizadores interagem com o seu marketing e o que impulsiona conversões.",
+ "label.average": "Média",
+ "label.back": "Voltar",
+ "label.before": "Antes",
+ "label.behavior": "Comportamento",
+ "label.boards": "Quadros",
+ "label.bounce-rate": "Taxa de rejeição",
+ "label.breakdown": "Detalhamento",
+ "label.browser": "Navegador",
+ "label.browsers": "Navegadores",
+ "label.campaigns": "Campanhas",
+ "label.cancel": "Cancelar",
+ "label.change-password": "Alterar senha",
+ "label.channels": "Canais",
+ "label.cities": "Cidades",
+ "label.city": "Cidade",
+ "label.clear-all": "Limpar tudo",
+ "label.cohort": "Cohorte",
+ "label.compare": "Comparar",
+ "label.compare-dates": "Comparar datas",
+ "label.confirm": "Confirmar",
+ "label.confirm-password": "Confirmar senha",
+ "label.contains": "Contains",
+ "label.content": "Conteúdo",
+ "label.continue": "Continue",
+ "label.conversion": "Conversão",
+ "label.conversion-rate": "Taxa de conversão",
+ "label.conversion-step": "Passo de conversão",
+ "label.count": "Contagem",
+ "label.countries": "Países",
+ "label.country": "País",
+ "label.create": "Criar",
+ "label.create-report": "Criar relatório",
+ "label.create-team": "Criar equipa",
+ "label.create-user": "Criar utilizador",
+ "label.created": "Criado",
+ "label.created-by": "Criado por",
+ "label.currency": "Moeda",
+ "label.current": "Atual",
+ "label.current-password": "Senha atual",
+ "label.custom-range": "Intervalo personalizado",
+ "label.dashboard": "Painel",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "Intervalo de datas",
+ "label.day": "Dia",
+ "label.default-date-range": "Intervalo de datas predefinido",
+ "label.delete": "Eliminar",
+ "label.delete-report": "Eliminar relatório",
+ "label.delete-team": "Eliminar equipa",
+ "label.delete-user": "Eliminar utilizador",
+ "label.delete-website": "Eliminar website",
+ "label.description": "Descrição",
+ "label.desktop": "Computador",
+ "label.details": "Detalhes",
+ "label.device": "Dispositivo",
+ "label.devices": "Dispositivos",
+ "label.direct": "Direto",
+ "label.dismiss": "Ignorar",
+ "label.distinct-id": "ID distinto",
+ "label.does-not-contain": "Não contém",
+ "label.does-not-include": "Não inclui",
+ "label.doest-not-exist": "Não existe",
+ "label.domain": "Domínio",
+ "label.dropoff": "Dropoff",
+ "label.edit": "Editar",
+ "label.edit-dashboard": "Editar painel",
+ "label.edit-member": "Editar membro",
+ "label.email": "Email",
+ "label.enable-share-url": "Ativar link de partilha",
+ "label.end-step": "Passo final",
+ "label.entry": "URL de entrada",
+ "label.event": "Evento",
+ "label.event-data": "Dados do evento",
+ "label.event-name": "Nome do evento",
+ "label.events": "Eventos",
+ "label.exists": "Existe",
+ "label.exit": "URL de saída",
+ "label.false": "Falso",
+ "label.field": "Campo",
+ "label.fields": "Campos",
+ "label.filter": "Filtro",
+ "label.filter-combined": "Combinado",
+ "label.filter-raw": "Dados brutos",
+ "label.filters": "Filtros",
+ "label.first-click": "Primeiro clique",
+ "label.first-seen": "Primeira visualização",
+ "label.funnel": "Funil",
+ "label.funnel-description": "Compreenda a taxa de conversão e abandono dos utilizadores.",
+ "label.funnels": "Funis",
+ "label.goal": "Objetivo",
+ "label.goals": "Objetivos",
+ "label.goals-description": "Acompanhe os seus objetivos para visualizações de página e eventos.",
+ "label.greater-than": "Maior que",
+ "label.greater-than-equals": "Maior ou igual a",
+ "label.grouped": "Agrupado",
+ "label.hostname": "Nome do host",
+ "label.includes": "Inclui",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "É",
+ "label.is-false": "É falso",
+ "label.is-not": "Não é",
+ "label.is-not-set": "Não definido",
+ "label.is-set": "Definido",
+ "label.is-true": "É verdadeiro",
+ "label.join": "Juntar-se",
+ "label.join-team": "Juntar-se à equipa",
+ "label.journey": "Jornada",
+ "label.journey-description": "Compreenda como os utilizadores navegam no seu website.",
+ "label.journeys": "Jornadas",
+ "label.language": "Língua",
+ "label.languages": "Línguas",
+ "label.laptop": "Portátil",
+ "label.last-click": "Último clique",
+ "label.last-days": "Últimos {x} dias",
+ "label.last-hours": "Últimas {x} horas",
+ "label.last-months": "Últimos {x} meses",
+ "label.last-seen": "Última visualização",
+ "label.leave": "Sair",
+ "label.leave-team": "Sair da equipa",
+ "label.less-than": "Menor que",
+ "label.less-than-equals": "Menor ou igual a",
+ "label.links": "Ligações",
+ "label.login": "Iniciar sessão",
+ "label.logout": "Sair",
+ "label.manage": "Gerir",
+ "label.manager": "Gestor",
+ "label.max": "Máximo",
+ "label.maximize": "Expandir",
+ "label.medium": "Médio",
+ "label.member": "Membro",
+ "label.members": "Membros",
+ "label.min": "Mínimo",
+ "label.mobile": "Telemóvel",
+ "label.model": "Modelo",
+ "label.more": "Mais",
+ "label.my-account": "A minha conta",
+ "label.my-websites": "Os meus websites",
+ "label.name": "Nome",
+ "label.new-password": "Nova senha",
+ "label.none": "Nenhum",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Pesquisa orgânica",
+ "label.organic-shopping": "Compras orgânicas",
+ "label.organic-social": "Social orgânico",
+ "label.organic-video": "Vídeo orgânico",
+ "label.os": "OS",
+ "label.other": "Outro",
+ "label.overview": "Overview",
+ "label.owner": "Proprietário",
+ "label.page": "Página",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "Visualizações da página",
+ "label.pageTitle": "Page title",
+ "label.pages": "Páginas",
+ "label.paid-ads": "Anúncios pagos",
+ "label.paid-search": "Pesquisa paga",
+ "label.paid-shopping": "Compras pagas",
+ "label.paid-social": "Social pago",
+ "label.paid-video": "Vídeo pago",
+ "label.password": "Senha",
+ "label.path": "Caminho",
+ "label.paths": "Caminhos",
+ "label.pixels": "Píxeis",
+ "label.powered-by": "Distribuído por {name}",
+ "label.previous": "Anterior",
+ "label.previous-period": "Período anterior",
+ "label.previous-year": "Ano anterior",
+ "label.profile": "Perfil",
+ "label.properties": "Propriedades",
+ "label.property": "Propriedade",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "Tempo real",
+ "label.referral": "Referência",
+ "label.referrer": "Referrer",
+ "label.referrers": "Referenciadores",
+ "label.refresh": "Atualizar",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Restante",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Obrigatório",
+ "label.reset": "Repor",
+ "label.reset-website": "Repor estatísticas",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Receita",
+ "label.revenue-description": "Veja a sua receita ao longo do tempo.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Guardar",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Selecionar filtro",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Sessão",
+ "label.session-data": "Dados da sessão",
+ "label.sessions": "Sessions",
+ "label.settings": "Definições",
+ "label.share": "Partilhar",
+ "label.share-url": "Partilhar link",
+ "label.single-day": "Dia único",
+ "label.sms": "SMS",
+ "label.sources": "Fontes",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Etiqueta",
+ "label.tags": "Etiquetas",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Gestor de equipa",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Definições da equipa",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Termos",
+ "label.theme": "Tema",
+ "label.this-month": "Este mês",
+ "label.this-week": "Esta semana",
+ "label.this-year": "Este ano",
+ "label.timezone": "Fuso horário",
+ "label.title": "Title",
+ "label.today": "Hoje",
+ "label.toggle-charts": "Alternar gráficos",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Código de rastreamento",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Visitantes únicos",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Desconhecido",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Nome de utilizador",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Ver detalhes",
+ "label.view-only": "View only",
+ "label.views": "Visualizações",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Tempo médio de visita",
+ "label.visitors": "Visitantes",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Websites",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} {x, plural, one {visitante} other {visitantes}} neste momento",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Tem a certeza que pretende eliminar {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Tem a certeza que pretende restaurar as estatísticas de {target}?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Todos os dados associados também serão eliminados.",
+ "message.error": "Ocorreu um erro.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Ir para as definições",
+ "message.incorrect-username-password": "Nome de utilizador/senha incorretos.",
+ "message.invalid-domain": "Domínio inválido",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Sem dados disponíveis.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "As senhas não coincidem",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Não tens nenhum website configurado.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Página não encontrada.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "Todas as estatísticas deste site serão eliminadas, mas o seu código de rastreamento permanecerá intacto.",
+ "message.saved": "Guardado com sucesso.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Este é o link de partilha público para {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Código de rastreamento",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Visitante de {country} a usar {browser} no {device} {os}"
+}
diff --git a/src/lang/ro-RO.json b/src/lang/ro-RO.json
new file mode 100644
index 0000000..7863330
--- /dev/null
+++ b/src/lang/ro-RO.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Cod de access",
+ "label.actions": "Acțiuni",
+ "label.activity": "Jurnal de activități",
+ "label.add": "Adaugă",
+ "label.add-board": "Adaugă panou",
+ "label.add-description": "Adaugă descriere",
+ "label.add-member": "Adaugă membru",
+ "label.add-step": "Adaugă pas",
+ "label.add-website": "Adaugă site web",
+ "label.admin": "Administrator",
+ "label.affiliate": "Afiliat",
+ "label.after": "După",
+ "label.all": "Toate",
+ "label.all-time": "Pentru tot timpul",
+ "label.analytics": "Analiză",
+ "label.apply": "Aplică",
+ "label.attribution": "Atribuire",
+ "label.attribution-description": "Vezi cum utilizatorii interacționează cu marketingul tău și ce determină conversiile.",
+ "label.average": "Mediu",
+ "label.back": "Înapoi",
+ "label.before": "Înainte",
+ "label.behavior": "Comportament",
+ "label.boards": "Panouri",
+ "label.bounce-rate": "Rata de respingere",
+ "label.breakdown": "Detaliat",
+ "label.browser": "Browser",
+ "label.browsers": "Browsere",
+ "label.campaigns": "Campanii",
+ "label.cancel": "Anulează",
+ "label.change-password": "Schimbare parolă",
+ "label.channels": "Canale",
+ "label.cities": "Orașe",
+ "label.city": "Oraș",
+ "label.clear-all": "Șterge tot",
+ "label.cohort": "Cohortă",
+ "label.compare": "Compară",
+ "label.compare-dates": "Compară datele",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "Confirmare parolă",
+ "label.contains": "Conține",
+ "label.content": "Conținut",
+ "label.continue": "Continuă",
+ "label.conversion": "Conversie",
+ "label.conversion-rate": "Rată de conversie",
+ "label.conversion-step": "Pas de conversie",
+ "label.count": "Număr",
+ "label.countries": "Țări",
+ "label.country": "Țară",
+ "label.create": "Crează",
+ "label.create-report": "Crează report",
+ "label.create-team": "Crează echipă",
+ "label.create-user": "Crează utilizator",
+ "label.created": "Creat",
+ "label.created-by": "Creat de",
+ "label.currency": "Monedă",
+ "label.current": "Curent",
+ "label.current-password": "Parola curentă",
+ "label.custom-range": "Interval personalizat",
+ "label.dashboard": "Tablou de bord",
+ "label.data": "Date",
+ "label.date": "Dată",
+ "label.date-range": "Interval",
+ "label.day": "Zi",
+ "label.default-date-range": "Interval implicit",
+ "label.delete": "Șterge",
+ "label.delete-report": "Șterge raport",
+ "label.delete-team": "Șterge echipă",
+ "label.delete-user": "Șterge utilizator",
+ "label.delete-website": "Șterge site web",
+ "label.description": "Descriere",
+ "label.desktop": "Desktop",
+ "label.details": "Detalii",
+ "label.device": "Dispozitiv",
+ "label.devices": "Dispozitive",
+ "label.direct": "Direct",
+ "label.dismiss": "Renunță",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Nu conține",
+ "label.does-not-include": "Nu include",
+ "label.doest-not-exist": "Nu există",
+ "label.domain": "Domeniu",
+ "label.dropoff": "Rată de abandon",
+ "label.edit": "Editare",
+ "label.edit-dashboard": "Editare tablou de bord",
+ "label.edit-member": "Editare membru",
+ "label.email": "Email",
+ "label.enable-share-url": "Activare adresă URL de distribuire",
+ "label.end-step": "Pas final",
+ "label.entry": "URL de intrare",
+ "label.event": "Eveniment",
+ "label.event-data": "Date despre eveniment",
+ "label.event-name": "Nume eveniment",
+ "label.events": "Evenimente",
+ "label.exists": "Există",
+ "label.exit": "URL de ieșire",
+ "label.false": "Fals",
+ "label.field": "Câmp",
+ "label.fields": "Câmpuri",
+ "label.filter": "Filtru",
+ "label.filter-combined": "Combinat",
+ "label.filter-raw": "Brut",
+ "label.filters": "Filtre",
+ "label.first-click": "Primul click",
+ "label.first-seen": "Văzut pentru prima dată",
+ "label.funnel": "Parcursul utilizatorului",
+ "label.funnel-description": "Înțelege rata de conversie și rata de abandon a utilizatorilor.",
+ "label.funnels": "Parcursuri",
+ "label.goal": "Obiectiv",
+ "label.goals": "Obiective",
+ "label.goals-description": "Urmărește obiectivele de vizualizări și evenimente.",
+ "label.greater-than": "Mai mare decât",
+ "label.greater-than-equals": "Mai mare sau egal cu",
+ "label.grouped": "Grupat",
+ "label.hostname": "Nume gazdă",
+ "label.includes": "Include",
+ "label.insight": "Perspectivă",
+ "label.insights": "Perspective",
+ "label.insights-description": "Aprofundează datele utilizând segmente și filtre.",
+ "label.is": "Este",
+ "label.is-false": "Este fals",
+ "label.is-not": "Nu este",
+ "label.is-not-set": "Nu este setat",
+ "label.is-set": "Este setat",
+ "label.is-true": "Este adevărat",
+ "label.join": "Alătură-te",
+ "label.join-team": "Alătură-te echipei",
+ "label.journey": "Traseu",
+ "label.journey-description": "Înțelege cum navighează vizitatorii prin website.",
+ "label.journeys": "Trasee",
+ "label.language": "Limbă",
+ "label.languages": "Limbi",
+ "label.laptop": "Laptop",
+ "label.last-click": "Ultimul click",
+ "label.last-days": "Ultimele {x} zile",
+ "label.last-hours": "Ultimele {x} ore",
+ "label.last-months": "Ultimele {x} luni",
+ "label.last-seen": "Văzut ultima dată",
+ "label.leave": "Părăsește",
+ "label.leave-team": "Părăsește echipa",
+ "label.less-than": "Mai puțin decât",
+ "label.less-than-equals": "Mai puțin sau egal cu",
+ "label.links": "Linkuri",
+ "label.login": "Autentificare",
+ "label.logout": "Ieșire din cont",
+ "label.manage": "Administrează",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Extinde",
+ "label.medium": "Mediu",
+ "label.member": "Membru",
+ "label.members": "Membri",
+ "label.min": "Min",
+ "label.mobile": "Mobil",
+ "label.model": "Model",
+ "label.more": "Mai mult",
+ "label.my-account": "Contul meu",
+ "label.my-websites": "Website-ul meu",
+ "label.name": "Nume",
+ "label.new-password": "Parolă nouă",
+ "label.none": "Niciunul",
+ "label.number-of-records": "{x} {x, plural, one {înregistrare} other {înregistrări}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Căutare organică",
+ "label.organic-shopping": "Cumpărături organice",
+ "label.organic-social": "Social organic",
+ "label.organic-video": "Video organic",
+ "label.os": "OS",
+ "label.other": "Altul",
+ "label.overview": "Vedere de ansamblu",
+ "label.owner": "Titular",
+ "label.page": "Pagină",
+ "label.page-of": "Pagina {current} din {total}",
+ "label.page-views": "Vizualizări de pagină",
+ "label.pageTitle": "Titlul paginii",
+ "label.pages": "Pagini",
+ "label.paid-ads": "Reclame plătite",
+ "label.paid-search": "Căutare plătită",
+ "label.paid-shopping": "Cumpărături plătite",
+ "label.paid-social": "Social plătit",
+ "label.paid-video": "Video plătit",
+ "label.password": "Parolă",
+ "label.path": "Rută",
+ "label.paths": "Rute",
+ "label.pixels": "Pixeli",
+ "label.powered-by": "Cu sprijinul {name}",
+ "label.previous": "Anterior",
+ "label.previous-period": "Perioda anterioară",
+ "label.previous-year": "Anul anterior",
+ "label.profile": "Profil",
+ "label.properties": "Proprietăți",
+ "label.property": "Proprietate",
+ "label.queries": "Interogări",
+ "label.query": "Interogare",
+ "label.query-parameters": "Parametri de interogare",
+ "label.realtime": "Timp real",
+ "label.referral": "Referral",
+ "label.referrer": "Proveniență",
+ "label.referrers": "Site-uri de proveniență",
+ "label.refresh": "Reîmprospătare",
+ "label.regenerate": "Regenerează",
+ "label.region": "Regiune",
+ "label.regions": "Regiuni",
+ "label.remaining": "Rămas",
+ "label.remove": "Îndepărtează",
+ "label.remove-member": "Îndepărtează membru",
+ "label.reports": "Rapoarte",
+ "label.required": "Obligatoriu",
+ "label.reset": "Resetează",
+ "label.reset-website": "Resetează statisticile pentru site",
+ "label.retention": "Retenție",
+ "label.retention-description": "Măsoară atractivitatea site-ului tău prin urmărirea frecvenței cu care utilizatorii se întorc.",
+ "label.revenue": "Venit",
+ "label.revenue-description": "Urmărește venitul în timp.",
+ "label.role": "Rol",
+ "label.run-query": "Execută interogarea",
+ "label.save": "Salvează",
+ "label.screens": "Ecrane",
+ "label.search": "Căutare",
+ "label.select": "Selectează",
+ "label.select-date": "Selectează data",
+ "label.select-filter": "Selectează filtru",
+ "label.select-role": "Selectează rolul",
+ "label.select-website": "Selectează website",
+ "label.session": "Sesiune",
+ "label.session-data": "Date sesiune",
+ "label.sessions": "Sesiuni",
+ "label.settings": "Setări",
+ "label.share": "Partajează",
+ "label.share-url": "Partajare URL",
+ "label.single-day": "O singură zi",
+ "label.sms": "SMS",
+ "label.sources": "Surse",
+ "label.start-step": "Pas de început",
+ "label.steps": "Pași",
+ "label.sum": "Sumă",
+ "label.tablet": "Tabletă",
+ "label.tag": "Etichetă",
+ "label.tags": "Etichete",
+ "label.team": "Echipă",
+ "label.team-id": "ID Echipă",
+ "label.team-manager": "Manager echipă",
+ "label.team-member": "Membru echipă",
+ "label.team-name": "Nume echipă",
+ "label.team-owner": "Titular echipă",
+ "label.team-settings": "Setări echipă",
+ "label.team-view-only": "Doar vizualizare echipă",
+ "label.team-websites": "Website-uri echipă",
+ "label.teams": "Echipă",
+ "label.terms": "Termeni",
+ "label.theme": "Temă",
+ "label.this-month": "Această lună",
+ "label.this-week": "Această săptămână",
+ "label.this-year": "Acest an",
+ "label.timezone": "Fus orar",
+ "label.title": "Titlu",
+ "label.today": "Astăzi",
+ "label.toggle-charts": "Schimbă graficele",
+ "label.total": "Total",
+ "label.total-records": "Total înregistrări",
+ "label.tracking-code": "Cod de urmărire",
+ "label.transactions": "Tranzacții",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "Adevărat",
+ "label.type": "Tip",
+ "label.unique": "Unici",
+ "label.unique-visitors": "Vizitatori unici",
+ "label.uniqueCustomers": "Clienți unici",
+ "label.unknown": "Necunoscut",
+ "label.untitled": "Fără titlu",
+ "label.update": "Update",
+ "label.user": "Utilizator",
+ "label.username": "Nume utilizator",
+ "label.users": "Utilizatori",
+ "label.utm": "UTM",
+ "label.utm-description": "Urmărește campaniile tale cu parametri UTM.",
+ "label.value": "Valoare",
+ "label.view": "Vizualizare",
+ "label.view-details": "Vizualizare detalii",
+ "label.view-only": "Doar vizualizare",
+ "label.views": "Vizualizări",
+ "label.views-per-visit": "Vizualizări per vizită",
+ "label.visit-duration": "Timp mediu de vizitare",
+ "label.visitors": "Vizitatori",
+ "label.visits": "Vizite",
+ "label.website": "Website",
+ "label.website-id": "ID Website",
+ "label.websites": "Site-uri web",
+ "label.window": "Fereastră",
+ "label.yesterday": "Ieri",
+ "message.action-confirmation": "Scrie {confirmation} în câmpul de mai jos pentru a confirma.",
+ "message.active-users": "{x} {x, plural, one {vizitator activ} other {vizitatori activi}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Date colectate",
+ "message.confirm-delete": "Ești sigur că vrei să ștergi {target}?",
+ "message.confirm-leave": "Ești sigur că vrei să părăsești {target}?",
+ "message.confirm-remove": "Ești sigur că vrei să ștergi {target}?",
+ "message.confirm-reset": "Ești sigur că vrei să resetezi statisticile pentru {target}?",
+ "message.delete-team-warning": "Ștergerea unei echipe va șterge și toate website-urile echipei.",
+ "message.delete-website-warning": "Toate datele asociate vor fi șterse, de asemenea.",
+ "message.error": "Ceva n-a mers bine.",
+ "message.event-log": "{event} la {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Mergi la Setări",
+ "message.incorrect-username-password": "Nume utilizator / parolă incorecte.",
+ "message.invalid-domain": "Domeniul nu este valid",
+ "message.min-password-length": "Lungimea minimă este de {n} caractere",
+ "message.new-version-available": "O nouă versiune de Umami {version} este disponibilă!",
+ "message.no-data-available": "Nicio informație disponibilă.",
+ "message.no-event-data": "Nu sunt disponibile date legate de eveniment.",
+ "message.no-match-password": "Parolele nu se potrivesc",
+ "message.no-results-found": "Nu a fost găsit niciun rezultat.",
+ "message.no-team-websites": "Echipa aceasta nu are niciun website.",
+ "message.no-teams": "Nu ai creat nicio echipă.",
+ "message.no-users": "Nu există utilizatori.",
+ "message.no-websites-configured": "Nu ai niciun site web configurat.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Pagina nu a fost găsită.",
+ "message.reset-website": "Pentru a reseta acest website, scrie {confirmation} În câmpul de mai jos pentru a confirma.",
+ "message.reset-website-warning": "Toate statisticile pentru acest site web vor fi șterse, dar codul de urmărire va rămâne intact.",
+ "message.saved": "Salvat cu succes.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Aceasta este adresa URL de partajare pentru {target}.",
+ "message.team-already-member": "Deja ești membru al acestei echipe.",
+ "message.team-not-found": "Echipa nu a fost găsită.",
+ "message.team-websites-info": "Site-urile web pot fi vizualizate de către oricare membru al echipei.",
+ "message.tracking-code": "Cod de urmărire",
+ "message.transfer-team-website-to-user": "Vrei să transferi acest website pe contul tău?",
+ "message.transfer-user-website-to-team": "Selectează echipa căreia vrei să îi transferi site-ul.",
+ "message.transfer-website": "Transferă titulatura site-ului către tine sau către o altă echipă.",
+ "message.triggered-event": "Eveniment declanșat",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Utilizator șters.",
+ "message.viewed-page": "Pagină vizualizată",
+ "message.visitor-log": "Vizitator din {country} folosind {browser} pe {os} {device}"
+}
diff --git a/src/lang/ru-RU.json b/src/lang/ru-RU.json
new file mode 100644
index 0000000..96d0538
--- /dev/null
+++ b/src/lang/ru-RU.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Код доступа",
+ "label.actions": "Действия",
+ "label.activity": "Журнал активности",
+ "label.add": "Добавить",
+ "label.add-board": "Добавить доску",
+ "label.add-description": "Добавить описание",
+ "label.add-member": "Добавить участника",
+ "label.add-step": "Добавить шаг",
+ "label.add-website": "Добавить сайт",
+ "label.admin": "Администратор",
+ "label.affiliate": "Партнер",
+ "label.after": "После",
+ "label.all": "Все",
+ "label.all-time": "Все время",
+ "label.analytics": "Аналитика",
+ "label.apply": "Применить",
+ "label.attribution": "Атрибуция",
+ "label.attribution-description": "Посмотрите, как пользователи взаимодействуют с вашим маркетингом и что приводит к конверсиям.",
+ "label.average": "Средний",
+ "label.back": "Назад",
+ "label.before": "До",
+ "label.behavior": "Поведение",
+ "label.boards": "Доски",
+ "label.bounce-rate": "Отказы",
+ "label.breakdown": "Авария",
+ "label.browser": "Браузер",
+ "label.browsers": "Браузеры",
+ "label.campaigns": "Кампании",
+ "label.cancel": "Отменить",
+ "label.change-password": "Изменить пароль",
+ "label.channels": "Каналы",
+ "label.cities": "Города",
+ "label.city": "Город",
+ "label.clear-all": "Очистить все",
+ "label.cohort": "Когорта",
+ "label.compare": "Сравнить",
+ "label.compare-dates": "Сравнить даты",
+ "label.confirm": "Подтвердить",
+ "label.confirm-password": "Подтвердить пароль",
+ "label.contains": "Содержит",
+ "label.content": "Контент",
+ "label.continue": "Продолжить",
+ "label.conversion": "Конверсия",
+ "label.conversion-rate": "Коэффициент конверсии",
+ "label.conversion-step": "Шаг конверсии",
+ "label.count": "Считать",
+ "label.countries": "Страны",
+ "label.country": "Страна",
+ "label.create": "Создать",
+ "label.create-report": "Создать отчет",
+ "label.create-team": "Создать команду",
+ "label.create-user": "Создать пользователя",
+ "label.created": "Создано",
+ "label.created-by": "Создано",
+ "label.currency": "Валюта",
+ "label.current": "Текущий",
+ "label.current-password": "Текущий пароль",
+ "label.custom-range": "Другой период",
+ "label.dashboard": "Информационная панель",
+ "label.data": "Данные",
+ "label.date": "Дата",
+ "label.date-range": "Диапазон дат",
+ "label.day": "День",
+ "label.default-date-range": "Диапазон дат по-умолчанию",
+ "label.delete": "Удалить",
+ "label.delete-report": "Удалить отчет",
+ "label.delete-team": "Удалить команду",
+ "label.delete-user": "Удалить пользователя",
+ "label.delete-website": "Удалить сайт",
+ "label.description": "Описание",
+ "label.desktop": "Настольный компьютер",
+ "label.details": "Подробности",
+ "label.device": "Устройство",
+ "label.devices": "Устройства",
+ "label.direct": "Direct",
+ "label.dismiss": "Отклонить",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Не содержит",
+ "label.does-not-include": "Не включает",
+ "label.doest-not-exist": "Не существует",
+ "label.domain": "Домен",
+ "label.dropoff": "Высадка",
+ "label.edit": "Изменить",
+ "label.edit-dashboard": "Редактировать дашборд",
+ "label.edit-member": "Редактировать участника",
+ "label.email": "Email",
+ "label.enable-share-url": "Разрешить делиться ссылкой",
+ "label.end-step": "Конечный шаг",
+ "label.entry": "URL-адрес входа",
+ "label.event": "Событие",
+ "label.event-data": "Данные о событии",
+ "label.event-name": "Название события",
+ "label.events": "События",
+ "label.exists": "Существует",
+ "label.exit": "URL-адрес выхода",
+ "label.false": "Ложь",
+ "label.field": "Поле",
+ "label.fields": "Поля",
+ "label.filter": "Фильтр",
+ "label.filter-combined": "Объединенные",
+ "label.filter-raw": "Сырые данные",
+ "label.filters": "Фильтры",
+ "label.first-click": "Первый клик",
+ "label.first-seen": "Первый вход",
+ "label.funnel": "Воронка",
+ "label.funnel-description": "Изучите коэффициент конверсии и ухода пользователей.",
+ "label.funnels": "Воронки",
+ "label.goal": "Цель",
+ "label.goals": "Цели",
+ "label.goals-description": "Отслеживайте свои цели по просмотрам страниц и событиям.",
+ "label.greater-than": "Больше, чем",
+ "label.greater-than-equals": "Больше или равно",
+ "label.grouped": "Группировано",
+ "label.hostname": "Имя хоста",
+ "label.includes": "Включает",
+ "label.insight": "Инсайт",
+ "label.insights": "Информация",
+ "label.insights-description": "Погрузитесь глубже в свои данные с помощью сегментов и фильтров.",
+ "label.is": "Является",
+ "label.is-false": "Ложно",
+ "label.is-not": "Не установлен",
+ "label.is-not-set": "Не установлено",
+ "label.is-set": "Установлен",
+ "label.is-true": "Истинно",
+ "label.join": "Присоединиться",
+ "label.join-team": "Присоединиться к команде",
+ "label.journey": "Journey",
+ "label.journey-description": "Поймите, как пользователи перемещаются по вашему сайту.",
+ "label.journeys": "Пути",
+ "label.language": "Язык",
+ "label.languages": "Языки",
+ "label.laptop": "Ноутбук",
+ "label.last-click": "Последний клик",
+ "label.last-days": "Последние {x} дней",
+ "label.last-hours": "Последние {x} часа",
+ "label.last-months": "Последние {x} месяцев",
+ "label.last-seen": "Последний вход",
+ "label.leave": "Уйти",
+ "label.leave-team": "Покинуть команду",
+ "label.less-than": "Меньше, чем",
+ "label.less-than-equals": "Меньше или равно",
+ "label.links": "Ссылки",
+ "label.login": "Войти",
+ "label.logout": "Выйти",
+ "label.manage": "Управление",
+ "label.manager": "Менеджер",
+ "label.max": "Максимум",
+ "label.maximize": "Развернуть",
+ "label.medium": "Средний",
+ "label.member": "Участник",
+ "label.members": "Участники",
+ "label.min": "Минимум",
+ "label.mobile": "Смартфон",
+ "label.model": "Модель",
+ "label.more": "Больше",
+ "label.my-account": "Мой профиль",
+ "label.my-websites": "Мои сайты",
+ "label.name": "Имя",
+ "label.new-password": "Новый пароль",
+ "label.none": "Не указано",
+ "label.number-of-records": "{x} {x, plural, one {запись} other {записи}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Органический поиск",
+ "label.organic-shopping": "Органические покупки",
+ "label.organic-social": "Органические соцсети",
+ "label.organic-video": "Органическое видео",
+ "label.os": "OS",
+ "label.other": "Другое",
+ "label.overview": "Обзор",
+ "label.owner": "Владелец",
+ "label.page": "Страница",
+ "label.page-of": "Страница {current} из {total}",
+ "label.page-views": "Просмотры страниц",
+ "label.pageTitle": "Название страницы",
+ "label.pages": "Страницы",
+ "label.paid-ads": "Платная реклама",
+ "label.paid-search": "Платный поиск",
+ "label.paid-shopping": "Платные покупки",
+ "label.paid-social": "Платные соцсети",
+ "label.paid-video": "Платное видео",
+ "label.password": "Пароль",
+ "label.path": "Путь",
+ "label.paths": "Пути",
+ "label.pixels": "Пиксели",
+ "label.powered-by": "На движке {name}",
+ "label.previous": "Предыдущий",
+ "label.previous-period": "Предыдущий период",
+ "label.previous-year": "Предыдущий год",
+ "label.profile": "Профиль",
+ "label.properties": "Свойства",
+ "label.property": "Свойство",
+ "label.queries": "Запросы",
+ "label.query": "Запрос",
+ "label.query-parameters": "Параметры запроса",
+ "label.realtime": "Реальное время",
+ "label.referral": "Referral",
+ "label.referrer": "Реферер",
+ "label.referrers": "Источники",
+ "label.refresh": "Обновить",
+ "label.regenerate": "Обновить",
+ "label.region": "Регион",
+ "label.regions": "Регионы",
+ "label.remaining": "Осталось",
+ "label.remove": "Удалить",
+ "label.remove-member": "Удалить участника",
+ "label.reports": "Отчеты",
+ "label.required": "Обязательное",
+ "label.reset": "Сбросить",
+ "label.reset-website": "Сбросить статистику",
+ "label.retention": "Удержание",
+ "label.retention-description": "Измерьте «прилипаемость» вашего сайта, отслеживая, как часто пользователи возвращаются на него.",
+ "label.revenue": "Выручка",
+ "label.revenue-description": "Изучите свои доходы за определенное время.",
+ "label.role": "Роль",
+ "label.run-query": "Выполнить запрос",
+ "label.save": "Сохранить",
+ "label.screens": "Экраны",
+ "label.search": "Поиск",
+ "label.select": "Выберите",
+ "label.select-date": "Выберите дату",
+ "label.select-filter": "Выберите фильтр",
+ "label.select-role": "Выберите роль",
+ "label.select-website": "Выбрать сайт",
+ "label.session": "Сессия",
+ "label.session-data": "Данные сессии",
+ "label.sessions": "Сессии",
+ "label.settings": "Настройки",
+ "label.share": "Поделиться",
+ "label.share-url": "Поделиться ссылкой",
+ "label.single-day": "Один день",
+ "label.sms": "SMS",
+ "label.sources": "Источники",
+ "label.start-step": "Начальный этап",
+ "label.steps": "Шаги",
+ "label.sum": "Сумма",
+ "label.tablet": "Планшет",
+ "label.tag": "Тег",
+ "label.tags": "Теги",
+ "label.team": "Команда",
+ "label.team-id": "ID команды",
+ "label.team-manager": "Менеджер команды",
+ "label.team-member": "Член команды",
+ "label.team-name": "Название команды",
+ "label.team-owner": "Владелец команды",
+ "label.team-settings": "Настройки команды",
+ "label.team-view-only": "Только командный просмотр",
+ "label.team-websites": "Веб-сайты команды",
+ "label.teams": "Команды",
+ "label.terms": "Условия",
+ "label.theme": "Тема",
+ "label.this-month": "Этот месяц",
+ "label.this-week": "Эта неделя",
+ "label.this-year": "Этот год",
+ "label.timezone": "Часовой пояс",
+ "label.title": "Заголовок",
+ "label.today": "Сегодня",
+ "label.toggle-charts": "Показать/скрыть графики",
+ "label.total": "Всего",
+ "label.total-records": "Всего записей",
+ "label.tracking-code": "Код отслеживания",
+ "label.transactions": "Транзакции",
+ "label.transfer": "Передача",
+ "label.transfer-website": "Передать сайт",
+ "label.true": "Правда",
+ "label.type": "Тип",
+ "label.unique": "Уникальный",
+ "label.unique-visitors": "Уникальные посетители",
+ "label.uniqueCustomers": "Уникальные клиенты",
+ "label.unknown": "Неизвестно",
+ "label.untitled": "Без названия",
+ "label.update": "Обновление",
+ "label.user": "Пользователь",
+ "label.username": "Имя пользователя",
+ "label.users": "Пользователи",
+ "label.utm": "UTM",
+ "label.utm-description": "Отслеживайте свои кампании с помощью UTM-параметров.",
+ "label.value": "Значение",
+ "label.view": "Просмотреть",
+ "label.view-details": "Посмотреть детали",
+ "label.view-only": "Только просмотр",
+ "label.views": "Просмотры",
+ "label.views-per-visit": "Просмотров за посещение",
+ "label.visit-duration": "Среднее время посещения",
+ "label.visitors": "Посетители",
+ "label.visits": "Посещения",
+ "label.website": "Сайт",
+ "label.website-id": "ID сайта",
+ "label.websites": "Сайты",
+ "label.window": "Окно",
+ "label.yesterday": "Вчера",
+ "message.action-confirmation": "Введите {confirmation} в поле ниже, чтобы подтвердить.",
+ "message.active-users": "{x} текущих посетителей",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Собранные данные",
+ "message.confirm-delete": "Вы уверены, что хотите удалить {target}?",
+ "message.confirm-leave": "Вы уверены, что хотите уйти {target}?",
+ "message.confirm-remove": "Вы уверены, что хотите удалить {target}?",
+ "message.confirm-reset": "Вы уверены, что хотите сбросить статистику {target}?",
+ "message.delete-team-warning": "При удалении команды будут удалены и все ее веб-сайты.",
+ "message.delete-website-warning": "Все связанные данные будут также удалены.",
+ "message.error": "Что-то пошло не так.",
+ "message.event-log": "{event} на {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Перейти к настройкам",
+ "message.incorrect-username-password": "Неверное имя пользователя/пароль.",
+ "message.invalid-domain": "Некорректный домен",
+ "message.min-password-length": "Минимальная длина {n} символов",
+ "message.new-version-available": "Вышла новая версия Umami {version}!",
+ "message.no-data-available": "Нет данных.",
+ "message.no-event-data": "Данные о событиях отсутствуют.",
+ "message.no-match-password": "Пароли не совпадают",
+ "message.no-results-found": "Результаты не найдены.",
+ "message.no-team-websites": "У этой команды нет ни одного сайта.",
+ "message.no-teams": "Вы не создали ни одной команды.",
+ "message.no-users": "Нет пользователей.",
+ "message.no-websites-configured": "У вас нет настроенных сайтов.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Страница не найдена.",
+ "message.reset-website": "Для сброса введите RESET",
+ "message.reset-website-warning": "Вся статистика для этого сайта будет удалена, но ваш код отслеживания останется нетронутым.",
+ "message.saved": "Успешно сохранено.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Это публичная ссылка для {target}.",
+ "message.team-already-member": "Вы уже состоите в команде.",
+ "message.team-not-found": "Команда не найдена.",
+ "message.team-websites-info": "Сайты могут просматривать все члены команды.",
+ "message.tracking-code": "Код отслеживания",
+ "message.transfer-team-website-to-user": "Перенести этот сайт в свой прфоиль?",
+ "message.transfer-user-website-to-team": "Выберите команду, которой нужно передать этот сайт.",
+ "message.transfer-website": "Передайте право владения сайтом своей учетной записи или другой команде.",
+ "message.triggered-event": "Запущенное событие",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Пользователь удален.",
+ "message.viewed-page": "Просмотренная страница",
+ "message.visitor-log": "Посетитель из {country} используя {browser} на {os} {device}"
+}
diff --git a/src/lang/si-LK.json b/src/lang/si-LK.json
new file mode 100644
index 0000000..3e6aff8
--- /dev/null
+++ b/src/lang/si-LK.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Access code",
+ "label.actions": "Actions",
+ "label.activity": "Activity log",
+ "label.add": "Add",
+ "label.add-board": "Add board",
+ "label.add-description": "Add description",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "වෙබ් අඩවිය එක් කරන්න",
+ "label.admin": "Administrator",
+ "label.affiliate": "Affiliate",
+ "label.after": "After",
+ "label.all": "සියල්ල",
+ "label.all-time": "හැම වෙලාවෙම",
+ "label.analytics": "Analytics",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "Average",
+ "label.back": "ආපසු",
+ "label.before": "Before",
+ "label.behavior": "අචරණය",
+ "label.boards": "Boards",
+ "label.bounce-rate": "Bounce rate",
+ "label.breakdown": "Breakdown",
+ "label.browser": "Browser",
+ "label.browsers": "Browsers",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "අවලංගු කරන්න",
+ "label.change-password": "මුරපදය වෙනස් කරන්න",
+ "label.channels": "Channels",
+ "label.cities": "Cities",
+ "label.city": "City",
+ "label.clear-all": "Clear all",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "මුරපදය සත්‍යාපනය කරන්න",
+ "label.contains": "Contains",
+ "label.content": "Content",
+ "label.continue": "Continue",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "Countries",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "Create report",
+ "label.create-team": "Create team",
+ "label.create-user": "Create user",
+ "label.created": "Created",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "වත්මන් මුරපදය",
+ "label.custom-range": "අභිරුචි පරාසය",
+ "label.dashboard": "උපකරණ පුවරුව",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "දින පරාසය",
+ "label.day": "Day",
+ "label.default-date-range": "පෙරනිමි දින පරාසය",
+ "label.delete": "මකන්න",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Delete team",
+ "label.delete-user": "Delete user",
+ "label.delete-website": "වෙබ් අඩවිය මකන්න",
+ "label.description": "Description",
+ "label.desktop": "Desktop",
+ "label.details": "Details",
+ "label.device": "Device",
+ "label.devices": "Devices",
+ "label.direct": "Direct",
+ "label.dismiss": "මගහරින්න",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Does not contain",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "වසම",
+ "label.dropoff": "Dropoff",
+ "label.edit": "සංස්කරණය කරන්න",
+ "label.edit-dashboard": "Edit dashboard",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "බෙදාගැනීමේ URL සබල කරන්න",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Event",
+ "label.event-data": "සිදුවීම් දත්ත",
+ "label.event-name": "Event name",
+ "label.events": "Events",
+ "label.exists": "Exists",
+ "label.exit": "Exit URL",
+ "label.false": "False",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "Filter",
+ "label.filter-combined": "Combined",
+ "label.filter-raw": "Raw",
+ "label.filters": "Filters",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Greater than",
+ "label.greater-than-equals": "Greater than or equals",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "Join",
+ "label.join-team": "Join team",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "භාෂාව",
+ "label.languages": "Languages",
+ "label.laptop": "Laptop",
+ "label.last-click": "Last click",
+ "label.last-days": "අන්තිම {x} දින",
+ "label.last-hours": "අන්තිම {x} පැය",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "Leave",
+ "label.leave-team": "Leave team",
+ "label.less-than": "Less than",
+ "label.less-than-equals": "Less than or equals",
+ "label.links": "Links",
+ "label.login": "ලොග් වෙන්න",
+ "label.logout": "පිටවීම",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "Members",
+ "label.min": "Min",
+ "label.mobile": "Mobile",
+ "label.model": "Model",
+ "label.more": "තවත්",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "නම",
+ "label.new-password": "අලුත් මුරපදය",
+ "label.none": "කිසිවක් නැත",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "OS",
+ "label.other": "Other",
+ "label.overview": "Overview",
+ "label.owner": "හිමිකරු",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "Page views",
+ "label.pageTitle": "Page title",
+ "label.pages": "Pages",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "මුරපදය",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "පැතිකඩ",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "තත්ය කාල",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "Referrers",
+ "label.refresh": "නැවුම් කරන්න",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Remaining",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "අවශ්‍යයි",
+ "label.reset": "යළි පිහිටුවන්න",
+ "label.reset-website": "සංඛ්යා ලේඛන නැවත සකසන්න",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "සුරකින්න",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "Sessions",
+ "label.settings": "සැකසුම්",
+ "label.share": "Share",
+ "label.share-url": "බෙදාගැනීමේ URL",
+ "label.single-day": "තනි දවස",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Terms",
+ "label.theme": "තේමාව",
+ "label.this-month": "මෙ මාසය",
+ "label.this-week": "මේ සතිය",
+ "label.this-year": "මේ අවුරුද්ද",
+ "label.timezone": "වේලා කලාපය",
+ "label.title": "Title",
+ "label.today": "අද",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "ලුහුබැඳීමේ කේතය",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Unique visitors",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "නොදනී",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "පරිශීලක නාමය",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "තොරතුරු පෙන්වන්න",
+ "label.view-only": "View only",
+ "label.views": "Views",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Visit duration",
+ "label.visitors": "Visitors",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "වෙබ් අඩවි",
+ "label.window": "Window",
+ "label.yesterday": "ඊයේ",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} දැන් {x, plural, one {අමුත්තා} other {අමුත්තන්}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "{target} මකා දැමීම ගැන විශ්වාසද?",
+ "message.confirm-leave": "Are you sure you want to 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.delete-website-warning": "All website data will be deleted.",
+ "message.error": "Something went wrong.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "සැකසීම් වෙත යන්න",
+ "message.incorrect-username-password": "වැරදි පරිශීලක නාමය/මුරපදය.",
+ "message.invalid-domain": "Invalid domain. Do not include http/https.",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "පෙන්වීමට දත්ත නොමැත.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Passwords do not match.",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "You do not have any websites configured.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "පිටුව හමු නොවීය.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your settings will remain intact.",
+ "message.saved": "Saved.",
+ "message.sever-error": "Server error",
+ "message.share-url": "මේ {target} සඳහා ප්‍රසිද්ධියේ බෙදාගත් URL එකයි.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "To track stats for this website, place the following code in the <head>...</head> section of your HTML.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}"
+}
diff --git a/src/lang/sk-SK.json b/src/lang/sk-SK.json
new file mode 100644
index 0000000..297d5e3
--- /dev/null
+++ b/src/lang/sk-SK.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Prístupový kód",
+ "label.actions": "Akcie",
+ "label.activity": "Denník aktivít",
+ "label.add": "Pridať",
+ "label.add-board": "Pridať tabuľu",
+ "label.add-description": "Pridať popis",
+ "label.add-member": "Pridať člena",
+ "label.add-step": "Pridať krok",
+ "label.add-website": "Pridať web",
+ "label.admin": "Administrátor",
+ "label.affiliate": "Partner",
+ "label.after": "Po",
+ "label.all": "Všetko",
+ "label.all-time": "Celý čas",
+ "label.analytics": "Analytika",
+ "label.apply": "Použiť",
+ "label.attribution": "Priradenie",
+ "label.attribution-description": "Pozrite sa, ako používatelia interagujú s vaším marketingom a čo vedie ku konverziám.",
+ "label.average": "Priemer",
+ "label.back": "Späť",
+ "label.before": "Pred",
+ "label.behavior": "Správanie",
+ "label.boards": "Tabule",
+ "label.bounce-rate": "Okamžité opustenie",
+ "label.breakdown": "Rozpis",
+ "label.browser": "Prehliadač",
+ "label.browsers": "Prehliadač",
+ "label.campaigns": "Kampane",
+ "label.cancel": "Zrušiť",
+ "label.change-password": "Zmeniť heslo",
+ "label.channels": "Kanály",
+ "label.cities": "Mestá",
+ "label.city": "Mesto",
+ "label.clear-all": "Vymazať všetko",
+ "label.cohort": "Kohorta",
+ "label.compare": "Porovnať",
+ "label.compare-dates": "Porovnať dátumy",
+ "label.confirm": "Potvrdiť",
+ "label.confirm-password": "Potvrdiť heslo",
+ "label.contains": "Contains",
+ "label.content": "Obsah",
+ "label.continue": "Continue",
+ "label.conversion": "Konverzia",
+ "label.conversion-rate": "Miera konverzie",
+ "label.conversion-step": "Krok konverzie",
+ "label.count": "Počet",
+ "label.countries": "Zem",
+ "label.country": "Krajina",
+ "label.create": "Vytvoriť",
+ "label.create-report": "Vytvoriť správu",
+ "label.create-team": "Vytvoriť tím",
+ "label.create-user": "Vytvoriť používateľa",
+ "label.created": "Vytvorené",
+ "label.created-by": "Vytvoril",
+ "label.currency": "Mena",
+ "label.current": "Aktuálny",
+ "label.current-password": "Aktuálne heslo",
+ "label.custom-range": "Vlastný rozsah",
+ "label.dashboard": "Prehlad",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "Obdobie",
+ "label.day": "Deň",
+ "label.default-date-range": "Predvolené obdobie",
+ "label.delete": "Zmazať",
+ "label.delete-report": "Zmazať správu",
+ "label.delete-team": "Zmazať tím",
+ "label.delete-user": "Zmazať používateľa",
+ "label.delete-website": "Zmazať web",
+ "label.description": "Popis",
+ "label.desktop": "Stolný počítač",
+ "label.details": "Details",
+ "label.device": "Zariadenie",
+ "label.devices": "Zariadenie",
+ "label.direct": "Priamy",
+ "label.dismiss": "Odísť",
+ "label.distinct-id": "Jedinečné ID",
+ "label.does-not-contain": "Neobsahuje",
+ "label.does-not-include": "Nezahŕňa",
+ "label.doest-not-exist": "Neexistuje",
+ "label.domain": "Doména",
+ "label.dropoff": "Dropoff",
+ "label.edit": "Upraviť",
+ "label.edit-dashboard": "Upraviť prehľad",
+ "label.edit-member": "Upraviť člena",
+ "label.email": "Email",
+ "label.enable-share-url": "Povoliť zdielanie URL",
+ "label.end-step": "Konečný krok",
+ "label.entry": "Vstupná URL",
+ "label.event": "Udalosť",
+ "label.event-data": "Dáta udalosti",
+ "label.event-name": "Názov udalosti",
+ "label.events": "Udalosti",
+ "label.exists": "Existuje",
+ "label.exit": "Výstupná URL",
+ "label.false": "Nepravda",
+ "label.field": "Pole",
+ "label.fields": "Polia",
+ "label.filter": "Filter",
+ "label.filter-combined": "Kombinácie",
+ "label.filter-raw": "Nezpracované",
+ "label.filters": "Filtre",
+ "label.first-click": "Prvé kliknutie",
+ "label.first-seen": "Prvýkrát videné",
+ "label.funnel": "Lievik",
+ "label.funnel-description": "Pochopte mieru konverzie a odchodu používateľov.",
+ "label.funnels": "Lieviky",
+ "label.goal": "Cieľ",
+ "label.goals": "Ciele",
+ "label.goals-description": "Sledujte svoje ciele pre zobrazenia stránok a udalosti.",
+ "label.greater-than": "Väčšie ako",
+ "label.greater-than-equals": "Väčšie alebo rovné",
+ "label.grouped": "Zoskupené",
+ "label.hostname": "Názov hostiteľa",
+ "label.includes": "Zahŕňa",
+ "label.insight": "Prehľad",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Je",
+ "label.is-false": "Je nepravda",
+ "label.is-not": "Nie je",
+ "label.is-not-set": "Nie je nastavené",
+ "label.is-set": "Nastavené",
+ "label.is-true": "Je pravda",
+ "label.join": "Pripojiť sa",
+ "label.join-team": "Pripojiť sa k tímu",
+ "label.journey": "Cesta",
+ "label.journey-description": "Pochopte, ako používatelia prechádzajú vaším webom.",
+ "label.journeys": "Cesty",
+ "label.language": "Jazyk",
+ "label.languages": "Jazyky",
+ "label.laptop": "Prenosný počítač",
+ "label.last-click": "Posledné kliknutie",
+ "label.last-days": "Posledných {x} dní",
+ "label.last-hours": "Posledných {x} hodín",
+ "label.last-months": "Posledných {x} mesiacov",
+ "label.last-seen": "Naposledy videné",
+ "label.leave": "Odísť",
+ "label.leave-team": "Opustiť tím",
+ "label.less-than": "Menej ako",
+ "label.less-than-equals": "Menej alebo rovné",
+ "label.links": "Odkazy",
+ "label.login": "Prihlásiť",
+ "label.logout": "Odhlásiť",
+ "label.manage": "Spravovať",
+ "label.manager": "Manažér",
+ "label.max": "Maximum",
+ "label.maximize": "Rozbaliť",
+ "label.medium": "Stredný",
+ "label.member": "Člen",
+ "label.members": "Členovia",
+ "label.min": "Minimum",
+ "label.mobile": "Mobilný telefon",
+ "label.model": "Model",
+ "label.more": "Viac",
+ "label.my-account": "Môj účet",
+ "label.my-websites": "Moje weby",
+ "label.name": "Meno",
+ "label.new-password": "Nové heslo",
+ "label.none": "Žiadny",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organické vyhľadávanie",
+ "label.organic-shopping": "Organické nakupovanie",
+ "label.organic-social": "Organické sociálne siete",
+ "label.organic-video": "Organické video",
+ "label.os": "OS",
+ "label.other": "Iné",
+ "label.overview": "Overview",
+ "label.owner": "Owner",
+ "label.page": "Stránka",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "Zobrazenie stánok",
+ "label.pageTitle": "Page title",
+ "label.pages": "Stránky",
+ "label.paid-ads": "Platené reklamy",
+ "label.paid-search": "Platené vyhľadávanie",
+ "label.paid-shopping": "Platené nakupovanie",
+ "label.paid-social": "Platené sociálne siete",
+ "label.paid-video": "Platené video",
+ "label.password": "Heslo",
+ "label.path": "Cesta",
+ "label.paths": "Cesty",
+ "label.pixels": "Pixely",
+ "label.powered-by": "Powered by {name}",
+ "label.previous": "Predchádzajúci",
+ "label.previous-period": "Predchádzajúce obdobie",
+ "label.previous-year": "Predchádzajúci rok",
+ "label.profile": "Profil",
+ "label.properties": "Vlastnosti",
+ "label.property": "Vlastnosť",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "Aktuálne",
+ "label.referral": "Odporúčanie",
+ "label.referrer": "Referrer",
+ "label.referrers": "Odkazy",
+ "label.refresh": "Obnoviť",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Zostáva",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "Povinné",
+ "label.reset": "Reset",
+ "label.reset-website": "Reset statistics",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Príjem",
+ "label.revenue-description": "Pozrite si svoj príjem v priebehu času.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "Uložiť",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Vybrať filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Sedenie",
+ "label.session-data": "Dáta sedenia",
+ "label.sessions": "Sessions",
+ "label.settings": "Nastavenia",
+ "label.share": "Zdieľať",
+ "label.share-url": "Zdielanie URL",
+ "label.single-day": "Jeden deň",
+ "label.sms": "SMS",
+ "label.sources": "Zdroje",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "Tablet",
+ "label.tag": "Značka",
+ "label.tags": "Značky",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Manažér tímu",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Nastavenia tímu",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Podmienky",
+ "label.theme": "Theme",
+ "label.this-month": "Tento mesiac",
+ "label.this-week": "Tento týždeň",
+ "label.this-year": "Tento rok",
+ "label.timezone": "Časová zóna",
+ "label.title": "Title",
+ "label.today": "Dnes",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "Sledovací kód",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "Jedinečné návštevy",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Neznámý",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "Užívateľské meno",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "Zobraziť detaily",
+ "label.view-only": "View only",
+ "label.views": "Zobrazení",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Priemerný čas návštevy",
+ "label.visitors": "Návštevy",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "Weby",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} aktuálne {x, plural, one {návštevník} other {návštěvníci}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Naozaj zmazať {target}?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "Všetky príbuzné data budu tiež zmazané.",
+ "message.error": "Niečo sa pokazilo.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Ísť do nastavení",
+ "message.incorrect-username-password": "Nesprávné meno/heslo.",
+ "message.invalid-domain": "Neplatná doména",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "Žiadne data.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "Hesla se nezhodujú",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "Nemáte nastavený žiadny web.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Stránka sa nenašla.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "Úspešne uložené.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Toto je zdielané URL pre {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "message.tracking-code": "Sledovací kód",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Návštevník z {country} s prehliadačom {browser} na {os} {device}"
+}
diff --git a/src/lang/sl-SI.json b/src/lang/sl-SI.json
new file mode 100644
index 0000000..3dd3226
--- /dev/null
+++ b/src/lang/sl-SI.json
@@ -0,0 +1,318 @@
+{
+ "label.access-code": "Koda za dostop",
+ "label.actions": "Dejanja",
+ "label.activity": "Dnevnik dejavnosti",
+ "label.add": "Dodaj",
+ "label.add-board": "Dodaj tablo",
+ "label.add-description": "Dodaj opis",
+ "label.add-member": "Dodaj člana",
+ "label.add-step": "Dodaj korak",
+ "label.add-website": "Dodaj spletno mesto",
+ "label.admin": "Administrator",
+ "label.affiliate": "Partner",
+ "label.after": "Po",
+ "label.all": "Vsi",
+ "label.all-time": "Ves čas",
+ "label.analytics": "Analitika",
+ "label.apply": "Uporabi",
+ "label.attribution": "Pripis",
+ "label.attribution-description": "Oglejte si, kako uporabniki sodelujejo z vašim marketingom in kaj spodbuja konverzije.",
+ "label.average": "Povprečno",
+ "label.back": "Nazaj",
+ "label.before": "Pred",
+ "label.behavior": "Obnašanje",
+ "label.boards": "Table",
+ "label.bounce-rate": "Odbojna stopnja",
+ "label.breakdown": "Razčlenitev",
+ "label.browser": "Brskalnik",
+ "label.browsers": "Brskalniki",
+ "label.campaigns": "Kampanje",
+ "label.cancel": "Prekliči",
+ "label.change-password": "Zamenjaj geslo",
+ "label.channels": "Kanali",
+ "label.cities": "Mesta",
+ "label.city": "Mesto",
+ "label.clear-all": "Počisti vse",
+ "label.compare": "Primerjaj",
+ "label.confirm": "Potrdi",
+ "label.confirm-password": "Potrdi geslo",
+ "label.contains": "Vsebuje",
+ "label.content": "Vsebina",
+ "label.continue": "Nadaljuj",
+ "label.count": "Število",
+ "label.countries": "Države",
+ "label.country": "Država",
+ "label.create": "Ustvari",
+ "label.create-report": "Ustvari poročilo",
+ "label.create-team": "Ustvari ekipo",
+ "label.create-user": "Ustvari uporabnika",
+ "label.created": "Ustvarjeno",
+ "label.created-by": "Ustvaril",
+ "label.current": "Trenutno",
+ "label.current-password": "Trenutno geslo",
+ "label.custom-range": "Obdobje po meri",
+ "label.dashboard": "Nadzorna plošča",
+ "label.data": "Podatki",
+ "label.date": "Datum",
+ "label.date-range": "Časovno obdobje",
+ "label.day": "Dan",
+ "label.default-date-range": "Privzeto časovno obdobje",
+ "label.delete": "Izbriši",
+ "label.delete-report": "Izbriši poročilo",
+ "label.delete-team": "Izbriši ekipo",
+ "label.delete-user": "Izbriši uporabnika",
+ "label.delete-website": "Izbriši spletno mesto",
+ "label.description": "Opis",
+ "label.desktop": "Namizni računalnik",
+ "label.details": "Podrobnosti",
+ "label.device": "Naprava",
+ "label.devices": "Naprave",
+ "label.direct": "Neposredno",
+ "label.dismiss": "Prezri",
+ "label.distinct-id": "Unikatni ID",
+ "label.does-not-contain": "Ne vsebuje",
+ "label.does-not-include": "Ne vključuje",
+ "label.doest-not-exist": "Ne obstaja",
+ "label.domain": "Domena",
+ "label.dropoff": "Zapustitev",
+ "label.edit": "Uredi",
+ "label.edit-dashboard": "Uredi nadzorno ploščo",
+ "label.edit-member": "Uredi člana",
+ "label.enable-share-url": "Omogoči povezavo za deljenje",
+ "label.end-step": "Končni korak",
+ "label.entry": "Vstopni URL",
+ "label.event": "Dogodek",
+ "label.event-data": "Podatki dogodka",
+ "label.event-name": "Ime dogodka",
+ "label.events": "Dogodki",
+ "label.exit": "Izhodni URL",
+ "label.false": "Napačno",
+ "label.field": "Polje",
+ "label.fields": "Polja",
+ "label.filter": "Filter",
+ "label.filter-combined": "Skupaj",
+ "label.filter-raw": "Neobdelano",
+ "label.filters": "Filtri",
+ "label.first-seen": "Prvič viden",
+ "label.funnel": "Prodajni lijak",
+ "label.funnel-description": "Razumite stopnjo konverzije in osipa uporabnikov.",
+ "label.goal": "Cilj",
+ "label.goals": "Cilji",
+ "label.goals-description": "Spremljajte svoje cilje za oglede strani in dogodke.",
+ "label.greater-than": "Večje od",
+ "label.greater-than-equals": "Večje ali enako kot",
+ "label.host": "Gostitelj",
+ "label.hosts": "Gostitelji",
+ "label.insights": "Vpogled",
+ "label.insights-description": "Poglobite se v podatke z uporabo segmentov in filtrov.",
+ "label.is": "Je",
+ "label.is-false": "Je napačno",
+ "label.is-not": "Ni",
+ "label.is-not-set": "Ni nastavljeno",
+ "label.is-set": "Je nastavljeno",
+ "label.is-true": "Je res",
+ "label.join": "Pridruži se",
+ "label.join-team": "Pridruži se ekipi",
+ "label.journey": "Uporabniška pot",
+ "label.journey-description": "Razumite, kako uporabniki krmarijo po vašem spletnem mestu.",
+ "label.language": "Jezik",
+ "label.languages": "Jeziki",
+ "label.laptop": "Prenosni računalnik",
+ "label.last-click": "Zadnji klik",
+ "label.last-days": "Zadnjih {x} dni",
+ "label.last-hours": "Zadnjih {x} ur",
+ "label.last-months": "Zadnjih {x} mesecev",
+ "label.last-seen": "Nazadnje viden",
+ "label.leave": "Zapusti",
+ "label.leave-team": "Zapusti ekipo",
+ "label.less-than": "Manjše kot",
+ "label.less-than-equals": "Manjše ali enako kot",
+ "label.links": "Povezave",
+ "label.login": "Prijava",
+ "label.logout": "Odjava",
+ "label.manage": "Upravljaj",
+ "label.manager": "Upravitelj",
+ "label.max": "Največ",
+ "label.member": "Član",
+ "label.members": "Člani",
+ "label.min": "Najmanj",
+ "label.mobile": "Mobilne naprave",
+ "label.model": "Model",
+ "label.more": "Več",
+ "label.my-account": "Moj račun",
+ "label.my-websites": "Moja spletna mesta",
+ "label.name": "Ime",
+ "label.new-password": "Novo geslo",
+ "label.none": "Noben",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organsko iskanje",
+ "label.organic-shopping": "Organski nakupi",
+ "label.organic-social": "Organska družbena omrežja",
+ "label.organic-video": "Organski video",
+ "label.os": "OS",
+ "label.other": "Drugo",
+ "label.overview": "Pregled",
+ "label.owner": "Lastnik",
+ "label.page": "Stran",
+ "label.page-of": "Stran {current} od {total}",
+ "label.page-views": "Obiski strani",
+ "label.pageTitle": "Naslov strani",
+ "label.pages": "Strani",
+ "label.paid-ads": "Plačani oglasi",
+ "label.paid-search": "Plačano iskanje",
+ "label.paid-shopping": "Plačani nakupi",
+ "label.paid-social": "Plačana družbena omrežja",
+ "label.paid-video": "Plačani video",
+ "label.password": "Geslo",
+ "label.path": "Pot",
+ "label.paths": "Poti",
+ "label.powered-by": "Poganja {name}",
+ "label.previous": "Prejšnji",
+ "label.previous-period": "Prejšnje obdobje",
+ "label.previous-year": "Prejšnje leto",
+ "label.profile": "Profil",
+ "label.properties": "Lastnosti",
+ "label.property": "Lastnost",
+ "label.queries": "Poizvedbe",
+ "label.query": "Poizvedba",
+ "label.query-parameters": "Parametri poizvedbe",
+ "label.realtime": "V živo",
+ "label.referral": "Napoten",
+ "label.referrer": "Vir",
+ "label.referrers": "Viri",
+ "label.refresh": "Osveži",
+ "label.regenerate": "Ponovno generiraj",
+ "label.region": "Regija",
+ "label.regions": "Regije",
+ "label.remaining": "Preostalo",
+ "label.remove": "Odstrani",
+ "label.remove-member": "Odstrani člana",
+ "label.reports": "Poročila",
+ "label.required": "Zahtevano",
+ "label.reset": "Ponastavi",
+ "label.reset-website": "Ponastavi statistiko",
+ "label.retention": "Ohranjanje uporabnikov",
+ "label.retention-description": "Merite uporabnikovo zadržanost s sledenjem, kako pogosto se vračajo.",
+ "label.revenue": "Prihodki",
+ "label.revenue-description": "Preglejte svoje prihodke skozi čas.",
+ "label.revenue-property": "Lastnost prihodkov",
+ "label.role": "Vloga",
+ "label.run-query": "Izvedi poizvedbo",
+ "label.save": "Shrani",
+ "label.screens": "Zasloni",
+ "label.search": "Išči",
+ "label.select": "Izberi",
+ "label.select-date": "Izberi datum",
+ "label.select-role": "Izberi vlogo",
+ "label.select-website": "Izberi spletno mesto",
+ "label.session": "Seja",
+ "label.sessions": "Seje",
+ "label.settings": "Nastavitve",
+ "label.share": "Deli",
+ "label.share-url": "Deli povezavo",
+ "label.single-day": "En dan",
+ "label.start-step": "Začetni korak",
+ "label.steps": "Koraki",
+ "label.sum": "Seštevek",
+ "label.tablet": "Tablični računalnik",
+ "label.tag": "Oznaka",
+ "label.tags": "Oznake",
+ "label.team": "Ekipa",
+ "label.team-id": "ID ekipe",
+ "label.team-manager": "Upravitelj ekipe",
+ "label.team-member": "Član ekipe",
+ "label.team-name": "Ime ekipe",
+ "label.team-owner": "Lastnik ekipe",
+ "label.team-view-only": "Ekipa samo za ogled",
+ "label.team-websites": "Spletna mesta ekipe",
+ "label.teams": "Ekipe",
+ "label.terms": "Pogoji",
+ "label.theme": "Tema",
+ "label.this-month": "Ta mesec",
+ "label.this-week": "Ta teden",
+ "label.this-year": "To leto",
+ "label.timezone": "Časovni pas",
+ "label.title": "Naslov",
+ "label.today": "Danes",
+ "label.toggle-charts": "Preklopi grafe",
+ "label.total": "Skupaj",
+ "label.total-records": "Skupni zapisi",
+ "label.tracking-code": "Koda za sledenje",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "Pravilno",
+ "label.type": "Vrsta",
+ "label.unique": "Unikatni",
+ "label.unique-visitors": "Unikatni obiskovalci",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Neznano",
+ "label.untitled": "Brez naslova",
+ "label.update": "Update",
+ "label.user": "Uporabnik",
+ "label.username": "Uporabniško ime",
+ "label.users": "Uporabniki",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Vrednost",
+ "label.view": "Poglej",
+ "label.view-details": "Poglej podrobnosti",
+ "label.view-only": "Samo ogledovanje",
+ "label.views": "Obiski",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Povprečni čas obiska",
+ "label.visitors": "Obiskovalci",
+ "label.visits": "Visits",
+ "label.website": "Spletno mesto",
+ "label.website-id": "ID spletnega mesta",
+ "label.websites": "Spletna mesta",
+ "label.window": "Okno",
+ "label.yesterday": "Včeraj",
+ "message.action-confirmation": "Za potrditev v spodnje polje vnesite {confirmation}.",
+ "message.active-users": "{x} trenutni {x, plural, one {obiskovalec} other {obiskovalcev}}",
+ "message.collected-data": "Zbrani podatki",
+ "message.confirm-delete": "Ste prepričani, da želite izbrisati {target}?",
+ "message.confirm-leave": "Ste prepričani, da želite zapustiti {target}?",
+ "message.confirm-remove": "Ali ste prepričani, da želite odstraniti {target}?",
+ "message.confirm-reset": "Ste prepričani, da želite ponastaviti statistiko {target}?",
+ "message.delete-team-warning": "Brisanje ekipe bo izbrisalo tudi vsa spletna mesta ekipe.",
+ "message.delete-website-warning": "Izbrisani bodo tudi vsi pripadajoči podatki.",
+ "message.error": "Nekaj je šlo narobe.",
+ "message.event-log": "{event} na {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Pojdi v nastavitve",
+ "message.incorrect-username-password": "Nepravilno uporabniško ime/geslo.",
+ "message.invalid-domain": "Neveljavna domena",
+ "message.min-password-length": "Najmanjša dolžina je {n} znakov",
+ "message.new-version-available": "Na voljo je nova verzija programa Umami {version}!",
+ "message.no-data-available": "Podatki niso na voljo.",
+ "message.no-event-data": "Podatki o dogodku niso na voljo.",
+ "message.no-match-password": "Gesli se ne ujemata",
+ "message.no-results-found": "Rezultatov ni bilo mogoče najti.",
+ "message.no-team-websites": "Ta ekipa nima spletnih mest.",
+ "message.no-teams": "Niste še ustvarili nobene ekipe.",
+ "message.no-users": "Ni uporabnikov.",
+ "message.no-websites-configured": "Nimate nastavljenih nobenih spletnih mest.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Stran ni bila najdena.",
+ "message.reset-website": "Za ponastavitev izbrisa tega spletnega mesta vnesite {confirmation} v spodnje polje.",
+ "message.reset-website-warning": "Vse statistike za to spletno mesto bodo izbrisane, koda za sledenje pa bo ostala nespremenjena.",
+ "message.saved": "Uspešno shranjeno.",
+ "message.sever-error": "Server error",
+ "message.share-url": "To je javno dostopna povezava za {target}.",
+ "message.team-already-member": "Ste že član ekipe.",
+ "message.team-not-found": "Ekipa ni bila najdena.",
+ "message.team-websites-info": "Spletne strani si lahko ogleda vsak član ekipe.",
+ "message.tracking-code": "Koda za sledenje",
+ "message.transfer-team-website-to-user": "Želite prenesti to spletno mesto v svoj račun?",
+ "message.transfer-user-website-to-team": "Izberite ekipo, na katero želite prenesti to spletno mesto.",
+ "message.transfer-website": "Prenesite lastništvo spletnega mesta na svoj račun ali drugo ekipo.",
+ "message.triggered-event": "Sprožen dogodek",
+ "message.user-deleted": "Uporabnik je izbrisan.",
+ "message.viewed-page": "Ogledana stran",
+ "message.visitor-log": "Obiskovalec iz {country} uporablja {browser} na {os} {device}",
+ "message.visitors-dropped-off": "Osip obiskovalcev"
+}
diff --git a/src/lang/sv-SE.json b/src/lang/sv-SE.json
new file mode 100644
index 0000000..1f456b0
--- /dev/null
+++ b/src/lang/sv-SE.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Åtkomstkod",
+ "label.actions": "Händelser",
+ "label.activity": "Aktivitetslogg",
+ "label.add": "Lägg till",
+ "label.add-board": "Lägg till anslagstavla",
+ "label.add-description": "Lägg till beskrivning",
+ "label.add-member": "Lägg till medlem",
+ "label.add-step": "Lägg till steg",
+ "label.add-website": "Lägg till webbplats",
+ "label.admin": "Administratör",
+ "label.affiliate": "Partner",
+ "label.after": "Efter",
+ "label.all": "Alla",
+ "label.all-time": "Sedan början",
+ "label.analytics": "Webbplats Analys",
+ "label.apply": "Tillämpa",
+ "label.attribution": "Attribuering",
+ "label.attribution-description": "Se hur användare interagerar med din marknadsföring och vad som driver konverteringar.",
+ "label.average": "Genomsnitt",
+ "label.back": "Tillbaka",
+ "label.before": "Före",
+ "label.behavior": "Beteende",
+ "label.boards": "Anslagstavlor",
+ "label.bounce-rate": "Avvisningsfrekvens",
+ "label.breakdown": "Analys",
+ "label.browser": "Webbläsare",
+ "label.browsers": "Webbläsare",
+ "label.campaigns": "Kampanjer",
+ "label.cancel": "Avbryt",
+ "label.change-password": "Byt lösenord",
+ "label.channels": "Kanaler",
+ "label.cities": "Städer",
+ "label.city": "Stad",
+ "label.clear-all": "Rensa alla",
+ "label.cohort": "Kohort",
+ "label.compare": "Jämför",
+ "label.compare-dates": "Jämför datum",
+ "label.confirm": "Bekräfta",
+ "label.confirm-password": "Bekräfta lösenord",
+ "label.contains": "Innehåller",
+ "label.content": "Innehåll",
+ "label.continue": "Fortsätt",
+ "label.conversion": "Konvertering",
+ "label.conversion-rate": "Konverteringsfrekvens",
+ "label.conversion-step": "Konverteringssteg",
+ "label.count": "Antal",
+ "label.countries": "Länder",
+ "label.country": "Land",
+ "label.create": "Skapa",
+ "label.create-report": "Skapa rapport",
+ "label.create-team": "Skapa team",
+ "label.create-user": "Skapa användare",
+ "label.created": "Skapad",
+ "label.created-by": "Skapad av",
+ "label.currency": "Valuta",
+ "label.current": "Nuvarande",
+ "label.current-password": "Nuvarande lösenord",
+ "label.custom-range": "Anpassat urval",
+ "label.dashboard": "Översikt",
+ "label.data": "Data",
+ "label.date": "Datum",
+ "label.date-range": "Tidsperiod",
+ "label.day": "Dag",
+ "label.default-date-range": "Standard datum-urval",
+ "label.delete": "Radera",
+ "label.delete-report": "Radera rapport",
+ "label.delete-team": "Radera team",
+ "label.delete-user": "Radera användare",
+ "label.delete-website": "Radera webbplats",
+ "label.description": "Beskrivning",
+ "label.desktop": "Stationär",
+ "label.details": "Detaljer",
+ "label.device": "Enhet",
+ "label.devices": "Enheter",
+ "label.direct": "Direkt",
+ "label.dismiss": "Avbryt",
+ "label.distinct-id": "Unikt ID",
+ "label.does-not-contain": "Innehåller inte",
+ "label.does-not-include": "Inkluderar inte",
+ "label.doest-not-exist": "Existerar inte",
+ "label.domain": "Domän",
+ "label.dropoff": "Bortfall",
+ "label.edit": "Redigera",
+ "label.edit-dashboard": "Redigera översikt",
+ "label.edit-member": "Redigera medlem",
+ "label.email": "Email",
+ "label.enable-share-url": "Aktivera delningslänk",
+ "label.end-step": "Slutsteg",
+ "label.entry": "Ingångs-URL",
+ "label.event": "Händelse",
+ "label.event-data": "Händelsedata",
+ "label.event-name": "Händelsenamn",
+ "label.events": "Händelser",
+ "label.exists": "Existerar",
+ "label.exit": "Exit URL",
+ "label.false": "Falskt",
+ "label.field": "Fält",
+ "label.fields": "Fältar",
+ "label.filter": "Filter",
+ "label.filter-combined": "Kombinerade",
+ "label.filter-raw": "Rådata",
+ "label.filters": "Filter",
+ "label.first-click": "Första klicket",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Förstå omvandlingen och bortfallsfrekvensen för användare.",
+ "label.funnels": "Trattar",
+ "label.goal": "Mål",
+ "label.goals": "Mål",
+ "label.goals-description": "Följ dina mål för sidvisningar och händelser.",
+ "label.greater-than": "Större än",
+ "label.greater-than-equals": "Större än eller lika med",
+ "label.grouped": "Grupperad",
+ "label.hostname": "Värdnamn",
+ "label.includes": "Inkluderar",
+ "label.insight": "Insikt",
+ "label.insights": "Insikter",
+ "label.insights-description": "Dyk djupare in i din data genom att använda olika segment och filter.",
+ "label.is": "Är",
+ "label.is-false": "Är falskt",
+ "label.is-not": "Är inte",
+ "label.is-not-set": "Är inte inställd",
+ "label.is-set": "Är inställd",
+ "label.is-true": "Är sant",
+ "label.join": "Gå med",
+ "label.join-team": "Gå med i team",
+ "label.journey": "Resa",
+ "label.journey-description": "Förstå hur användare navigerar på din webbplats.",
+ "label.journeys": "Resor",
+ "label.language": "Språk",
+ "label.languages": "Språk",
+ "label.laptop": "Bärbar",
+ "label.last-click": "Sista klicket",
+ "label.last-days": "Senaste {x} dagarna",
+ "label.last-hours": "Senaste {x} timmarna",
+ "label.last-months": "Senaste {x} månaderna",
+ "label.last-seen": "Senast sedd",
+ "label.leave": "Lämna",
+ "label.leave-team": "Lämna team",
+ "label.less-than": "Mindre än",
+ "label.less-than-equals": "Mindre än eller lika med",
+ "label.links": "Länkar",
+ "label.login": "Logga in",
+ "label.logout": "Logga ut",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Expandera",
+ "label.medium": "Medium",
+ "label.member": "Medlem",
+ "label.members": "Medlemmar",
+ "label.min": "Min",
+ "label.mobile": "Mobil",
+ "label.model": "Modell",
+ "label.more": "Mer",
+ "label.my-account": "Mitt konto",
+ "label.my-websites": "Mina webbplatser",
+ "label.name": "Namn",
+ "label.new-password": "Nytt lösenord",
+ "label.none": "Ingen",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organisk sökning",
+ "label.organic-shopping": "Organisk shopping",
+ "label.organic-social": "Organisk social",
+ "label.organic-video": "Organisk video",
+ "label.os": "Operativsystem",
+ "label.other": "Annat",
+ "label.overview": "Översikt",
+ "label.owner": "Ägare",
+ "label.page": "Sida",
+ "label.page-of": "Sida {current} av {total}",
+ "label.page-views": "Sidvisningar",
+ "label.pageTitle": "Sidtitel",
+ "label.pages": "Sidor",
+ "label.paid-ads": "Betalda annonser",
+ "label.paid-search": "Betald sökning",
+ "label.paid-shopping": "Betald shopping",
+ "label.paid-social": "Betald social",
+ "label.paid-video": "Betald video",
+ "label.password": "Lösenord",
+ "label.path": "Sökväg",
+ "label.paths": "Sökvägar",
+ "label.pixels": "Pixlar",
+ "label.powered-by": "Drivs av {name}",
+ "label.previous": "Föregående",
+ "label.previous-period": "Föregående period",
+ "label.previous-year": "Föregående år",
+ "label.profile": "Profil",
+ "label.properties": "Egenskaper",
+ "label.property": "Egenskap",
+ "label.queries": "Frågor",
+ "label.query": "Fråga",
+ "label.query-parameters": "Frågeparametrar",
+ "label.realtime": "Realtid",
+ "label.referral": "Hänvisning",
+ "label.referrer": "Hänvisare",
+ "label.referrers": "Hänvisare",
+ "label.refresh": "Uppdatera",
+ "label.regenerate": "Förnya",
+ "label.region": "Region",
+ "label.regions": "Regioner",
+ "label.remaining": "Återstår",
+ "label.remove": "Ta bort",
+ "label.remove-member": "Remove member",
+ "label.reports": "Rapporter",
+ "label.required": "Krävs",
+ "label.reset": "Återställ",
+ "label.reset-website": "Återställ webbplats",
+ "label.retention": "Retention",
+ "label.retention-description": "Mät din webbplats engagemang genom att följa hur ofta användare återvänder.",
+ "label.revenue": "Intäkter",
+ "label.revenue-description": "Se dina intäkter över tid.",
+ "label.role": "Roll",
+ "label.run-query": "Kör sökning",
+ "label.save": "Spara",
+ "label.screens": "Upplösning",
+ "label.search": "Sök",
+ "label.select": "Select",
+ "label.select-date": "Välj datum",
+ "label.select-filter": "Välj filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Välj webbplats",
+ "label.session": "Session",
+ "label.session-data": "Sessionsdata",
+ "label.sessions": "Sessioner",
+ "label.settings": "Inställningar",
+ "label.share": "Dela",
+ "label.share-url": "Delningslänk",
+ "label.single-day": "En dag",
+ "label.sms": "SMS",
+ "label.sources": "Källor",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Summa",
+ "label.tablet": "Surfplatta",
+ "label.tag": "Tagg",
+ "label.tags": "Taggar",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Teamledare",
+ "label.team-member": "Team-medlem",
+ "label.team-name": "Team namn",
+ "label.team-owner": "Team-ägare",
+ "label.team-settings": "Teaminställningar",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team webbplatser",
+ "label.teams": "Team",
+ "label.terms": "Villkor",
+ "label.theme": "Tema",
+ "label.this-month": "Denna månad",
+ "label.this-week": "Denna vecka",
+ "label.this-year": "Detta år",
+ "label.timezone": "Tidszon",
+ "label.title": "Titel",
+ "label.today": "Idag",
+ "label.toggle-charts": "Visa/göm grafer",
+ "label.total": "Totalt",
+ "label.total-records": "Totala poster",
+ "label.tracking-code": "Spårningskod",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "Sant",
+ "label.type": "Typ",
+ "label.unique": "Unikt",
+ "label.unique-visitors": "Unika besökare",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Okänt",
+ "label.untitled": "Namnlös",
+ "label.update": "Update",
+ "label.user": "Användare",
+ "label.username": "Användarnamn",
+ "label.users": "Användare",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Värde",
+ "label.view": "Visa",
+ "label.view-details": "Visa detaljer",
+ "label.view-only": "Endast visning",
+ "label.views": "Visningar",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "Genomsnittlig besökstid",
+ "label.visitors": "Besökare",
+ "label.visits": "Visits",
+ "label.website": "Webbplats",
+ "label.website-id": "Webbplats ID",
+ "label.websites": "Webbplatser",
+ "label.window": "Fönster",
+ "label.yesterday": "Igår",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} {x, plural, one {besökare} other {besökare}} just nu",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Är du säker på att du vill radera {target}?",
+ "message.confirm-leave": "Är du säker på att du vill lämna {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Är du säker på att du vill återställa statistiken för {target}?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "All tillhörande data kommer också att raderas.",
+ "message.error": "Något gick fel.",
+ "message.event-log": "{event} på {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Gå till inställningar",
+ "message.incorrect-username-password": "Felaktigt användarnamn/lösenord.",
+ "message.invalid-domain": "Ogiltig domän",
+ "message.min-password-length": "Minst {n} tecken",
+ "message.new-version-available": "En ny version av Umami {version} är tillgänglig!",
+ "message.no-data-available": "Ingen data tillgänglig.",
+ "message.no-event-data": "Ingen händelsedata är tillgänglig.",
+ "message.no-match-password": "Lösenorden matchar inte",
+ "message.no-results-found": "Inga resultat hittades.",
+ "message.no-team-websites": "Det här teamet har inga webbplatser.",
+ "message.no-teams": "Du har inte skapat några team.",
+ "message.no-users": "Det finns inga användare.",
+ "message.no-websites-configured": "Du har inte konfigurerat några webbplatser.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Sidan kunde inte hittas.",
+ "message.reset-website": "För att återställa webbplatsen, skriv {confirmation} i rutan nedan.",
+ "message.reset-website-warning": "All statistik för webbplatsen tas bort, men spårningskoden förblir oförändrad.",
+ "message.saved": "Sparat!",
+ "message.sever-error": "Server error",
+ "message.share-url": "Det här är den offentliga delningslänken för {target}.",
+ "message.team-already-member": "Du är redan medlem i teamet.",
+ "message.team-not-found": "Teamet kunde inte hittas.",
+ "message.team-websites-info": "Webbplatserna kan ses av alla i teamet.",
+ "message.tracking-code": "Spårningskod",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "Användaren har raderats.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "Besökare från {country} med {browser} på {os} {device}"
+}
diff --git a/src/lang/ta-IN.json b/src/lang/ta-IN.json
new file mode 100644
index 0000000..9e33d7b
--- /dev/null
+++ b/src/lang/ta-IN.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Access code",
+ "label.actions": "செயல்கள்",
+ "label.activity": "Activity log",
+ "label.add": "Add",
+ "label.add-board": "Add board",
+ "label.add-description": "Add description",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "வலைத்தளத்தைச் சேர்க்க",
+ "label.admin": "நிர்வாகியைச் சேர்க்க",
+ "label.affiliate": "Affiliate",
+ "label.after": "After",
+ "label.all": "எல்லாம்",
+ "label.all-time": "All time",
+ "label.analytics": "Analytics",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "Average",
+ "label.back": "பின்னால்",
+ "label.before": "Before",
+ "label.boards": "Boards",
+ "label.behavior": "நடத்தை",
+ "label.bounce-rate": "துள்ளல் விகிதம்",
+ "label.breakdown": "Breakdown",
+ "label.browser": "Browser",
+ "label.browsers": "உலாவிகள்",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "ரத்துசெய்",
+ "label.change-password": "கடவுச்சொல்லை மாற்று",
+ "label.channels": "Channels",
+ "label.cities": "Cities",
+ "label.city": "City",
+ "label.clear-all": "Clear all",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "கடவுச்சொல்லை உறுதிப்படுத்தவும்",
+ "label.contains": "Contains",
+ "label.content": "Content",
+ "label.continue": "Continue",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "நாடுகள்",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "Create report",
+ "label.create-team": "Create team",
+ "label.create-user": "Create user",
+ "label.created": "Created",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "தற்போதைய கடவுச்சொல்",
+ "label.custom-range": "தனிப்பயன் வேறுபாட்டெல்லை",
+ "label.dashboard": "முகப்பு",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "தேதி வரம்பு",
+ "label.day": "Day",
+ "label.default-date-range": "இயல்புநிலை தேதி வரம்பு",
+ "label.delete": "அழி",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Delete team",
+ "label.delete-user": "Delete user",
+ "label.delete-website": "வலைத்தளத்தை நீக்கு",
+ "label.description": "Description",
+ "label.desktop": "மேசை கணினி",
+ "label.details": "Details",
+ "label.device": "Device",
+ "label.devices": "சாதனங்கள்",
+ "label.direct": "Direct",
+ "label.dismiss": "நீக்கு",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Does not contain",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "கள முகவரி",
+ "label.dropoff": "Dropoff",
+ "label.edit": "திருத்துதல்",
+ "label.edit-dashboard": "Edit dashboard",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "கள முகவரியை பகிரலாம்",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Event",
+ "label.event-data": "Event data",
+ "label.event-name": "Event name",
+ "label.events": "நிகழ்வுகள்",
+ "label.exists": "Exists",
+ "label.exit": "Exit URL",
+ "label.false": "False",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "Filter",
+ "label.filter-combined": "ஒருங்கிணைந்த",
+ "label.filter-raw": "மூல",
+ "label.filters": "Filters",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Greater than",
+ "label.greater-than-equals": "Greater than or equals",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "Join",
+ "label.join-team": "Join team",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "Language",
+ "label.languages": "Languages",
+ "label.laptop": "மடிக்கணினி",
+ "label.last-click": "Last click",
+ "label.last-days": "முந்தைய {x} நாட்கள்",
+ "label.last-hours": "முந்தைய {x} மணி",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "Leave",
+ "label.leave-team": "Leave team",
+ "label.less-than": "Less than",
+ "label.less-than-equals": "Less than or equals",
+ "label.links": "Links",
+ "label.login": "உள்நுழைய",
+ "label.logout": "வெளியேறு",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "Members",
+ "label.min": "Min",
+ "label.mobile": "கைபேசி",
+ "label.model": "Model",
+ "label.more": "மேலும்",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "பெயர்",
+ "label.new-password": "புதிய கடவுச்சொல்",
+ "label.none": "None",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "OS",
+ "label.other": "Other",
+ "label.overview": "Overview",
+ "label.owner": "Owner",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "பக்க காட்சிகள்",
+ "label.pageTitle": "Page title",
+ "label.pages": "பக்கங்கள்",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "கடவுச்சொல்",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "{name} ஆல் இயக்கப்படுகிறது",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "சுயவிவரம்",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "தற்போதைய",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "குறிப்பிடுவோர்",
+ "label.refresh": "புதுப்பிப்பு",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Remaining",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "தேவையானவை",
+ "label.reset": "மீட்டமை",
+ "label.reset-website": "Reset statistics",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "சேமி",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "Sessions",
+ "label.settings": "அமைப்புகள்",
+ "label.share": "Share",
+ "label.share-url": "வலைத்தள களத்தைப் பகிரவும்",
+ "label.single-day": "ஒரு நாள்",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "கையடக்க கணினி",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Terms",
+ "label.theme": "Theme",
+ "label.this-month": "இந்த மாதம்",
+ "label.this-week": "இந்த வாரம்",
+ "label.this-year": "இந்த வருடம்",
+ "label.timezone": "நேர மண்டலம்",
+ "label.title": "Title",
+ "label.today": "இன்று",
+ "label.toggle-charts": "Toggle charts",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "கண்காணிப்பு குறியீடு",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "தனிப்பட்ட பார்வையாளர்கள்",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "தெரியாத",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "பயனர்பெயர்",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "விபரங்களை பார்",
+ "label.view-only": "View only",
+ "label.views": "பார்வைகள்",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "சராசரி வருகை நேரம்",
+ "label.visitors": "பார்வையாளர்கள்",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "வலைத்தளங்கள்",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} தற்போதைய {x, plural, one {ஒன்று} other {மற்ற}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "நீங்கள் நிச்சயமாக {target} நீக்க விரும்புகிறீர்களா?",
+ "message.confirm-leave": "Are you sure you want to leave {target}?",
+ "message.confirm-remove": "Are you sure you want to remove {target}?",
+ "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?",
+ "message.delete-team-warning": "Deleting a team will also delete all team websites.",
+ "message.delete-website-warning": "தொடர்புடைய எல்லா தரவும் நீக்கப்படும்.",
+ "message.error": "ஏதோ தவறு நடந்துவிட்டது.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "அமைப்புகளுக்குச் செல்லவும்",
+ "message.incorrect-username-password": "தவறான பயனர்பெயர் / கடவுச்சொல்.",
+ "message.invalid-domain": "தவறான கள முகவரி",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "தரவு எதுவும் கிடைக்கவில்லை.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "இருக்கடவுச்சொல் பொருந்தவில்லை",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "உங்களிடம் எந்த வலைத்தளங்களும் கட்டமைக்கப்படவில்லை.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "பக்கம் கிடைக்கவில்லை.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.",
+ "message.saved": "வெற்றிகரமாக சேமிக்கப்பட்டது.",
+ "message.sever-error": "Server error",
+ "message.share-url": "{target} இது பொதுவில் பகிரும் வலைத்தள முகவரி.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "{country}வில் இருந்து பார்வையாளர் {browser} ஐ {os} {device}லில் பயன்படுத்துகிறார்"
+}
diff --git a/src/lang/th-TH.json b/src/lang/th-TH.json
new file mode 100644
index 0000000..b94ca90
--- /dev/null
+++ b/src/lang/th-TH.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Access code",
+ "label.actions": "การกระทำ",
+ "label.activity": "Activity log",
+ "label.add": "Add",
+ "label.add-board": "Add board",
+ "label.add-description": "Add description",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "เพิ่มเว็บไซต์",
+ "label.admin": "ผู้ดูแลระบบ",
+ "label.affiliate": "Affiliate",
+ "label.after": "After",
+ "label.all": "ทั้งหมด",
+ "label.all-time": "ทุกช่วงเวลา",
+ "label.analytics": "Analytics",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "Average",
+ "label.back": "ย้อนกลับ",
+ "label.before": "Before",
+ "label.behavior": "พฤติกรรม",
+ "label.boards": "Boards",
+ "label.bounce-rate": "อัตราตีกลับ",
+ "label.breakdown": "Breakdown",
+ "label.browser": "Browser",
+ "label.browsers": "เบราว์เซอร์",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "ยกเลิก",
+ "label.change-password": "เปลี่ยนรหัสผ่าน",
+ "label.channels": "Channels",
+ "label.cities": "Cities",
+ "label.city": "City",
+ "label.clear-all": "Clear all",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "ยืนยันรหัสผ่าน",
+ "label.contains": "Contains",
+ "label.content": "Content",
+ "label.continue": "Continue",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "ประเทศ",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "Create report",
+ "label.create-team": "Create team",
+ "label.create-user": "Create user",
+ "label.created": "Created",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "รหัสผ่านปัจจุบัน",
+ "label.custom-range": "กำหนดช่วงเวลา",
+ "label.dashboard": "แดชบอร์ด",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "ตั้งแต่วันที่",
+ "label.day": "Day",
+ "label.default-date-range": "ช่วงเวลา",
+ "label.delete": "ลบ",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Delete team",
+ "label.delete-user": "Delete user",
+ "label.delete-website": "ลบเว็บไซต์",
+ "label.description": "Description",
+ "label.desktop": "เดสก์ท็อป",
+ "label.details": "Details",
+ "label.device": "Device",
+ "label.devices": "อุปกรณ์",
+ "label.direct": "Direct",
+ "label.dismiss": "ยกเลิก",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Does not contain",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "โดเมน",
+ "label.dropoff": "Dropoff",
+ "label.edit": "แก้ไข",
+ "label.edit-dashboard": "Edit dashboard",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "เปิดใช้งานการแชร์ลิงก์",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Event",
+ "label.event-data": "Event data",
+ "label.event-name": "Event name",
+ "label.events": "เหตุการณ์",
+ "label.exists": "Exists",
+ "label.exit": "Exit URL",
+ "label.false": "False",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "Filter",
+ "label.filter-combined": "ข้อมูลรวม",
+ "label.filter-raw": "ข้อมูลดิบ",
+ "label.filters": "Filters",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Greater than",
+ "label.greater-than-equals": "Greater than or equals",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "Join",
+ "label.join-team": "Join team",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "ภาษา",
+ "label.languages": "ภาษา",
+ "label.laptop": "แล็ปท็อป",
+ "label.last-click": "Last click",
+ "label.last-days": "{x} วันที่ผ่านมา",
+ "label.last-hours": "{x} ชั่วโมงที่ผ่านมา",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "Leave",
+ "label.leave-team": "Leave team",
+ "label.less-than": "Less than",
+ "label.less-than-equals": "Less than or equals",
+ "label.links": "Links",
+ "label.login": "เข้าสู่ระบบ",
+ "label.logout": "ออกจากระบบ",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "Members",
+ "label.min": "Min",
+ "label.mobile": "โทรศัพท์มือถือ",
+ "label.model": "Model",
+ "label.more": "เพิ่มเติม",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "ชื่อ",
+ "label.new-password": "รหัสผ่านใหม่",
+ "label.none": "ไม่ได้กำหนด",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "OS",
+ "label.other": "Other",
+ "label.overview": "Overview",
+ "label.owner": "เจ้าของ",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "การเข้าชม",
+ "label.pageTitle": "Page title",
+ "label.pages": "หน้าเพจ",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "รหัสผ่าน",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "ขับเคลื่อนโดย {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "โปรไฟล์",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "เรียลไทม์",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "แหล่งที่มา",
+ "label.refresh": "รีเฟรช",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Remaining",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "ต้องการ",
+ "label.reset": "รีเซต",
+ "label.reset-website": "รีเซตข้อมูลสถิติ",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "บันทึก",
+ "label.screens": "ขนาดหน้าจอ",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "Sessions",
+ "label.settings": "ตั้งค่า",
+ "label.share": "Share",
+ "label.share-url": "แชร์ลิงก์",
+ "label.single-day": "วันที่",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "แท็บเล็ต",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Terms",
+ "label.theme": "ธีม",
+ "label.this-month": "เดือนปัจจุบัน",
+ "label.this-week": "สัปดาห์ปัจจุบัน",
+ "label.this-year": "ปีปัจจุบัน",
+ "label.timezone": "เขตเวลา",
+ "label.title": "Title",
+ "label.today": "วันนี้",
+ "label.toggle-charts": "เปิด/ปิดแผนภูมิ",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "โค้ดสำหรับใช้ติดตาม",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "ผู้เข้าชม",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "ไม่รู้จัก",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "ชื่อผู้ใช้",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "แสดงรายละเอียด",
+ "label.view-only": "View only",
+ "label.views": "การเข้าชม",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "ระยะเวลาเข้าชมเฉลี่ย",
+ "label.visitors": "ผู้เข้าชม",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "เว็บไซต์",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "มีผู้ใช้งาน {x} {x, plural, one {คนในขณะนี้} other {คนในขณะนี้}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "คุณแน่ใจหรือไม่ว่าต้องการลบ {target} ?",
+ "message.confirm-leave": "Are you sure you want to 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.delete-website-warning": "ข้อมูลที่เกี่ยวข้องทั้งหมดจะถูกลบ.",
+ "message.error": "เกิดข้อผิดพลาด.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "ไปที่การตั้งค่า",
+ "message.incorrect-username-password": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง.",
+ "message.invalid-domain": "โดเมนไม่ถูกต้อง",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "ไม่มีข้อมูล.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "รหัสผ่านไม่ตรงกัน",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "คุณยังไม่ได้ตั้งค่าเว็บไซต์ใด ๆ ไว้.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "ไม่พบหน้านี้.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "สถิติทั้งหมดสำหรับเว็บไซต์นี้จะถูกลบออก แต่โค้ดสำหรับใช้ติดตามของคุณจะยังคงอยู่เหมือนเดิม.",
+ "message.saved": "บันทึกข้อมูลเรียบร้อย.",
+ "message.sever-error": "Server error",
+ "message.share-url": "นี่คือลิงก์ที่แชร์แบบสาธารณะสำหรับ {target}.",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "ผู้เข้าชมจาก {country} กำลังใช้งานผ่าน {browser} บน {os} {device}"
+}
diff --git a/src/lang/tr-TR.json b/src/lang/tr-TR.json
new file mode 100644
index 0000000..3a2dce4
--- /dev/null
+++ b/src/lang/tr-TR.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Erişim Kodu",
+ "label.actions": "Hareketler",
+ "label.activity": "Aktivite Kaydı",
+ "label.add": "Ekle",
+ "label.add-board": "Pano ekle",
+ "label.add-description": "Açıklama ekle",
+ "label.add-member": "Üye ekle",
+ "label.add-step": "Adım ekle",
+ "label.add-website": "Web sitesi ekle",
+ "label.admin": "Administrator",
+ "label.affiliate": "Ortak",
+ "label.after": "Sonra",
+ "label.all": "Tümü",
+ "label.all-time": "Tüm zamanlar",
+ "label.analytics": "Analitik",
+ "label.apply": "Uygula",
+ "label.attribution": "Atıf",
+ "label.attribution-description": "Kullanıcıların pazarlamanızla nasıl etkileşime girdiğini ve dönüşümleri neyin tetiklediğini görün.",
+ "label.average": "Ortalama",
+ "label.back": "Geri",
+ "label.before": "Önce",
+ "label.behavior": "Davranış",
+ "label.boards": "Panolar",
+ "label.bounce-rate": "Tek sayfa ziyaret oranı",
+ "label.breakdown": "Dağılım",
+ "label.browser": "Tarayıcı",
+ "label.browsers": "Tarayıcılar",
+ "label.campaigns": "Kampanyalar",
+ "label.cancel": "İptal",
+ "label.change-password": "Şifre değiştir",
+ "label.channels": "Kanallar",
+ "label.cities": "Şehirler",
+ "label.city": "Şehir",
+ "label.clear-all": "Hepsini temizle",
+ "label.cohort": "Kohort",
+ "label.compare": "Karşılaştır",
+ "label.compare-dates": "Tarihleri karşılaştır",
+ "label.confirm": "Onayla",
+ "label.confirm-password": "Parolayı onayla",
+ "label.contains": "İçeriği",
+ "label.content": "İçerik",
+ "label.continue": "Devam et",
+ "label.conversion": "Dönüşüm",
+ "label.conversion-rate": "Dönüşüm oranı",
+ "label.conversion-step": "Dönüşüm adımı",
+ "label.count": "Adet",
+ "label.countries": "Ülkeler",
+ "label.country": "Ülke",
+ "label.create": "Oluştur",
+ "label.create-report": "Rapor oluştur",
+ "label.create-team": "Takım oluştur",
+ "label.create-user": "Kullanıcı oluştur",
+ "label.created": "Oluşturuldu",
+ "label.created-by": "Tarafından oluşturldu",
+ "label.currency": "Para birimi",
+ "label.current": "Mevcut",
+ "label.current-password": "Mevcut parola",
+ "label.custom-range": "Özelleştirilmiş aralık",
+ "label.dashboard": "Kontrol Paneli",
+ "label.data": "Veri",
+ "label.date": "Tarih",
+ "label.date-range": "Tarih aralığı",
+ "label.day": "Gün",
+ "label.default-date-range": "Varsayılan tarih aralığı",
+ "label.delete": "Sil",
+ "label.delete-report": "Rapor sil",
+ "label.delete-team": "Takım sil",
+ "label.delete-user": "Kullanıcı sil",
+ "label.delete-website": "Web sitesini sil",
+ "label.description": "Açıklama",
+ "label.desktop": "Masaüstü",
+ "label.details": "Detaylar",
+ "label.device": "Cihaz",
+ "label.devices": "Cihazlar",
+ "label.direct": "Doğrudan",
+ "label.dismiss": "Reddet",
+ "label.distinct-id": "Benzersiz ID",
+ "label.does-not-contain": "İçermez",
+ "label.does-not-include": "İçermiyor",
+ "label.doest-not-exist": "Mevcut değil",
+ "label.domain": "Alan adı",
+ "label.dropoff": "Bırakma",
+ "label.edit": "Düzenle",
+ "label.edit-dashboard": "Kontrol panelini düzenle",
+ "label.edit-member": "Üyeyi düzenle",
+ "label.email": "Email",
+ "label.enable-share-url": "Anonim paylaşım URL'i aktif",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Olay",
+ "label.event-data": "Olay verisi",
+ "label.event-name": "Olay adı",
+ "label.events": "Olaylar",
+ "label.exists": "Mevcut",
+ "label.exit": "Exit URL",
+ "label.false": "Yanlış",
+ "label.field": "Alan",
+ "label.fields": "Alanlar",
+ "label.filter": "Filtre",
+ "label.filter-combined": "Birleşik filtre",
+ "label.filter-raw": "Ham filtre",
+ "label.filters": "Filtreler",
+ "label.first-click": "İlk tıklama",
+ "label.first-seen": "First seen",
+ "label.funnel": "Huni",
+ "label.funnel-description": "Kullanıcıların dönüşüm ve ayrılma oranlarını anlayın.",
+ "label.funnels": "Huniler",
+ "label.goal": "Hedef",
+ "label.goals": "Hedefler",
+ "label.goals-description": "Sayfa görüntüleme ve olaylar için hedeflerinizi takip edin.",
+ "label.greater-than": "Büyüktür",
+ "label.greater-than-equals": "Büyük veya eşittir",
+ "label.grouped": "Gruplandırılmış",
+ "label.hostname": "Sunucu adı",
+ "label.includes": "İçerir",
+ "label.insight": "İçgörü",
+ "label.insights": "Insights",
+ "label.insights-description": "Segmentleri ve filtreleri kullanarak verilerinizi derinlemesine inceleyin.",
+ "label.is": "Is",
+ "label.is-false": "Yanlış",
+ "label.is-not": "Değil",
+ "label.is-not-set": "Ayarlanmamış",
+ "label.is-set": "Ayarlandı",
+ "label.is-true": "Doğru",
+ "label.join": "Katıl",
+ "label.join-team": "Takıma katıl",
+ "label.journey": "Yolculuk",
+ "label.journey-description": "Kullanıcıların sitenizde nasıl gezindiğini anlayın.",
+ "label.journeys": "Yolculuklar",
+ "label.language": "Dil",
+ "label.languages": "Diller",
+ "label.laptop": "Dizüstü",
+ "label.last-click": "Son tıklama",
+ "label.last-days": "Son {x} gün",
+ "label.last-hours": "Son {x} saat",
+ "label.last-months": "Son {x} ay",
+ "label.last-seen": "Son görüldü",
+ "label.leave": "Ayrıl",
+ "label.leave-team": "Takımdan Ayrıl",
+ "label.less-than": "Küçüktür",
+ "label.less-than-equals": "Küçük veya eşittir",
+ "label.links": "Bağlantılar",
+ "label.login": "Giriş Yap",
+ "label.logout": "Çıkış Yap",
+ "label.manage": "Yönet",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Genişlet",
+ "label.medium": "Orta",
+ "label.member": "Üye",
+ "label.members": "Üyeler",
+ "label.min": "Min",
+ "label.mobile": "Mobil Cihaz",
+ "label.model": "Model",
+ "label.more": "Detaylı göster",
+ "label.my-account": "Hesabım",
+ "label.my-websites": "Web sitelerim",
+ "label.name": "İsim",
+ "label.new-password": "Yeni parola",
+ "label.none": "Yok",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "TAMAM",
+ "label.online": "Online",
+ "label.organic-search": "Organik arama",
+ "label.organic-shopping": "Organik alışveriş",
+ "label.organic-social": "Organik sosyal",
+ "label.organic-video": "Organik video",
+ "label.os": "OS",
+ "label.other": "Diğer",
+ "label.overview": "Genel bakış",
+ "label.owner": "Sahibi",
+ "label.page": "Sayfa",
+ "label.page-of": "{total} sayfada {current} ",
+ "label.page-views": "Sayfa görünümü",
+ "label.pageTitle": "Sayfa başlığı",
+ "label.pages": "Sayfalar",
+ "label.paid-ads": "Ücretli reklamlar",
+ "label.paid-search": "Ücretli arama",
+ "label.paid-shopping": "Ücretli alışveriş",
+ "label.paid-social": "Ücretli sosyal",
+ "label.paid-video": "Ücretli video",
+ "label.password": "Parola",
+ "label.path": "Yol",
+ "label.paths": "Yollar",
+ "label.pixels": "Pikseller",
+ "label.powered-by": "Sağlayıcı: {name}",
+ "label.previous": "Önceki",
+ "label.previous-period": "Önceki dönem",
+ "label.previous-year": "Önceki yıl",
+ "label.profile": "Profil",
+ "label.properties": "Özellikler",
+ "label.property": "Özellik",
+ "label.queries": "Sorgular",
+ "label.query": "Sorgu",
+ "label.query-parameters": "Sorgu parametreleri",
+ "label.realtime": "Gerçek Zamanlı",
+ "label.referral": "Yönlendirme",
+ "label.referrer": "Referrer",
+ "label.referrers": "Yönlendirenler",
+ "label.refresh": "Yenile",
+ "label.regenerate": "Yeniden Oluştur",
+ "label.region": "Bölge",
+ "label.regions": "Bölgeler",
+ "label.remaining": "Kalan",
+ "label.remove": "Kaldır",
+ "label.remove-member": "Üyeyi kaldır",
+ "label.reports": "Raporlar",
+ "label.required": "Zorunlu alan",
+ "label.reset": "Sıfırla",
+ "label.reset-website": "İstatistikleri sıfırla",
+ "label.retention": "Geri dönüş",
+ "label.retention-description": "Kullanıcıların ne sıklıkla geri döndüğünü takip ederek web sitenizin kalıcılığını ölçün.",
+ "label.revenue": "Gelir",
+ "label.revenue-description": "Gelirinizi zaman içinde inceleyin.",
+ "label.role": "Rol",
+ "label.run-query": "Sorgu çalıştır",
+ "label.save": "Kaydet",
+ "label.screens": "Ekranlar",
+ "label.search": "Ara",
+ "label.select": "Seç",
+ "label.select-date": "Tarih seç",
+ "label.select-filter": "Filtre seç",
+ "label.select-role": "Rol seç",
+ "label.select-website": "Web sitesi seç",
+ "label.session": "Oturum",
+ "label.session-data": "Oturum verisi",
+ "label.sessions": "Sessions",
+ "label.settings": "Ayarlar",
+ "label.share": "Paylaş",
+ "label.share-url": "Paylaşım adresi",
+ "label.single-day": "Tekil gün",
+ "label.sms": "SMS",
+ "label.sources": "Kaynaklar",
+ "label.start-step": "Start Step",
+ "label.steps": "Adımlar",
+ "label.sum": "Toplam",
+ "label.tablet": "Tablet",
+ "label.tag": "Etiket",
+ "label.tags": "Etiketler",
+ "label.team": "Takım",
+ "label.team-id": "Takım ID",
+ "label.team-manager": "Takım yöneticisi",
+ "label.team-member": "Takım üyesi",
+ "label.team-name": "Takım ismi",
+ "label.team-owner": "Takım sahibi",
+ "label.team-settings": "Takım ayarları",
+ "label.team-view-only": "Yalnızca ekip görünümü",
+ "label.team-websites": "Takım web siteleri",
+ "label.teams": "Takımlar",
+ "label.terms": "Koşullar",
+ "label.theme": "Tema",
+ "label.this-month": "Bu ay",
+ "label.this-week": "Bu hafta",
+ "label.this-year": "Bu yıl",
+ "label.timezone": "Zaman dilimi",
+ "label.title": "Başlık",
+ "label.today": "Bugün",
+ "label.toggle-charts": "Grafikleri değiştir",
+ "label.total": "Toplam",
+ "label.total-records": "Toplam kayıt",
+ "label.tracking-code": "İzleme kodu",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer web sitesi",
+ "label.true": "Doğru",
+ "label.type": "Tip",
+ "label.unique": "Benzersiz",
+ "label.unique-visitors": "Tekil kullanıcı",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Bilinmeyen",
+ "label.untitled": "İsimsiz",
+ "label.update": "Güncelle",
+ "label.user": "Kullanıcı",
+ "label.username": "Kullanıcı adı",
+ "label.users": "Kullanıcılar",
+ "label.utm": "UTM",
+ "label.utm-description": "Kampanyalarınızı UTM parametreleri aracılığıyla takip edin.",
+ "label.value": "Değer",
+ "label.view": "Görünüm",
+ "label.view-details": "Detayı incele",
+ "label.view-only": "Sadece görünüm",
+ "label.views": "Görüntüleme",
+ "label.views-per-visit": "Ziyaret başına görüntüleme",
+ "label.visit-duration": "Ortalama ziyaret süresi",
+ "label.visitors": "Ziyaretçi",
+ "label.visits": "Ziyaretler",
+ "label.website": "Web sitesi",
+ "label.website-id": "Website ID",
+ "label.websites": "Web siteleri",
+ "label.window": "Pencere",
+ "label.yesterday": "Dün",
+ "message.action-confirmation": "Onaylamak için aşağıdaki kutuya {confirmation} yazın.",
+ "message.active-users": "{x} aktif ziyaretçi",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "{target} kaydını silmek istediğinizden emin misiniz?",
+ "message.confirm-leave": "{target} kaydından ayrılmak istediğinizden emin misiniz?",
+ "message.confirm-remove": "{target} kaydını kaldırmak istediğinizden emin misiniz?",
+ "message.confirm-reset": "{target} istatistiklerini sıfırlamak istediğinizden emin misiniz?",
+ "message.delete-team-warning": "Bir takımı silmek tüm takım web sitelerini de silecektir.",
+ "message.delete-website-warning": "İlişkili tüm veriler de silinecektir.",
+ "message.error": "Bir şeyler ters gitti!",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Ayarlara git",
+ "message.incorrect-username-password": "Hatalı kullanıcı adı ya da parola.",
+ "message.invalid-domain": "Geçersiz alan adı",
+ "message.min-password-length": "Minimum {n} karakter uzunluğu",
+ "message.new-version-available": "Yeni versiyon Umami {version} mevcut!",
+ "message.no-data-available": "Henüz hiç veri yok.",
+ "message.no-event-data": "Hiçbir olay verisi mevcut değil.",
+ "message.no-match-password": "Parolalar uyuşmuyor",
+ "message.no-results-found": "Hiçbir sonuç bulunamadı.",
+ "message.no-team-websites": "Bu takımın herhangi bir web sitesi yok.",
+ "message.no-teams": "Herhangi bir takım oluşturmadınız.",
+ "message.no-users": "Kullanıcı yok.",
+ "message.no-websites-configured": "Henüz hiç web sitesi tanımlamadınız",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Sayfa bulunamadı.",
+ "message.reset-website": "Bu websitesini sıfılamak için aşağıdaki kutuya {confirmation} yazın.",
+ "message.reset-website-warning": "Bu web sitesi için tüm istatistikler silinecek, ancak izleme kodunuz bozulmadan kalacaktır.",
+ "message.saved": "Başarıyla kaydedildi.",
+ "message.sever-error": "Server error",
+ "message.share-url": "{target} için kullanılabilir anonim paylaşım adresidir.",
+ "message.team-already-member": "Zaten bu takımın üyesisiniz",
+ "message.team-not-found": "Takım bulunamadı",
+ "message.team-websites-info": "Web siteleri takımdaki herkes tarafından görüntülenebilir.",
+ "message.tracking-code": "İzleme kodu",
+ "message.transfer-team-website-to-user": "Bu web sitesi hesbınıza aktarılsın mı?",
+ "message.transfer-user-website-to-team": "Bu web sitesinin aktarılacağı takımı seçin.",
+ "message.transfer-website": "Web sitesi sahipliğini hesabınıza veya başka bir takıma aktarın",
+ "message.triggered-event": "Tetiklenen olay",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Kullanıcı silindi.",
+ "message.viewed-page": "Görüntülenen sayfa",
+ "message.visitor-log": "Yeni ziyaretçi: {country}, {os}, {device}, {browser}"
+}
diff --git a/src/lang/uk-UA.json b/src/lang/uk-UA.json
new file mode 100644
index 0000000..768015b
--- /dev/null
+++ b/src/lang/uk-UA.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Код доступу",
+ "label.actions": "Дії",
+ "label.activity": "Журнал",
+ "label.add": "Додати",
+ "label.add-board": "Додати дошку",
+ "label.add-description": "Додати опис",
+ "label.add-member": "Додати учасника",
+ "label.add-step": "Додати крок",
+ "label.add-website": "Додати сайт",
+ "label.admin": "Адміністратор",
+ "label.affiliate": "Партнер",
+ "label.after": "Після",
+ "label.all": "Всі",
+ "label.all-time": "Весь час",
+ "label.analytics": "Аналітика",
+ "label.apply": "Застосувати",
+ "label.attribution": "Атрибуція",
+ "label.attribution-description": "Дивіться, як користувачі взаємодіють з вашим маркетингом і що сприяє конверсіям.",
+ "label.average": "Середній",
+ "label.back": "Назад",
+ "label.before": "До",
+ "label.behavior": "Поведінка",
+ "label.boards": "Дошки",
+ "label.bounce-rate": "Показник відмов",
+ "label.breakdown": "Розподіл",
+ "label.browser": "Браузер",
+ "label.browsers": "Браузери",
+ "label.campaigns": "Кампанії",
+ "label.cancel": "Відмінити",
+ "label.change-password": "Змінити пароль",
+ "label.channels": "Канали",
+ "label.cities": "Міста",
+ "label.city": "Місто",
+ "label.clear-all": "Очистити все",
+ "label.cohort": "Когорта",
+ "label.compare": "Порівняти",
+ "label.compare-dates": "Порівняти дати",
+ "label.confirm": "Підтвердити",
+ "label.confirm-password": "Підтвердити пароль",
+ "label.contains": "Містить",
+ "label.content": "Вміст",
+ "label.continue": "Продовжити",
+ "label.conversion": "Конверсія",
+ "label.conversion-rate": "Рівень конверсії",
+ "label.conversion-step": "Крок конверсії",
+ "label.count": "Кількість",
+ "label.countries": "Країни",
+ "label.country": "Країна",
+ "label.create": "Створити",
+ "label.create-report": "Створити звіт",
+ "label.create-team": "Створити команду",
+ "label.create-user": "Створити користувача",
+ "label.created": "Створено",
+ "label.created-by": "Створено",
+ "label.currency": "Валюта",
+ "label.current": "Поточний",
+ "label.current-password": "Поточний пароль",
+ "label.custom-range": "Довільний період",
+ "label.dashboard": "Інформаційна панель",
+ "label.data": "Дані",
+ "label.date": "Дата",
+ "label.date-range": "Діапазон дат",
+ "label.day": "День",
+ "label.default-date-range": "Діапазон дат за замовчуванням",
+ "label.delete": "Видалити",
+ "label.delete-report": "Видалити звіт",
+ "label.delete-team": "Видалити команду",
+ "label.delete-user": "Видалити користувача",
+ "label.delete-website": "Видалити сайт",
+ "label.description": "Опис",
+ "label.desktop": "Настільний ПК",
+ "label.details": "Деталі",
+ "label.device": "Пристрій",
+ "label.devices": "Пристрої",
+ "label.direct": "Прямий",
+ "label.dismiss": "Відхилити",
+ "label.distinct-id": "Унікальний ID",
+ "label.does-not-contain": "Не містить",
+ "label.does-not-include": "Не включає",
+ "label.doest-not-exist": "Не існує",
+ "label.domain": "Домен",
+ "label.dropoff": "Відсів",
+ "label.edit": "Редагувати",
+ "label.edit-dashboard": "Редагувати панель",
+ "label.edit-member": "Редагувати учасника",
+ "label.email": "Email",
+ "label.enable-share-url": "Увімкнути спільне посилання",
+ "label.end-step": "Кінцевий крок",
+ "label.entry": "Вхідний URL",
+ "label.event": "Подія",
+ "label.event-data": "Дані події",
+ "label.event-name": "Назва події",
+ "label.events": "Події",
+ "label.exists": "Існує",
+ "label.exit": "Exit URL",
+ "label.false": "False",
+ "label.field": "Поле",
+ "label.fields": "Поля",
+ "label.filter": "Фільтр",
+ "label.filter-combined": "Об'єднані",
+ "label.filter-raw": "Сирі дані",
+ "label.filters": "Фільтри",
+ "label.first-click": "Перший клік",
+ "label.first-seen": "First seen",
+ "label.funnel": "Воронка",
+ "label.funnel-description": "Зрозуміти рівень конверсії та відсіву користувачів.",
+ "label.funnels": "Воронки",
+ "label.goal": "Мета",
+ "label.goals": "Мети",
+ "label.goals-description": "Відстежуйте свої цілі для переглядів сторінок і подій.",
+ "label.greater-than": "Більше ніж",
+ "label.greater-than-equals": "Більше або рівно",
+ "label.grouped": "Груповано",
+ "label.hostname": "Ім'я хоста",
+ "label.includes": "Включає",
+ "label.insight": "Інсайт",
+ "label.insights": "Інсайти",
+ "label.insights-description": "Зануртеся глибше у свої дані за допомогою сегментів та фільтрів.",
+ "label.is": "Є",
+ "label.is-false": "Хибно",
+ "label.is-not": "Не є",
+ "label.is-not-set": "Не встановлено",
+ "label.is-set": "Встановлено",
+ "label.is-true": "Правдиво",
+ "label.join": "Приєднатись",
+ "label.join-team": "Приєднатись до команди",
+ "label.journey": "Шлях",
+ "label.journey-description": "Зрозумійте, як користувачі переміщаються вашим сайтом.",
+ "label.journeys": "Шляхи",
+ "label.language": "Мова",
+ "label.languages": "Мови",
+ "label.laptop": "Ноутбук",
+ "label.last-click": "Останній клік",
+ "label.last-days": "Останні {x} днів",
+ "label.last-hours": "Останні {x} годин",
+ "label.last-months": "Останні {x} місяців",
+ "label.last-seen": "Останній перегляд",
+ "label.leave": "Покинути",
+ "label.leave-team": "Покинути команду",
+ "label.less-than": "Менше ніж",
+ "label.less-than-equals": "Менше або дорівнює",
+ "label.links": "Посилання",
+ "label.login": "Увійти",
+ "label.logout": "Вийти",
+ "label.manage": "Керувати",
+ "label.manager": "Manager",
+ "label.max": "Макс.",
+ "label.maximize": "Розгорнути",
+ "label.medium": "Середній",
+ "label.member": "Учасник",
+ "label.members": "Учасники",
+ "label.min": "Мін.",
+ "label.mobile": "Мобільний",
+ "label.model": "Модель",
+ "label.more": "Більше",
+ "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.online": "Online",
+ "label.organic-search": "Органічний пошук",
+ "label.organic-shopping": "Органічні покупки",
+ "label.organic-social": "Органічні соцмережі",
+ "label.organic-video": "Органічне відео",
+ "label.os": "ОС",
+ "label.other": "Інше",
+ "label.overview": "Огляд",
+ "label.owner": "Власник",
+ "label.page": "Сторінка",
+ "label.page-of": "Сторінка {current} з {total}",
+ "label.page-views": "Перегляди сторінок",
+ "label.pageTitle": "Заголовок сторінки",
+ "label.pages": "Сторінки",
+ "label.paid-ads": "Платна реклама",
+ "label.paid-search": "Платний пошук",
+ "label.paid-shopping": "Платні покупки",
+ "label.paid-social": "Платні соцмережі",
+ "label.paid-video": "Платне відео",
+ "label.password": "Пароль",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Пікселі",
+ "label.powered-by": "На базі {name}",
+ "label.previous": "Попередній",
+ "label.previous-period": "Попередній період",
+ "label.previous-year": "Попередній рік",
+ "label.profile": "Профіль",
+ "label.properties": "Властивості",
+ "label.property": "Властивість",
+ "label.queries": "Запити",
+ "label.query": "Запит",
+ "label.query-parameters": "Параметри запиту",
+ "label.realtime": "У реальному часі",
+ "label.referral": "Реферал",
+ "label.referrer": "Джерело",
+ "label.referrers": "Джерела",
+ "label.refresh": "Оновити",
+ "label.regenerate": "Згенерувати знову",
+ "label.region": "Регіон",
+ "label.regions": "Регіони",
+ "label.remaining": "Залишилось",
+ "label.remove": "Видалити",
+ "label.remove-member": "Видалити користувача",
+ "label.reports": "Звіти",
+ "label.required": "Обов'язкове",
+ "label.reset": "Скинути",
+ "label.reset-website": "Скинути статистику сайту",
+ "label.retention": "Липкість",
+ "label.retention-description": "Виміряйте липкість вашого сайту, відстежуючи, як часто користувачі повертаються на нього.",
+ "label.revenue": "Дохід",
+ "label.revenue-description": "Перегляньте свій дохід за певний період.",
+ "label.role": "Роль",
+ "label.run-query": "Виконати запит",
+ "label.save": "Зберегти",
+ "label.screens": "Екрани",
+ "label.search": "Пошук",
+ "label.select": "Вибрати",
+ "label.select-date": "Вибрати дату",
+ "label.select-filter": "Вибрати фільтр",
+ "label.select-role": "Вибрати роль",
+ "label.select-website": "Вибрати сайт",
+ "label.session": "Сесія",
+ "label.session-data": "Дані сесії",
+ "label.sessions": "Сесії",
+ "label.settings": "Налаштування",
+ "label.share": "Поділитися",
+ "label.share-url": "Поділитися посилання",
+ "label.single-day": "Один день",
+ "label.sms": "SMS",
+ "label.sources": "Джерела",
+ "label.start-step": "Start Step",
+ "label.steps": "Кроки",
+ "label.sum": "Сума",
+ "label.tablet": "Планшет",
+ "label.tag": "Тег",
+ "label.tags": "Теги",
+ "label.team": "Команда",
+ "label.team-id": "Ідентифікатор команди",
+ "label.team-manager": "Менеджер команди",
+ "label.team-member": "Учасник команди",
+ "label.team-name": "Назва команди",
+ "label.team-owner": "Власник команди",
+ "label.team-settings": "Налаштування команди",
+ "label.team-view-only": "Тільки для командного перегляду",
+ "label.team-websites": "Сайти команди",
+ "label.teams": "Команди",
+ "label.terms": "Умови",
+ "label.theme": "Тема",
+ "label.this-month": "Цього місяця",
+ "label.this-week": "Цього тижня",
+ "label.this-year": "Цього ріку",
+ "label.timezone": "Часовий пояс",
+ "label.title": "Заголовок",
+ "label.today": "Сьогодні",
+ "label.toggle-charts": "Переключити графіки",
+ "label.total": "Всього",
+ "label.total-records": "Всього записів",
+ "label.tracking-code": "Код для відслідковування",
+ "label.transactions": "Transactions",
+ "label.transfer": "Передати",
+ "label.transfer-website": "Передати сайт",
+ "label.true": "True",
+ "label.type": "Тип",
+ "label.unique": "Унікальний",
+ "label.unique-visitors": "Унікальні відвідувачі",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "Невідомо",
+ "label.untitled": "Без заголовку",
+ "label.update": "Оновлення",
+ "label.user": "Користувач",
+ "label.username": "Ім'я користувача",
+ "label.users": "Користувачі",
+ "label.utm": "UTM",
+ "label.utm-description": "Відстежуйте свої кампанії за допомогою параметрів UTM.",
+ "label.value": "Значення",
+ "label.view": "Перегляд",
+ "label.view-details": "Переглянути деталі",
+ "label.view-only": "Тільки для перегляду",
+ "label.views": "Перегляди",
+ "label.views-per-visit": "Перегляди за одне відвідування",
+ "label.visit-duration": "Visit duration",
+ "label.visitors": "Відвідувачі",
+ "label.visits": "Відвідування",
+ "label.website": "Сайт",
+ "label.website-id": "Ідентифікатор сайту",
+ "label.websites": "Сайти",
+ "label.window": "Вікно",
+ "label.yesterday": "Вчора",
+ "message.action-confirmation": "Введіть {confirmation} у полі нижче, щоб підтвердити.",
+ "message.active-users": "{x} поточних відвідувачів",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "Ви впевнені, що бажаєте видалити {target}?",
+ "message.confirm-leave": "Ви впевнені, що бажаєте покинути {target}?",
+ "message.confirm-remove": "Ви впевнені, що бажаєте видалити {target}?",
+ "message.confirm-reset": "Ви впевнені, що бажаєте скинути статистику для {target}?",
+ "message.delete-team-warning": "Видалення команди також призведе до видалення всіх її веб-сайтів.",
+ "message.delete-website-warning": "Усі пов'язані дані будуть видалені також.",
+ "message.error": "Щось пішло не так.",
+ "message.event-log": "{event} на {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "Перейти до налаштувань",
+ "message.incorrect-username-password": "Невірне ім'я користувача або пароль.",
+ "message.invalid-domain": "Некоректний домен",
+ "message.min-password-length": "Мінімальна довжина {n} символів",
+ "message.new-version-available": "Вийшла нова версія Umami {version}!",
+ "message.no-data-available": "Немає даних.",
+ "message.no-event-data": "Дані про події відсутні.",
+ "message.no-match-password": "Паролі не співпадають",
+ "message.no-results-found": "Не знайдено жодного результату.",
+ "message.no-team-websites": "У цієї команди немає жодного веб-сайту.",
+ "message.no-teams": "Ви не створили жодної команди.",
+ "message.no-users": "Немає жодного користувача.",
+ "message.no-websites-configured": "У вас немає налаштованих сайтів.",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "Сторінку не знайдено.",
+ "message.reset-website": "Щоб скинути налаштування цього веб-сайту, введіть {confirmation} у полі нижче для підтвердження.",
+ "message.reset-website-warning": "Вся статистика для цього сайту буде видалена, проте код відслідковування буде продовжувати працювати.",
+ "message.saved": "Збережено успішно.",
+ "message.sever-error": "Server error",
+ "message.share-url": "Це публічне посилання для {target}.",
+ "message.team-already-member": "Ви вже є членом команди.",
+ "message.team-not-found": "Команду не знайдено.",
+ "message.team-websites-info": "Веб-сайти може переглядати будь-хто з команди.",
+ "message.tracking-code": "Код для відслідковування",
+ "message.transfer-team-website-to-user": "Перенести цей сайт до свого облікового запису?",
+ "message.transfer-user-website-to-team": "Виберіть команду, до якої ви хочете передати цей веб-сайт.",
+ "message.transfer-website": "Передайте право власності на сайт своєму акаунту або іншій команді.",
+ "message.triggered-event": "Подія, що спрацювала",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "Користувача видалено.",
+ "message.viewed-page": "Переглянута сторінка",
+ "message.visitor-log": "Відвідувач з {country} використовуючи {browser} на {os} {device}"
+}
diff --git a/src/lang/ur-PK.json b/src/lang/ur-PK.json
new file mode 100644
index 0000000..5cc3121
--- /dev/null
+++ b/src/lang/ur-PK.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "Access code",
+ "label.actions": "اعمال",
+ "label.activity": "Activity log",
+ "label.add": "Add",
+ "label.add-board": "Add board",
+ "label.add-description": "Add description",
+ "label.add-member": "Add member",
+ "label.add-step": "Add step",
+ "label.add-website": "ویب سائٹ کا اضافہ کریں",
+ "label.admin": "منتظم",
+ "label.affiliate": "Affiliate",
+ "label.after": "After",
+ "label.all": "تمام",
+ "label.all-time": "تمام وقت",
+ "label.analytics": "Analytics",
+ "label.apply": "Apply",
+ "label.attribution": "Attribution",
+ "label.attribution-description": "See how users engage with your marketing and what drives conversions.",
+ "label.average": "Average",
+ "label.back": "پیچھے",
+ "label.before": "Before",
+ "label.behavior": "رویے",
+ "label.boards": "Boards",
+ "label.bounce-rate": "اچھال کی شرح",
+ "label.breakdown": "Breakdown",
+ "label.browser": "Browser",
+ "label.browsers": "براؤزرز",
+ "label.campaigns": "Campaigns",
+ "label.cancel": "منسوخ",
+ "label.change-password": "پاس ورڈ تبدیل کریں",
+ "label.channels": "Channels",
+ "label.cities": "Cities",
+ "label.city": "City",
+ "label.clear-all": "Clear all",
+ "label.cohort": "Cohort",
+ "label.compare": "Compare",
+ "label.compare-dates": "Compare dates",
+ "label.confirm": "Confirm",
+ "label.confirm-password": "پاس ورڈ کی تصدیق کریں",
+ "label.contains": "Contains",
+ "label.content": "Content",
+ "label.continue": "Continue",
+ "label.conversion": "Conversion",
+ "label.conversion-rate": "Conversion rate",
+ "label.conversion-step": "Conversion step",
+ "label.count": "Count",
+ "label.countries": "ممالک",
+ "label.country": "Country",
+ "label.create": "Create",
+ "label.create-report": "Create report",
+ "label.create-team": "Create team",
+ "label.create-user": "Create user",
+ "label.created": "Created",
+ "label.created-by": "Created By",
+ "label.currency": "Currency",
+ "label.current": "Current",
+ "label.current-password": "موجودہ پاس ورڈ",
+ "label.custom-range": "اپنی مرضی کی حد",
+ "label.dashboard": "ڈیش بورڈ",
+ "label.data": "Data",
+ "label.date": "Date",
+ "label.date-range": "تاریخ کی حد",
+ "label.day": "Day",
+ "label.default-date-range": "پہلے سے طے شدہ تاریخ کی حد",
+ "label.delete": "حذف کریں",
+ "label.delete-report": "Delete report",
+ "label.delete-team": "Delete team",
+ "label.delete-user": "Delete user",
+ "label.delete-website": "ویب سائٹ مٹایں",
+ "label.description": "Description",
+ "label.desktop": "ڈیسک ٹاپ",
+ "label.details": "Details",
+ "label.device": "Device",
+ "label.devices": "آلات",
+ "label.direct": "Direct",
+ "label.dismiss": "مسترد کریں",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "Does not contain",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "ڈومین",
+ "label.dropoff": "Dropoff",
+ "label.edit": "ترمیم",
+ "label.edit-dashboard": "Edit dashboard",
+ "label.edit-member": "Edit member",
+ "label.email": "Email",
+ "label.enable-share-url": "شیئر یو آر ایل کو فعال کریں",
+ "label.end-step": "End Step",
+ "label.entry": "Entry URL",
+ "label.event": "Event",
+ "label.event-data": "Event data",
+ "label.event-name": "Event name",
+ "label.events": "واقعات",
+ "label.exists": "Exists",
+ "label.exit": "Exit URL",
+ "label.false": "False",
+ "label.field": "Field",
+ "label.fields": "Fields",
+ "label.filter": "Filter",
+ "label.filter-combined": "مشترکہ",
+ "label.filter-raw": "خام",
+ "label.filters": "Filters",
+ "label.first-click": "First click",
+ "label.first-seen": "First seen",
+ "label.funnel": "Funnel",
+ "label.funnel-description": "Understand the conversion and drop-off rate of users.",
+ "label.funnels": "Funnels",
+ "label.goal": "Goal",
+ "label.goals": "Goals",
+ "label.goals-description": "Track your goals for pageviews and events.",
+ "label.greater-than": "Greater than",
+ "label.greater-than-equals": "Greater than or equals",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "Insights",
+ "label.insights-description": "Dive deeper into your data by using segments and filters.",
+ "label.is": "Is",
+ "label.is-false": "Is false",
+ "label.is-not": "Is not",
+ "label.is-not-set": "Is not set",
+ "label.is-set": "Is set",
+ "label.is-true": "Is true",
+ "label.join": "Join",
+ "label.join-team": "Join team",
+ "label.journey": "Journey",
+ "label.journey-description": "Understand how users navigate through your website.",
+ "label.journeys": "Journeys",
+ "label.language": "Language",
+ "label.languages": "زبانیں",
+ "label.laptop": "لیپ ٹاپ",
+ "label.last-click": "Last click",
+ "label.last-days": "پچھلے {x} دن",
+ "label.last-hours": "پچھلے {x} گھنٹے",
+ "label.last-months": "Last {x} months",
+ "label.last-seen": "Last seen",
+ "label.leave": "Leave",
+ "label.leave-team": "Leave team",
+ "label.less-than": "Less than",
+ "label.less-than-equals": "Less than or equals",
+ "label.links": "Links",
+ "label.login": "لاگ ان",
+ "label.logout": "لاگ آوٹ",
+ "label.manage": "Manage",
+ "label.manager": "Manager",
+ "label.max": "Max",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "Member",
+ "label.members": "Members",
+ "label.min": "Min",
+ "label.mobile": "موبائل",
+ "label.model": "Model",
+ "label.more": "مزید",
+ "label.my-account": "My account",
+ "label.my-websites": "My websites",
+ "label.name": "نام",
+ "label.new-password": "نیا پاس ورڈ",
+ "label.none": "None",
+ "label.number-of-records": "{x} {x, plural, one {record} other {records}}",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "OS",
+ "label.other": "Other",
+ "label.overview": "Overview",
+ "label.owner": "مالک",
+ "label.page": "Page",
+ "label.page-of": "Page {current} of {total}",
+ "label.page-views": "صفحہ کے نظارے",
+ "label.pageTitle": "Page title",
+ "label.pages": "صفحات",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "پاس ورڈ",
+ "label.path": "Path",
+ "label.paths": "Paths",
+ "label.pixels": "Pixels",
+ "label.powered-by": "تقویت یافتہ بذریعہ {name}",
+ "label.previous": "Previous",
+ "label.previous-period": "Previous period",
+ "label.previous-year": "Previous year",
+ "label.profile": "پروفائل",
+ "label.properties": "Properties",
+ "label.property": "Property",
+ "label.queries": "Queries",
+ "label.query": "Query",
+ "label.query-parameters": "Query parameters",
+ "label.realtime": "براہ راست",
+ "label.referral": "Referral",
+ "label.referrer": "Referrer",
+ "label.referrers": "بھیجنے والے",
+ "label.refresh": "تازہ دم کریں",
+ "label.regenerate": "Regenerate",
+ "label.region": "Region",
+ "label.regions": "Regions",
+ "label.remaining": "Remaining",
+ "label.remove": "Remove",
+ "label.remove-member": "Remove member",
+ "label.reports": "Reports",
+ "label.required": "درکار ہے",
+ "label.reset": "دوبارہ ترتیب دیں",
+ "label.reset-website": "اعدادوشمار کو دوبارہ ترتیب دیں",
+ "label.retention": "Retention",
+ "label.retention-description": "Measure your website stickiness by tracking how often users return.",
+ "label.revenue": "Revenue",
+ "label.revenue-description": "Look into your revenue across time.",
+ "label.role": "Role",
+ "label.run-query": "Run query",
+ "label.save": "محفوظ کریں",
+ "label.screens": "Screens",
+ "label.search": "Search",
+ "label.select": "Select",
+ "label.select-date": "Select date",
+ "label.select-filter": "Select filter",
+ "label.select-role": "Select role",
+ "label.select-website": "Select website",
+ "label.session": "Session",
+ "label.session-data": "Session data",
+ "label.sessions": "Sessions",
+ "label.settings": "ترتیبات",
+ "label.share": "Share",
+ "label.share-url": "URL کا اشتراک کریں",
+ "label.single-day": "ایک دن",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "Start Step",
+ "label.steps": "Steps",
+ "label.sum": "Sum",
+ "label.tablet": "ٹیبلیٹ",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "Team",
+ "label.team-id": "Team ID",
+ "label.team-manager": "Team manager",
+ "label.team-member": "Team member",
+ "label.team-name": "Team name",
+ "label.team-owner": "Team owner",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "Team view only",
+ "label.team-websites": "Team websites",
+ "label.teams": "Teams",
+ "label.terms": "Terms",
+ "label.theme": "Theme",
+ "label.this-month": "اس مہینے",
+ "label.this-week": "اس ہفتے",
+ "label.this-year": "اس سال",
+ "label.timezone": "ٹائم زون",
+ "label.title": "Title",
+ "label.today": "آج",
+ "label.toggle-charts": "چارٹ تبدیل کریں",
+ "label.total": "Total",
+ "label.total-records": "Total records",
+ "label.tracking-code": "ٹریکنگ کوڈ",
+ "label.transactions": "Transactions",
+ "label.transfer": "Transfer",
+ "label.transfer-website": "Transfer website",
+ "label.true": "True",
+ "label.type": "Type",
+ "label.unique": "Unique",
+ "label.unique-visitors": "منفرد زائرین",
+ "label.uniqueCustomers": "Unique Customers",
+ "label.unknown": "نامعلوم",
+ "label.untitled": "Untitled",
+ "label.update": "Update",
+ "label.user": "User",
+ "label.username": "صارف نام",
+ "label.users": "Users",
+ "label.utm": "UTM",
+ "label.utm-description": "Track your campaigns through UTM parameters.",
+ "label.value": "Value",
+ "label.view": "View",
+ "label.view-details": "تفصیلات دیکھیں",
+ "label.view-only": "View only",
+ "label.views": "مناظر",
+ "label.views-per-visit": "Views per visit",
+ "label.visit-duration": "وزٹ کا اوسط وقت",
+ "label.visitors": "زائرین",
+ "label.visits": "Visits",
+ "label.website": "Website",
+ "label.website-id": "Website ID",
+ "label.websites": "ویب سائٹس",
+ "label.window": "Window",
+ "label.yesterday": "Yesterday",
+ "message.action-confirmation": "Type {confirmation} in the box below to confirm.",
+ "message.active-users": "{x} موجودہ {x, plural, one {زائر} other {زائرین}}",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "Collected data",
+ "message.confirm-delete": "کیا آپ واقعی {target} کو حذف کرنا چاہتے ہیں؟",
+ "message.confirm-leave": "Are you sure you want to 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.delete-website-warning": "تمام متعلقہ ڈیٹا بھی حذف کر دیا جائے گا۔",
+ "message.error": "کچھ غلط ہو گیا.",
+ "message.event-log": "{event} on {url}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "ترتیبات پر جائیں",
+ "message.incorrect-username-password": "غلط صارف نام/پاس ورڈ۔",
+ "message.invalid-domain": "غلط ڈومین",
+ "message.min-password-length": "Minimum length of {n} characters",
+ "message.new-version-available": "A new version of Umami {version} is available!",
+ "message.no-data-available": "مواد موجود نہیں ہے.",
+ "message.no-event-data": "No event data is available.",
+ "message.no-match-password": "پاس ورڈز مماثل نہیں ہیں",
+ "message.no-results-found": "No results were found.",
+ "message.no-team-websites": "This team does not have any websites.",
+ "message.no-teams": "You have not created any teams.",
+ "message.no-users": "There are no users.",
+ "message.no-websites-configured": "آپ کے پاس کوئی ویب سائٹ کنفیگر نہیں ہے۔",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "صفحہ نہیں ملا.",
+ "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
+ "message.reset-website-warning": "اس ویب سائٹ کے تمام اعدادوشمار کو حذف کر دیا جائے گا، لیکن آپ کا ٹریکنگ کوڈ برقرار رہے گا۔",
+ "message.saved": "کامیابی سے محفوظ ہو گیا۔",
+ "message.sever-error": "Server error",
+ "message.share-url": "یہ {target} کے لیے عوامی طور پر اشتراک کردہ URL ہے۔",
+ "message.team-already-member": "You are already a member of the team.",
+ "message.team-not-found": "Team not found.",
+ "message.team-websites-info": "Websites can be viewed by anyone on the team.",
+ "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.unauthorized": "Unauthorized",
+ "message.user-deleted": "User deleted.",
+ "message.viewed-page": "Viewed page",
+ "message.visitor-log": "{os} {device} پر {browser} کا استعمال کرتے ہوئے {country} سے آنے والا"
+}
diff --git a/src/lang/uz-UZ.json b/src/lang/uz-UZ.json
new file mode 100644
index 0000000..cf58945
--- /dev/null
+++ b/src/lang/uz-UZ.json
@@ -0,0 +1,280 @@
+{
+ "label.access-code": "Kirish kodi",
+ "label.actions": "Amallar",
+ "label.activity": "Faoliyat",
+ "label.add": "Qoʻshish",
+ "label.add-description": "Tavsif qoʻshish",
+ "label.add-member": "A'zo qoʻshish",
+ "label.add-step": "Qadam qoʻshish",
+ "label.add-website": "Veb-sayt qoʻshish",
+ "label.admin": "Administrator",
+ "label.after": "Keyin",
+ "label.all": "Barchasi",
+ "label.all-time": "Barcha vaqtlar",
+ "label.analytics": "Tahlil",
+ "label.average": "Oʻrtacha",
+ "label.back": "Orqaga",
+ "label.before": "Oldin",
+ "label.behavior": "Xulq-atvor",
+ "label.bounce-rate": "Chiqib ketish darajasi",
+ "label.breakdown": "Tahlil",
+ "label.browser": "Brauzer",
+ "label.browsers": "Brauzerlar",
+ "label.cancel": "Bekor qilish",
+ "label.change-password": "Parolni oʻzgartirish",
+ "label.cities": "Shaharlar",
+ "label.city": "Shahar",
+ "label.clear-all": "Barchasini tozalash",
+ "label.compare": "Taqqoslash",
+ "label.confirm": "Tasdiqlash",
+ "label.confirm-password": "Parolni tasdiqlash",
+ "label.contains": "Oʻz ichiga oladi",
+ "label.continue": "Davom etish",
+ "label.count": "Soni",
+ "label.countries": "Davlatlar",
+ "label.country": "Davlat",
+ "label.create": "Yaratish",
+ "label.create-report": "Hisobot yaratish",
+ "label.create-team": "Jamoa yaratish",
+ "label.create-user": "Foydalanuvchi yaratish",
+ "label.created": "Yaratilgan",
+ "label.created-by": "Kim tomonidan yaratilgan",
+ "label.current": "Joriy",
+ "label.current-password": "Joriy parol",
+ "label.custom-range": "Maxsus oraliq",
+ "label.dashboard": "Boshqaruv paneli",
+ "label.data": "Ma'lumotlar",
+ "label.date": "Sana",
+ "label.date-range": "Sana oraligʻi",
+ "label.day": "Kun",
+ "label.default-date-range": "Standart sana oraligʻi",
+ "label.delete": "Oʻchirish",
+ "label.delete-report": "Hisobotni oʻchirish",
+ "label.delete-team": "Jamoani oʻchirish",
+ "label.delete-user": "Foydalanuvchini oʻchirish",
+ "label.delete-website": "Veb-saytni oʻchirish",
+ "label.description": "Tavsif",
+ "label.desktop": "Ish stoli",
+ "label.details": "Batafsil ma'lumot",
+ "label.device": "Qurilma",
+ "label.devices": "Qurilmalar",
+ "label.dismiss": "Yopish",
+ "label.does-not-contain": "Oʻz ichiga olmaydi",
+ "label.domain": "Domen",
+ "label.dropoff": "Tashlab ketish",
+ "label.edit": "Tahrirlash",
+ "label.edit-dashboard": "Boshqaruv panelini tahrirlash",
+ "label.edit-member": "A'zoni tahrirlash",
+ "label.enable-share-url": "Ulashish URL'ini yoqish",
+ "label.end-step": "Yakuniy qadam",
+ "label.entry": "Kirish yoʻli",
+ "label.event": "Hodisa",
+ "label.event-data": "Hodisa ma'lumotlari",
+ "label.events": "Hodisalar",
+ "label.exit": "Chiqish yoʻli",
+ "label.false": "Yolgʻon",
+ "label.field": "Maydon",
+ "label.fields": "Maydonlar",
+ "label.filter": "Filtr",
+ "label.filter-combined": "Birlashtirilgan",
+ "label.filter-raw": "Xom",
+ "label.filters": "Filtrlar",
+ "label.first-seen": "Birinchi koʻrilgan",
+ "label.funnel": "Voronka",
+ "label.funnel-description": "Foydalanuvchilarning konversiya va tashlab ketish darajasini tushunish.",
+ "label.goal": "Maqsad",
+ "label.goals": "Maqsadlar",
+ "label.goals-description": "Sahifa koʻrishlari va hodisalar uchun maqsadlaringizni kuzatib boring.",
+ "label.greater-than": "Kattaroq",
+ "label.greater-than-equals": "Kattaroq yoki teng",
+ "label.host": "Xost",
+ "label.hosts": "Xostlar",
+ "label.insights": "Tushunchalar",
+ "label.insights-description": "Segmentlar va filtrlardan foydalanib ma'lumotlaringizga chuqurroq kiring.",
+ "label.is": "Teng",
+ "label.is-not": "Teng emas",
+ "label.is-not-set": "Oʻrnatilmagan",
+ "label.is-set": "Oʻrnatilgan",
+ "label.join": "Qoʻshilish",
+ "label.join-team": "Jamoaga qoʻshilish",
+ "label.journey": "Sayohat",
+ "label.journey-description": "Foydalanuvchilar veb-saytingizda qanday harakat qilishlarini tushunish.",
+ "label.language": "Til",
+ "label.languages": "Tillar",
+ "label.laptop": "Noutbuk",
+ "label.last-days": "Oxirgi {x} kun",
+ "label.last-hours": "Oxirgi {x} soat",
+ "label.last-months": "Oxirgi {x} oy",
+ "label.last-seen": "Oxirgi koʻrilgan",
+ "label.leave": "Tark etish",
+ "label.leave-team": "Jamoani tark etish",
+ "label.less-than": "Kichikroq",
+ "label.less-than-equals": "Kichikroq yoki teng",
+ "label.login": "Kirish",
+ "label.logout": "Chiqish",
+ "label.manage": "Boshqarish",
+ "label.manager": "Menejer",
+ "label.max": "Maksimal",
+ "label.member": "A'zo",
+ "label.members": "A'zolar",
+ "label.min": "Minimal",
+ "label.mobile": "Mobil",
+ "label.more": "Koʻproq",
+ "label.my-account": "Mening hisobim",
+ "label.my-websites": "Mening veb-saytlarim",
+ "label.name": "Ism",
+ "label.new-password": "Yangi parol",
+ "label.none": "Hech biri",
+ "label.number-of-records": "{x} yozuv",
+ "label.ok": "OK",
+ "label.os": "OT (Operatsion tizim)",
+ "label.overview": "Umumiy koʻrinish",
+ "label.owner": "Egasi",
+ "label.page-of": "Sahifa {current} dan {total}",
+ "label.page-views": "Sahifa koʻrishlari",
+ "label.pageTitle": "Sahifa sarlavhasi",
+ "label.pages": "Sahifalar",
+ "label.password": "Parol",
+ "label.path": "Yoʻl",
+ "label.paths": "Yoʻllar",
+ "label.powered-by": "{name} tomonidan quvvatlanadi",
+ "label.previous": "Oldingi",
+ "label.previous-period": "Oldingi davr",
+ "label.previous-year": "Oldingi yil",
+ "label.profile": "Profil",
+ "label.properties": "Xususiyatlar",
+ "label.property": "Xususiyat",
+ "label.queries": "Soʻrovlar",
+ "label.query": "Soʻrov",
+ "label.query-parameters": "Soʻrov parametrlari",
+ "label.realtime": "Haqiqiy vaqt",
+ "label.referrer": "Tavsiya etuvchi",
+ "label.referrers": "Tavsiya etuvchilar",
+ "label.refresh": "Yangilash",
+ "label.regenerate": "Qayta yaratish",
+ "label.region": "Viloyat/Mintaqa",
+ "label.regions": "Viloyatlar/Mintaqalar",
+ "label.remove": "Olib tashlash",
+ "label.remove-member": "A'zoni olib tashlash",
+ "label.reports": "Hisobotlar",
+ "label.required": "Majburiy",
+ "label.reset": "Qayta tiklash",
+ "label.reset-website": "Veb-saytni qayta tiklash",
+ "label.retention": "Saqlanish",
+ "label.retention-description": "Foydalanuvchilarning qaytish chastotasini kuzatib, veb-saytingizning jozibadorligini oʻlchang.",
+ "label.revenue": "Daromad",
+ "label.revenue-description": "Vaqt oʻtishi bilan daromadingizni tekshiring.",
+ "label.revenue-property": "Daromad xususiyati",
+ "label.role": "Rol",
+ "label.run-query": "Soʻrovni ishga tushirish",
+ "label.save": "Saqlash",
+ "label.screens": "Ekranlar",
+ "label.search": "Qidiruv",
+ "label.select": "Tanlash",
+ "label.select-date": "Sanani tanlash",
+ "label.select-role": "Rolni tanlash",
+ "label.select-website": "Veb-saytni tanlash",
+ "label.session": "Sessiya",
+ "label.sessions": "Sessiyalar",
+ "label.settings": "Sozlamalar",
+ "label.share-url": "Ulashish URL'i",
+ "label.single-day": "Bir kun",
+ "label.start-step": "Boshlanish qadami",
+ "label.steps": "Qadamlar",
+ "label.sum": "Yigʻindi",
+ "label.tablet": "Planshet",
+ "label.team": "Jamoa",
+ "label.team-id": "Jamoa ID'si",
+ "label.team-manager": "Jamoa menejeri",
+ "label.team-member": "Jamoa a'zosi",
+ "label.team-name": "Jamoa nomi",
+ "label.team-owner": "Jamoa egasi",
+ "label.team-view-only": "Jamoa faqat koʻrish",
+ "label.team-websites": "Jamoa veb-saytlari",
+ "label.teams": "Jamoalar",
+ "label.theme": "Mavzu",
+ "label.this-month": "Shu oy",
+ "label.this-week": "Shu hafta",
+ "label.this-year": "Shu yil",
+ "label.timezone": "Vaqt zonasi",
+ "label.title": "Sarlavha",
+ "label.today": "Bugun",
+ "label.toggle-charts": "Grafiklarni almashtirish",
+ "label.total": "Jami",
+ "label.total-records": "Jami yozuvlar",
+ "label.tracking-code": "Kuzatuv kodi",
+ "label.transactions": "Tranzaksiyalar",
+ "label.transfer": "Oʻtkazish",
+ "label.transfer-website": "Veb-saytni oʻtkazish",
+ "label.true": "Rost",
+ "label.type": "Tur",
+ "label.unique": "Noyob",
+ "label.unique-visitors": "Noyob tashrif buyuruvchilar",
+ "label.uniqueCustomers": "Noyob mijozlar",
+ "label.unknown": "Noma'lum",
+ "label.untitled": "Sarlavhasiz",
+ "label.update": "Yangilash",
+ "label.url": "URL",
+ "label.urls": "URL'lar",
+ "label.user": "Foydalanuvchi",
+ "label.user-property": "Foydalanuvchi xususiyati",
+ "label.username": "Foydalanuvchi nomi",
+ "label.users": "Foydalanuvchilar",
+ "label.utm": "UTM",
+ "label.utm-description": "UTM parametrlari orqali kampaniyalaringizni kuzatib boring.",
+ "label.value": "Qiymat",
+ "label.view": "Koʻrish",
+ "label.view-details": "Batafsil koʻrish",
+ "label.view-only": "Faqat koʻrish",
+ "label.views": "Koʻrishlar",
+ "label.views-per-visit": "Tashrifga koʻrishlar soni",
+ "label.visit-duration": "Tashrif davomiyligi",
+ "label.visitors": "Tashrif buyuruvchilar",
+ "label.visits": "Tashriflar",
+ "label.website": "Veb-sayt",
+ "label.website-id": "Veb-sayt ID'si",
+ "label.websites": "Veb-saytlar",
+ "label.window": "Oyna",
+ "label.yesterday": "Kecha",
+ "message.action-confirmation": "Tasdiqlash uchun pastdagi qutiga **{confirmation}** yozing.",
+ "message.active-users": "{x} joriy {x, plural, one {tashrif buyuruvchi} other {tashrif buyuruvchilar}}",
+ "message.collected-data": "Yigʻilgan ma'lumotlar",
+ "message.confirm-delete": "**{target}** ni oʻchirmoqchi ekanligingizga ishonchingiz komilmi?",
+ "message.confirm-leave": "**{target}** ni tark etmoqchi ekanligingizga ishonchingiz komilmi?",
+ "message.confirm-remove": "**{target}** ni olib tashlamoqchi ekanligingizga ishonchingiz komilmi?",
+ "message.confirm-reset": "**{target}** ni qayta tiklamoqchi ekanligingizga ishonchingiz komilmi?",
+ "message.delete-team-warning": "Jamoani oʻchirish, shuningdek, barcha jamoa veb-saytlarini ham oʻchiradi.",
+ "message.delete-website-warning": "Barcha veb-sayt ma'lumotlari oʻchiriladi.",
+ "message.error": "Nimadir xato ketdi.",
+ "message.event-log": "**{url}** da **{event}** hodisasi",
+ "message.go-to-settings": "Sozlamalarga oʻtish",
+ "message.incorrect-username-password": "Notoʻgʻri foydalanuvchi nomi va/yoki parol.",
+ "message.invalid-domain": "Notoʻgʻri domen. http/https qoʻshmang.",
+ "message.min-password-length": "Minimal uzunligi {n} belgidan",
+ "message.new-version-available": "Umami'ning yangi **{version}** versiyasi mavjud!",
+ "message.no-data-available": "Ma'lumotlar mavjud emas.",
+ "message.no-event-data": "Hodisa ma'lumotlari mavjud emas.",
+ "message.no-match-password": "Parollar mos kelmadi.",
+ "message.no-results-found": "Hech qanday natija topilmadi.",
+ "message.no-team-websites": "Bu jamoada hech qanday veb-sayt yoʻq.",
+ "message.no-teams": "Siz hech qanday jamoa yaratmagansiz.",
+ "message.no-users": "Hech qanday foydalanuvchi yoʻq.",
+ "message.no-websites-configured": "Sizda hech qanday veb-sayt sozlanmagan.",
+ "message.page-not-found": "Sahifa topilmadi",
+ "message.reset-website": "Bu veb-saytni qayta tiklash uchun tasdiqlash uchun pastdagi qutiga **{confirmation}** yozing.",
+ "message.reset-website-warning": "Bu veb-sayt uchun barcha statistik ma'lumotlar oʻchiriladi, lekin sozlamalaringiz saqlanib qoladi.",
+ "message.saved": "Saqlandi.",
+ "message.share-url": "Sizning veb-sayt statistikalaringiz quyidagi URL'da ochiqdir:",
+ "message.team-already-member": "Siz allaqachon jamoa a'zosisiz.",
+ "message.team-not-found": "Jamoa topilmadi.",
+ "message.team-websites-info": "Veb-saytlarni jamoaning har bir a'zosi koʻrishi mumkin.",
+ "message.tracking-code": "Bu veb-sayt uchun statistikani kuzatish uchun quyidagi kodni HTML'ingizdagi **<head>...</head>** qismiga joylashtiring.",
+ "message.transfer-team-website-to-user": "Bu veb-saytni oʻz hisobingizga oʻtkazasizmi?",
+ "message.transfer-user-website-to-team": "Bu veb-saytni oʻtkazish uchun jamoani tanlang.",
+ "message.transfer-website": "Veb-sayt egaligini oʻz hisobingizga yoki boshqa jamoaga oʻtkazish.",
+ "message.triggered-event": "Hodisa ishga tushirildi",
+ "message.user-deleted": "Foydalanuvchi oʻchirildi.",
+ "message.viewed-page": "Sahifa koʻrildi",
+ "message.visitor-log": "{os} {device} da {browser} dan foydalanayotgan {country} dan tashrif buyuruvchi",
+ "message.visitors-dropped-off": "Tashrif buyuruvchilar tashlab ketishdi"
+}
diff --git a/src/lang/vi-VN.json b/src/lang/vi-VN.json
new file mode 100644
index 0000000..fc0a8c1
--- /dev/null
+++ b/src/lang/vi-VN.json
@@ -0,0 +1,280 @@
+{
+ "label.access-code": "Mã truy cập",
+ "label.actions": "Hành động",
+ "label.activity": "Nhật ký hoạt động",
+ "label.add": "Thêm",
+ "label.add-description": "Thêm mô tả",
+ "label.add-member": "Thêm thành viên",
+ "label.add-step": "Thêm bước",
+ "label.add-website": "Thêm website",
+ "label.admin": "Quản trị",
+ "label.after": "Sau đó",
+ "label.all": "Tất cả",
+ "label.all-time": "Toàn thời gian",
+ "label.analytics": "Phân tích",
+ "label.average": "Trung bình",
+ "label.back": "Quay lại",
+ "label.before": "Trước đó",
+ "label.behavior": "Hành vi",
+ "label.bounce-rate": "Tỷ lệ thoát trang",
+ "label.breakdown": "Phân tích chi tiết",
+ "label.browser": "Trình duyệt",
+ "label.browsers": "Các trình duyệt",
+ "label.cancel": "Hủy bỏ",
+ "label.change-password": "Đổi mật khẩu",
+ "label.cities": "Các thành phố",
+ "label.city": "Thành phố",
+ "label.clear-all": "Xóa tất cả",
+ "label.compare": "So sánh",
+ "label.confirm": "Xác nhận",
+ "label.confirm-password": "Xác nhận mật khẩu",
+ "label.contains": "Chứa",
+ "label.continue": "Tiếp tục",
+ "label.count": "Số lượng",
+ "label.countries": "Các quốc gia",
+ "label.country": "Quốc gia",
+ "label.create": "Tạo",
+ "label.create-report": "Tạo báo cáo",
+ "label.create-team": "Tạo nhóm",
+ "label.create-user": "Tạo người dùng",
+ "label.created": "Đã tạo",
+ "label.created-by": "Được tạo bởi",
+ "label.current": "Hiện tại",
+ "label.current-password": "Mật khẩu hiện tại",
+ "label.custom-range": "Phạm vi tùy chỉnh",
+ "label.dashboard": "Bảng điều khiển",
+ "label.data": "Dữ liệu",
+ "label.date": "Ngày",
+ "label.date-range": "Phạm vi ngày",
+ "label.day": "Ngày",
+ "label.default-date-range": "Khoảng thời gian mặc định",
+ "label.delete": "Xóa",
+ "label.delete-report": "Xóa báo cáo",
+ "label.delete-team": "Xóa nhóm",
+ "label.delete-user": "Xóa người dùng",
+ "label.delete-website": "Xóa website",
+ "label.description": "Mô tả",
+ "label.desktop": "Máy tính để bàn",
+ "label.details": "Chi tiết",
+ "label.device": "Thiết bị",
+ "label.devices": "Các thiết bị",
+ "label.dismiss": "Bỏ qua",
+ "label.does-not-contain": "Không chứa",
+ "label.domain": "Tên miền",
+ "label.dropoff": "Tỷ lệ bỏ qua",
+ "label.edit": "Chỉnh sửa",
+ "label.edit-dashboard": "Chỉnh sửa bảng điều khiển",
+ "label.edit-member": "Chỉnh sửa thành viên",
+ "label.enable-share-url": "Bật chia sẻ URL",
+ "label.end-step": "Bước kết thúc",
+ "label.entry": "URL truy cập",
+ "label.event": "Sự kiện",
+ "label.event-data": "Dữ liệu sự kiện",
+ "label.events": "Các sự kiện",
+ "label.exit": "URL thoát",
+ "label.false": "Sai",
+ "label.field": "Trường",
+ "label.fields": "Các trường",
+ "label.filter": "Lọc",
+ "label.filter-combined": "Kết hợp lọc",
+ "label.filter-raw": "Lọc thô",
+ "label.filters": "Bộ lọc",
+ "label.first-seen": "Lần đầu tiên nhìn thấy",
+ "label.funnel": "Phễu",
+ "label.funnel-description": "Tìm hiểu tỷ lệ chuyển đổi và bỏ qua của người dùng.",
+ "label.goal": "Mục tiêu",
+ "label.goals": "Các mục tiêu",
+ "label.goals-description": "Theo dõi các mục tiêu của bạn cho lượt xem trang và sự kiện.",
+ "label.greater-than": "Lớn hơn",
+ "label.greater-than-equals": "Lớn hơn hoặc bằng",
+ "label.host": "Máy chủ",
+ "label.hosts": "Các máy chủ",
+ "label.insights": "Thông tin chi tiết",
+ "label.insights-description": "Tìm hiểu sâu hơn về dữ liệu của bạn bằng cách sử dụng phân đoạn và bộ lọc.",
+ "label.is": "Là",
+ "label.is-not": "Không phải là",
+ "label.is-not-set": "Chưa được đặt",
+ "label.is-set": "Đã đặt",
+ "label.join": "Tham gia",
+ "label.join-team": "Tham gia nhóm",
+ "label.journey": "Hành trình",
+ "label.journey-description": "Hiểu cách người dùng điều hướng qua website của bạn.",
+ "label.language": "Ngôn ngữ",
+ "label.languages": "Các ngôn ngữ",
+ "label.laptop": "Máy tính xách tay",
+ "label.last-days": "{x} ngày gần nhất",
+ "label.last-hours": "{x} giờ gần nhất",
+ "label.last-months": "{x} tháng gần nhất",
+ "label.last-seen": "Lần cuối cùng nhìn thấy",
+ "label.leave": "Rời khỏi",
+ "label.leave-team": "Rời nhóm",
+ "label.less-than": "Nhỏ hơn",
+ "label.less-than-equals": "Nhỏ hơn hoặc bằng",
+ "label.login": "Đăng nhập",
+ "label.logout": "Đăng xuất",
+ "label.manage": "Quản lý",
+ "label.manager": "Quản lý",
+ "label.max": "Tối đa",
+ "label.member": "Thành viên",
+ "label.members": "Các thành viên",
+ "label.min": "Tối thiểu",
+ "label.mobile": "Di động",
+ "label.more": "Thêm",
+ "label.my-account": "Tài khoản của tôi",
+ "label.my-websites": "Các website của tôi",
+ "label.name": "Tên",
+ "label.new-password": "Mật khẩu mới",
+ "label.none": "Không",
+ "label.number-of-records": "{x} {x, plural, one {bản ghi} other {bản ghi}}",
+ "label.ok": "OK",
+ "label.os": "Hệ điều hành",
+ "label.overview": "Tổng quan",
+ "label.owner": "Chủ sở hữu",
+ "label.page-of": "Trang {current} trên {total}",
+ "label.page-views": "Lượt xem trang",
+ "label.pageTitle": "Tiêu đề trang",
+ "label.pages": "Các trang",
+ "label.password": "Mật khẩu",
+ "label.path": "Đường dẫn",
+ "label.paths": "Các đường dẫn",
+ "label.powered-by": "Được cung cấp bởi {name}",
+ "label.previous": "Trước",
+ "label.previous-period": "Kỳ trước",
+ "label.previous-year": "Năm trước",
+ "label.profile": "Hồ sơ",
+ "label.properties": "Thuộc tính",
+ "label.property": "Thuộc tính",
+ "label.queries": "Truy vấn",
+ "label.query": "Truy vấn",
+ "label.query-parameters": "Tham số truy vấn",
+ "label.realtime": "Thời gian thực",
+ "label.referrer": "Nguồn giới thiệu",
+ "label.referrers": "Các nguồn giới thiệu",
+ "label.refresh": "Làm mới",
+ "label.regenerate": "Tạo lại",
+ "label.region": "Vùng",
+ "label.regions": "Các vùng",
+ "label.remove": "Xóa",
+ "label.remove-member": "Xóa thành viên",
+ "label.reports": "Báo cáo",
+ "label.required": "Yêu cầu",
+ "label.reset": "Đặt lại",
+ "label.reset-website": "Đặt lại thống kê website",
+ "label.retention": "Tỷ lệ giữ chân",
+ "label.retention-description": "Đo lường mức độ gắn bó của website bằng cách theo dõi tần suất người dùng quay lại.",
+ "label.revenue": "Doanh thu",
+ "label.revenue-description": "Xem xét doanh thu của bạn theo thời gian.",
+ "label.revenue-property": "Thuộc tính doanh thu",
+ "label.role": "Vai trò",
+ "label.run-query": "Chạy truy vấn",
+ "label.save": "Lưu",
+ "label.screens": "Màn hình",
+ "label.search": "Tìm kiếm",
+ "label.select": "Chọn",
+ "label.select-date": "Chọn ngày",
+ "label.select-role": "Chọn vai trò",
+ "label.select-website": "Chọn website",
+ "label.session": "Phiên",
+ "label.sessions": "Các phiên",
+ "label.settings": "Cài đặt",
+ "label.share-url": "Chia sẻ URL",
+ "label.single-day": "Một ngày",
+ "label.start-step": "Bước bắt đầu",
+ "label.steps": "Các bước",
+ "label.sum": "Tổng",
+ "label.tablet": "Máy tính bảng",
+ "label.team": "Nhóm",
+ "label.team-id": "ID nhóm",
+ "label.team-manager": "Quản lý nhóm",
+ "label.team-member": "Thành viên nhóm",
+ "label.team-name": "Tên nhóm",
+ "label.team-owner": "Chủ sở hữu nhóm",
+ "label.team-view-only": "Chỉ xem nhóm",
+ "label.team-websites": "Các website của nhóm",
+ "label.teams": "Các nhóm",
+ "label.theme": "Chủ đề",
+ "label.this-month": "Tháng này",
+ "label.this-week": "Tuần này",
+ "label.this-year": "Năm nay",
+ "label.timezone": "Múi giờ",
+ "label.title": "Tiêu đề",
+ "label.today": "Hôm nay",
+ "label.toggle-charts": "Bật/tắt biểu đồ",
+ "label.total": "Tổng",
+ "label.total-records": "Tổng số bản ghi",
+ "label.tracking-code": "Mã theo dõi",
+ "label.transactions": "Giao dịch",
+ "label.transfer": "Chuyển giao",
+ "label.transfer-website": "Chuyển giao website",
+ "label.true": "Đúng",
+ "label.type": "Loại",
+ "label.unique": "Duy nhất",
+ "label.unique-visitors": "Khách truy cập duy nhất",
+ "label.uniqueCustomers": "Khách hàng duy nhất",
+ "label.unknown": "Không rõ",
+ "label.untitled": "Không có tiêu đề",
+ "label.update": "Cập nhật",
+ "label.url": "URL",
+ "label.urls": "Các URL",
+ "label.user": "Người dùng",
+ "label.user-property": "Thuộc tính người dùng",
+ "label.username": "Tên đăng nhập",
+ "label.users": "Người dùng",
+ "label.utm": "UTM",
+ "label.utm-description": "Theo dõi các chiến dịch của bạn thông qua các tham số UTM.",
+ "label.value": "Giá trị",
+ "label.view": "Xem",
+ "label.view-details": "Xem chi tiết",
+ "label.view-only": "Chỉ xem",
+ "label.views": "Lượt xem",
+ "label.views-per-visit": "Lượt xem trên mỗi lượt truy cập",
+ "label.visit-duration": "Thời lượng truy cập",
+ "label.visitors": "Khách truy cập",
+ "label.visits": "Lượt truy cập",
+ "label.website": "Website",
+ "label.website-id": "ID website",
+ "label.websites": "Các website",
+ "label.window": "Cửa sổ",
+ "label.yesterday": "Hôm qua",
+ "message.action-confirmation": "Nhập {confirmation} vào ô bên dưới để xác nhận.",
+ "message.active-users": "{x} {x, plural, one {người dùng} other {người dùng}} đang hoạt động",
+ "message.collected-data": "Dữ liệu đã thu thập",
+ "message.confirm-delete": "Bạn có chắc chắn muốn xóa {target}?",
+ "message.confirm-leave": "Bạn có chắc chắn muốn rời {target}?",
+ "message.confirm-remove": "Bạn có chắc chắn muốn xóa {target}?",
+ "message.confirm-reset": "Bạn có chắc chắn muốn đặt lại thống kê {target}?",
+ "message.delete-team-warning": "Việc xóa một nhóm cũng sẽ xóa tất cả các website của nhóm.",
+ "message.delete-website-warning": "Tất cả dữ liệu liên quan cũng sẽ bị xóa.",
+ "message.error": "Đã xảy ra lỗi.",
+ "message.event-log": "{event} trên {url}",
+ "message.go-to-settings": "Chuyển đến cài đặt",
+ "message.incorrect-username-password": "Sai tên đăng nhập/mật khẩu.",
+ "message.invalid-domain": "Tên miền không hợp lệ",
+ "message.min-password-length": "Độ dài tối thiểu {n} ký tự",
+ "message.new-version-available": "Có phiên bản mới của Umami {version}!",
+ "message.no-data-available": "Không có dữ liệu.",
+ "message.no-event-data": "Không có dữ liệu sự kiện.",
+ "message.no-match-password": "Mật khẩu không khớp",
+ "message.no-results-found": "Không tìm thấy kết quả nào.",
+ "message.no-team-websites": "Nhóm này không có bất kỳ website nào.",
+ "message.no-teams": "Bạn chưa tạo nhóm nào.",
+ "message.no-users": "Không có người dùng nào.",
+ "message.no-websites-configured": "Bạn chưa cấu hình bất kỳ website nào.",
+ "message.page-not-found": "Không tìm thấy trang.",
+ "message.reset-website": "Để đặt lại website này, nhập {confirmation} vào ô bên dưới để xác nhận.",
+ "message.reset-website-warning": "Tất cả số liệu thống kê của website này sẽ bị xóa, nhưng mã theo dõi sẽ vẫn giữ nguyên.",
+ "message.saved": "Đã lưu thành công.",
+ "message.share-url": "Đây là đường dẫn URL cho {target}.",
+ "message.team-already-member": "Bạn đã là thành viên của nhóm.",
+ "message.team-not-found": "Không tìm thấy nhóm.",
+ "message.team-websites-info": "Bất kỳ ai trong nhóm đều có thể xem các website.",
+ "message.tracking-code": "Mã theo dõi",
+ "message.transfer-team-website-to-user": "Chuyển website này sang tài khoản của bạn?",
+ "message.transfer-user-website-to-team": "Chọn nhóm để chuyển website này đến.",
+ "message.transfer-website": "Chuyển quyền sở hữu website sang tài khoản của bạn hoặc một nhóm khác.",
+ "message.triggered-event": "Sự kiện được kích hoạt",
+ "message.user-deleted": "Người dùng đã bị xóa.",
+ "message.viewed-page": "Đã xem trang",
+ "message.visitor-log": "Khách từ {country} đang sử dụng {browser} trên {os} {device}",
+ "message.visitors-dropped-off": "Khách truy cập đã rời đi"
+}
diff --git a/src/lang/zh-CN.json b/src/lang/zh-CN.json
new file mode 100644
index 0000000..c6f01dd
--- /dev/null
+++ b/src/lang/zh-CN.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "访问代码",
+ "label.actions": "用户行为",
+ "label.activity": "活动日志",
+ "label.add": "添加",
+ "label.add-board": "添加看板",
+ "label.add-description": "添加描述",
+ "label.add-member": "添加成员",
+ "label.add-step": "添加步骤",
+ "label.add-website": "添加网站",
+ "label.admin": "管理员",
+ "label.affiliate": "联盟",
+ "label.after": "之后",
+ "label.all": "所有",
+ "label.all-time": "所有时间段",
+ "label.analytics": "分析",
+ "label.apply": "应用",
+ "label.attribution": "归因",
+ "label.attribution-description": "查看用户如何与您的营销互动,以及是什么促成了转化。",
+ "label.average": "平均",
+ "label.back": "返回",
+ "label.before": "之前",
+ "label.behavior": "行为",
+ "label.boards": "看板",
+ "label.bounce-rate": "跳出率",
+ "label.breakdown": "故障",
+ "label.browser": "浏览器",
+ "label.browsers": "浏览器",
+ "label.campaigns": "活动",
+ "label.cancel": "取消",
+ "label.change-password": "修改密码",
+ "label.channels": "渠道",
+ "label.cities": "市/县",
+ "label.city": "市/县",
+ "label.clear-all": "清除全部",
+ "label.cohort": "队列",
+ "label.compare": "比较",
+ "label.compare-dates": "比较日期",
+ "label.confirm": "确认",
+ "label.confirm-password": "确认密码",
+ "label.contains": "包含",
+ "label.content": "内容",
+ "label.continue": "继续",
+ "label.conversion": "转化",
+ "label.conversion-rate": "转化率",
+ "label.conversion-step": "转化步骤",
+ "label.count": "统计",
+ "label.countries": "国家/地区",
+ "label.country": "国家/地区",
+ "label.create": "创建",
+ "label.create-report": "创建报告",
+ "label.create-team": "创建团队",
+ "label.create-user": "创建用户",
+ "label.created": "已创建",
+ "label.created-by": "创建者",
+ "label.currency": "货币",
+ "label.current": "当前",
+ "label.current-password": "当前密码",
+ "label.custom-range": "自定义时间段",
+ "label.dashboard": "仪表盘",
+ "label.data": "统计数据",
+ "label.date": "日期",
+ "label.date-range": "时间段",
+ "label.day": "日",
+ "label.default-date-range": "默认时间段",
+ "label.delete": "删除",
+ "label.delete-report": "删除报告",
+ "label.delete-team": "删除团队",
+ "label.delete-user": "删除用户",
+ "label.delete-website": "删除网站",
+ "label.description": "描述",
+ "label.desktop": "台式机",
+ "label.details": "详细信息",
+ "label.device": "设备",
+ "label.devices": "设备",
+ "label.direct": "直接",
+ "label.dismiss": "关闭",
+ "label.distinct-id": "唯一ID",
+ "label.does-not-contain": "不包含",
+ "label.does-not-include": "不包括",
+ "label.doest-not-exist": "不存在",
+ "label.domain": "域名",
+ "label.dropoff": "丢弃",
+ "label.edit": "编辑",
+ "label.edit-dashboard": "编辑仪表盘",
+ "label.edit-member": "编辑成员",
+ "label.email": "Email",
+ "label.enable-share-url": "启用共享链接",
+ "label.end-step": "结束步骤",
+ "label.entry": "入口 URL",
+ "label.event": "事件",
+ "label.event-data": "事件数据",
+ "label.event-name": "事件名称",
+ "label.events": "行为类别",
+ "label.exists": "存在",
+ "label.exit": "退出 URL",
+ "label.false": "否",
+ "label.field": "字段",
+ "label.fields": "字段",
+ "label.filter": "筛选器",
+ "label.filter-combined": "合并",
+ "label.filter-raw": "原始",
+ "label.filters": "筛选",
+ "label.first-click": "首次点击",
+ "label.first-seen": "首次出现",
+ "label.funnel": "分析",
+ "label.funnel-description": "了解用户的转化率和跳出率。",
+ "label.funnels": "漏斗",
+ "label.goal": "目标",
+ "label.goals": "目标",
+ "label.goals-description": "跟踪页面浏览量和事件的目标。",
+ "label.greater-than": "大于",
+ "label.greater-than-equals": "大于或等于",
+ "label.grouped": "分组",
+ "label.hostname": "主机名",
+ "label.includes": "包括",
+ "label.insight": "洞察",
+ "label.insights": "见解",
+ "label.insights-description": "通过使用筛选器和划分时间段来更深入地研究数据。",
+ "label.is": "等于",
+ "label.is-false": "否",
+ "label.is-not": "不等于",
+ "label.is-not-set": "未设置",
+ "label.is-set": "已设置",
+ "label.is-true": "是",
+ "label.join": "加入",
+ "label.join-team": "加入团队",
+ "label.journey": "用户浏览轨迹",
+ "label.journey-description": "了解用户如何浏览网站。",
+ "label.journeys": "用户路径",
+ "label.language": "语言",
+ "label.languages": "语言",
+ "label.laptop": "笔记本",
+ "label.last-click": "最后点击",
+ "label.last-days": "最近 {x} 天",
+ "label.last-hours": "最近 {x} 小时",
+ "label.last-months": "最近 {x} 个月",
+ "label.last-seen": "最后出现",
+ "label.leave": "离开",
+ "label.leave-team": "离开团队",
+ "label.less-than": "少于",
+ "label.less-than-equals": "少于等于",
+ "label.links": "链接",
+ "label.login": "登录",
+ "label.logout": "退出",
+ "label.manage": "管理",
+ "label.manager": "管理者",
+ "label.max": "最大",
+ "label.maximize": "展开",
+ "label.medium": "中等",
+ "label.member": "成员",
+ "label.members": "成员",
+ "label.min": "最小",
+ "label.mobile": "手机",
+ "label.model": "模型",
+ "label.more": "更多",
+ "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": "好的",
+ "label.online": "Online",
+ "label.organic-search": "自然搜索",
+ "label.organic-shopping": "自然购物",
+ "label.organic-social": "自然社交",
+ "label.organic-video": "自然视频",
+ "label.os": "操作系统",
+ "label.other": "其他",
+ "label.overview": "概览",
+ "label.owner": "所有者",
+ "label.page": "页面",
+ "label.page-of": "总 {total} 中的第 {current} 页",
+ "label.page-views": "页面浏览量",
+ "label.pageTitle": "标题",
+ "label.pages": "网页",
+ "label.paid-ads": "付费广告",
+ "label.paid-search": "付费搜索",
+ "label.paid-shopping": "付费购物",
+ "label.paid-social": "付费社交",
+ "label.paid-video": "付费视频",
+ "label.password": "密码",
+ "label.path": "路径",
+ "label.paths": "路径",
+ "label.pixels": "像素",
+ "label.powered-by": "由 {name} 提供支持",
+ "label.previous": "先前",
+ "label.previous-period": "上一时期",
+ "label.previous-year": "上一年",
+ "label.profile": "个人资料",
+ "label.properties": "属性",
+ "label.property": "属性",
+ "label.queries": "查询",
+ "label.query": "查询",
+ "label.query-parameters": "查询参数",
+ "label.realtime": "实时",
+ "label.referral": "Referral",
+ "label.referrer": "来源",
+ "label.referrers": "来源域名",
+ "label.refresh": "刷新",
+ "label.regenerate": "重新生成",
+ "label.region": "州/省",
+ "label.regions": "州/省",
+ "label.remaining": "剩余",
+ "label.remove": "移除",
+ "label.remove-member": "移除成员",
+ "label.reports": "报告",
+ "label.required": "必填",
+ "label.reset": "重置",
+ "label.reset-website": "重置统计数据",
+ "label.retention": "保留",
+ "label.retention-description": "通过追踪用户回访频率来衡量您网站的用户粘性。",
+ "label.revenue": "收入",
+ "label.revenue-description": "查看随时间变化的收入数据。",
+ "label.role": "角色",
+ "label.run-query": "查询",
+ "label.save": "保存",
+ "label.screens": "屏幕尺寸",
+ "label.search": "搜索",
+ "label.select": "选择",
+ "label.select-date": "选择日期",
+ "label.select-filter": "选择筛选器",
+ "label.select-role": "选择角色",
+ "label.select-website": "选择网站",
+ "label.session": "会话",
+ "label.session-data": "会话数据",
+ "label.sessions": "会话",
+ "label.settings": "设置",
+ "label.share": "分享",
+ "label.share-url": "共享链接",
+ "label.single-day": "单日",
+ "label.sms": "SMS",
+ "label.sources": "来源",
+ "label.start-step": "开始步骤",
+ "label.steps": "步骤",
+ "label.sum": "总和",
+ "label.tablet": "平板",
+ "label.tag": "标签",
+ "label.tags": "标签",
+ "label.team": "团队",
+ "label.team-id": "团队 ID",
+ "label.team-manager": "团队管理员",
+ "label.team-member": "团队成员",
+ "label.team-name": "团队名称",
+ "label.team-owner": "团队所有者",
+ "label.team-settings": "团队设置",
+ "label.team-view-only": "仅团队视图",
+ "label.team-websites": "团队网站",
+ "label.teams": "团队",
+ "label.terms": "条款",
+ "label.theme": "主题",
+ "label.this-month": "本月",
+ "label.this-week": "本周",
+ "label.this-year": "今年",
+ "label.timezone": "时区",
+ "label.title": "标题",
+ "label.today": "今天",
+ "label.toggle-charts": "切换图表",
+ "label.total": "总数",
+ "label.total-records": "总记录数",
+ "label.tracking-code": "跟踪代码",
+ "label.transactions": "交易",
+ "label.transfer": "转移",
+ "label.transfer-website": "转移网站",
+ "label.true": "是",
+ "label.type": "类型",
+ "label.unique": "独立",
+ "label.unique-visitors": "独立访客",
+ "label.uniqueCustomers": "独特客户",
+ "label.unknown": "未知",
+ "label.untitled": "未命名",
+ "label.update": "更新",
+ "label.user": "用户",
+ "label.username": "用户名",
+ "label.users": "用户",
+ "label.utm": "UTM",
+ "label.utm-description": "通过 UTM 参数追踪您的广告活动。",
+ "label.value": "值",
+ "label.view": "查看",
+ "label.view-details": "查看更多",
+ "label.view-only": "仅浏览",
+ "label.views": "浏览量",
+ "label.views-per-visit": "每次访问的浏览量",
+ "label.visit-duration": "平均访问时长",
+ "label.visitors": "访客",
+ "label.visits": "访问次数",
+ "label.website": "网站",
+ "label.website-id": "网站 ID",
+ "label.websites": "网站",
+ "label.window": "窗口",
+ "label.yesterday": "昨天",
+ "message.action-confirmation": "请在下方输入框中输入 {confirmation} 以确认操作。",
+ "message.active-users": "当前在线 {x} 位访客",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "已收集的数据",
+ "message.confirm-delete": "你确定要删除 {target} 吗?",
+ "message.confirm-leave": "你确定要离开 {target} 吗?",
+ "message.confirm-remove": "您确定要移除 {target} ?",
+ "message.confirm-reset": "您确定要重置 {target} 的数据吗?",
+ "message.delete-team-warning": "删除团队也会删除所有团队网站。",
+ "message.delete-website-warning": "所有相关数据将会被删除。",
+ "message.error": "发生错误。",
+ "message.event-log": "{url} 上的 {event}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "去设置",
+ "message.incorrect-username-password": "用户名或密码不正确。",
+ "message.invalid-domain": "无效域名",
+ "message.min-password-length": "密码最短长度为 {n} 个字符",
+ "message.new-version-available": "Umami 新版本 {version} 已发布!",
+ "message.no-data-available": "暂无数据。",
+ "message.no-event-data": "无可用事件。",
+ "message.no-match-password": "密码不一致",
+ "message.no-results-found": "未找到结果。",
+ "message.no-team-websites": "该团队暂无网站。",
+ "message.no-teams": "您尚未创建任何团队。",
+ "message.no-users": "暂无用户。",
+ "message.no-websites-configured": "你还没有设置任何网站。",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "页面未找到。",
+ "message.reset-website": "如确定要重置该网站,请在下面输入 {confirmation} 以确认。",
+ "message.reset-website-warning": "此网站的所有统计数据将被删除,但您的跟踪代码将保持不变。",
+ "message.saved": "保存成功。",
+ "message.sever-error": "Server error",
+ "message.share-url": "这是 {target} 的共享链接。",
+ "message.team-already-member": "你已是该团队的成员。",
+ "message.team-not-found": "未找到团队。",
+ "message.team-websites-info": "团队成员均可查看网站数据。",
+ "message.tracking-code": "跟踪代码",
+ "message.transfer-team-website-to-user": "将此网站转移到您的账户?",
+ "message.transfer-user-website-to-team": "选择要转移此网站的团队。",
+ "message.transfer-website": "将网站所有权转移到您的账户或其他团队。",
+ "message.triggered-event": "触发事件",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "用户已删除。",
+ "message.viewed-page": "已浏览页面",
+ "message.visitor-log": "来自 {country} 的访客在搭载 {os} 的 {device} 上使用 {browser} 浏览器进行访问。"
+}
diff --git a/src/lang/zh-TW.json b/src/lang/zh-TW.json
new file mode 100644
index 0000000..030d11d
--- /dev/null
+++ b/src/lang/zh-TW.json
@@ -0,0 +1,339 @@
+{
+ "label.access-code": "存取碼",
+ "label.actions": "行為",
+ "label.activity": "活動紀錄",
+ "label.add": "新增",
+ "label.add-board": "新增看板",
+ "label.add-description": "新增描述",
+ "label.add-member": "新增成員",
+ "label.add-step": "新增步驟",
+ "label.add-website": "新增網站",
+ "label.admin": "管理員",
+ "label.affiliate": "聯盟",
+ "label.after": "之後",
+ "label.all": "全部",
+ "label.all-time": "所有時間",
+ "label.analytics": "分析",
+ "label.apply": "套用",
+ "label.attribution": "歸因",
+ "label.attribution-description": "查看使用者如何與您的行銷互動,以及什麼促成了轉換。",
+ "label.average": "平均",
+ "label.back": "返回",
+ "label.before": "之前",
+ "label.behavior": "行為",
+ "label.boards": "看板",
+ "label.bounce-rate": "跳出率",
+ "label.breakdown": "細項分析",
+ "label.browser": "瀏覽器",
+ "label.browsers": "瀏覽器",
+ "label.campaigns": "活動",
+ "label.cancel": "取消",
+ "label.change-password": "更改密碼",
+ "label.channels": "Channels",
+ "label.cities": "城市",
+ "label.city": "城市",
+ "label.clear-all": "全部清除",
+ "label.cohort": "群組",
+ "label.compare": "比較",
+ "label.compare-dates": "比較日期",
+ "label.confirm": "確認",
+ "label.confirm-password": "確認密碼",
+ "label.contains": "包含",
+ "label.content": "內容",
+ "label.continue": "繼續",
+ "label.conversion": "轉換",
+ "label.conversion-rate": "轉換率",
+ "label.conversion-step": "轉換步驟",
+ "label.count": "數量",
+ "label.countries": "國家",
+ "label.country": "國家",
+ "label.create": "建立",
+ "label.create-report": "建立報表",
+ "label.create-team": "建立團隊",
+ "label.create-user": "建立使用者",
+ "label.created": "已建立",
+ "label.created-by": "建立者",
+ "label.currency": "Currency",
+ "label.current": "目前",
+ "label.current-password": "目前密碼",
+ "label.custom-range": "自訂範圍",
+ "label.dashboard": "儀表板",
+ "label.data": "資料",
+ "label.date": "日期",
+ "label.date-range": "日期範圍",
+ "label.day": "日",
+ "label.default-date-range": "預設日期範圍",
+ "label.delete": "刪除",
+ "label.delete-report": "刪除報表",
+ "label.delete-team": "刪除團隊",
+ "label.delete-user": "刪除使用者",
+ "label.delete-website": "刪除網站",
+ "label.description": "描述",
+ "label.desktop": "桌上型電腦",
+ "label.details": "詳細資訊",
+ "label.device": "裝置",
+ "label.devices": "裝置",
+ "label.direct": "Direct",
+ "label.dismiss": "關閉",
+ "label.distinct-id": "Distinct ID",
+ "label.does-not-contain": "不包含",
+ "label.does-not-include": "Does not include",
+ "label.doest-not-exist": "Does not exist",
+ "label.domain": "網域",
+ "label.dropoff": "離開",
+ "label.edit": "編輯",
+ "label.edit-dashboard": "編輯儀表板",
+ "label.edit-member": "編輯成員",
+ "label.email": "Email",
+ "label.enable-share-url": "啟用分享連結",
+ "label.end-step": "結束步驟",
+ "label.entry": "進入網址",
+ "label.event": "事件",
+ "label.event-data": "事件資料",
+ "label.event-name": "Event name",
+ "label.events": "事件",
+ "label.exists": "Exists",
+ "label.exit": "離開網址",
+ "label.false": "否",
+ "label.field": "欄位",
+ "label.fields": "欄位",
+ "label.filter": "篩選器",
+ "label.filter-combined": "組合",
+ "label.filter-raw": "原始",
+ "label.filters": "篩選條件",
+ "label.first-click": "First click",
+ "label.first-seen": "首次造訪",
+ "label.funnel": "漏斗分析",
+ "label.funnel-description": "瞭解使用者的轉換率與流失率。",
+ "label.funnels": "Funnels",
+ "label.goal": "目標",
+ "label.goals": "目標",
+ "label.goals-description": "追蹤網頁瀏覽和事件的目標。",
+ "label.greater-than": "大於",
+ "label.greater-than-equals": "大於或等於",
+ "label.grouped": "Grouped",
+ "label.hostname": "Hostname",
+ "label.includes": "Includes",
+ "label.insight": "Insight",
+ "label.insights": "洞察",
+ "label.insights-description": "使用區段和篩選器來深入分析您的資料。",
+ "label.is": "是",
+ "label.is-false": "Is false",
+ "label.is-not": "不是",
+ "label.is-not-set": "未設定",
+ "label.is-set": "已設定",
+ "label.is-true": "Is true",
+ "label.join": "加入",
+ "label.join-team": "加入團隊",
+ "label.journey": "使用者旅程",
+ "label.journey-description": "瞭解使用者如何瀏覽您的網站。",
+ "label.journeys": "Journeys",
+ "label.language": "語言",
+ "label.languages": "語言",
+ "label.laptop": "筆記型電腦",
+ "label.last-click": "Last click",
+ "label.last-days": "最近 {x} 天",
+ "label.last-hours": "最近 {x} 小時",
+ "label.last-months": "最近 {x} 個月",
+ "label.last-seen": "最後造訪",
+ "label.leave": "離開",
+ "label.leave-team": "離開團隊",
+ "label.less-than": "小於",
+ "label.less-than-equals": "小於或等於",
+ "label.links": "Links",
+ "label.login": "登入",
+ "label.logout": "登出",
+ "label.manage": "管理",
+ "label.manager": "管理者",
+ "label.max": "最大值",
+ "label.maximize": "Expand",
+ "label.medium": "Medium",
+ "label.member": "成員",
+ "label.members": "成員",
+ "label.min": "最小值",
+ "label.mobile": "行動裝置",
+ "label.model": "Model",
+ "label.more": "更多",
+ "label.my-account": "我的帳號",
+ "label.my-websites": "我的網站",
+ "label.name": "名稱",
+ "label.new-password": "新密碼",
+ "label.none": "無",
+ "label.number-of-records": "{x} 筆紀錄",
+ "label.ok": "OK",
+ "label.online": "Online",
+ "label.organic-search": "Organic search",
+ "label.organic-shopping": "Organic shopping",
+ "label.organic-social": "Organic social",
+ "label.organic-video": "Organic video",
+ "label.os": "作業系統",
+ "label.other": "Other",
+ "label.overview": "總覽",
+ "label.owner": "擁有者",
+ "label.page": "Page",
+ "label.page-of": "第 {current} 頁,共 {total} 頁",
+ "label.page-views": "網頁瀏覽次數",
+ "label.pageTitle": "網頁標題",
+ "label.pages": "網頁",
+ "label.paid-ads": "Paid ads",
+ "label.paid-search": "Paid search",
+ "label.paid-shopping": "Paid shopping",
+ "label.paid-social": "Paid social",
+ "label.paid-video": "Paid video",
+ "label.password": "密碼",
+ "label.path": "路徑",
+ "label.paths": "路徑",
+ "label.pixels": "Pixels",
+ "label.powered-by": "由 {name} 提供技術支援",
+ "label.previous": "上一個",
+ "label.previous-period": "上一期間",
+ "label.previous-year": "去年",
+ "label.profile": "個人檔案",
+ "label.properties": "屬性",
+ "label.property": "屬性",
+ "label.queries": "查詢",
+ "label.query": "查詢",
+ "label.query-parameters": "查詢參數",
+ "label.realtime": "即時",
+ "label.referral": "Referral",
+ "label.referrer": "參照來源",
+ "label.referrers": "參照來源",
+ "label.refresh": "重新整理",
+ "label.regenerate": "重新產生",
+ "label.region": "地區",
+ "label.regions": "地區",
+ "label.remaining": "Remaining",
+ "label.remove": "移除",
+ "label.remove-member": "移除成員",
+ "label.reports": "報表",
+ "label.required": "必填",
+ "label.reset": "重設",
+ "label.reset-website": "重設網站統計資料",
+ "label.retention": "留存率",
+ "label.retention-description": "透過追蹤使用者回訪的頻率來衡量您的網站黏著度。",
+ "label.revenue": "營收",
+ "label.revenue-description": "查看您的營收趨勢。",
+ "label.role": "角色",
+ "label.run-query": "執行查詢",
+ "label.save": "儲存",
+ "label.screens": "螢幕",
+ "label.search": "搜尋",
+ "label.select": "選取",
+ "label.select-date": "選取日期",
+ "label.select-filter": "Select filter",
+ "label.select-role": "選取角色",
+ "label.select-website": "選取網站",
+ "label.session": "工作階段",
+ "label.session-data": "Session data",
+ "label.sessions": "工作階段",
+ "label.settings": "設定",
+ "label.share": "Share",
+ "label.share-url": "分享連結",
+ "label.single-day": "單日",
+ "label.sms": "SMS",
+ "label.sources": "Sources",
+ "label.start-step": "起始步驟",
+ "label.steps": "步驟",
+ "label.sum": "總和",
+ "label.tablet": "平板",
+ "label.tag": "Tag",
+ "label.tags": "Tags",
+ "label.team": "團隊",
+ "label.team-id": "團隊 ID",
+ "label.team-manager": "團隊管理者",
+ "label.team-member": "團隊成員",
+ "label.team-name": "團隊名稱",
+ "label.team-owner": "團隊擁有者",
+ "label.team-settings": "Team settings",
+ "label.team-view-only": "團隊僅供檢視",
+ "label.team-websites": "團隊網站",
+ "label.teams": "團隊",
+ "label.terms": "Terms",
+ "label.theme": "主題",
+ "label.this-month": "本月",
+ "label.this-week": "本週",
+ "label.this-year": "今年",
+ "label.timezone": "時區",
+ "label.title": "標題",
+ "label.today": "今天",
+ "label.toggle-charts": "切換圖表",
+ "label.total": "總計",
+ "label.total-records": "紀錄總數",
+ "label.tracking-code": "追蹤代碼",
+ "label.transactions": "交易",
+ "label.transfer": "轉移",
+ "label.transfer-website": "轉移網站",
+ "label.true": "是",
+ "label.type": "類型",
+ "label.unique": "不重複",
+ "label.unique-visitors": "不重複訪客",
+ "label.uniqueCustomers": "不重複客戶",
+ "label.unknown": "未知",
+ "label.untitled": "未命名",
+ "label.update": "更新",
+ "label.user": "使用者",
+ "label.username": "使用者名稱",
+ "label.users": "使用者",
+ "label.utm": "UTM",
+ "label.utm-description": "透過 UTM 參數追蹤您的行銷活動。",
+ "label.value": "值",
+ "label.view": "檢視",
+ "label.view-details": "檢視詳細資訊",
+ "label.view-only": "僅供檢視",
+ "label.views": "瀏覽次數",
+ "label.views-per-visit": "每次造訪的瀏覽次數",
+ "label.visit-duration": "造訪時間",
+ "label.visitors": "訪客",
+ "label.visits": "造訪次數",
+ "label.website": "網站",
+ "label.website-id": "網站 ID",
+ "label.websites": "網站",
+ "label.window": "視窗",
+ "label.yesterday": "昨天",
+ "message.action-confirmation": "請在下方欄位輸入 {confirmation} 以確認。",
+ "message.active-users": "目前有 {x} 位訪客",
+ "message.bad-request": "Bad request",
+ "message.collected-data": "已蒐集的資料",
+ "message.confirm-delete": "您確定要刪除 {target} 嗎?",
+ "message.confirm-leave": "您確定要離開 {target} 嗎?",
+ "message.confirm-remove": "您確定要移除 {target} 嗎?",
+ "message.confirm-reset": "您確定要重設 {target} 的統計資料嗎?",
+ "message.delete-team-warning": "刪除團隊的同時也會刪除所有團隊的網站。",
+ "message.delete-website-warning": "所有網站資料都將被刪除。",
+ "message.error": "發生錯誤。",
+ "message.event-log": "在 {url} 上的 {event}",
+ "message.forbidden": "Forbidden",
+ "message.go-to-settings": "前往設定",
+ "message.incorrect-username-password": "使用者名稱或密碼不正確。",
+ "message.invalid-domain": "無效的網域。請勿包含 http/https。",
+ "message.min-password-length": "密碼長度至少需 {n} 個字元",
+ "message.new-version-available": "Umami {version} 的新版本已推出!",
+ "message.no-data-available": "沒有可用的資料。",
+ "message.no-event-data": "沒有可用的事件資料。",
+ "message.no-match-password": "密碼不一致。",
+ "message.no-results-found": "找不到結果。",
+ "message.no-team-websites": "此團隊沒有任何網站。",
+ "message.no-teams": "您尚未建立任何團隊。",
+ "message.no-users": "沒有任何使用者。",
+ "message.no-websites-configured": "您尚未設定任何網站。",
+ "message.not-found": "Not found",
+ "message.nothing-selected": "Nothing selected.",
+ "message.page-not-found": "找不到網頁",
+ "message.reset-website": "要重設此網站的統計資料,請在下方欄位輸入 {confirmation} 以確認。",
+ "message.reset-website-warning": "此網站的所有統計資料都將被刪除,但您的設定將保持不變。",
+ "message.saved": "已儲存。",
+ "message.sever-error": "Server error",
+ "message.share-url": "您的網站統計資料可在以下網址公開檢視:",
+ "message.team-already-member": "您已是該團隊的成員。",
+ "message.team-not-found": "找不到團隊。",
+ "message.team-websites-info": "團隊中的所有成員都可以檢視網站。",
+ "message.tracking-code": "要追蹤此網站的統計資料,請將以下程式碼放在您 HTML 的 <head>...</head> 區段中。",
+ "message.transfer-team-website-to-user": "要將此網站轉移至您的帳號嗎?",
+ "message.transfer-user-website-to-team": "請選擇要轉移此網站的團隊。",
+ "message.transfer-website": "將網站所有權轉移至您的帳號或其他團隊。",
+ "message.triggered-event": "已觸發的事件",
+ "message.unauthorized": "Unauthorized",
+ "message.user-deleted": "使用者已刪除。",
+ "message.viewed-page": "已瀏覽的網頁",
+ "message.visitor-log": "來自 {country} 的訪客在 {device} 上的 {os} 使用 {browser} 瀏覽。"
+}
diff --git a/src/lib/__tests__/charts.test.ts b/src/lib/__tests__/charts.test.ts
new file mode 100644
index 0000000..e81be16
--- /dev/null
+++ b/src/lib/__tests__/charts.test.ts
@@ -0,0 +1,39 @@
+import { renderNumberLabels } from '../charts';
+
+// test for renderNumberLabels
+
+describe('renderNumberLabels', () => {
+ test.each([
+ ['1000000', '1.0m'],
+ ['2500000', '2.5m'],
+ ])("formats numbers ≥ 1 million as 'Xm' (%s → %s)", (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([['150000', '150k']])("formats numbers ≥ 100K as 'Xk' (%s → %s)", (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([
+ ['12500', '12.5k'],
+ ])("formats numbers ≥ 10K as 'X.Xk' (%s → %s)", (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([['1500', '1.50k']])("formats numbers ≥ 1K as 'X.XXk' (%s → %s)", (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([
+ ['999', '999'],
+ ])('calls formatNumber for values < 1000 (%s → %s)', (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+
+ test.each([
+ ['0', '0'],
+ ['-5000', '-5000'],
+ ])('handles edge cases correctly (%s → %s)', (input, expected) => {
+ expect(renderNumberLabels(input)).toBe(expected);
+ });
+});
diff --git a/src/lib/__tests__/detect.test.ts b/src/lib/__tests__/detect.test.ts
new file mode 100644
index 0000000..0395aef
--- /dev/null
+++ b/src/lib/__tests__/detect.test.ts
@@ -0,0 +1,22 @@
+import { getIpAddress } from '../ip';
+
+const IP = '127.0.0.1';
+const BAD_IP = '127.127.127.127';
+
+test('getIpAddress: Custom header', () => {
+ process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
+
+ expect(getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP);
+});
+
+test('getIpAddress: CloudFlare header', () => {
+ expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP);
+});
+
+test('getIpAddress: Standard header', () => {
+ expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP);
+});
+
+test('getIpAddress: No header', () => {
+ expect(getIpAddress(new Headers())).toEqual(null);
+});
diff --git a/src/lib/__tests__/format.test.ts b/src/lib/__tests__/format.test.ts
new file mode 100644
index 0000000..6e1b319
--- /dev/null
+++ b/src/lib/__tests__/format.test.ts
@@ -0,0 +1,38 @@
+import * as format from '../format';
+
+test('parseTime', () => {
+ expect(format.parseTime(86400 + 3600 + 60 + 1)).toEqual({
+ days: 1,
+ hours: 1,
+ minutes: 1,
+ seconds: 1,
+ ms: 0,
+ });
+});
+
+test('formatTime', () => {
+ expect(format.formatTime(3600 + 60 + 1)).toBe('1:01:01');
+});
+
+test('formatShortTime', () => {
+ expect(format.formatShortTime(3600 + 60 + 1)).toBe('1m1s');
+
+ expect(format.formatShortTime(3600 + 60 + 1, ['h', 'm', 's'])).toBe('1h1m1s');
+});
+
+test('formatNumber', () => {
+ expect(format.formatNumber('10.2')).toBe('10');
+ expect(format.formatNumber('10.5')).toBe('11');
+});
+
+test('formatLongNumber', () => {
+ expect(format.formatLongNumber(1200000)).toBe('1.2m');
+ expect(format.formatLongNumber(575000)).toBe('575k');
+ expect(format.formatLongNumber(10500)).toBe('10.5k');
+ expect(format.formatLongNumber(1200)).toBe('1.20k');
+});
+
+test('stringToColor', () => {
+ expect(format.stringToColor('hello')).toBe('#d218e9');
+ expect(format.stringToColor('goodbye')).toBe('#11e956');
+});
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..ba6d8b0
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,80 @@
+import debug from 'debug';
+import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants';
+import { secret } from '@/lib/crypto';
+import { getRandomChars } from '@/lib/generate';
+import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt';
+import redis from '@/lib/redis';
+import { ensureArray } from '@/lib/utils';
+import { getUser } from '@/queries/prisma/user';
+
+const log = debug('umami:auth');
+
+export function getBearerToken(request: Request) {
+ const auth = request.headers.get('authorization');
+
+ return auth?.split(' ')[1];
+}
+
+export async function checkAuth(request: Request) {
+ const token = getBearerToken(request);
+ const payload = parseSecureToken(token, secret());
+ const shareToken = await parseShareToken(request);
+
+ let user = null;
+ const { userId, authKey } = payload || {};
+
+ if (userId) {
+ user = await getUser(userId);
+ } else if (redis.enabled && authKey) {
+ const key = await redis.client.get(authKey);
+
+ if (key?.userId) {
+ user = await getUser(key.userId);
+ }
+ }
+
+ log({ token, payload, authKey, shareToken, user });
+
+ if (!user?.id && !shareToken) {
+ log('User not authorized');
+ return null;
+ }
+
+ if (user) {
+ user.isAdmin = user.role === ROLES.admin;
+ }
+
+ return {
+ token,
+ authKey,
+ shareToken,
+ user,
+ };
+}
+
+export async function saveAuth(data: any, expire = 0) {
+ const authKey = `auth:${getRandomChars(32)}`;
+
+ if (redis.enabled) {
+ await redis.client.set(authKey, data);
+
+ if (expire) {
+ await redis.client.expire(authKey, expire);
+ }
+ }
+
+ return createSecureToken({ authKey }, secret());
+}
+
+export async function hasPermission(role: string, permission: string | string[]) {
+ return ensureArray(permission).some(e => ROLE_PERMISSIONS[role]?.includes(e));
+}
+
+export function parseShareToken(request: Request) {
+ try {
+ return parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret());
+ } catch (e) {
+ log(e);
+ return null;
+ }
+}
diff --git a/src/lib/charts.ts b/src/lib/charts.ts
new file mode 100644
index 0000000..7d4208e
--- /dev/null
+++ b/src/lib/charts.ts
@@ -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, 'PP', locale).replace(/\W*20\d{2}\W*/, ''); // Remove year
+ case 'month':
+ return formatDate(d, 'MMM', locale);
+ case 'year':
+ return formatDate(d, 'yyyy', locale);
+ default:
+ return label;
+ }
+ };
+}
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
new file mode 100644
index 0000000..f2ebbb7
--- /dev/null
+++ b/src/lib/clickhouse.ts
@@ -0,0 +1,273 @@
+import { type ClickHouseClient, createClient } from '@clickhouse/client';
+import { formatInTimeZone } from 'date-fns-tz';
+import debug from 'debug';
+import { CLICKHOUSE } from '@/lib/db';
+import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants';
+import { filtersObjectToArray } from './params';
+import type { QueryFilters, QueryOptions } from './types';
+
+export const CLICKHOUSE_DATE_FORMATS = {
+ utc: '%Y-%m-%dT%H:%i:%SZ',
+ second: '%Y-%m-%d %H:%i:%S',
+ minute: '%Y-%m-%d %H:%i:00',
+ hour: '%Y-%m-%d %H:00:00',
+ day: '%Y-%m-%d',
+ month: '%Y-%m-01',
+ year: '%Y-01-01',
+};
+
+const log = debug('umami:clickhouse');
+
+let clickhouse: ClickHouseClient;
+const enabled = Boolean(process.env.CLICKHOUSE_URL);
+
+function getClient() {
+ const {
+ hostname,
+ port,
+ pathname,
+ protocol,
+ username = 'default',
+ password,
+ } = new URL(process.env.CLICKHOUSE_URL);
+
+ const client = createClient({
+ url: `${protocol}//${hostname}:${port}`,
+ database: pathname.replace('/', ''),
+ username: username,
+ password,
+ });
+
+ if (process.env.NODE_ENV !== 'production') {
+ globalThis[CLICKHOUSE] = client;
+ }
+
+ log('Clickhouse initialized');
+
+ return client;
+}
+
+function getUTCString(date?: Date | string | number) {
+ return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss');
+}
+
+function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
+ if (timezone) {
+ return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`;
+ }
+
+ return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
+}
+
+function getDateSQL(field: string, unit: string, timezone?: string) {
+ if (timezone) {
+ return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`;
+ }
+ return `toDateTime(date_trunc('${unit}', ${field}))`;
+}
+
+function getSearchSQL(column: string, param: string = 'search'): string {
+ return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`;
+}
+
+function mapFilter(column: string, operator: string, name: string, type: string = 'String') {
+ const value = `{${name}:${type}}`;
+
+ switch (operator) {
+ case OPERATORS.equals:
+ return `${column} = ${value}`;
+ case OPERATORS.notEquals:
+ return `${column} != ${value}`;
+ case OPERATORS.contains:
+ return `positionCaseInsensitive(${column}, ${value}) > 0`;
+ case OPERATORS.doesNotContain:
+ return `positionCaseInsensitive(${column}, ${value}) = 0`;
+ default:
+ return '';
+ }
+}
+
+function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
+ const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => {
+ const isCohort = options?.isCohort;
+
+ if (isCohort) {
+ column = FILTER_COLUMNS[name.slice('cohort_'.length)];
+ }
+
+ if (column) {
+ if (name === 'eventType') {
+ arr.push(`and ${mapFilter(column, operator, name, 'UInt32')}`);
+ } else {
+ arr.push(`and ${mapFilter(column, operator, name)}`);
+ }
+
+ if (name === 'referrer') {
+ arr.push(`and referrer_domain != hostname`);
+ }
+ }
+
+ return arr;
+ }, []);
+
+ return query.join('\n');
+}
+
+function getCohortQuery(filters: Record<string, any>) {
+ if (!filters || Object.keys(filters).length === 0) {
+ return '';
+ }
+
+ const filterQuery = getFilterQuery(filters, { isCohort: true });
+
+ return `join (
+ select distinct session_id
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {cohort_startDate:DateTime64} and {cohort_endDate:DateTime64}
+ ${filterQuery}
+ ) as cohort
+ on cohort.session_id = website_event.session_id
+ `;
+}
+
+function getDateQuery(filters: Record<string, any>) {
+ const { startDate, endDate, timezone } = filters;
+
+ if (startDate) {
+ if (endDate) {
+ if (timezone) {
+ return `and created_at between toTimezone({startDate:DateTime64},{timezone:String}) and toTimezone({endDate:DateTime64},{timezone:String})`;
+ }
+ return `and created_at between {startDate:DateTime64} and {endDate:DateTime64}`;
+ } else {
+ if (timezone) {
+ return `and created_at >= toTimezone({startDate:DateTime64},{timezone:String})`;
+ }
+ return `and created_at >= {startDate:DateTime64}`;
+ }
+ }
+
+ return '';
+}
+
+function getQueryParams(filters: Record<string, any>) {
+ return {
+ ...filters,
+ ...filtersObjectToArray(filters).reduce((obj, { name, value }) => {
+ if (name && value !== undefined) {
+ obj[name] = value;
+ }
+
+ return obj;
+ }, {}),
+ };
+}
+
+function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
+ const cohortFilters = Object.fromEntries(
+ Object.entries(filters).filter(([key]) => key.startsWith('cohort_')),
+ );
+
+ return {
+ filterQuery: getFilterQuery(filters, options),
+ dateQuery: getDateQuery(filters),
+ queryParams: getQueryParams(filters),
+ cohortQuery: getCohortQuery(cohortFilters),
+ };
+}
+
+async function pagedRawQuery(
+ query: string,
+ queryParams: Record<string, any>,
+ filters: QueryFilters,
+ name?: string,
+) {
+ const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters;
+ const size = +pageSize || DEFAULT_PAGE_SIZE;
+ const offset = +size * (+page - 1);
+ const direction = sortDescending ? 'desc' : 'asc';
+
+ const statements = [
+ orderBy && `order by ${orderBy} ${direction}`,
+ +size > 0 && `limit ${+size} offset ${+offset}`,
+ ]
+ .filter(n => n)
+ .join('\n');
+
+ const count = await rawQuery(`select count(*) as num from (${query}) t`, queryParams).then(
+ res => res[0].num,
+ );
+
+ const data = await rawQuery(`${query}${statements}`, queryParams, name);
+
+ return { data, count, page: +page, pageSize: size, orderBy, search };
+}
+
+async function rawQuery<T = unknown>(
+ query: string,
+ params: Record<string, unknown> = {},
+ name?: string,
+): Promise<T> {
+ if (process.env.LOG_QUERY) {
+ log({ query, params, name });
+ }
+
+ await connect();
+
+ const resultSet = await clickhouse.query({
+ query: query,
+ query_params: params,
+ format: 'JSONEachRow',
+ clickhouse_settings: {
+ date_time_output_format: 'iso',
+ output_format_json_quote_64bit_integers: 0,
+ },
+ });
+
+ return (await resultSet.json()) as T;
+}
+
+async function insert(table: string, values: any[]) {
+ await connect();
+
+ return clickhouse.insert({ table, values, format: 'JSONEachRow' });
+}
+
+async function findUnique(data: any[]) {
+ if (data.length > 1) {
+ throw `${data.length} records found when expecting 1.`;
+ }
+
+ return findFirst(data);
+}
+
+async function findFirst(data: any[]) {
+ return data[0] ?? null;
+}
+
+async function connect() {
+ if (enabled && !clickhouse) {
+ clickhouse = process.env.CLICKHOUSE_URL && (globalThis[CLICKHOUSE] || getClient());
+ }
+
+ return clickhouse;
+}
+
+export default {
+ enabled,
+ client: clickhouse,
+ log,
+ connect,
+ getDateStringSQL,
+ getDateSQL,
+ getSearchSQL,
+ getFilterQuery,
+ getUTCString,
+ parseFilters,
+ pagedRawQuery,
+ findUnique,
+ findFirst,
+ rawQuery,
+ insert,
+};
diff --git a/src/lib/client.ts b/src/lib/client.ts
new file mode 100644
index 0000000..e176215
--- /dev/null
+++ b/src/lib/client.ts
@@ -0,0 +1,14 @@
+import { getItem, removeItem, setItem } from '@/lib/storage';
+import { AUTH_TOKEN } from './constants';
+
+export function getClientAuthToken() {
+ return getItem(AUTH_TOKEN);
+}
+
+export function setClientAuthToken(token: string) {
+ setItem(AUTH_TOKEN, token);
+}
+
+export function removeClientAuthToken() {
+ removeItem(AUTH_TOKEN);
+}
diff --git a/src/lib/colors.ts b/src/lib/colors.ts
new file mode 100644
index 0000000..2ae9bda
--- /dev/null
+++ b/src/lib/colors.ts
@@ -0,0 +1,91 @@
+import { colord } from 'colord';
+import { THEME_COLORS } from '@/lib/constants';
+
+export function hex6(str: string) {
+ let h = 0x811c9dc5; // FNV-1a 32-bit offset
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = (h >>> 0) * 0x01000193; // FNV prime
+ }
+ // use lower 24 bits; pad to 6 hex chars
+ return ((h >>> 0) & 0xffffff).toString(16).padStart(6, '0');
+}
+
+export const pick = (num: number, arr: any[]) => {
+ return arr[num % arr.length];
+};
+
+export function clamp(num: number, min: number, max: number) {
+ return num < min ? min : num > max ? max : num;
+}
+
+export function hex2RGB(color: string, min: number = 0, max: number = 255) {
+ const c = color.replace(/^#/, '');
+ const diff = max - min;
+
+ const normalize = (num: number) => {
+ return Math.floor((num / 255) * diff + min);
+ };
+
+ const r = normalize(parseInt(c.substring(0, 2), 16));
+ const g = normalize(parseInt(c.substring(2, 4), 16));
+ const b = normalize(parseInt(c.substring(4, 6), 16));
+
+ return { r, g, b };
+}
+
+export function rgb2Hex(r: number, g: number, b: number, prefix = '') {
+ return `${prefix}${r.toString(16)}${g.toString(16)}${b.toString(16)}`;
+}
+
+export function getPastel(color: string, factor: number = 0.5, prefix = '') {
+ let { r, g, b } = hex2RGB(color);
+
+ r = Math.floor((r + 255 * factor) / (1 + factor));
+ g = Math.floor((g + 255 * factor) / (1 + factor));
+ b = Math.floor((b + 255 * factor) / (1 + factor));
+
+ return rgb2Hex(r, g, b, prefix);
+}
+
+export function getColor(seed: string, min: number = 0, max: number = 255) {
+ const color = hex6(seed);
+ const { r, g, b } = hex2RGB(color, min, max);
+
+ return rgb2Hex(r, g, b);
+}
+
+export function getThemeColors(theme: string) {
+ const { primary, text, line, fill } = THEME_COLORS[theme];
+ const primaryColor = colord(THEME_COLORS[theme].primary);
+
+ return {
+ colors: {
+ theme: {
+ ...THEME_COLORS[theme],
+ },
+ chart: {
+ text,
+ line,
+ views: {
+ hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(),
+ backgroundColor: primaryColor.alpha(0.4).toRgbString(),
+ borderColor: primaryColor.alpha(0.7).toRgbString(),
+ hoverBorderColor: primaryColor.toRgbString(),
+ },
+ visitors: {
+ hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
+ backgroundColor: primaryColor.alpha(0.6).toRgbString(),
+ borderColor: primaryColor.alpha(0.9).toRgbString(),
+ hoverBorderColor: primaryColor.toRgbString(),
+ },
+ },
+ map: {
+ baseColor: primary,
+ fillColor: fill,
+ strokeColor: primary,
+ hoverColor: primary,
+ },
+ },
+ };
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 0000000..e5090c3
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1,682 @@
+export const CURRENT_VERSION = process.env.currentVersion;
+export const AUTH_TOKEN = 'umami.auth';
+export const LOCALE_CONFIG = 'umami.locale';
+export const TIMEZONE_CONFIG = 'umami.timezone';
+export const DATE_RANGE_CONFIG = 'umami.date-range';
+export const THEME_CONFIG = 'umami.theme';
+export const DASHBOARD_CONFIG = 'umami.dashboard';
+export const LAST_TEAM_CONFIG = 'umami.last-team';
+export const VERSION_CHECK = 'umami.version-check';
+export const SHARE_TOKEN_HEADER = 'x-umami-share-token';
+export const HOMEPAGE_URL = 'https://umami.is';
+export const DOCS_URL = 'https://umami.is/docs';
+export const REPO_URL = 'https://github.com/umami-software/umami';
+export const UPDATES_URL = 'https://api.umami.is/v1/updates';
+export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png';
+export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico';
+export const LINKS_URL = `${globalThis?.location?.origin}/q`;
+export const PIXELS_URL = `${globalThis?.location?.origin}/p`;
+
+export const DEFAULT_LOCALE = 'en-US';
+export const DEFAULT_THEME = 'light';
+export const DEFAULT_ANIMATION_DURATION = 300;
+export const DEFAULT_DATE_RANGE_VALUE = '24hour';
+export const DEFAULT_WEBSITE_LIMIT = 10;
+export const DEFAULT_RESET_DATE = '2000-01-01';
+export const DEFAULT_PAGE_SIZE = 20;
+export const DEFAULT_DATE_COMPARE = 'prev';
+
+export const REALTIME_RANGE = 30;
+export const REALTIME_INTERVAL = 10000;
+
+export const UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute'];
+
+export const EVENT_COLUMNS = [
+ 'path',
+ 'entry',
+ 'exit',
+ 'referrer',
+ 'domain',
+ 'title',
+ 'query',
+ 'event',
+ 'tag',
+ 'hostname',
+];
+
+export const SESSION_COLUMNS = [
+ 'browser',
+ 'os',
+ 'device',
+ 'screen',
+ 'language',
+ 'country',
+ 'city',
+ 'region',
+];
+
+export const SEGMENT_TYPES = {
+ segment: 'segment',
+ cohort: 'cohort',
+};
+
+export const FILTER_COLUMNS = {
+ path: 'url_path',
+ entry: 'url_path',
+ exit: 'url_path',
+ referrer: 'referrer_domain',
+ domain: 'referrer_domain',
+ hostname: 'hostname',
+ title: 'page_title',
+ query: 'url_query',
+ os: 'os',
+ browser: 'browser',
+ device: 'device',
+ country: 'country',
+ region: 'region',
+ city: 'city',
+ language: 'language',
+ event: 'event_name',
+ tag: 'tag',
+ eventType: 'event_type',
+};
+
+export const COLLECTION_TYPE = {
+ event: 'event',
+ identify: 'identify',
+} as const;
+
+export const EVENT_TYPE = {
+ pageView: 1,
+ customEvent: 2,
+ linkEvent: 3,
+ pixelEvent: 4,
+} as const;
+
+export const DATA_TYPE = {
+ string: 1,
+ number: 2,
+ boolean: 3,
+ date: 4,
+ array: 5,
+} as const;
+
+export const OPERATORS = {
+ equals: 'eq',
+ notEquals: 'neq',
+ set: 's',
+ notSet: 'ns',
+ contains: 'c',
+ doesNotContain: 'dnc',
+ true: 't',
+ false: 'f',
+ greaterThan: 'gt',
+ lessThan: 'lt',
+ greaterThanEquals: 'gte',
+ lessThanEquals: 'lte',
+ before: 'bf',
+ after: 'af',
+} as const;
+
+export const DATA_TYPES = {
+ [DATA_TYPE.string]: 'string',
+ [DATA_TYPE.number]: 'number',
+ [DATA_TYPE.boolean]: 'boolean',
+ [DATA_TYPE.date]: 'date',
+ [DATA_TYPE.array]: 'array',
+} as const;
+
+export const ROLES = {
+ admin: 'admin',
+ user: 'user',
+ viewOnly: 'view-only',
+ teamOwner: 'team-owner',
+ teamManager: 'team-manager',
+ teamMember: 'team-member',
+ teamViewOnly: 'team-view-only',
+} as const;
+
+export const PERMISSIONS = {
+ all: 'all',
+ websiteCreate: 'website:create',
+ websiteUpdate: 'website:update',
+ websiteDelete: 'website:delete',
+ websiteTransferToTeam: 'website:transfer-to-team',
+ websiteTransferToUser: 'website:transfer-to-user',
+ teamCreate: 'team:create',
+ teamUpdate: 'team:update',
+ teamDelete: 'team:delete',
+} as const;
+
+export const ROLE_PERMISSIONS = {
+ [ROLES.admin]: [PERMISSIONS.all],
+ [ROLES.user]: [
+ PERMISSIONS.websiteCreate,
+ PERMISSIONS.websiteUpdate,
+ PERMISSIONS.websiteDelete,
+ PERMISSIONS.teamCreate,
+ ],
+ [ROLES.viewOnly]: [],
+ [ROLES.teamOwner]: [
+ PERMISSIONS.teamUpdate,
+ PERMISSIONS.teamDelete,
+ PERMISSIONS.websiteCreate,
+ PERMISSIONS.websiteUpdate,
+ PERMISSIONS.websiteDelete,
+ PERMISSIONS.websiteTransferToTeam,
+ PERMISSIONS.websiteTransferToUser,
+ ],
+ [ROLES.teamManager]: [
+ PERMISSIONS.teamUpdate,
+ PERMISSIONS.websiteCreate,
+ PERMISSIONS.websiteUpdate,
+ PERMISSIONS.websiteDelete,
+ PERMISSIONS.websiteTransferToTeam,
+ ],
+ [ROLES.teamMember]: [
+ PERMISSIONS.websiteCreate,
+ PERMISSIONS.websiteUpdate,
+ PERMISSIONS.websiteDelete,
+ ],
+ [ROLES.teamViewOnly]: [],
+} as const;
+
+export const THEME_COLORS = {
+ light: {
+ primary: '#2680eb',
+ text: '#838383',
+ line: '#d9d9d9',
+ fill: '#f9f9f9',
+ },
+ dark: {
+ primary: '#2680eb',
+ text: '#7b7b7b',
+ line: '#3a3a3a',
+ fill: '#191919',
+ },
+} as const;
+
+export const CHART_COLORS = [
+ '#2680eb',
+ '#9256d9',
+ '#44b556',
+ '#e68619',
+ '#e34850',
+ '#f7bd12',
+ '#01bad7',
+ '#6734bc',
+ '#89c541',
+ '#ffc301',
+ '#ec1562',
+ '#ffec16',
+];
+
+export const DOMAIN_REGEX =
+ /^(localhost(:[1-9]\d{0,4})?|((?=[a-z0-9-_]{1,63}\.)(xn--)?[a-z0-9-_]+(-[a-z0-9-_]+)*\.)+(xn--)?[a-z0-9-_]{2,63})$/;
+export const SHARE_ID_REGEX = /^[a-zA-Z0-9]{8,50}$/;
+export const DATETIME_REGEX =
+ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3}(Z|\+[0-9]{2}:[0-9]{2})?)?$/;
+
+export const URL_LENGTH = 500;
+export const PAGE_TITLE_LENGTH = 500;
+export const EVENT_NAME_LENGTH = 50;
+
+export const UTM_PARAMS = ['utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term'];
+
+export const OS_NAMES = {
+ 'Android OS': 'Android',
+ 'Chrome OS': 'ChromeOS',
+ 'Mac OS': 'macOS',
+ 'Sun OS': 'SunOS',
+ 'Windows 10': 'Windows 10/11',
+} as const;
+
+export const BROWSERS = {
+ android: 'Android',
+ aol: 'AOL',
+ bb10: 'BlackBerry 10',
+ beaker: 'Beaker',
+ chrome: 'Chrome',
+ 'chromium-webview': 'Chrome (webview)',
+ crios: 'Chrome (iOS)',
+ curl: 'Curl',
+ edge: 'Edge',
+ 'edge-chromium': 'Edge (Chromium)',
+ 'edge-ios': 'Edge (iOS)',
+ facebook: 'Facebook',
+ firefox: 'Firefox',
+ fxios: 'Firefox (iOS)',
+ ie: 'IE',
+ instagram: 'Instagram',
+ ios: 'iOS',
+ 'ios-webview': 'iOS (webview)',
+ kakaotalk: 'KakaoTalk',
+ miui: 'MIUI',
+ opera: 'Opera',
+ 'opera-mini': 'Opera Mini',
+ phantomjs: 'PhantomJS',
+ safari: 'Safari',
+ samsung: 'Samsung',
+ searchbot: 'Searchbot',
+ silk: 'Silk',
+ yandexbrowser: 'Yandex',
+} as const;
+
+export const SOCIAL_DOMAINS = [
+ 'bsky.app',
+ 'facebook.com',
+ 'fb.com',
+ 'ig.com',
+ 'instagram.com',
+ 'linkedin.',
+ 'news.ycombinator.com',
+ 'pinterest.',
+ 'reddit.',
+ 'snapchat.',
+ 't.co',
+ 'threads.net',
+ 'tiktok.',
+ 'twitter.com',
+ 'x.com',
+];
+
+export const SEARCH_DOMAINS = [
+ 'baidu.com',
+ 'bing.com',
+ 'chatgpt.com',
+ 'duckduckgo.com',
+ 'ecosia.org',
+ 'google.',
+ 'msn.com',
+ 'perplexity.ai',
+ 'search.brave.com',
+ 'yandex.',
+];
+
+export const SHOPPING_DOMAINS = [
+ 'alibaba.com',
+ 'aliexpress.com',
+ 'amazon.',
+ 'bestbuy.com',
+ 'ebay.com',
+ 'etsy.com',
+ 'newegg.com',
+ 'target.com',
+ 'walmart.com',
+];
+
+export const EMAIL_DOMAINS = [
+ 'gmail.',
+ 'hotmail.',
+ 'mail.yahoo.',
+ 'outlook.',
+ 'proton.me',
+ 'protonmail.',
+];
+
+export const VIDEO_DOMAINS = ['twitch.', 'youtube.'];
+
+export const PAID_AD_PARAMS = [
+ 'ad_id=',
+ 'aid=',
+ 'dclid=',
+ 'epik=',
+ 'fbclid=',
+ 'gclid=',
+ 'li_fat_id=',
+ 'msclkid=',
+ 'ob_click_id=',
+ 'pc_id=',
+ 'rdt_cid=',
+ 'scid=',
+ 'ttclid=',
+ 'twclid=',
+ 'utm_medium=cpc',
+ 'utm_medium=paid',
+ 'utm_medium=paid_social',
+ 'utm_source=google',
+];
+
+export const GROUPED_DOMAINS = [
+ { name: 'Baidu', domain: 'baidu.com', match: 'baidu.' },
+ { name: 'Bing', domain: 'bing.com', match: 'bing.' },
+ { name: 'Brave', domain: 'brave.com', match: 'brave.' },
+ { name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' },
+ { name: 'DuckDuckGo', domain: 'duckduckgo.com', match: 'duckduckgo.' },
+ { name: 'Facebook', domain: 'facebook.com', match: 'facebook.' },
+ { name: 'GitHub', domain: 'github.com', match: 'github.' },
+ { name: 'Google', domain: 'google.com', match: 'google.' },
+ { name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' },
+ { name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] },
+ { name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' },
+ { name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' },
+ { name: 'Reddit', domain: 'reddit.com', match: 'reddit.' },
+ { name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' },
+ { name: 'Twitter', domain: 'twitter.com', match: ['twitter.', 't.co', 'x.com'] },
+ { name: 'Yahoo', domain: 'yahoo.com', match: 'yahoo.' },
+ { name: 'Yandex', domain: 'yandex.ru', match: 'yandex.' },
+];
+
+export const MAP_FILE = '/datamaps.world.json';
+
+export const ISO_COUNTRIES = {
+ ABW: 'AW',
+ AFG: 'AF',
+ AGO: 'AO',
+ AIA: 'AI',
+ ALA: 'AX',
+ ALB: 'AL',
+ AND: 'AD',
+ ANT: 'AN',
+ ARE: 'AE',
+ ARG: 'AR',
+ ARM: 'AM',
+ ASM: 'AS',
+ ATF: 'TF',
+ ATG: 'AG',
+ AUS: 'AU',
+ AUT: 'AT',
+ AZE: 'AZ',
+ BDI: 'BI',
+ BEL: 'BE',
+ BEN: 'BJ',
+ BFA: 'BF',
+ BGD: 'BD',
+ BGR: 'BG',
+ BHR: 'BH',
+ BHS: 'BS',
+ BIH: 'BA',
+ BLR: 'BY',
+ BLZ: 'BZ',
+ BLM: 'BL',
+ BMU: 'BM',
+ BOL: 'BO',
+ BRA: 'BR',
+ BRB: 'BB',
+ BRN: 'BN',
+ BTN: 'BT',
+ BVT: 'BV',
+ BWA: 'BW',
+ CAF: 'CF',
+ CAN: 'CA',
+ CCK: 'CC',
+ CHE: 'CH',
+ CHL: 'CL',
+ CHN: 'CN',
+ CIV: 'CI',
+ CMR: 'CM',
+ COD: 'CD',
+ COG: 'CG',
+ COK: 'CK',
+ COL: 'CO',
+ COM: 'KM',
+ CPV: 'CV',
+ CRI: 'CR',
+ CUB: 'CU',
+ CXR: 'CX',
+ CYM: 'KY',
+ CYP: 'CY',
+ CZE: 'CZ',
+ DEU: 'DE',
+ DJI: 'DJ',
+ DMA: 'DM',
+ DNK: 'DK',
+ DOM: 'DO',
+ DZA: 'DZ',
+ ECU: 'EC',
+ EGY: 'EG',
+ ERI: 'ER',
+ ESH: 'EH',
+ ESP: 'ES',
+ EST: 'EE',
+ ETH: 'ET',
+ FIN: 'FI',
+ FJI: 'FJ',
+ FLK: 'FK',
+ FRA: 'FR',
+ FRO: 'FO',
+ FSM: 'FM',
+ GAB: 'GA',
+ GBR: 'GB',
+ GEO: 'GE',
+ GGY: 'GG',
+ GHA: 'GH',
+ GIB: 'GI',
+ GIN: 'GN',
+ GLP: 'GP',
+ GMB: 'GM',
+ GNB: 'GW',
+ GNQ: 'GQ',
+ GRC: 'GR',
+ GRD: 'GD',
+ GRL: 'GL',
+ GTM: 'GT',
+ GUF: 'GF',
+ GUM: 'GU',
+ GUY: 'GY',
+ HKG: 'HK',
+ HMD: 'HM',
+ HND: 'HN',
+ HRV: 'HR',
+ HTI: 'HT',
+ HUN: 'HU',
+ IDN: 'ID',
+ IMN: 'IM',
+ IND: 'IN',
+ IOT: 'IO',
+ IRL: 'IE',
+ IRN: 'IR',
+ IRQ: 'IQ',
+ ISL: 'IS',
+ ISR: 'IL',
+ ITA: 'IT',
+ JAM: 'JM',
+ JEY: 'JE',
+ JOR: 'JO',
+ JPN: 'JP',
+ KAZ: 'KZ',
+ KEN: 'KE',
+ KGZ: 'KG',
+ KHM: 'KH',
+ KIR: 'KI',
+ KNA: 'KN',
+ KOR: 'KR',
+ KWT: 'KW',
+ LAO: 'LA',
+ LBN: 'LB',
+ LBR: 'LR',
+ LBY: 'LY',
+ LCA: 'LC',
+ LIE: 'LI',
+ LKA: 'LK',
+ LSO: 'LS',
+ LTU: 'LT',
+ LUX: 'LU',
+ LVA: 'LV',
+ MAF: 'MF',
+ MAR: 'MA',
+ MCO: 'MC',
+ MDA: 'MD',
+ MDG: 'MG',
+ MDV: 'MV',
+ MEX: 'MX',
+ MHL: 'MH',
+ MKD: 'MK',
+ MLI: 'ML',
+ MLT: 'MT',
+ MMR: 'MM',
+ MNE: 'ME',
+ MNG: 'MN',
+ MNP: 'MP',
+ MOZ: 'MZ',
+ MRT: 'MR',
+ MSR: 'MS',
+ MTQ: 'MQ',
+ MUS: 'MU',
+ MWI: 'MW',
+ MYS: 'MY',
+ MYT: 'YT',
+ NAM: 'NA',
+ NCL: 'NC',
+ NER: 'NE',
+ NFK: 'NF',
+ NGA: 'NG',
+ NIC: 'NI',
+ NIU: 'NU',
+ NLD: 'NL',
+ NOR: 'NO',
+ NPL: 'NP',
+ NRU: 'NR',
+ NZL: 'NZ',
+ OMN: 'OM',
+ PAK: 'PK',
+ PAN: 'PA',
+ PCN: 'PN',
+ PER: 'PE',
+ PHL: 'PH',
+ PLW: 'PW',
+ PNG: 'PG',
+ POL: 'PL',
+ PRI: 'PR',
+ PRK: 'KP',
+ PRT: 'PT',
+ PRY: 'PY',
+ PSE: 'PS',
+ PYF: 'PF',
+ QAT: 'QA',
+ REU: 'RE',
+ ROU: 'RO',
+ RUS: 'RU',
+ RWA: 'RW',
+ SAU: 'SA',
+ SDN: 'SD',
+ SEN: 'SN',
+ SGP: 'SG',
+ SGS: 'GS',
+ SHN: 'SH',
+ SJM: 'SJ',
+ SLB: 'SB',
+ SLE: 'SL',
+ SLV: 'SV',
+ SMR: 'SM',
+ SOM: 'SO',
+ SPM: 'PM',
+ SRB: 'RS',
+ SUR: 'SR',
+ STP: 'ST',
+ SVK: 'SK',
+ SVN: 'SI',
+ SWE: 'SE',
+ SWZ: 'SZ',
+ SYC: 'SC',
+ SYR: 'SY',
+ TCA: 'TC',
+ TCD: 'TD',
+ TGO: 'TG',
+ THA: 'TH',
+ TJK: 'TJ',
+ TKL: 'TK',
+ TKM: 'TM',
+ TLS: 'TL',
+ TON: 'TO',
+ TTO: 'TT',
+ TUN: 'TN',
+ TUR: 'TR',
+ TUV: 'TV',
+ TWN: 'TW',
+ TZA: 'TZ',
+ UGA: 'UG',
+ UKR: 'UA',
+ UMI: 'UM',
+ URY: 'UY',
+ USA: 'US',
+ UZB: 'UZ',
+ VAT: 'VA',
+ VCT: 'VC',
+ VEN: 'VE',
+ VGB: 'VG',
+ VIR: 'VI',
+ VNM: 'VN',
+ VUT: 'VU',
+ WLF: 'WF',
+ WSM: 'WS',
+ XKX: 'XK',
+ YEM: 'YE',
+ ZAF: 'ZA',
+ ZMB: 'ZM',
+ ZWE: 'ZW',
+};
+
+export const CURRENCIES = [
+ { id: 'USD', name: 'US Dollar' },
+ { id: 'EUR', name: 'Euro' },
+ { id: 'GBP', name: 'British Pound' },
+ { id: 'JPY', name: 'Japanese Yen' },
+ { id: 'CNY', name: 'Chinese Renminbi (Yuan)' },
+ { id: 'CAD', name: 'Canadian Dollar' },
+ { id: 'HKD', name: 'Hong Kong Dollar' },
+ { id: 'AUD', name: 'Australian Dollar' },
+ { id: 'SGD', name: 'Singapore Dollar' },
+ { id: 'CHF', name: 'Swiss Franc' },
+ { id: 'SEK', name: 'Swedish Krona' },
+ { id: 'PLN', name: 'Polish Złoty' },
+ { id: 'NOK', name: 'Norwegian Krone' },
+ { id: 'DKK', name: 'Danish Krone' },
+ { id: 'NZD', name: 'New Zealand Dollar' },
+ { id: 'ZAR', name: 'South African Rand' },
+ { id: 'MXN', name: 'Mexican Peso' },
+ { id: 'THB', name: 'Thai Baht' },
+ { id: 'HUF', name: 'Hungarian Forint' },
+ { id: 'MYR', name: 'Malaysian Ringgit' },
+ { id: 'INR', name: 'Indian Rupee' },
+ { id: 'KRW', name: 'South Korean Won' },
+ { id: 'BRL', name: 'Brazilian Real' },
+ { id: 'TRY', name: 'Turkish Lira' },
+ { id: 'CZK', name: 'Czech Koruna' },
+ { id: 'ILS', name: 'Israeli New Shekel' },
+ { id: 'RUB', name: 'Russian Ruble' },
+ { id: 'AED', name: 'United Arab Emirates Dirham' },
+ { id: 'IDR', name: 'Indonesian Rupiah' },
+ { id: 'PHP', name: 'Philippine Peso' },
+ { id: 'RON', name: 'Romanian Leu' },
+ { id: 'COP', name: 'Colombian Peso' },
+ { id: 'SAR', name: 'Saudi Riyal' },
+ { id: 'ARS', name: 'Argentine Peso' },
+ { id: 'VND', name: 'Vietnamese Dong' },
+ { id: 'CLP', name: 'Chilean Peso' },
+ { id: 'EGP', name: 'Egyptian Pound' },
+ { id: 'KWD', name: 'Kuwaiti Dinar' },
+ { id: 'PKR', name: 'Pakistani Rupee' },
+ { id: 'QAR', name: 'Qatari Riyal' },
+ { id: 'BHD', name: 'Bahraini Dinar' },
+ { id: 'UAH', name: 'Ukrainian Hryvnia' },
+ { id: 'PEN', name: 'Peruvian Sol' },
+ { id: 'BDT', name: 'Bangladeshi Taka' },
+ { id: 'MAD', name: 'Moroccan Dirham' },
+ { id: 'KES', name: 'Kenyan Shilling' },
+ { id: 'NGN', name: 'Nigerian Naira' },
+ { id: 'TND', name: 'Tunisian Dinar' },
+ { id: 'OMR', name: 'Omani Rial' },
+ { id: 'GHS', name: 'Ghanaian Cedi' },
+];
+
+export const TIMEZONE_LEGACY: Record<string, string> = {
+ 'Asia/Batavia': 'Asia/Jakarta',
+ 'Asia/Calcutta': 'Asia/Kolkata',
+ 'Asia/Chongqing': 'Asia/Shanghai',
+ 'Asia/Harbin': 'Asia/Shanghai',
+ 'Asia/Jayapura': 'Asia/Pontianak',
+ 'Asia/Katmandu': 'Asia/Kathmandu',
+ 'Asia/Macao': 'Asia/Macau',
+ 'Asia/Rangoon': 'Asia/Yangon',
+ 'Asia/Saigon': 'Asia/Ho_Chi_Minh',
+ 'Europe/Kiev': 'Europe/Kyiv',
+ 'Europe/Zaporozhye': 'Europe/Kyiv',
+ 'Etc/UTC': 'UTC',
+ 'US/Arizona': 'America/Phoenix',
+ 'US/Central': 'America/Chicago',
+ 'US/Eastern': 'America/New_York',
+ 'US/Mountain': 'America/Denver',
+ 'US/Pacific': 'America/Los_Angeles',
+ 'US/Samoa': 'Pacific/Pago_Pago',
+};
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
new file mode 100644
index 0000000..a6d912b
--- /dev/null
+++ b/src/lib/crypto.ts
@@ -0,0 +1,65 @@
+import crypto from 'node:crypto';
+import { v4, v5, v7 } from 'uuid';
+
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 16;
+const SALT_LENGTH = 64;
+const TAG_LENGTH = 16;
+const TAG_POSITION = SALT_LENGTH + IV_LENGTH;
+const ENC_POSITION = TAG_POSITION + TAG_LENGTH;
+
+const HASH_ALGO = 'sha512';
+const HASH_ENCODING = 'hex';
+
+const getKey = (password: string, salt: Buffer) =>
+ crypto.pbkdf2Sync(password, salt, 10000, 32, 'sha512');
+
+export function encrypt(value: any, secret: any) {
+ const iv = crypto.randomBytes(IV_LENGTH);
+ const salt = crypto.randomBytes(SALT_LENGTH);
+ const key = getKey(secret, salt);
+
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
+
+ const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
+
+ const tag = cipher.getAuthTag();
+
+ return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
+}
+
+export function decrypt(value: any, secret: any) {
+ const str = Buffer.from(String(value), 'base64');
+ const salt = str.subarray(0, SALT_LENGTH);
+ const iv = str.subarray(SALT_LENGTH, TAG_POSITION);
+ const tag = str.subarray(TAG_POSITION, ENC_POSITION);
+ const encrypted = str.subarray(ENC_POSITION);
+
+ const key = getKey(secret, salt);
+
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
+
+ decipher.setAuthTag(tag);
+
+ return decipher.update(encrypted) + decipher.final('utf8');
+}
+
+export function hash(...args: string[]) {
+ return crypto.createHash(HASH_ALGO).update(args.join('')).digest(HASH_ENCODING);
+}
+
+export function md5(...args: string[]) {
+ return crypto.createHash('md5').update(args.join('')).digest('hex');
+}
+
+export function secret() {
+ return hash(process.env.APP_SECRET || process.env.DATABASE_URL);
+}
+
+export function uuid(...args: any) {
+ if (args.length) {
+ return v5(hash(...args, secret()), v5.DNS);
+ }
+
+ return process.env.USE_UUIDV7 ? v7() : v4();
+}
diff --git a/src/lib/data.ts b/src/lib/data.ts
new file mode 100644
index 0000000..fe69edf
--- /dev/null
+++ b/src/lib/data.ts
@@ -0,0 +1,94 @@
+import { DATA_TYPE, DATETIME_REGEX } from './constants';
+import type { DynamicDataType } from './types';
+
+export function flattenJSON(
+ eventData: Record<string, any>,
+ keyValues: { key: string; value: any; dataType: DynamicDataType }[] = [],
+ parentKey = '',
+): { key: string; value: any; dataType: DynamicDataType }[] {
+ return Object.keys(eventData).reduce(
+ (acc, key) => {
+ const value = eventData[key];
+ const type = typeof eventData[key];
+
+ // nested object
+ if (value && type === 'object' && !Array.isArray(value) && !isValidDateValue(value)) {
+ flattenJSON(value, acc.keyValues, getKeyName(key, parentKey));
+ } else {
+ createKey(getKeyName(key, parentKey), value, acc);
+ }
+
+ return acc;
+ },
+ { keyValues, parentKey },
+ ).keyValues;
+}
+
+export function isValidDateValue(value: string) {
+ return typeof value === 'string' && DATETIME_REGEX.test(value);
+}
+
+export function getDataType(value: any): string {
+ let type: string = typeof value;
+
+ if (isValidDateValue(value)) {
+ type = 'date';
+ }
+
+ return type;
+}
+
+export function getStringValue(value: string, dataType: number) {
+ if (dataType === DATA_TYPE.number) {
+ return parseFloat(value).toFixed(4);
+ }
+
+ if (dataType === DATA_TYPE.date) {
+ return new Date(value).toISOString();
+ }
+
+ return value;
+}
+
+function createKey(key: string, value: string, acc: { keyValues: any[]; parentKey: string }) {
+ const type = getDataType(value);
+
+ let dataType = null;
+
+ switch (type) {
+ case 'number':
+ dataType = DATA_TYPE.number;
+ break;
+ case 'string':
+ dataType = DATA_TYPE.string;
+ break;
+ case 'boolean':
+ dataType = DATA_TYPE.boolean;
+ value = value ? 'true' : 'false';
+ break;
+ case 'date':
+ dataType = DATA_TYPE.date;
+ break;
+ case 'object':
+ dataType = DATA_TYPE.array;
+ value = JSON.stringify(value);
+ break;
+ default:
+ dataType = DATA_TYPE.string;
+ break;
+ }
+
+ acc.keyValues.push({ key, value, dataType });
+}
+
+function getKeyName(key: string, parentKey: string) {
+ if (!parentKey) {
+ return key;
+ }
+
+ return `${parentKey}.${key}`;
+}
+
+export function objectToArray(obj: object) {
+ return Object.keys(obj).map(key => obj[key]);
+}
diff --git a/src/lib/date.ts b/src/lib/date.ts
new file mode 100644
index 0000000..3c1fd1b
--- /dev/null
+++ b/src/lib/date.ts
@@ -0,0 +1,375 @@
+import {
+ addDays,
+ addHours,
+ addMinutes,
+ addMonths,
+ addWeeks,
+ addYears,
+ differenceInCalendarDays,
+ differenceInCalendarMonths,
+ differenceInCalendarWeeks,
+ differenceInCalendarYears,
+ differenceInHours,
+ differenceInMinutes,
+ endOfDay,
+ endOfHour,
+ endOfMinute,
+ endOfMonth,
+ endOfWeek,
+ endOfYear,
+ format,
+ isBefore,
+ isDate,
+ isEqual,
+ isSameDay,
+ max,
+ min,
+ startOfDay,
+ startOfHour,
+ startOfMinute,
+ startOfMonth,
+ startOfWeek,
+ startOfYear,
+ subDays,
+ subHours,
+ subMinutes,
+ subMonths,
+ subWeeks,
+ subYears,
+} from 'date-fns';
+import { utcToZonedTime } from 'date-fns-tz';
+import { getDateLocale } from '@/lib/lang';
+import type { DateRange } from '@/lib/types';
+
+export const TIME_UNIT = {
+ minute: 'minute',
+ hour: 'hour',
+ day: 'day',
+ week: 'week',
+ month: 'month',
+ year: 'year',
+};
+
+export const DATE_FUNCTIONS = {
+ minute: {
+ diff: differenceInMinutes,
+ add: addMinutes,
+ sub: subMinutes,
+ start: startOfMinute,
+ end: endOfMinute,
+ },
+ hour: {
+ diff: differenceInHours,
+ add: addHours,
+ sub: subHours,
+ start: startOfHour,
+ end: endOfHour,
+ },
+ day: {
+ diff: differenceInCalendarDays,
+ add: addDays,
+ sub: subDays,
+ start: startOfDay,
+ end: endOfDay,
+ },
+ week: {
+ diff: differenceInCalendarWeeks,
+ add: addWeeks,
+ sub: subWeeks,
+ start: startOfWeek,
+ end: endOfWeek,
+ },
+ month: {
+ diff: differenceInCalendarMonths,
+ add: addMonths,
+ sub: subMonths,
+ start: startOfMonth,
+ end: endOfMonth,
+ },
+ year: {
+ diff: differenceInCalendarYears,
+ add: addYears,
+ sub: subYears,
+ start: startOfYear,
+ end: endOfYear,
+ },
+};
+
+export const DATE_FORMATS = {
+ minute: 'yyyy-MM-dd HH:mm',
+ hour: 'yyyy-MM-dd HH',
+ day: 'yyyy-MM-dd',
+ week: "yyyy-'W'II",
+ month: 'yyyy-MM',
+ year: 'yyyy',
+};
+
+const TIMEZONE_MAPPINGS: Record<string, string> = {
+ 'Asia/Calcutta': 'Asia/Kolkata',
+};
+
+export function normalizeTimezone(timezone: string): string {
+ return TIMEZONE_MAPPINGS[timezone] || timezone;
+}
+
+export function isValidTimezone(timezone: string) {
+ try {
+ const normalizedTimezone = normalizeTimezone(timezone);
+ Intl.DateTimeFormat(undefined, { timeZone: normalizedTimezone });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function getTimezone() {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
+}
+
+export function parseDateValue(value: string) {
+ const match = value.match?.(/^(?<num>[0-9-]+)(?<unit>hour|day|week|month|year)$/);
+
+ if (!match) return null;
+
+ const { num, unit } = match.groups;
+
+ return { num: +num, unit };
+}
+
+export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange {
+ if (typeof value !== 'string') {
+ return null;
+ }
+
+ if (value.startsWith('range')) {
+ const [, startTime, endTime] = value.split(':');
+
+ const startDate = new Date(+startTime);
+ const endDate = new Date(+endTime);
+ const unit = getMinimumUnit(startDate, endDate);
+
+ return {
+ startDate,
+ endDate,
+ value,
+ ...parseDateValue(value),
+ unit,
+ };
+ }
+
+ const date = new Date();
+ const now = timezone ? utcToZonedTime(date, timezone) : date;
+ const dateLocale = getDateLocale(locale);
+ const { num = 1, unit } = parseDateValue(value);
+
+ switch (unit) {
+ case 'hour':
+ return {
+ startDate: num ? subHours(startOfHour(now), num) : startOfHour(now),
+ endDate: endOfHour(now),
+ offset: 0,
+ num: num || 1,
+ unit,
+ value,
+ };
+ case 'day':
+ return {
+ startDate: num ? subDays(startOfDay(now), num) : startOfDay(now),
+ endDate: endOfDay(now),
+ unit: num ? 'day' : 'hour',
+ offset: 0,
+ num: num || 1,
+ value,
+ };
+ case 'week':
+ return {
+ startDate: num
+ ? subWeeks(startOfWeek(now, { locale: dateLocale }), num)
+ : startOfWeek(now, { locale: dateLocale }),
+ endDate: endOfWeek(now, { locale: dateLocale }),
+ unit: 'day',
+ offset: 0,
+ num: num || 1,
+ value,
+ };
+ case 'month':
+ return {
+ startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now),
+ endDate: endOfMonth(now),
+ unit: num ? 'month' : 'day',
+ offset: 0,
+ num: num || 1,
+ value,
+ };
+ case 'year':
+ return {
+ startDate: num ? subYears(startOfYear(now), num) : startOfYear(now),
+ endDate: endOfYear(now),
+ unit: 'month',
+ offset: 0,
+ num: num || 1,
+ value,
+ };
+ }
+}
+
+export function getOffsetDateRange(dateRange: DateRange, offset: number) {
+ if (offset === 0) {
+ return dateRange;
+ }
+
+ const { startDate, endDate, unit, num, value } = dateRange;
+
+ const change = num * offset;
+ const { add } = DATE_FUNCTIONS[unit];
+ const { unit: originalUnit } = parseDateValue(value) || {};
+
+ switch (originalUnit) {
+ case 'day':
+ return {
+ ...dateRange,
+ offset,
+ startDate: addDays(startDate, change),
+ endDate: addDays(endDate, change),
+ };
+ case 'week':
+ return {
+ ...dateRange,
+ offset,
+ startDate: addWeeks(startDate, change),
+ endDate: addWeeks(endDate, change),
+ };
+ case 'month':
+ return {
+ ...dateRange,
+ offset,
+ startDate: addMonths(startDate, change),
+ endDate: addMonths(endDate, change),
+ };
+ case 'year':
+ return {
+ ...dateRange,
+ offset,
+ startDate: addYears(startDate, change),
+ endDate: addYears(endDate, change),
+ };
+ default:
+ return {
+ startDate: add(startDate, change),
+ endDate: add(endDate, change),
+ offset,
+ value,
+ unit,
+ num,
+ };
+ }
+}
+
+export function getAllowedUnits(startDate: Date, endDate: Date) {
+ const units = ['minute', 'hour', 'day', 'month', 'year'];
+ const minUnit = getMinimumUnit(startDate, endDate);
+ const index = units.indexOf(minUnit === 'year' ? 'month' : minUnit);
+
+ return index >= 0 ? units.splice(index) : [];
+}
+
+export function getMinimumUnit(startDate: number | Date, endDate: number | Date) {
+ if (differenceInMinutes(endDate, startDate) <= 60) {
+ return 'minute';
+ } else if (differenceInHours(endDate, startDate) <= 48) {
+ return 'hour';
+ } else if (differenceInCalendarMonths(endDate, startDate) <= 6) {
+ return 'day';
+ } else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
+ return 'month';
+ }
+
+ return 'year';
+}
+
+export function maxDate(...args: Date[]) {
+ return max(args.filter(n => isDate(n)));
+}
+
+export function minDate(...args: any[]) {
+ return min(args.filter(n => isDate(n)));
+}
+
+export function getCompareDate(compare: string, startDate: Date, endDate: Date) {
+ if (compare === 'yoy') {
+ return { compare, startDate: subYears(startDate, 1), endDate: subYears(endDate, 1) };
+ }
+
+ if (compare === 'prev') {
+ const diff = differenceInMinutes(endDate, startDate);
+
+ return { compare, startDate: subMinutes(startDate, diff), endDate: subMinutes(endDate, diff) };
+ }
+
+ return {};
+}
+
+export function getDayOfWeekAsDate(dayOfWeek: number) {
+ const startOfWeekDay = startOfWeek(new Date());
+ const daysToAdd = [0, 1, 2, 3, 4, 5, 6].indexOf(dayOfWeek);
+ let currentDate = addDays(startOfWeekDay, daysToAdd);
+
+ // Ensure we're not returning a past date
+ if (isSameDay(currentDate, startOfWeekDay)) {
+ currentDate = addDays(currentDate, 7);
+ }
+
+ return currentDate;
+}
+
+export function formatDate(
+ date: string | number | Date,
+ dateFormat: string = 'PPpp',
+ locale = 'en-US',
+) {
+ return format(typeof date === 'string' ? new Date(date) : date, dateFormat, {
+ locale: getDateLocale(locale),
+ });
+}
+
+export function generateTimeSeries(
+ data: { x: string; y: number; d?: string }[],
+ minDate: Date,
+ maxDate: Date,
+ unit: string,
+ locale: string,
+) {
+ const add = DATE_FUNCTIONS[unit].add;
+ const start = DATE_FUNCTIONS[unit].start;
+ const fmt = DATE_FORMATS[unit];
+
+ let current = start(minDate);
+ const end = start(maxDate);
+
+ const timeseries: string[] = [];
+
+ while (isBefore(current, end) || isEqual(current, end)) {
+ timeseries.push(formatDate(current, fmt, locale));
+ current = add(current, 1);
+ }
+
+ const lookup = new Map(data.map(({ x, y, d }) => [formatDate(x, fmt, locale), { x, y, d }]));
+
+ return timeseries.map(t => {
+ const { x, y, d } = lookup.get(t) || {};
+
+ return { x: t, d: d ?? x, y: y ?? null };
+ });
+}
+
+export function getDateRangeValue(startDate: Date, endDate: Date) {
+ return `range:${startDate.getTime()}:${endDate.getTime()}`;
+}
+
+export function getMonthDateRangeValue(date: Date) {
+ return getDateRangeValue(startOfMonth(date), endOfMonth(date));
+}
+
+export function isInvalidDate(date: any) {
+ return date instanceof Date && Number.isNaN(date.getTime());
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
new file mode 100644
index 0000000..7b6e836
--- /dev/null
+++ b/src/lib/db.ts
@@ -0,0 +1,40 @@
+export const PRISMA = 'prisma';
+export const POSTGRESQL = 'postgresql';
+export const CLICKHOUSE = 'clickhouse';
+export const KAFKA = 'kafka';
+export const KAFKA_PRODUCER = 'kafka-producer';
+
+// Fixes issue with converting bigint values
+BigInt.prototype.toJSON = function () {
+ return Number(this);
+};
+
+export function getDatabaseType(url = process.env.DATABASE_URL) {
+ const type = url?.split(':')[0];
+
+ if (type === 'postgres') {
+ return POSTGRESQL;
+ }
+
+ return type;
+}
+
+export async function runQuery(queries: any) {
+ if (process.env.CLICKHOUSE_URL) {
+ if (queries[KAFKA]) {
+ return queries[KAFKA]();
+ }
+
+ return queries[CLICKHOUSE]();
+ }
+
+ const db = getDatabaseType();
+
+ if (db === POSTGRESQL) {
+ return queries[PRISMA]();
+ }
+}
+
+export function notImplemented() {
+ throw new Error('Not implemented.');
+}
diff --git a/src/lib/detect.ts b/src/lib/detect.ts
new file mode 100644
index 0000000..68cb667
--- /dev/null
+++ b/src/lib/detect.ts
@@ -0,0 +1,154 @@
+import path from 'node:path';
+import { browserName, detectOS } from 'detect-browser';
+import ipaddr from 'ipaddr.js';
+import isLocalhost from 'is-localhost-ip';
+import maxmind from 'maxmind';
+import { UAParser } from 'ua-parser-js';
+import { getIpAddress, stripPort } from '@/lib/ip';
+import { safeDecodeURIComponent } from '@/lib/url';
+
+const MAXMIND = 'maxmind';
+
+const PROVIDER_HEADERS = [
+ // Cloudflare headers
+ {
+ countryHeader: 'cf-ipcountry',
+ regionHeader: 'cf-region-code',
+ cityHeader: 'cf-ipcity',
+ },
+ // Vercel headers
+ {
+ countryHeader: 'x-vercel-ip-country',
+ regionHeader: 'x-vercel-ip-country-region',
+ cityHeader: 'x-vercel-ip-city',
+ },
+ // CloudFront headers
+ {
+ countryHeader: 'cloudfront-viewer-country',
+ regionHeader: 'cloudfront-viewer-country-region',
+ cityHeader: 'cloudfront-viewer-city',
+ },
+];
+
+export function getDevice(userAgent: string, screen: string = '') {
+ const { device } = UAParser(userAgent);
+
+ const [width] = screen.split('x');
+
+ const type = device?.type || 'desktop';
+
+ if (type === 'desktop' && screen && +width <= 1920) {
+ return 'laptop';
+ }
+
+ return type;
+}
+
+function getRegionCode(country: string, region: string) {
+ if (!country || !region) {
+ return undefined;
+ }
+
+ return region.includes('-') ? region : `${country}-${region}`;
+}
+
+function decodeHeader(s: string | undefined | null): string | undefined | null {
+ if (s === undefined || s === null) {
+ return s;
+ }
+
+ return Buffer.from(s, 'latin1').toString('utf-8');
+}
+
+export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) {
+ // Ignore local ips
+ if (!ip || (await isLocalhost(ip))) {
+ return null;
+ }
+
+ if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) {
+ for (const provider of PROVIDER_HEADERS) {
+ const countryHeader = headers.get(provider.countryHeader);
+ if (countryHeader) {
+ const country = decodeHeader(countryHeader);
+ const region = decodeHeader(headers.get(provider.regionHeader));
+ const city = decodeHeader(headers.get(provider.cityHeader));
+
+ return {
+ country,
+ region: getRegionCode(country, region),
+ city,
+ };
+ }
+ }
+ }
+
+ // Database lookup
+ if (!globalThis[MAXMIND]) {
+ const dir = path.join(process.cwd(), 'geo');
+
+ globalThis[MAXMIND] = await maxmind.open(
+ process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'),
+ );
+ }
+
+ const result = globalThis[MAXMIND]?.get(stripPort(ip));
+
+ if (result) {
+ const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
+ const region = result.subdivisions?.[0]?.iso_code;
+ const city = result.city?.names?.en;
+
+ return {
+ country,
+ region: getRegionCode(country, region),
+ city,
+ };
+ }
+}
+
+export async function getClientInfo(request: Request, payload: Record<string, any>) {
+ const userAgent = payload?.userAgent || request.headers.get('user-agent');
+ const ip = payload?.ip || getIpAddress(request.headers);
+ const location = await getLocation(ip, request.headers, !!payload?.ip);
+ const country = safeDecodeURIComponent(location?.country);
+ const region = safeDecodeURIComponent(location?.region);
+ const city = safeDecodeURIComponent(location?.city);
+ const browser = payload?.browser ?? browserName(userAgent);
+ const os = payload?.os ?? (detectOS(userAgent) as string);
+ const device = payload?.device ?? getDevice(userAgent, payload?.screen);
+
+ return { userAgent, browser, os, ip, country, region, city, device };
+}
+
+export function hasBlockedIp(clientIp: string) {
+ const ignoreIps = process.env.IGNORE_IP;
+
+ if (ignoreIps) {
+ const ips = [];
+
+ if (ignoreIps) {
+ ips.push(...ignoreIps.split(',').map(n => n.trim()));
+ }
+
+ return ips.find(ip => {
+ if (ip === clientIp) {
+ return true;
+ }
+
+ // CIDR notation
+ if (ip.indexOf('/') > 0) {
+ const addr = ipaddr.parse(clientIp);
+ const range = ipaddr.parseCIDR(ip);
+
+ if (addr.kind() === range[0].kind() && addr.match(range)) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+ }
+
+ return false;
+}
diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts
new file mode 100644
index 0000000..1086973
--- /dev/null
+++ b/src/lib/fetch.ts
@@ -0,0 +1,58 @@
+import { buildPath } from '@/lib/url';
+
+export interface ErrorResponse {
+ error: {
+ status: number;
+ message: string;
+ code?: string;
+ };
+}
+
+export interface FetchResponse {
+ ok: boolean;
+ status: number;
+ data?: any;
+ error?: ErrorResponse;
+}
+
+export async function request(
+ method: string,
+ url: string,
+ body?: string,
+ headers: object = {},
+): Promise<FetchResponse> {
+ return fetch(url, {
+ method,
+ cache: 'no-cache',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ ...headers,
+ },
+ body,
+ }).then(async res => {
+ const data = await res.json();
+
+ return {
+ ok: res.ok,
+ status: res.status,
+ data,
+ };
+ });
+}
+
+export async function httpGet(path: string, params: object = {}, headers: object = {}) {
+ return request('GET', buildPath(path, params), undefined, headers);
+}
+
+export async function httpDelete(path: string, params: object = {}, headers: object = {}) {
+ return request('DELETE', buildPath(path, params), undefined, headers);
+}
+
+export async function httpPost(path: string, params: object = {}, headers: object = {}) {
+ return request('POST', path, JSON.stringify(params), headers);
+}
+
+export async function httpPut(path: string, params: object = {}, headers: object = {}) {
+ return request('PUT', path, JSON.stringify(params), headers);
+}
diff --git a/src/lib/filters.ts b/src/lib/filters.ts
new file mode 100644
index 0000000..3da268d
--- /dev/null
+++ b/src/lib/filters.ts
@@ -0,0 +1,31 @@
+export const percentFilter = (data: any[]) => {
+ if (!Array.isArray(data)) return [];
+ const total = data.reduce((n, { y }) => n + y, 0);
+ return data.map(({ x, y, ...props }) => ({ x, y, z: total ? (y / total) * 100 : 0, ...props }));
+};
+
+export const paramFilter = (data: any[]) => {
+ const map = data.reduce((obj, { x, y }) => {
+ try {
+ const searchParams = new URLSearchParams(x);
+
+ for (const [key, value] of searchParams) {
+ if (!obj[key]) {
+ obj[key] = { [value]: y };
+ } else if (!obj[key][value]) {
+ obj[key][value] = y;
+ } else {
+ obj[key][value] += y;
+ }
+ }
+ } catch {
+ // Ignore
+ }
+
+ return obj;
+ }, {});
+
+ return Object.keys(map).flatMap(key =>
+ Object.keys(map[key]).map(n => ({ x: `${key}=${n}`, p: key, v: n, y: map[key][n] })),
+ );
+};
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..52fd304
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,118 @@
+export function parseTime(val: number) {
+ const days = ~~(val / 86400);
+ const hours = ~~(val / 3600) - days * 24;
+ const minutes = ~~(val / 60) - days * 1440 - hours * 60;
+ const seconds = ~~val - days * 86400 - hours * 3600 - minutes * 60;
+ const ms = (val - ~~val) * 1000;
+
+ return {
+ days,
+ hours,
+ minutes,
+ seconds,
+ ms,
+ };
+}
+
+export function formatTime(val: number) {
+ const { hours, minutes, seconds } = parseTime(val);
+ const h = hours > 0 ? `${hours}:` : '';
+ const m = hours > 0 ? minutes.toString().padStart(2, '0') : minutes;
+ const s = seconds.toString().padStart(2, '0');
+
+ return `${h}${m}:${s}`;
+}
+
+export function formatShortTime(val: number, formats = ['m', 's'], space = '') {
+ const { days, hours, minutes, seconds, ms } = parseTime(val);
+ let t = '';
+
+ if (days > 0 && formats.indexOf('d') !== -1) t += `${days}d${space}`;
+ if (hours > 0 && formats.indexOf('h') !== -1) t += `${hours}h${space}`;
+ if (minutes > 0 && formats.indexOf('m') !== -1) t += `${minutes}m${space}`;
+ if (seconds > 0 && formats.indexOf('s') !== -1) t += `${seconds}s${space}`;
+ if (ms > 0 && formats.indexOf('ms') !== -1) t += `${ms}ms`;
+
+ if (!t) {
+ return `0${formats[formats.length - 1]}`;
+ }
+
+ return t;
+}
+
+export function formatNumber(n: string | number) {
+ return Number(n).toFixed(0);
+}
+
+export function formatLongNumber(value: number) {
+ const n = Number(value);
+
+ if (n >= 1000000000) {
+ return `${(n / 1000000).toFixed(1)}b`;
+ }
+ if (n >= 1000000) {
+ return `${(n / 1000000).toFixed(1)}m`;
+ }
+ if (n >= 100000) {
+ return `${(n / 1000).toFixed(0)}k`;
+ }
+ if (n >= 10000) {
+ return `${(n / 1000).toFixed(1)}k`;
+ }
+ if (n >= 1000) {
+ return `${(n / 1000).toFixed(2)}k`;
+ }
+
+ return formatNumber(n);
+}
+
+export function stringToColor(str: string) {
+ if (!str) {
+ return '#ffffff';
+ }
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ let color = '#';
+ for (let i = 0; i < 3; i++) {
+ const value = (hash >> (i * 8)) & 0xff;
+ color += `00${value.toString(16)}`.slice(-2);
+ }
+ return color;
+}
+
+export function formatCurrency(value: number, currency: string, locale = 'en-US') {
+ let formattedValue: Intl.NumberFormat;
+
+ try {
+ formattedValue = new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: currency,
+ });
+ } catch {
+ // Fallback to default currency format if an error occurs
+ formattedValue = new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: 'USD',
+ });
+ }
+
+ return formattedValue.format(value);
+}
+
+export function formatLongCurrency(value: number, currency: string, locale = 'en-US') {
+ const n = Number(value);
+
+ if (n >= 1000000000) {
+ return `${formatCurrency(n / 1000000000, currency, locale)}b`;
+ }
+ if (n >= 1000000) {
+ return `${formatCurrency(n / 1000000, currency, locale)}m`;
+ }
+ if (n >= 1000) {
+ return `${formatCurrency(n / 1000, currency, locale)}k`;
+ }
+
+ return formatCurrency(n, currency, locale);
+}
diff --git a/src/lib/generate.ts b/src/lib/generate.ts
new file mode 100644
index 0000000..8e25aa0
--- /dev/null
+++ b/src/lib/generate.ts
@@ -0,0 +1,20 @@
+import prand from 'pure-rand';
+
+const seed = Date.now() ^ (Math.random() * 0x100000000);
+const rng = prand.xoroshiro128plus(seed);
+
+export function random(min: number, max: number) {
+ return prand.unsafeUniformIntDistribution(min, max, rng);
+}
+
+export function getRandomChars(
+ n: number,
+ chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
+) {
+ const arr = chars.split('');
+ let s = '';
+ for (let i = 0; i < n; i++) {
+ s += arr[random(0, arr.length - 1)];
+ }
+ return s;
+}
diff --git a/src/lib/ip.ts b/src/lib/ip.ts
new file mode 100644
index 0000000..5cd7757
--- /dev/null
+++ b/src/lib/ip.ts
@@ -0,0 +1,60 @@
+export const IP_ADDRESS_HEADERS = [
+ 'true-client-ip', // CDN
+ 'cf-connecting-ip', // Cloudflare
+ 'fastly-client-ip', // Fastly
+ 'x-nf-client-connection-ip', // Netlify
+ 'do-connecting-ip', // Digital Ocean
+ 'x-real-ip', // Reverse proxy
+ 'x-appengine-user-ip', // Google App Engine
+ 'x-forwarded-for',
+ 'forwarded',
+ 'x-client-ip',
+ 'x-cluster-client-ip',
+ 'x-forwarded',
+];
+
+export function getIpAddress(headers: Headers) {
+ const customHeader = process.env.CLIENT_IP_HEADER;
+
+ if (customHeader && headers.get(customHeader)) {
+ return headers.get(customHeader);
+ }
+
+ const header = IP_ADDRESS_HEADERS.find(name => {
+ return headers.get(name);
+ });
+
+ const ip = headers.get(header);
+
+ if (header === 'x-forwarded-for') {
+ return ip?.split(',')?.[0]?.trim();
+ }
+
+ if (header === 'forwarded') {
+ const match = ip.match(/for=(\[?[0-9a-fA-F:.]+\]?)/);
+
+ if (match) {
+ return match[1];
+ }
+ }
+
+ return ip;
+}
+
+export function stripPort(ip: string) {
+ if (ip.startsWith('[')) {
+ const endBracket = ip.indexOf(']');
+ if (endBracket !== -1) {
+ return ip.slice(0, endBracket + 1);
+ }
+ }
+
+ const idx = ip.lastIndexOf(':');
+ if (idx !== -1) {
+ if (ip.includes('.') || /^[a-zA-Z0-9.-]+$/.test(ip.slice(0, idx))) {
+ return ip.slice(0, idx);
+ }
+ }
+
+ return ip;
+}
diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts
new file mode 100644
index 0000000..470c48f
--- /dev/null
+++ b/src/lib/jwt.ts
@@ -0,0 +1,36 @@
+import jwt from 'jsonwebtoken';
+import { decrypt, encrypt } from '@/lib/crypto';
+
+export function createToken(payload: any, secret: any, options?: any) {
+ return jwt.sign(payload, secret, options);
+}
+
+export function parseToken(token: string, secret: any) {
+ try {
+ return jwt.verify(token, secret);
+ } catch {
+ return null;
+ }
+}
+
+export function createSecureToken(payload: any, secret: any, options?: any) {
+ return encrypt(createToken(payload, secret, options), secret);
+}
+
+export function parseSecureToken(token: string, secret: any) {
+ try {
+ return jwt.verify(decrypt(token, secret), secret);
+ } catch {
+ return null;
+ }
+}
+
+export async function parseAuthToken(req: Request, secret: string) {
+ try {
+ const token = req.headers.get('authorization')?.split(' ')?.[1];
+
+ return parseSecureToken(token as string, secret);
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/kafka.ts b/src/lib/kafka.ts
new file mode 100644
index 0000000..1d60e1f
--- /dev/null
+++ b/src/lib/kafka.ts
@@ -0,0 +1,112 @@
+import type * as tls from 'node:tls';
+import debug from 'debug';
+import { Kafka, logLevel, type Producer, type RecordMetadata, type SASLOptions } from 'kafkajs';
+import { serializeError } from 'serialize-error';
+import { KAFKA, KAFKA_PRODUCER } from '@/lib/db';
+
+const log = debug('umami:kafka');
+const CONNECT_TIMEOUT = 5000;
+const SEND_TIMEOUT = 3000;
+const ACKS = 1;
+
+let kafka: Kafka;
+let producer: Producer;
+const enabled = Boolean(process.env.KAFKA_URL && process.env.KAFKA_BROKER);
+
+function getClient() {
+ const { username, password } = new URL(process.env.KAFKA_URL);
+ const brokers = process.env.KAFKA_BROKER.split(',');
+ const mechanism =
+ (process.env.KAFKA_SASL_MECHANISM as 'plain' | 'scram-sha-256' | 'scram-sha-512') || 'plain';
+
+ const ssl: { ssl?: tls.ConnectionOptions | boolean; sasl?: SASLOptions } =
+ username && password
+ ? {
+ ssl: {
+ rejectUnauthorized: false,
+ },
+ sasl: {
+ mechanism,
+ username,
+ password,
+ },
+ }
+ : {};
+
+ const client: Kafka = new Kafka({
+ clientId: 'umami',
+ brokers: brokers,
+ connectionTimeout: CONNECT_TIMEOUT,
+ logLevel: logLevel.ERROR,
+ ...ssl,
+ });
+
+ if (process.env.NODE_ENV !== 'production') {
+ globalThis[KAFKA] = client;
+ }
+
+ log('Kafka initialized');
+
+ return client;
+}
+
+async function getProducer(): Promise<Producer> {
+ const producer = kafka.producer();
+ await producer.connect();
+
+ if (process.env.NODE_ENV !== 'production') {
+ globalThis[KAFKA_PRODUCER] = producer;
+ }
+
+ log('Kafka producer initialized');
+
+ return producer;
+}
+
+async function sendMessage(
+ topic: string,
+ message: Record<string, string | number> | Record<string, string | number>[],
+): Promise<RecordMetadata[]> {
+ try {
+ await connect();
+
+ return producer.send({
+ topic,
+ messages: Array.isArray(message)
+ ? message.map(a => {
+ return { value: JSON.stringify(a) };
+ })
+ : [
+ {
+ value: JSON.stringify(message),
+ },
+ ],
+ timeout: SEND_TIMEOUT,
+ acks: ACKS,
+ });
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.log('KAFKA ERROR:', serializeError(e));
+ }
+}
+
+async function connect(): Promise<Kafka> {
+ if (!kafka) {
+ kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (globalThis[KAFKA] || getClient());
+
+ if (kafka) {
+ producer = globalThis[KAFKA_PRODUCER] || (await getProducer());
+ }
+ }
+
+ return kafka;
+}
+
+export default {
+ enabled,
+ client: kafka,
+ producer,
+ log,
+ connect,
+ sendMessage,
+};
diff --git a/src/lib/lang.ts b/src/lib/lang.ts
new file mode 100644
index 0000000..f874640
--- /dev/null
+++ b/src/lib/lang.ts
@@ -0,0 +1,111 @@
+import {
+ arSA,
+ be,
+ bg,
+ bn,
+ bs,
+ ca,
+ cs,
+ da,
+ de,
+ el,
+ enGB,
+ enUS,
+ es,
+ faIR,
+ fi,
+ fr,
+ he,
+ hi,
+ hr,
+ hu,
+ id,
+ it,
+ ja,
+ km,
+ ko,
+ lt,
+ mn,
+ ms,
+ nb,
+ nl,
+ pl,
+ pt,
+ ptBR,
+ ro,
+ ru,
+ sk,
+ sl,
+ sv,
+ ta,
+ th,
+ tr,
+ uk,
+ uz,
+ vi,
+ zhCN,
+ zhTW,
+} from 'date-fns/locale';
+
+export const languages = {
+ 'ar-SA': { label: 'العربية', dateLocale: arSA, dir: 'rtl' },
+ 'be-BY': { label: 'Беларуская', dateLocale: be },
+ 'bg-BG': { label: 'български език', dateLocale: bg },
+ 'bn-BD': { label: 'বাংলা', dateLocale: bn },
+ 'bs-BA': { label: 'Bosanski', dateLocale: bs },
+ 'ca-ES': { label: 'Català', dateLocale: ca },
+ 'cs-CZ': { label: 'Čeština', dateLocale: cs },
+ 'da-DK': { label: 'Dansk', dateLocale: da },
+ 'de-CH': { label: 'Schwiizerdütsch', dateLocale: de },
+ 'de-DE': { label: 'Deutsch', dateLocale: de },
+ 'el-GR': { label: 'Ελληνικά', dateLocale: el },
+ 'en-GB': { label: 'English (UK)', dateLocale: enGB },
+ 'en-US': { label: 'English (US)', dateLocale: enUS },
+ 'es-ES': { label: 'Español', dateLocale: es },
+ 'fa-IR': { label: 'فارسی', dateLocale: faIR, dir: 'rtl' },
+ 'fi-FI': { label: 'Suomi', dateLocale: fi },
+ 'fo-FO': { label: 'Føroyskt' },
+ 'fr-FR': { label: 'Français', dateLocale: fr },
+ 'ga-ES': { label: 'Galacian (Spain)', dateLocale: es },
+ 'he-IL': { label: 'עברית', dateLocale: he },
+ 'hi-IN': { label: 'हिन्दी', dateLocale: hi },
+ 'hr-HR': { label: 'Hrvatski', dateLocale: hr },
+ 'hu-HU': { label: 'Hungarian', dateLocale: hu },
+ 'id-ID': { label: 'Bahasa Indonesia', dateLocale: id },
+ 'it-IT': { label: 'Italiano', dateLocale: it },
+ 'ja-JP': { label: '日本語', dateLocale: ja },
+ 'km-KH': { label: 'ភាសាខ្មែរ', dateLocale: km },
+ 'ko-KR': { label: '한국어', dateLocale: ko },
+ 'lt-LT': { label: 'Lietuvių', dateLocale: lt },
+ 'mn-MN': { label: 'Монгол', dateLocale: mn },
+ 'ms-MY': { label: 'Malay', dateLocale: ms },
+ 'my-MM': { label: 'မြန်မာဘာသာ', dateLocale: enUS },
+ 'nl-NL': { label: 'Nederlands', dateLocale: nl },
+ 'nb-NO': { label: 'Norsk Bokmål', dateLocale: nb },
+ 'pl-PL': { label: 'Polski', dateLocale: pl },
+ 'pt-BR': { label: 'Português do Brasil', dateLocale: ptBR },
+ 'pt-PT': { label: 'Português', dateLocale: pt },
+ 'ro-RO': { label: 'Română', dateLocale: ro },
+ 'ru-RU': { label: 'Русский', dateLocale: ru },
+ 'si-LK': { label: 'සිංහල', dateLocale: id },
+ 'sk-SK': { label: 'Slovenčina', dateLocale: sk },
+ 'sl-SI': { label: 'Slovenščina', dateLocale: sl },
+ 'sv-SE': { label: 'Svenska', dateLocale: sv },
+ 'ta-IN': { label: 'தமிழ்', dateLocale: ta },
+ 'th-TH': { label: 'ภาษาไทย', dateLocale: th },
+ 'tr-TR': { label: 'Türkçe', dateLocale: tr },
+ 'uk-UA': { label: 'українська', dateLocale: uk },
+ 'ur-PK': { label: 'Urdu (Pakistan)', dateLocale: uk, dir: 'rtl' },
+ 'uz-UZ': { label: 'O‘zbekcha', dateLocale: uz },
+ 'vi-VN': { label: 'Tiếng Việt', dateLocale: vi },
+ 'zh-CN': { label: '中文', dateLocale: zhCN },
+ 'zh-TW': { label: '中文(繁體)', dateLocale: zhTW },
+};
+
+export function getDateLocale(locale: string) {
+ return languages[locale]?.dateLocale || enUS;
+}
+
+export function getTextDirection(locale: string) {
+ return languages[locale]?.dir || 'ltr';
+}
diff --git a/src/lib/load.ts b/src/lib/load.ts
new file mode 100644
index 0000000..d4d6c3c
--- /dev/null
+++ b/src/lib/load.ts
@@ -0,0 +1,40 @@
+import type { Session, Website } from '@/generated/prisma/client';
+import redis from '@/lib/redis';
+import { getWebsite } from '@/queries/prisma';
+import { getWebsiteSession } from '@/queries/sql';
+
+export async function fetchWebsite(websiteId: string): Promise<Website> {
+ let website = null;
+
+ if (redis.enabled) {
+ website = await redis.client.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400);
+ } else {
+ website = await getWebsite(websiteId);
+ }
+
+ if (!website || website.deletedAt) {
+ return null;
+ }
+
+ return website;
+}
+
+export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> {
+ let session = null;
+
+ if (redis.enabled) {
+ session = await redis.client.fetch(
+ `session:${sessionId}`,
+ () => getWebsiteSession(websiteId, sessionId),
+ 86400,
+ );
+ } else {
+ session = await getWebsiteSession(websiteId, sessionId);
+ }
+
+ if (!session) {
+ return null;
+ }
+
+ return session;
+}
diff --git a/src/lib/params.ts b/src/lib/params.ts
new file mode 100644
index 0000000..ab2d586
--- /dev/null
+++ b/src/lib/params.ts
@@ -0,0 +1,62 @@
+import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
+import type { Filter, QueryFilters, QueryOptions } from '@/lib/types';
+
+export function parseFilterValue(param: any) {
+ if (typeof param === 'string') {
+ const operatorValues = Object.values(OPERATORS).join('|');
+
+ const regex = new RegExp(`^(${operatorValues})\\.(.*)$`);
+
+ const [, operator, value] = param.match(regex) || [];
+
+ return { operator: operator || OPERATORS.equals, value: value || param };
+ }
+
+ return { operator: OPERATORS.equals, value: param };
+}
+
+export function isEqualsOperator(operator: any) {
+ return [OPERATORS.equals, OPERATORS.notEquals].includes(operator);
+}
+
+export function isSearchOperator(operator: any) {
+ return [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator);
+}
+
+export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}): Filter[] {
+ if (!filters) {
+ return [];
+ }
+
+ return Object.keys(filters).reduce((arr, key) => {
+ const filter = filters[key];
+
+ if (filter === undefined || filter === null) {
+ return arr;
+ }
+
+ if (filter?.name && filter?.value !== undefined) {
+ return arr.concat({ ...filter, column: options?.columns?.[key] ?? FILTER_COLUMNS[key] });
+ }
+
+ const { operator, value } = parseFilterValue(filter);
+
+ return arr.concat({
+ name: key,
+ column: options?.columns?.[key] ?? FILTER_COLUMNS[key],
+ operator,
+ value,
+ prefix: options?.prefix,
+ });
+ }, []);
+}
+
+export function filtersArrayToObject(filters: Filter[]) {
+ return filters.reduce((obj, filter: Filter) => {
+ const { name, operator, value } = filter;
+
+ obj[name] = `${operator}.${value}`;
+
+ return obj;
+ }, {});
+}
diff --git a/src/lib/password.ts b/src/lib/password.ts
new file mode 100644
index 0000000..f5c450b
--- /dev/null
+++ b/src/lib/password.ts
@@ -0,0 +1,11 @@
+import bcrypt from 'bcryptjs';
+
+const SALT_ROUNDS = 10;
+
+export function hashPassword(password: string, rounds = SALT_ROUNDS) {
+ return bcrypt.hashSync(password, rounds);
+}
+
+export function checkPassword(password: string, passwordHash: string) {
+ return bcrypt.compareSync(password, passwordHash);
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
new file mode 100644
index 0000000..64cb870
--- /dev/null
+++ b/src/lib/prisma.ts
@@ -0,0 +1,368 @@
+import { PrismaPg } from '@prisma/adapter-pg';
+import { readReplicas } from '@prisma/extension-read-replicas';
+import debug from 'debug';
+import { PrismaClient } from '@/generated/prisma/client';
+import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants';
+import { filtersObjectToArray } from './params';
+import type { Operator, QueryFilters, QueryOptions } from './types';
+
+const log = debug('umami:prisma');
+
+const PRISMA = 'prisma';
+
+const PRISMA_LOG_OPTIONS = {
+ log: [
+ {
+ emit: 'event' as const,
+ level: 'query' as const,
+ },
+ ],
+};
+
+const DATE_FORMATS = {
+ minute: 'YYYY-MM-DD HH24:MI:00',
+ hour: 'YYYY-MM-DD HH24:00:00',
+ day: 'YYYY-MM-DD HH24:00:00',
+ month: 'YYYY-MM-01 HH24:00:00',
+ year: 'YYYY-01-01 HH24:00:00',
+};
+
+const DATE_FORMATS_UTC = {
+ minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"',
+ hour: 'YYYY-MM-DD"T"HH24:00:00"Z"',
+ day: 'YYYY-MM-DD"T"HH24:00:00"Z"',
+ month: 'YYYY-MM-01"T"HH24:00:00"Z"',
+ year: 'YYYY-01-01"T"HH24:00:00"Z"',
+};
+
+function getAddIntervalQuery(field: string, interval: string): string {
+ return `${field} + interval '${interval}'`;
+}
+
+function getDayDiffQuery(field1: string, field2: string): string {
+ return `${field1}::date - ${field2}::date`;
+}
+
+function getCastColumnQuery(field: string, type: string): string {
+ return `${field}::${type}`;
+}
+
+function getDateSQL(field: string, unit: string, timezone?: string): string {
+ if (timezone && timezone !== 'utc') {
+ return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`;
+ }
+
+ return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
+}
+
+function getDateWeeklySQL(field: string, timezone?: string) {
+ return `concat(extract(dow from (${field} at time zone '${timezone}')), ':', to_char((${field} at time zone '${timezone}'), 'HH24'))`;
+}
+
+export function getTimestampSQL(field: string) {
+ return `floor(extract(epoch from ${field}))`;
+}
+
+function getTimestampDiffSQL(field1: string, field2: string): string {
+ return `floor(extract(epoch from (${field2} - ${field1})))`;
+}
+
+function getSearchSQL(column: string, param: string = 'search'): string {
+ return `and ${column} ilike {{${param}}}`;
+}
+
+function mapFilter(column: string, operator: string, name: string, type: string = '') {
+ const value = `{{${name}${type ? `::${type}` : ''}}}`;
+
+ switch (operator) {
+ case OPERATORS.equals:
+ return `${column} = ${value}`;
+ case OPERATORS.notEquals:
+ return `${column} != ${value}`;
+ case OPERATORS.contains:
+ return `${column} ilike ${value}`;
+ case OPERATORS.doesNotContain:
+ return `${column} not ilike ${value}`;
+ default:
+ return '';
+ }
+}
+
+function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
+ const query = filtersObjectToArray(filters, options).reduce(
+ (arr, { name, column, operator, prefix = '' }) => {
+ const isCohort = options?.isCohort;
+
+ if (isCohort) {
+ column = FILTER_COLUMNS[name.slice('cohort_'.length)];
+ }
+
+ if (column) {
+ arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`);
+
+ if (name === 'referrer') {
+ arr.push(
+ `and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`,
+ );
+ }
+ }
+
+ return arr;
+ },
+ [],
+ );
+
+ return query.join('\n');
+}
+
+function getCohortQuery(filters: QueryFilters = {}) {
+ if (!filters || Object.keys(filters).length === 0) {
+ return '';
+ }
+
+ const filterQuery = getFilterQuery(filters, { isCohort: true });
+
+ return `join
+ (select distinct website_event.session_id
+ from website_event
+ join session on session.session_id = website_event.session_id
+ and session.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId}}
+ and website_event.created_at between {{cohort_startDate}} and {{cohort_endDate}}
+ ${filterQuery}
+ ) cohort
+ on cohort.session_id = website_event.session_id
+ `;
+}
+
+function getDateQuery(filters: Record<string, any>) {
+ const { startDate, endDate } = filters;
+
+ if (startDate) {
+ if (endDate) {
+ return `and website_event.created_at between {{startDate}} and {{endDate}}`;
+ } else {
+ return `and website_event.created_at >= {{startDate}}`;
+ }
+ }
+
+ return '';
+}
+
+function getQueryParams(filters: Record<string, any>) {
+ return {
+ ...filters,
+ ...filtersObjectToArray(filters).reduce((obj, { name, operator, value }) => {
+ obj[name] = ([OPERATORS.contains, OPERATORS.doesNotContain] as Operator[]).includes(operator)
+ ? `%${value}%`
+ : value;
+
+ return obj;
+ }, {}),
+ };
+}
+
+function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
+ const joinSession = Object.keys(filters).find(key =>
+ ['referrer', ...SESSION_COLUMNS].includes(key),
+ );
+
+ const cohortFilters = Object.fromEntries(
+ Object.entries(filters).filter(([key]) => key.startsWith('cohort_')),
+ );
+
+ return {
+ joinSessionQuery:
+ options?.joinSession || joinSession
+ ? `inner join session on website_event.session_id = session.session_id and website_event.website_id = session.website_id`
+ : '',
+ dateQuery: getDateQuery(filters),
+ filterQuery: getFilterQuery(filters, options),
+ queryParams: getQueryParams(filters),
+ cohortQuery: getCohortQuery(cohortFilters),
+ };
+}
+
+async function rawQuery(sql: string, data: Record<string, any>, name?: string): Promise<any> {
+ if (process.env.LOG_QUERY) {
+ log('QUERY:\n', sql);
+ log('PARAMETERS:\n', data);
+ log('NAME:\n', name);
+ }
+ const params = [];
+ const schema = getSchema();
+
+ if (schema) {
+ await client.$executeRawUnsafe(`SET search_path TO "${schema}";`);
+ }
+
+ const query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => {
+ const [, name, type] = args;
+
+ const value = data[name];
+
+ params.push(value);
+
+ return `$${params.length}${type ?? ''}`;
+ });
+
+ if (process.env.DATABASE_REPLICA_URL && '$replica' in client) {
+ return client.$replica().$queryRawUnsafe(query, ...params);
+ }
+
+ return client.$queryRawUnsafe(query, ...params);
+}
+
+async function pagedQuery<T>(model: string, criteria: T, filters?: QueryFilters) {
+ const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters || {};
+ const size = +pageSize || DEFAULT_PAGE_SIZE;
+
+ const data = await client[model].findMany({
+ ...criteria,
+ ...{
+ ...(size > 0 && { take: +size, skip: +size * (+page - 1) }),
+ ...(orderBy && {
+ orderBy: [
+ {
+ [orderBy]: sortDescending ? 'desc' : 'asc',
+ },
+ ],
+ }),
+ },
+ });
+
+ const count = await client[model].count({ where: (criteria as any).where });
+
+ return { data, count, page: +page, pageSize: size, orderBy, search };
+}
+
+async function pagedRawQuery(
+ query: string,
+ queryParams: Record<string, any>,
+ filters: QueryFilters,
+ name?: string,
+) {
+ const { page = 1, pageSize, orderBy, sortDescending = false } = filters;
+ const size = +pageSize || DEFAULT_PAGE_SIZE;
+ const offset = +size * (+page - 1);
+ const direction = sortDescending ? 'desc' : 'asc';
+
+ const statements = [
+ orderBy && `order by ${orderBy} ${direction}`,
+ +size > 0 && `limit ${+size} offset ${offset}`,
+ ]
+ .filter(n => n)
+ .join('\n');
+
+ const count = await rawQuery(`select count(*) as num from (${query}) t`, queryParams).then(
+ res => res[0].num,
+ );
+
+ const data = await rawQuery(`${query}${statements}`, queryParams, name);
+
+ return { data, count, page: +page, pageSize: size, orderBy };
+}
+
+function getSearchParameters(query: string, filters: Record<string, any>[]) {
+ if (!query) return;
+
+ const parseFilter = (filter: Record<string, any>) => {
+ const [[key, value]] = Object.entries(filter);
+
+ return {
+ [key]:
+ typeof value === 'string'
+ ? {
+ [value]: query,
+ mode: 'insensitive',
+ }
+ : parseFilter(value),
+ };
+ };
+
+ const params = filters.map(filter => parseFilter(filter));
+
+ return {
+ AND: {
+ OR: params,
+ },
+ };
+}
+
+function transaction(input: any, options?: any) {
+ return client.$transaction(input, options);
+}
+
+function getSchema() {
+ const connectionUrl = new URL(process.env.DATABASE_URL);
+
+ return connectionUrl.searchParams.get('schema');
+}
+
+function getClient() {
+ const url = process.env.DATABASE_URL;
+ const replicaUrl = process.env.DATABASE_REPLICA_URL;
+ const logQuery = process.env.LOG_QUERY;
+ const schema = getSchema();
+
+ const baseAdapter = new PrismaPg({ connectionString: url }, { schema });
+
+ const baseClient = new PrismaClient({
+ adapter: baseAdapter,
+ errorFormat: 'pretty',
+ ...(logQuery ? PRISMA_LOG_OPTIONS : {}),
+ });
+
+ if (logQuery) {
+ baseClient.$on('query', log);
+ }
+
+ if (!replicaUrl) {
+ log('Prisma initialized');
+ globalThis[PRISMA] ??= baseClient;
+ return baseClient;
+ }
+
+ const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema });
+
+ const replicaClient = new PrismaClient({
+ adapter: replicaAdapter,
+ errorFormat: 'pretty',
+ ...(logQuery ? PRISMA_LOG_OPTIONS : {}),
+ });
+
+ if (logQuery) {
+ replicaClient.$on('query', log);
+ }
+
+ const extended = baseClient.$extends(
+ readReplicas({
+ replicas: [replicaClient],
+ }),
+ );
+
+ log('Prisma initialized (with replica)');
+ globalThis[PRISMA] ??= extended;
+
+ return extended;
+}
+
+const client = (globalThis[PRISMA] || getClient()) as ReturnType<typeof getClient>;
+
+export default {
+ client,
+ transaction,
+ getAddIntervalQuery,
+ getCastColumnQuery,
+ getDayDiffQuery,
+ getDateSQL,
+ getDateWeeklySQL,
+ getFilterQuery,
+ getSearchParameters,
+ getTimestampDiffSQL,
+ getSearchSQL,
+ pagedQuery,
+ pagedRawQuery,
+ parseFilters,
+ rawQuery,
+};
diff --git a/src/lib/react.ts b/src/lib/react.ts
new file mode 100644
index 0000000..668cdf1
--- /dev/null
+++ b/src/lib/react.ts
@@ -0,0 +1,77 @@
+import {
+ Children,
+ cloneElement,
+ type FC,
+ Fragment,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+} from 'react';
+
+export function getFragmentChildren(children: ReactNode) {
+ return (children as ReactElement)?.type === Fragment
+ ? (children as ReactElement).props.children
+ : children;
+}
+
+export function isValidChild(child: ReactElement, types: FC | FC[]) {
+ if (!isValidElement(child)) {
+ return false;
+ }
+ return (Array.isArray(types) ? types : [types]).find(type => type === child.type);
+}
+
+export function mapChildren(
+ children: ReactNode,
+ handler: (child: ReactElement, index: number) => any,
+) {
+ return Children.map(getFragmentChildren(children) as ReactElement[], (child, index) => {
+ if (!child?.props) {
+ return null;
+ }
+ return handler(child, index);
+ });
+}
+
+export function cloneChildren(
+ children: ReactNode,
+ handler: (child: ReactElement, index: number) => any,
+ options?: { validChildren?: any[]; onlyRenderValid?: boolean },
+): ReactNode {
+ if (!children) {
+ return null;
+ }
+
+ const { validChildren, onlyRenderValid = false } = options || {};
+
+ return mapChildren(children, (child, index) => {
+ const invalid = validChildren && !isValidChild(child as ReactElement, validChildren);
+
+ if (onlyRenderValid && invalid) {
+ return null;
+ }
+
+ if (!invalid && isValidElement(child)) {
+ return cloneElement(child, handler(child, index));
+ }
+
+ return child;
+ });
+}
+
+export function renderChildren(
+ children: ReactNode | ((item: any, index: number, array: any) => ReactNode),
+ items: any[],
+ handler: (child: ReactElement, index: number) => object | undefined,
+ options?: { validChildren?: any[]; onlyRenderValid?: boolean },
+): ReactNode {
+ if (typeof children === 'function' && items?.length > 0) {
+ return cloneChildren(items.map(children), handler, options);
+ }
+
+ return cloneChildren(getFragmentChildren(children as ReactNode), handler, options);
+}
+
+export function countChildren(children: ReactNode): number {
+ return Children.count(getFragmentChildren(children));
+}
diff --git a/src/lib/redis.ts b/src/lib/redis.ts
new file mode 100644
index 0000000..edde3d6
--- /dev/null
+++ b/src/lib/redis.ts
@@ -0,0 +1,18 @@
+import { UmamiRedisClient } from '@umami/redis-client';
+
+const REDIS = 'redis';
+const enabled = !!process.env.REDIS_URL;
+
+function getClient() {
+ const redis = new UmamiRedisClient({ url: process.env.REDIS_URL });
+
+ if (process.env.NODE_ENV !== 'production') {
+ globalThis[REDIS] = redis;
+ }
+
+ return redis;
+}
+
+const client = globalThis[REDIS] || getClient();
+
+export default { client, enabled };
diff --git a/src/lib/request.ts b/src/lib/request.ts
new file mode 100644
index 0000000..42c4490
--- /dev/null
+++ b/src/lib/request.ts
@@ -0,0 +1,145 @@
+import { z } from 'zod';
+import { checkAuth } from '@/lib/auth';
+import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS } from '@/lib/constants';
+import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date';
+import { fetchWebsite } from '@/lib/load';
+import { filtersArrayToObject } from '@/lib/params';
+import { badRequest, unauthorized } from '@/lib/response';
+import type { QueryFilters } from '@/lib/types';
+import { getWebsiteSegment } from '@/queries/prisma';
+
+export async function parseRequest(
+ request: Request,
+ schema?: any,
+ options?: { skipAuth: boolean },
+): Promise<any> {
+ const url = new URL(request.url);
+ let query = Object.fromEntries(url.searchParams);
+ let body = await getJsonBody(request);
+ let error: () => undefined | undefined;
+ let auth = null;
+
+ if (schema) {
+ const isGet = request.method === 'GET';
+ const result = schema.safeParse(isGet ? query : body);
+
+ if (!result.success) {
+ error = () => badRequest(z.treeifyError(result.error));
+ } else if (isGet) {
+ query = result.data;
+ } else {
+ body = result.data;
+ }
+ }
+
+ if (!options?.skipAuth && !error) {
+ auth = await checkAuth(request);
+
+ if (!auth) {
+ error = () => unauthorized();
+ }
+ }
+
+ return { url, query, body, auth, error };
+}
+
+export async function getJsonBody(request: Request) {
+ try {
+ return await request.clone().json();
+ } catch {
+ return undefined;
+ }
+}
+
+export function getRequestDateRange(query: Record<string, string>) {
+ const { startAt, endAt, unit, timezone } = query;
+
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ return {
+ startDate,
+ endDate,
+ timezone,
+ unit: getAllowedUnits(startDate, endDate).includes(unit)
+ ? unit
+ : getMinimumUnit(startDate, endDate),
+ };
+}
+
+export function getRequestFilters(query: Record<string, any>) {
+ const result: Record<string, any> = {};
+
+ for (const key of Object.keys(FILTER_COLUMNS)) {
+ const value = query[key];
+ if (value !== undefined) {
+ result[key] = value;
+ }
+ }
+
+ return result;
+}
+
+export async function setWebsiteDate(websiteId: string, data: Record<string, any>) {
+ const website = await fetchWebsite(websiteId);
+
+ if (website?.resetAt) {
+ data.startDate = maxDate(data.startDate, new Date(website?.resetAt));
+ }
+
+ return data;
+}
+
+export async function getQueryFilters(
+ params: Record<string, any>,
+ websiteId?: string,
+): Promise<QueryFilters> {
+ const dateRange = getRequestDateRange(params);
+ const filters = getRequestFilters(params);
+
+ if (websiteId) {
+ await setWebsiteDate(websiteId, dateRange);
+
+ if (params.segment) {
+ const segmentParams = (await getWebsiteSegment(websiteId, params.segment))
+ ?.parameters as Record<string, any>;
+
+ Object.assign(filters, filtersArrayToObject(segmentParams.filters));
+ }
+
+ if (params.cohort) {
+ const cohortParams = (await getWebsiteSegment(websiteId, params.cohort))
+ ?.parameters as Record<string, any>;
+
+ const { startDate, endDate } = parseDateRange(cohortParams.dateRange);
+
+ const cohortFilters = cohortParams.filters.map(({ name, ...props }) => ({
+ ...props,
+ name: `cohort_${name}`,
+ }));
+
+ cohortFilters.push({
+ name: `cohort_${cohortParams.action.type}`,
+ operator: 'eq',
+ value: cohortParams.action.value,
+ });
+
+ Object.assign(filters, {
+ ...filtersArrayToObject(cohortFilters),
+ cohort_startDate: startDate,
+ cohort_endDate: endDate,
+ });
+ }
+ }
+
+ return {
+ ...dateRange,
+ ...filters,
+ page: params?.page,
+ pageSize: params?.pageSize ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined,
+ orderBy: params?.orderBy,
+ sortDescending: params?.sortDescending,
+ search: params?.search,
+ compare: params?.compare,
+ };
+}
diff --git a/src/lib/response.ts b/src/lib/response.ts
new file mode 100644
index 0000000..f1ad5c7
--- /dev/null
+++ b/src/lib/response.ts
@@ -0,0 +1,58 @@
+export function ok() {
+ return Response.json({ ok: true });
+}
+
+export function json(data: Record<string, any> = {}) {
+ return Response.json(data);
+}
+
+export function badRequest(error?: Record<string, any>) {
+ return Response.json(
+ {
+ error: { message: 'Bad request', code: 'bad-request', status: 400, ...error },
+ },
+ { status: 400 },
+ );
+}
+
+export function unauthorized(error?: Record<string, any>) {
+ return Response.json(
+ {
+ error: {
+ message: 'Unauthorized',
+ code: 'unauthorized',
+ status: 401,
+ ...error,
+ },
+ },
+ { status: 401 },
+ );
+}
+
+export function forbidden(error?: Record<string, any>) {
+ return Response.json(
+ { error: { message: 'Forbidden', code: 'forbidden', status: 403, ...error } },
+ { status: 403 },
+ );
+}
+
+export function notFound(error?: Record<string, any>) {
+ return Response.json(
+ { error: { message: 'Not found', code: 'not-found', status: 404, ...error } },
+ { status: 404 },
+ );
+}
+
+export function serverError(error?: Record<string, any>) {
+ return Response.json(
+ {
+ error: {
+ message: 'Server error',
+ code: 'server-error',
+ status: 500,
+ ...error,
+ },
+ },
+ { status: 500 },
+ );
+}
diff --git a/src/lib/schema.ts b/src/lib/schema.ts
new file mode 100644
index 0000000..38f7339
--- /dev/null
+++ b/src/lib/schema.ts
@@ -0,0 +1,232 @@
+import { z } from 'zod';
+import { isValidTimezone, normalizeTimezone } from '@/lib/date';
+import { UNIT_TYPES } from './constants';
+
+export const timezoneParam = z
+ .string()
+ .refine((value: string) => isValidTimezone(value), {
+ message: 'Invalid timezone',
+ })
+ .transform((value: string) => normalizeTimezone(value));
+
+export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), {
+ message: 'Invalid unit',
+});
+
+export const dateRangeParams = {
+ startAt: z.coerce.number().optional(),
+ endAt: z.coerce.number().optional(),
+ startDate: z.coerce.date().optional(),
+ endDate: z.coerce.date().optional(),
+ timezone: timezoneParam.optional(),
+ unit: unitParam.optional(),
+ compare: z.string().optional(),
+};
+
+export const filterParams = {
+ path: z.string().optional(),
+ referrer: z.string().optional(),
+ title: z.string().optional(),
+ query: z.string().optional(),
+ os: z.string().optional(),
+ browser: z.string().optional(),
+ device: z.string().optional(),
+ country: z.string().optional(),
+ region: z.string().optional(),
+ city: z.string().optional(),
+ tag: z.string().optional(),
+ hostname: z.string().optional(),
+ language: z.string().optional(),
+ event: z.string().optional(),
+ segment: z.uuid().optional(),
+ cohort: z.uuid().optional(),
+ eventType: z.coerce.number().int().positive().optional(),
+};
+
+export const searchParams = {
+ search: z.string().optional(),
+};
+
+export const pagingParams = {
+ page: z.coerce.number().int().positive().optional(),
+ pageSize: z.coerce.number().int().positive().optional(),
+};
+
+export const sortingParams = {
+ orderBy: z.string().optional(),
+};
+
+export const userRoleParam = z.enum(['admin', 'user', 'view-only']);
+
+export const teamRoleParam = z.enum(['team-member', 'team-view-only', 'team-manager']);
+
+export const anyObjectParam = z.record(z.string(), z.any());
+
+export const urlOrPathParam = z.string().refine(
+ value => {
+ try {
+ new URL(value, 'https://localhost');
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ {
+ message: 'Invalid URL.',
+ },
+);
+
+export const fieldsParam = z.enum([
+ 'path',
+ 'referrer',
+ 'title',
+ 'query',
+ 'os',
+ 'browser',
+ 'device',
+ 'country',
+ 'region',
+ 'city',
+ 'tag',
+ 'hostname',
+ 'language',
+ 'event',
+]);
+
+export const reportTypeParam = z.enum([
+ 'attribution',
+ 'breakdown',
+ 'funnel',
+ 'goal',
+ 'journey',
+ 'retention',
+ 'revenue',
+ 'utm',
+]);
+
+export const goalReportSchema = z.object({
+ type: z.literal('goal'),
+ parameters: z
+ .object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ type: z.string(),
+ value: z.string(),
+ operator: z.enum(['count', 'sum', 'average']).optional(),
+ property: z.string().optional(),
+ })
+ .refine(data => {
+ if (data.type === 'event' && data.property) {
+ return data.operator && data.property;
+ }
+ return true;
+ }),
+});
+
+export const funnelReportSchema = z.object({
+ type: z.literal('funnel'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ window: z.coerce.number().positive(),
+ steps: z
+ .array(
+ z.object({
+ type: z.enum(['path', 'event']),
+ value: z.string(),
+ }),
+ )
+ .min(2)
+ .max(8),
+ }),
+});
+
+export const journeyReportSchema = z.object({
+ type: z.literal('journey'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ steps: z.coerce.number().min(2).max(7),
+ startStep: z.string().optional(),
+ endStep: z.string().optional(),
+ }),
+});
+
+export const retentionReportSchema = z.object({
+ type: z.literal('retention'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ timezone: z.string().optional(),
+ }),
+});
+
+export const utmReportSchema = z.object({
+ type: z.literal('utm'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ }),
+});
+
+export const revenueReportSchema = z.object({
+ type: z.literal('revenue'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ timezone: z.string().optional(),
+ currency: z.string(),
+ }),
+});
+
+export const attributionReportSchema = z.object({
+ type: z.literal('attribution'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ model: z.enum(['first-click', 'last-click']),
+ type: z.enum(['path', 'event']),
+ step: z.string(),
+ currency: z.string().optional(),
+ }),
+});
+
+export const breakdownReportSchema = z.object({
+ type: z.literal('breakdown'),
+ parameters: z.object({
+ startDate: z.coerce.date(),
+ endDate: z.coerce.date(),
+ fields: z.array(fieldsParam),
+ }),
+});
+
+export const reportBaseSchema = z.object({
+ websiteId: z.uuid(),
+ type: reportTypeParam,
+ name: z.string().max(200),
+ description: z.string().max(500).optional(),
+ parameters: anyObjectParam,
+});
+
+export const reportTypeSchema = z.discriminatedUnion('type', [
+ goalReportSchema,
+ funnelReportSchema,
+ journeyReportSchema,
+ retentionReportSchema,
+ utmReportSchema,
+ revenueReportSchema,
+ attributionReportSchema,
+ breakdownReportSchema,
+]);
+
+export const reportSchema = reportBaseSchema;
+
+export const reportResultSchema = z.intersection(
+ z.object({
+ websiteId: z.uuid(),
+ filters: z.object({ ...filterParams }),
+ }),
+ reportTypeSchema,
+);
+
+export const segmentTypeParam = z.enum(['segment', 'cohort']);
diff --git a/src/lib/sql.ts b/src/lib/sql.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/lib/sql.ts
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..19681a2
--- /dev/null
+++ b/src/lib/storage.ts
@@ -0,0 +1,25 @@
+export function setItem(key: string, data: any, session?: boolean) {
+ if (typeof window !== 'undefined' && data) {
+ return (session ? sessionStorage : localStorage).setItem(key, JSON.stringify(data));
+ }
+}
+
+export function getItem(key: string, session?: boolean): any {
+ if (typeof window !== 'undefined') {
+ const value = (session ? sessionStorage : localStorage).getItem(key);
+
+ if (value !== 'undefined' && value !== null) {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+ }
+ }
+}
+
+export function removeItem(key: string, session?: boolean) {
+ if (typeof window !== 'undefined') {
+ return (session ? sessionStorage : localStorage).removeItem(key);
+ }
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..9c06197
--- /dev/null
+++ b/src/lib/types.ts
@@ -0,0 +1,143 @@
+import type { UseQueryOptions } from '@tanstack/react-query';
+import type { DATA_TYPE, OPERATORS, ROLES } from './constants';
+import type { TIME_UNIT } from './date';
+
+export type ObjectValues<T> = T[keyof T];
+
+export type ReactQueryOptions<T = any> = Omit<UseQueryOptions<T, Error, T>, 'queryKey' | 'queryFn'>;
+
+export type TimeUnit = ObjectValues<typeof TIME_UNIT>;
+export type Role = ObjectValues<typeof ROLES>;
+export type DynamicDataType = ObjectValues<typeof DATA_TYPE>;
+export type Operator = (typeof OPERATORS)[keyof typeof OPERATORS];
+
+export interface Auth {
+ user?: {
+ id: string;
+ username: string;
+ role: string;
+ isAdmin: boolean;
+ };
+ shareToken?: {
+ websiteId: string;
+ };
+}
+
+export interface Filter {
+ name: string;
+ operator: Operator;
+ value: string;
+ type?: string;
+ column?: string;
+ prefix?: string;
+}
+
+export interface DateRange {
+ startDate: Date;
+ endDate: Date;
+ value?: string;
+ unit?: TimeUnit;
+ num?: number;
+ offset?: number;
+}
+
+export interface DynamicData {
+ [key: string]: number | string | number[] | string[];
+}
+
+export interface QueryOptions {
+ joinSession?: boolean;
+ columns?: Record<string, string>;
+ limit?: number;
+ prefix?: string;
+ isCohort?: boolean;
+}
+
+export interface QueryFilters
+ extends DateParams,
+ FilterParams,
+ SortParams,
+ PageParams,
+ SegmentParams {
+ cohortFilters?: QueryFilters;
+}
+
+export interface DateParams {
+ startDate?: Date;
+ endDate?: Date;
+ unit?: string;
+ timezone?: string;
+ compareDate?: Date;
+}
+
+export interface FilterParams {
+ path?: string;
+ referrer?: string;
+ title?: string;
+ query?: string;
+ host?: string;
+ os?: string;
+ browser?: string;
+ device?: string;
+ country?: string;
+ region?: string;
+ city?: string;
+ language?: string;
+ event?: string;
+ search?: string;
+ tag?: string;
+ eventType?: number;
+ segment?: string;
+ cohort?: string;
+ compare?: string;
+}
+
+export interface SortParams {
+ orderBy?: string;
+ sortDescending?: boolean;
+}
+
+export interface PageParams {
+ page?: number;
+ pageSize?: number;
+}
+
+export interface SegmentParams {
+ segment?: string;
+ cohort?: string;
+}
+
+export interface PageResult<T> {
+ data: T;
+ count: number;
+ page: number;
+ pageSize: number;
+ orderBy?: string;
+ sortDescending?: boolean;
+ search?: string;
+}
+
+export interface RealtimeData {
+ countries: Record<string, number>;
+ events: any[];
+ pageviews: any[];
+ referrers: Record<string, number>;
+ timestamp: number;
+ series: {
+ views: any[];
+ visitors: any[];
+ };
+ totals: {
+ views: number;
+ visitors: number;
+ events: number;
+ countries: number;
+ };
+ urls: Record<string, number>;
+ visitors: any[];
+}
+
+export interface ApiError extends Error {
+ code?: string;
+ message: string;
+}
diff --git a/src/lib/url.ts b/src/lib/url.ts
new file mode 100644
index 0000000..f6772fe
--- /dev/null
+++ b/src/lib/url.ts
@@ -0,0 +1,49 @@
+export function getQueryString(params: object = {}): string {
+ const searchParams = new URLSearchParams();
+
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined) {
+ searchParams.append(key, value);
+ }
+ });
+
+ return searchParams.toString();
+}
+
+export function buildPath(path: string, params: object = {}): string {
+ const queryString = getQueryString(params);
+ return queryString ? `${path}?${queryString}` : path;
+}
+
+export function safeDecodeURI(s: string | undefined | null): string | undefined | null {
+ if (s === undefined || s === null) {
+ return s;
+ }
+
+ try {
+ return decodeURI(s);
+ } catch {
+ return s;
+ }
+}
+
+export function safeDecodeURIComponent(s: string | undefined | null): string | undefined | null {
+ if (s === undefined || s === null) {
+ return s;
+ }
+
+ try {
+ return decodeURIComponent(s);
+ } catch {
+ return s;
+ }
+}
+
+export function isValidUrl(url: string) {
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..2b0d9ff
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,46 @@
+export function hook(
+ _this: { [x: string]: any },
+ method: string | number,
+ callback: (arg0: any) => void,
+) {
+ const orig = _this[method];
+
+ return (...args: any) => {
+ callback.apply(_this, args);
+
+ return orig.apply(_this, args);
+ };
+}
+
+export function sleep(ms: number | undefined) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+export function shuffleArray(a) {
+ const arr = a.slice();
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const temp = arr[i];
+ arr[i] = arr[j];
+ arr[j] = temp;
+ }
+ return arr;
+}
+
+export function chunkArray(arr: any[], size: number) {
+ const chunks: any[] = [];
+
+ let index = 0;
+ while (index < arr.length) {
+ chunks.push(arr.slice(index, size + index));
+ index += size;
+ }
+
+ return chunks;
+}
+
+export function ensureArray(arr?: any) {
+ if (arr === undefined || arr === null) return [];
+ if (Array.isArray(arr)) return arr;
+ return [arr];
+}
diff --git a/src/permissions/index.ts b/src/permissions/index.ts
new file mode 100644
index 0000000..a70808e
--- /dev/null
+++ b/src/permissions/index.ts
@@ -0,0 +1,6 @@
+export * from './link';
+export * from './pixel';
+export * from './report';
+export * from './team';
+export * from './user';
+export * from './website';
diff --git a/src/permissions/link.ts b/src/permissions/link.ts
new file mode 100644
index 0000000..c027a0b
--- /dev/null
+++ b/src/permissions/link.ts
@@ -0,0 +1,64 @@
+import { hasPermission } from '@/lib/auth';
+import { PERMISSIONS } from '@/lib/constants';
+import type { Auth } from '@/lib/types';
+import { getLink, getTeamUser } from '@/queries/prisma';
+
+export async function canViewLink({ user }: Auth, linkId: string) {
+ if (user?.isAdmin) {
+ return true;
+ }
+
+ const link = await getLink(linkId);
+
+ if (link.userId) {
+ return user.id === link.userId;
+ }
+
+ if (link.teamId) {
+ const teamUser = await getTeamUser(link.teamId, user.id);
+
+ return !!teamUser;
+ }
+
+ return false;
+}
+
+export async function canUpdateLink({ user }: Auth, linkId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const link = await getLink(linkId);
+
+ if (link.userId) {
+ return user.id === link.userId;
+ }
+
+ if (link.teamId) {
+ const teamUser = await getTeamUser(link.teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate);
+ }
+
+ return false;
+}
+
+export async function canDeleteLink({ user }: Auth, linkId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const link = await getLink(linkId);
+
+ if (link.userId) {
+ return user.id === link.userId;
+ }
+
+ if (link.teamId) {
+ const teamUser = await getTeamUser(link.teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete);
+ }
+
+ return false;
+}
diff --git a/src/permissions/pixel.ts b/src/permissions/pixel.ts
new file mode 100644
index 0000000..2131874
--- /dev/null
+++ b/src/permissions/pixel.ts
@@ -0,0 +1,64 @@
+import { hasPermission } from '@/lib/auth';
+import { PERMISSIONS } from '@/lib/constants';
+import type { Auth } from '@/lib/types';
+import { getPixel, getTeamUser } from '@/queries/prisma';
+
+export async function canViewPixel({ user }: Auth, pixelId: string) {
+ if (user?.isAdmin) {
+ return true;
+ }
+
+ const pixel = await getPixel(pixelId);
+
+ if (pixel.userId) {
+ return user.id === pixel.userId;
+ }
+
+ if (pixel.teamId) {
+ const teamUser = await getTeamUser(pixel.teamId, user.id);
+
+ return !!teamUser;
+ }
+
+ return false;
+}
+
+export async function canUpdatePixel({ user }: Auth, pixelId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const pixel = await getPixel(pixelId);
+
+ if (pixel.userId) {
+ return user.id === pixel.userId;
+ }
+
+ if (pixel.teamId) {
+ const teamUser = await getTeamUser(pixel.teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate);
+ }
+
+ return false;
+}
+
+export async function canDeletePixel({ user }: Auth, pixelId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const pixel = await getPixel(pixelId);
+
+ if (pixel.userId) {
+ return user.id === pixel.userId;
+ }
+
+ if (pixel.teamId) {
+ const teamUser = await getTeamUser(pixel.teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete);
+ }
+
+ return false;
+}
diff --git a/src/permissions/report.ts b/src/permissions/report.ts
new file mode 100644
index 0000000..01b5476
--- /dev/null
+++ b/src/permissions/report.ts
@@ -0,0 +1,27 @@
+import type { Report } from '@/generated/prisma/client';
+import type { Auth } from '@/lib/types';
+import { canViewWebsite } from './website';
+
+export async function canViewReport(auth: Auth, report: Report) {
+ if (auth.user.isAdmin) {
+ return true;
+ }
+
+ if (auth.user.id === report.userId) {
+ return true;
+ }
+
+ return !!(await canViewWebsite(auth, report.websiteId));
+}
+
+export async function canUpdateReport({ user }: Auth, report: Report) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ return user.id === report.userId;
+}
+
+export async function canDeleteReport(auth: Auth, report: Report) {
+ return canUpdateReport(auth, report);
+}
diff --git a/src/permissions/team.ts b/src/permissions/team.ts
new file mode 100644
index 0000000..0f07c1a
--- /dev/null
+++ b/src/permissions/team.ts
@@ -0,0 +1,68 @@
+import { hasPermission } from '@/lib/auth';
+import { PERMISSIONS } from '@/lib/constants';
+import type { Auth } from '@/lib/types';
+import { getTeamUser } from '@/queries/prisma';
+
+export async function canViewTeam({ user }: Auth, teamId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ return getTeamUser(teamId, user.id);
+}
+
+export async function canCreateTeam({ user }: Auth) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ return !!user;
+}
+
+export async function canUpdateTeam({ user }: Auth, teamId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const teamUser = await getTeamUser(teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate);
+}
+
+export async function canDeleteTeam({ user }: Auth, teamId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const teamUser = await getTeamUser(teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamDelete);
+}
+
+export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUserId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ if (removeUserId === user.id) {
+ return true;
+ }
+
+ const teamUser = await getTeamUser(teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate);
+}
+
+export async function canCreateTeamWebsite({ user }: Auth, teamId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const teamUser = await getTeamUser(teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteCreate);
+}
+
+export async function canViewAllTeams({ user }: Auth) {
+ return user.isAdmin;
+}
diff --git a/src/permissions/user.ts b/src/permissions/user.ts
new file mode 100644
index 0000000..2ed8f27
--- /dev/null
+++ b/src/permissions/user.ts
@@ -0,0 +1,29 @@
+import type { Auth } from '@/lib/types';
+
+export async function canCreateUser({ user }: Auth) {
+ return user.isAdmin;
+}
+
+export async function canViewUser({ user }: Auth, viewedUserId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ return user.id === viewedUserId;
+}
+
+export async function canViewUsers({ user }: Auth) {
+ return user.isAdmin;
+}
+
+export async function canUpdateUser({ user }: Auth, viewedUserId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ return user.id === viewedUserId;
+}
+
+export async function canDeleteUser({ user }: Auth) {
+ return user.isAdmin;
+}
diff --git a/src/permissions/website.ts b/src/permissions/website.ts
new file mode 100644
index 0000000..97952ee
--- /dev/null
+++ b/src/permissions/website.ts
@@ -0,0 +1,128 @@
+import { hasPermission } from '@/lib/auth';
+import { PERMISSIONS } from '@/lib/constants';
+import type { Auth } from '@/lib/types';
+import { getLink, getPixel, getTeamUser, getWebsite } from '@/queries/prisma';
+
+export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) {
+ if (user?.isAdmin) {
+ return true;
+ }
+
+ if (shareToken?.websiteId === websiteId) {
+ return true;
+ }
+
+ const website = await getWebsite(websiteId);
+ const link = await getLink(websiteId);
+ const pixel = await getPixel(websiteId);
+
+ const entity = website || link || pixel;
+
+ if (!entity) {
+ return false;
+ }
+
+ if (entity.userId) {
+ return user.id === entity.userId;
+ }
+
+ if (entity.teamId) {
+ const teamUser = await getTeamUser(entity.teamId, user.id);
+
+ return !!teamUser;
+ }
+
+ return false;
+}
+
+export async function canViewAllWebsites({ user }: Auth) {
+ return user.isAdmin;
+}
+
+export async function canCreateWebsite({ user }: Auth) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ return hasPermission(user.role, PERMISSIONS.websiteCreate);
+}
+
+export async function canUpdateWebsite({ user }: Auth, websiteId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const website = await getWebsite(websiteId);
+
+ if (!website) {
+ return false;
+ }
+
+ if (website.userId) {
+ return user.id === website.userId;
+ }
+
+ if (website.teamId) {
+ const teamUser = await getTeamUser(website.teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate);
+ }
+
+ return false;
+}
+
+export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
+ if (user.isAdmin) {
+ return true;
+ }
+
+ const website = await getWebsite(websiteId);
+
+ if (!website) {
+ return false;
+ }
+
+ if (website.userId) {
+ return user.id === website.userId;
+ }
+
+ if (website.teamId) {
+ const teamUser = await getTeamUser(website.teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete);
+ }
+
+ return false;
+}
+
+export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string, userId: string) {
+ const website = await getWebsite(websiteId);
+
+ if (!website) {
+ return false;
+ }
+
+ if (website.teamId && user.id === userId) {
+ const teamUser = await getTeamUser(website.teamId, userId);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteTransferToUser);
+ }
+
+ return false;
+}
+
+export async function canTransferWebsiteToTeam({ user }: Auth, websiteId: string, teamId: string) {
+ const website = await getWebsite(websiteId);
+
+ if (!website) {
+ return false;
+ }
+
+ if (website.userId && website.userId === user.id) {
+ const teamUser = await getTeamUser(teamId, user.id);
+
+ return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteTransferToTeam);
+ }
+
+ return false;
+}
diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts
new file mode 100644
index 0000000..b9730f5
--- /dev/null
+++ b/src/queries/prisma/index.ts
@@ -0,0 +1,8 @@
+export * from './link';
+export * from './pixel';
+export * from './report';
+export * from './segment';
+export * from './team';
+export * from './teamUser';
+export * from './user';
+export * from './website';
diff --git a/src/queries/prisma/link.ts b/src/queries/prisma/link.ts
new file mode 100644
index 0000000..9b971de
--- /dev/null
+++ b/src/queries/prisma/link.ts
@@ -0,0 +1,66 @@
+import type { Prisma } from '@/generated/prisma/client';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export async function findLink(criteria: Prisma.LinkFindUniqueArgs) {
+ return prisma.client.link.findUnique(criteria);
+}
+
+export async function getLink(linkId: string) {
+ return findLink({
+ where: {
+ id: linkId,
+ },
+ });
+}
+
+export async function getLinks(criteria: Prisma.LinkFindManyArgs, filters: QueryFilters = {}) {
+ const { search } = filters;
+ const { getSearchParameters, pagedQuery } = prisma;
+
+ const where: Prisma.LinkWhereInput = {
+ ...criteria.where,
+ ...getSearchParameters(search, [
+ { name: 'contains' },
+ { url: 'contains' },
+ { slug: 'contains' },
+ ]),
+ };
+
+ return pagedQuery('link', { ...criteria, where }, filters);
+}
+
+export async function getUserLinks(userId: string, filters?: QueryFilters) {
+ return getLinks(
+ {
+ where: {
+ userId,
+ deletedAt: null,
+ },
+ },
+ filters,
+ );
+}
+
+export async function getTeamLinks(teamId: string, filters?: QueryFilters) {
+ return getLinks(
+ {
+ where: {
+ teamId,
+ },
+ },
+ filters,
+ );
+}
+
+export async function createLink(data: Prisma.LinkUncheckedCreateInput) {
+ return prisma.client.link.create({ data });
+}
+
+export async function updateLink(linkId: string, data: any) {
+ return prisma.client.link.update({ where: { id: linkId }, data });
+}
+
+export async function deleteLink(linkId: string) {
+ return prisma.client.link.delete({ where: { id: linkId } });
+}
diff --git a/src/queries/prisma/pixel.ts b/src/queries/prisma/pixel.ts
new file mode 100644
index 0000000..4c9e132
--- /dev/null
+++ b/src/queries/prisma/pixel.ts
@@ -0,0 +1,60 @@
+import type { Prisma } from '@/generated/prisma/client';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export async function findPixel(criteria: Prisma.PixelFindUniqueArgs) {
+ return prisma.client.pixel.findUnique(criteria);
+}
+
+export async function getPixel(pixelId: string) {
+ return findPixel({
+ where: {
+ id: pixelId,
+ },
+ });
+}
+
+export async function getPixels(criteria: Prisma.PixelFindManyArgs, filters: QueryFilters = {}) {
+ const { search } = filters;
+
+ const where: Prisma.PixelWhereInput = {
+ ...criteria.where,
+ ...prisma.getSearchParameters(search, [{ name: 'contains' }, { slug: 'contains' }]),
+ };
+
+ return prisma.pagedQuery('pixel', { ...criteria, where }, filters);
+}
+
+export async function getUserPixels(userId: string, filters?: QueryFilters) {
+ return getPixels(
+ {
+ where: {
+ userId,
+ },
+ },
+ filters,
+ );
+}
+
+export async function getTeamPixels(teamId: string, filters?: QueryFilters) {
+ return getPixels(
+ {
+ where: {
+ teamId,
+ },
+ },
+ filters,
+ );
+}
+
+export async function createPixel(data: Prisma.PixelUncheckedCreateInput) {
+ return prisma.client.pixel.create({ data });
+}
+
+export async function updatePixel(pixelId: string, data: any) {
+ return prisma.client.pixel.update({ where: { id: pixelId }, data });
+}
+
+export async function deletePixel(pixelId: string) {
+ return prisma.client.pixel.delete({ where: { id: pixelId } });
+}
diff --git a/src/queries/prisma/report.ts b/src/queries/prisma/report.ts
new file mode 100644
index 0000000..4a5b755
--- /dev/null
+++ b/src/queries/prisma/report.ts
@@ -0,0 +1,89 @@
+import { Prisma } from '@/generated/prisma/client';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+import ReportFindManyArgs = Prisma.ReportFindManyArgs;
+
+async function findReport(criteria: Prisma.ReportFindUniqueArgs) {
+ return prisma.client.report.findUnique(criteria);
+}
+
+export async function getReport(reportId: string) {
+ return findReport({
+ where: {
+ id: reportId,
+ },
+ });
+}
+
+export async function getReports(criteria: ReportFindManyArgs, filters: QueryFilters = {}) {
+ const { search } = filters;
+
+ const where: Prisma.ReportWhereInput = {
+ ...criteria.where,
+ ...prisma.getSearchParameters(search, [
+ { name: 'contains' },
+ { description: 'contains' },
+ { type: 'contains' },
+ {
+ user: {
+ username: 'contains',
+ },
+ },
+ {
+ website: {
+ name: 'contains',
+ },
+ },
+ {
+ website: {
+ domain: 'contains',
+ },
+ },
+ ]),
+ };
+
+ return prisma.pagedQuery('report', { ...criteria, where }, filters);
+}
+
+export async function getUserReports(userId: string, filters?: QueryFilters) {
+ return getReports(
+ {
+ where: {
+ userId,
+ },
+ include: {
+ website: {
+ select: {
+ domain: true,
+ userId: true,
+ },
+ },
+ },
+ },
+ filters,
+ );
+}
+
+export async function getWebsiteReports(websiteId: string, filters: QueryFilters = {}) {
+ return getReports(
+ {
+ where: {
+ websiteId,
+ },
+ },
+ filters,
+ );
+}
+
+export async function createReport(data: Prisma.ReportUncheckedCreateInput) {
+ return prisma.client.report.create({ data });
+}
+
+export async function updateReport(reportId: string, data: any) {
+ return prisma.client.report.update({ where: { id: reportId }, data });
+}
+
+export async function deleteReport(reportId: string) {
+ return prisma.client.report.delete({ where: { id: reportId } });
+}
diff --git a/src/queries/prisma/segment.ts b/src/queries/prisma/segment.ts
new file mode 100644
index 0000000..3a17d27
--- /dev/null
+++ b/src/queries/prisma/segment.ts
@@ -0,0 +1,61 @@
+import type { Prisma } from '@/generated/prisma/client';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+async function findSegment(criteria: Prisma.SegmentFindUniqueArgs) {
+ return prisma.client.segment.findUnique(criteria);
+}
+
+export async function getSegment(segmentId: string) {
+ return findSegment({
+ where: {
+ id: segmentId,
+ },
+ });
+}
+
+export async function getSegments(criteria: Prisma.SegmentFindManyArgs, filters: QueryFilters) {
+ const { search } = filters;
+ const { getSearchParameters, pagedQuery } = prisma;
+
+ const where: Prisma.SegmentWhereInput = {
+ ...criteria.where,
+ ...getSearchParameters(search, [
+ {
+ name: 'contains',
+ },
+ ]),
+ };
+
+ return pagedQuery('segment', { ...criteria, where }, filters);
+}
+
+export async function getWebsiteSegment(websiteId: string, segmentId: string) {
+ return prisma.client.segment.findFirst({
+ where: { id: segmentId, websiteId },
+ });
+}
+
+export async function getWebsiteSegments(websiteId: string, type: string, filters?: QueryFilters) {
+ return getSegments(
+ {
+ where: {
+ websiteId,
+ type,
+ },
+ },
+ filters,
+ );
+}
+
+export async function createSegment(data: Prisma.SegmentUncheckedCreateInput) {
+ return prisma.client.segment.create({ data });
+}
+
+export async function updateSegment(SegmentId: string, data: Prisma.SegmentUpdateInput) {
+ return prisma.client.segment.update({ where: { id: SegmentId }, data });
+}
+
+export async function deleteSegment(SegmentId: string) {
+ return prisma.client.segment.delete({ where: { id: SegmentId } });
+}
diff --git a/src/queries/prisma/team.ts b/src/queries/prisma/team.ts
new file mode 100644
index 0000000..5987c1d
--- /dev/null
+++ b/src/queries/prisma/team.ts
@@ -0,0 +1,165 @@
+import { Prisma, type Team } from '@/generated/prisma/client';
+import { ROLES } from '@/lib/constants';
+import { uuid } from '@/lib/crypto';
+import prisma from '@/lib/prisma';
+import type { PageResult, QueryFilters } from '@/lib/types';
+
+import TeamFindManyArgs = Prisma.TeamFindManyArgs;
+
+export async function findTeam(criteria: Prisma.TeamFindUniqueArgs): Promise<Team> {
+ return prisma.client.team.findUnique(criteria);
+}
+
+export async function getTeam(
+ teamId: string,
+ options: { includeMembers?: boolean } = {},
+): Promise<Team> {
+ const { includeMembers } = options;
+
+ return findTeam({
+ where: {
+ id: teamId,
+ },
+ ...(includeMembers && { include: { members: true } }),
+ });
+}
+
+export async function getTeams(
+ criteria: TeamFindManyArgs,
+ filters: QueryFilters,
+): Promise<PageResult<Team[]>> {
+ const { getSearchParameters } = prisma;
+ const { search } = filters;
+
+ const where: Prisma.TeamWhereInput = {
+ ...criteria.where,
+ ...getSearchParameters(search, [{ name: 'contains' }]),
+ };
+
+ return prisma.pagedQuery<TeamFindManyArgs>(
+ 'team',
+ {
+ ...criteria,
+ where,
+ },
+ filters,
+ );
+}
+
+export async function getUserTeams(userId: string, filters: QueryFilters = {}) {
+ return getTeams(
+ {
+ where: {
+ deletedAt: null,
+ members: {
+ some: { userId },
+ },
+ },
+ include: {
+ members: {
+ include: {
+ user: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ },
+ _count: {
+ select: {
+ websites: {
+ where: { deletedAt: null },
+ },
+ members: {
+ where: {
+ user: { deletedAt: null },
+ },
+ },
+ },
+ },
+ },
+ },
+ filters,
+ );
+}
+
+export async function getAllUserTeams(userId: string) {
+ return prisma.client.team.findMany({
+ where: {
+ deletedAt: null,
+ members: {
+ some: { userId },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ logoUrl: true,
+ },
+ });
+}
+
+export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise<any> {
+ const { id } = data;
+ const { client, transaction } = prisma;
+
+ return transaction([
+ client.team.create({
+ data,
+ }),
+ client.teamUser.create({
+ data: {
+ id: uuid(),
+ teamId: id,
+ userId,
+ role: ROLES.teamOwner,
+ },
+ }),
+ ]);
+}
+
+export async function updateTeam(teamId: string, data: Prisma.TeamUpdateInput): Promise<Team> {
+ const { client } = prisma;
+
+ return client.team.update({
+ where: {
+ id: teamId,
+ },
+ data: {
+ ...data,
+ updatedAt: new Date(),
+ },
+ });
+}
+
+export async function deleteTeam(teamId: string) {
+ const { client, transaction } = prisma;
+ const cloudMode = !!process.env.CLOUD_MODE;
+
+ if (cloudMode) {
+ return transaction([
+ client.team.update({
+ data: {
+ deletedAt: new Date(),
+ },
+ where: {
+ id: teamId,
+ },
+ }),
+ ]);
+ }
+
+ return transaction([
+ client.teamUser.deleteMany({
+ where: {
+ teamId,
+ },
+ }),
+ client.team.delete({
+ where: {
+ id: teamId,
+ },
+ }),
+ ]);
+}
diff --git a/src/queries/prisma/teamUser.ts b/src/queries/prisma/teamUser.ts
new file mode 100644
index 0000000..2210dee
--- /dev/null
+++ b/src/queries/prisma/teamUser.ts
@@ -0,0 +1,66 @@
+import { Prisma } from '@/generated/prisma/client';
+import { uuid } from '@/lib/crypto';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+import TeamUserFindManyArgs = Prisma.TeamUserFindManyArgs;
+
+export async function findTeamUser(criteria: Prisma.TeamUserFindUniqueArgs) {
+ return prisma.client.teamUser.findUnique(criteria);
+}
+
+export async function getTeamUser(teamId: string, userId: string) {
+ return prisma.client.teamUser.findFirst({
+ where: {
+ teamId,
+ userId,
+ },
+ });
+}
+
+export async function getTeamUsers(criteria: TeamUserFindManyArgs, filters?: QueryFilters) {
+ const { search } = filters;
+
+ const where: Prisma.TeamUserWhereInput = {
+ ...criteria.where,
+ ...prisma.getSearchParameters(search, [{ user: { username: 'contains' } }]),
+ };
+
+ return prisma.pagedQuery(
+ 'teamUser',
+ {
+ ...criteria,
+ where,
+ },
+ filters,
+ );
+}
+
+export async function createTeamUser(userId: string, teamId: string, role: string) {
+ return prisma.client.teamUser.create({
+ data: {
+ id: uuid(),
+ userId,
+ teamId,
+ role,
+ },
+ });
+}
+
+export async function updateTeamUser(teamUserId: string, data: Prisma.TeamUserUpdateInput) {
+ return prisma.client.teamUser.update({
+ where: {
+ id: teamUserId,
+ },
+ data,
+ });
+}
+
+export async function deleteTeamUser(teamId: string, userId: string) {
+ return prisma.client.teamUser.deleteMany({
+ where: {
+ teamId,
+ userId,
+ },
+ });
+}
diff --git a/src/queries/prisma/user.ts b/src/queries/prisma/user.ts
new file mode 100644
index 0000000..14376fc
--- /dev/null
+++ b/src/queries/prisma/user.ts
@@ -0,0 +1,206 @@
+import { Prisma } from '@/generated/prisma/client';
+import { ROLES } from '@/lib/constants';
+import { getRandomChars } from '@/lib/generate';
+import prisma from '@/lib/prisma';
+import type { QueryFilters, Role } from '@/lib/types';
+
+import UserFindManyArgs = Prisma.UserFindManyArgs;
+
+export interface GetUserOptions {
+ includePassword?: boolean;
+ showDeleted?: boolean;
+}
+
+async function findUser(criteria: Prisma.UserFindUniqueArgs, options: GetUserOptions = {}) {
+ const { includePassword = false, showDeleted = false } = options;
+
+ return prisma.client.user.findUnique({
+ ...criteria,
+ where: {
+ ...criteria.where,
+ ...(showDeleted && { deletedAt: null }),
+ },
+ select: {
+ id: true,
+ username: true,
+ password: includePassword,
+ role: true,
+ createdAt: true,
+ },
+ });
+}
+
+export async function getUser(userId: string, options: GetUserOptions = {}) {
+ return findUser(
+ {
+ where: {
+ id: userId,
+ },
+ },
+ options,
+ );
+}
+
+export async function getUserByUsername(username: string, options: GetUserOptions = {}) {
+ return findUser({ where: { username } }, options);
+}
+
+export async function getUsers(criteria: UserFindManyArgs, filters: QueryFilters = {}) {
+ const { search } = filters;
+
+ const where: Prisma.UserWhereInput = {
+ ...criteria.where,
+ ...prisma.getSearchParameters(search, [{ username: 'contains' }]),
+ deletedAt: null,
+ };
+
+ return prisma.pagedQuery(
+ 'user',
+ {
+ ...criteria,
+ where,
+ },
+ {
+ orderBy: 'createdAt',
+ sortDescending: true,
+ ...filters,
+ },
+ );
+}
+
+export async function createUser(data: {
+ id: string;
+ username: string;
+ password: string;
+ role: Role;
+}) {
+ return prisma.client.user.create({
+ data,
+ select: {
+ id: true,
+ username: true,
+ role: true,
+ },
+ });
+}
+
+export async function updateUser(userId: string, data: Prisma.UserUpdateInput) {
+ return prisma.client.user.update({
+ where: {
+ id: userId,
+ },
+ data,
+ select: {
+ id: true,
+ username: true,
+ role: true,
+ createdAt: true,
+ },
+ });
+}
+
+export async function deleteUser(userId: string) {
+ const { client, transaction } = prisma;
+ const cloudMode = !!process.env.CLOUD_MODE;
+
+ const websites = await client.website.findMany({
+ where: { userId },
+ });
+
+ let websiteIds = [];
+
+ if (websites.length > 0) {
+ websiteIds = websites.map(a => a.id);
+ }
+
+ const teams = await client.team.findMany({
+ where: {
+ members: {
+ some: {
+ userId,
+ role: ROLES.teamOwner,
+ },
+ },
+ },
+ });
+
+ const teamIds = teams.map(a => a.id);
+
+ if (cloudMode) {
+ return transaction([
+ client.website.updateMany({
+ data: {
+ deletedAt: new Date(),
+ },
+ where: { id: { in: websiteIds } },
+ }),
+ client.user.update({
+ data: {
+ username: getRandomChars(32),
+ deletedAt: new Date(),
+ },
+ where: {
+ id: userId,
+ },
+ }),
+ ]);
+ }
+
+ return transaction([
+ client.eventData.deleteMany({
+ where: { websiteId: { in: websiteIds } },
+ }),
+ client.sessionData.deleteMany({
+ where: { websiteId: { in: websiteIds } },
+ }),
+ client.websiteEvent.deleteMany({
+ where: { websiteId: { in: websiteIds } },
+ }),
+ client.session.deleteMany({
+ where: { websiteId: { in: websiteIds } },
+ }),
+ client.teamUser.deleteMany({
+ where: {
+ OR: [
+ {
+ teamId: {
+ in: teamIds,
+ },
+ },
+ {
+ userId,
+ },
+ ],
+ },
+ }),
+ client.team.deleteMany({
+ where: {
+ id: {
+ in: teamIds,
+ },
+ },
+ }),
+ client.report.deleteMany({
+ where: {
+ OR: [
+ {
+ websiteId: {
+ in: websiteIds,
+ },
+ },
+ {
+ userId,
+ },
+ ],
+ },
+ }),
+ client.website.deleteMany({
+ where: { id: { in: websiteIds } },
+ }),
+ client.user.delete({
+ where: {
+ id: userId,
+ },
+ }),
+ ]);
+}
diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts
new file mode 100644
index 0000000..79cb724
--- /dev/null
+++ b/src/queries/prisma/website.ts
@@ -0,0 +1,234 @@
+import type { Prisma } from '@/generated/prisma/client';
+import { ROLES } from '@/lib/constants';
+import prisma from '@/lib/prisma';
+import redis from '@/lib/redis';
+import type { QueryFilters } from '@/lib/types';
+
+export async function findWebsite(criteria: Prisma.WebsiteFindUniqueArgs) {
+ return prisma.client.website.findUnique(criteria);
+}
+
+export async function getWebsite(websiteId: string) {
+ return findWebsite({
+ where: {
+ id: websiteId,
+ },
+ });
+}
+
+export async function getSharedWebsite(shareId: string) {
+ return findWebsite({
+ where: {
+ shareId,
+ deletedAt: null,
+ },
+ });
+}
+
+export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) {
+ const { search } = filters;
+ const { getSearchParameters, pagedQuery } = prisma;
+
+ const where: Prisma.WebsiteWhereInput = {
+ ...criteria.where,
+ ...getSearchParameters(search, [
+ {
+ name: 'contains',
+ },
+ { domain: 'contains' },
+ ]),
+ deletedAt: null,
+ };
+
+ return pagedQuery('website', { ...criteria, where }, filters);
+}
+
+export async function getAllUserWebsitesIncludingTeamOwner(userId: string, filters?: QueryFilters) {
+ return getWebsites(
+ {
+ where: {
+ OR: [
+ { userId },
+ {
+ team: {
+ deletedAt: null,
+ members: {
+ some: {
+ role: ROLES.teamOwner,
+ userId,
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ orderBy: 'name',
+ ...filters,
+ },
+ );
+}
+
+export async function getUserWebsites(userId: string, filters?: QueryFilters) {
+ return getWebsites(
+ {
+ where: {
+ userId,
+ },
+ include: {
+ user: {
+ select: {
+ username: true,
+ id: true,
+ },
+ },
+ },
+ },
+ {
+ orderBy: 'name',
+ ...filters,
+ },
+ );
+}
+
+export async function getTeamWebsites(teamId: string, filters?: QueryFilters) {
+ return getWebsites(
+ {
+ where: {
+ teamId,
+ },
+ include: {
+ createUser: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ },
+ filters,
+ );
+}
+
+export async function createWebsite(
+ data: Prisma.WebsiteCreateInput | Prisma.WebsiteUncheckedCreateInput,
+) {
+ return prisma.client.website.create({
+ data,
+ });
+}
+
+export async function updateWebsite(
+ websiteId: string,
+ data: Prisma.WebsiteUpdateInput | Prisma.WebsiteUncheckedUpdateInput,
+) {
+ return prisma.client.website.update({
+ where: {
+ id: websiteId,
+ },
+ data,
+ });
+}
+
+export async function resetWebsite(websiteId: string) {
+ const { client, transaction } = prisma;
+ const cloudMode = !!process.env.CLOUD_MODE;
+
+ return transaction(
+ [
+ client.revenue.deleteMany({
+ where: { websiteId },
+ }),
+ client.eventData.deleteMany({
+ where: { websiteId },
+ }),
+ client.sessionData.deleteMany({
+ where: { websiteId },
+ }),
+ client.websiteEvent.deleteMany({
+ where: { websiteId },
+ }),
+ client.session.deleteMany({
+ where: { websiteId },
+ }),
+ client.website.update({
+ where: { id: websiteId },
+ data: {
+ resetAt: new Date(),
+ },
+ }),
+ ],
+ {
+ timeout: 30000,
+ },
+ ).then(async data => {
+ if (cloudMode) {
+ await redis.client.set(
+ `website:${websiteId}`,
+ data.find(website => website.id),
+ );
+ }
+
+ return data;
+ });
+}
+
+export async function deleteWebsite(websiteId: string) {
+ const { client, transaction } = prisma;
+ const cloudMode = !!process.env.CLOUD_MODE;
+
+ return transaction(
+ [
+ client.revenue.deleteMany({
+ where: { websiteId },
+ }),
+ client.eventData.deleteMany({
+ where: { websiteId },
+ }),
+ client.sessionData.deleteMany({
+ where: { websiteId },
+ }),
+ client.websiteEvent.deleteMany({
+ where: { websiteId },
+ }),
+ client.session.deleteMany({
+ where: { websiteId },
+ }),
+ client.report.deleteMany({
+ where: { websiteId },
+ }),
+ client.segment.deleteMany({
+ where: { websiteId },
+ }),
+ cloudMode
+ ? client.website.update({
+ data: {
+ deletedAt: new Date(),
+ },
+ where: { id: websiteId },
+ })
+ : client.website.delete({
+ where: { id: websiteId },
+ }),
+ ],
+ {
+ timeout: 30000,
+ },
+ ).then(async data => {
+ if (cloudMode) {
+ await redis.client.del(`website:${websiteId}`);
+ }
+
+ return data;
+ });
+}
+
+export async function getWebsiteCount(userId: string) {
+ return prisma.client.website.count({
+ where: {
+ userId,
+ deletedAt: null,
+ },
+ });
+}
diff --git a/src/queries/sql/events/getEventData.ts b/src/queries/sql/events/getEventData.ts
new file mode 100644
index 0000000..f12c95c
--- /dev/null
+++ b/src/queries/sql/events/getEventData.ts
@@ -0,0 +1,63 @@
+import type { EventData } from '@/generated/prisma/client';
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+
+const FUNCTION_NAME = 'getEventData';
+
+export async function getEventData(
+ ...args: [websiteId: string, eventId: string]
+): Promise<EventData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, eventId: string) {
+ const { rawQuery } = prisma;
+
+ return rawQuery(
+ `
+ select event_data.website_id as "websiteId",
+ event_data.website_event_id as "eventId",
+ website_event.event_name as "eventName",
+ event_data.data_key as "dataKey",
+ event_data.string_value as "stringValue",
+ event_data.number_value as "numberValue",
+ event_data.date_value as "dateValue",
+ event_data.data_type as "dataType",
+ event_data.created_at as "createdAt"
+ from event_data
+ join website_event on website_event.event_id = event_data.website_event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.website_event_id = {{eventId::uuid}}
+ `,
+ { websiteId, eventId },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(websiteId: string, eventId: string): Promise<EventData[]> {
+ const { rawQuery } = clickhouse;
+
+ return rawQuery(
+ `
+ select website_id as websiteId,
+ event_id as eventId,
+ event_name as eventName,
+ data_key as dataKey,
+ string_value as stringValue,
+ number_value as numberValue,
+ date_value as dateValue,
+ data_type as dataType,
+ created_at as createdAt
+ from event_data
+ where website_id = {websiteId:UUID}
+ and event_id = {eventId:UUID}
+ `,
+ { websiteId, eventId },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataEvents.ts b/src/queries/sql/events/getEventDataEvents.ts
new file mode 100644
index 0000000..6c8f12c
--- /dev/null
+++ b/src/queries/sql/events/getEventDataEvents.ts
@@ -0,0 +1,139 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataEvents';
+
+export interface WebsiteEventData {
+ eventName?: string;
+ propertyName: string;
+ dataType: number;
+ propertyValue?: string;
+ total: number;
+}
+
+export async function getEventDataEvents(
+ ...args: [websiteId: string, filters: QueryFilters]
+): Promise<WebsiteEventData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { rawQuery, parseFilters } = prisma;
+ const { event } = filters;
+ const { queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ if (event) {
+ return rawQuery(
+ `
+ select
+ website_event.event_name as "eventName",
+ event_data.data_key as "propertyName",
+ event_data.data_type as "dataType",
+ event_data.string_value as "propertyValue",
+ count(*) as "total"
+ from event_data
+ inner join website_event
+ on website_event.event_id = event_data.website_event_id
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_name = {{event}}
+ group by website_event.event_name, event_data.data_key, event_data.data_type, event_data.string_value
+ order by 1 asc, 2 asc, 3 asc, 5 desc
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+ }
+
+ return rawQuery(
+ `
+ select
+ website_event.event_name as "eventName",
+ event_data.data_key as "propertyName",
+ event_data.data_type as "dataType",
+ count(*) as "total"
+ from event_data
+ inner join website_event
+ on website_event.event_id = event_data.website_event_id
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ limit 500
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<{ eventName: string; propertyName: string; dataType: number; total: number }[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { event } = filters;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ if (event) {
+ return rawQuery(
+ `
+ select
+ event_name as eventName,
+ data_key as propertyName,
+ data_type as dataType,
+ string_value as propertyValue,
+ count(*) as total
+ from event_data
+ join website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.website_id = event_data.website_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${cohortQuery}
+ where event_data.website_id = {websiteId:UUID}
+ and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_data.event_name = {event:String}
+ ${filterQuery}
+ group by data_key, data_type, string_value, event_name
+ order by 1 asc, 2 asc, 3 asc, 5 desc
+ limit 500
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+ }
+
+ return rawQuery(
+ `
+ select
+ event_name as eventName,
+ data_key as propertyName,
+ data_type as dataType,
+ count(*) as total
+ from event_data
+ join website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.website_id = event_data.website_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${cohortQuery}
+ where event_data.website_id = {websiteId:UUID}
+ and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by data_key, data_type, event_name
+ order by 1 asc, 2 asc
+ limit 500
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataFields.ts b/src/queries/sql/events/getEventDataFields.ts
new file mode 100644
index 0000000..9337769
--- /dev/null
+++ b/src/queries/sql/events/getEventDataFields.ts
@@ -0,0 +1,84 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataFields';
+
+export async function getEventDataFields(...args: [websiteId: string, filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { rawQuery, parseFilters, getDateSQL } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ data_key as "propertyName",
+ data_type as "dataType",
+ case
+ when data_type = 2 then replace(string_value, '.0000', '')
+ when data_type = 4 then ${getDateSQL('date_value', 'hour')}
+ else string_value
+ end as "value",
+ count(*) as "total"
+ from event_data
+ join website_event on website_event.event_id = event_data.website_event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by data_key, data_type, value
+ order by 2 desc
+ limit 100
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+
+ return rawQuery(
+ `
+ select
+ data_key as propertyName,
+ data_type as dataType,
+ multiIf(data_type = 2, replaceAll(string_value, '.0000', ''),
+ data_type = 4, toString(date_trunc('hour', date_value)),
+ string_value) as "value",
+ count(*) as "total"
+ from event_data
+ join website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.website_id = event_data.website_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${cohortQuery}
+ where event_data.website_id = {websiteId:UUID}
+ and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by data_key, data_type, value
+ order by 2 desc
+ limit 100
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataProperties.ts b/src/queries/sql/events/getEventDataProperties.ts
new file mode 100644
index 0000000..82c078f
--- /dev/null
+++ b/src/queries/sql/events/getEventDataProperties.ts
@@ -0,0 +1,88 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataProperties';
+
+export async function getEventDataProperties(
+ ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ filters: QueryFilters & { propertyName?: string },
+) {
+ const { rawQuery, parseFilters } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters(
+ { ...filters, websiteId },
+ {
+ columns: { propertyName: 'data_key' },
+ },
+ );
+
+ return rawQuery(
+ `
+ select
+ website_event.event_name as "eventName",
+ event_data.data_key as "propertyName",
+ count(*) as "total"
+ from event_data
+ join website_event on website_event.event_id = event_data.website_event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by website_event.event_name, event_data.data_key
+ order by 3 desc
+ limit 500
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters & { propertyName?: string },
+): Promise<{ eventName: string; propertyName: string; total: number }[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters(
+ { ...filters, websiteId },
+ {
+ columns: { propertyName: 'data_key' },
+ },
+ );
+
+ return rawQuery(
+ `
+ select
+ event_name as eventName,
+ data_key as propertyName,
+ count(*) as total
+ from event_data
+ join website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.website_id = event_data.website_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${cohortQuery}
+ where event_data.website_id = {websiteId:UUID}
+ and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by event_name, data_key
+ order by 1, 3 desc
+ limit 500
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataStats.ts b/src/queries/sql/events/getEventDataStats.ts
new file mode 100644
index 0000000..89e1358
--- /dev/null
+++ b/src/queries/sql/events/getEventDataStats.ts
@@ -0,0 +1,90 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataStats';
+
+export async function getEventDataStats(
+ ...args: [websiteId: string, filters: QueryFilters]
+): Promise<{
+ events: number;
+ properties: number;
+ records: number;
+}> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ }).then(results => results?.[0]);
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { rawQuery, parseFilters } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ count(distinct t.website_event_id) as "events",
+ count(distinct t.data_key) as "properties",
+ sum(t.total) as "records"
+ from (
+ select
+ website_event_id,
+ data_key,
+ count(*) as "total"
+ from event_data
+ join website_event on website_event.event_id = event_data.website_event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by website_event_id, data_key
+ ) as t
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<{ events: number; properties: number; records: number }[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+
+ return rawQuery(
+ `
+ select
+ count(distinct t.event_id) as "events",
+ count(distinct t.data_key) as "properties",
+ sum(t.total) as "records"
+ from (
+ select
+ event_id,
+ data_key,
+ count(*) as "total"
+ from event_data
+ join website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.website_id = event_data.website_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${cohortQuery}
+ where event_data.website_id = {websiteId:UUID}
+ and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by event_id, data_key
+ ) as t
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataUsage.ts b/src/queries/sql/events/getEventDataUsage.ts
new file mode 100644
index 0000000..50613a7
--- /dev/null
+++ b/src/queries/sql/events/getEventDataUsage.ts
@@ -0,0 +1,38 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, notImplemented, PRISMA, runQuery } from '@/lib/db';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataUsage';
+
+export function getEventDataUsage(...args: [websiteIds: string[], filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: notImplemented,
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+function clickhouseQuery(
+ websiteIds: string[],
+ filters: QueryFilters,
+): Promise<{ websiteId: string; count: number }[]> {
+ const { rawQuery } = clickhouse;
+ const { startDate, endDate } = filters;
+
+ return rawQuery(
+ `
+ select
+ website_id as websiteId,
+ count(*) as count
+ from event_data
+ where created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and website_id in {websiteIds:Array(UUID)}
+ group by website_id
+ `,
+ {
+ websiteIds,
+ startDate,
+ endDate,
+ },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataValues.ts b/src/queries/sql/events/getEventDataValues.ts
new file mode 100644
index 0000000..0426e64
--- /dev/null
+++ b/src/queries/sql/events/getEventDataValues.ts
@@ -0,0 +1,93 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataValues';
+
+interface WebsiteEventData {
+ value: string;
+ total: number;
+}
+
+export async function getEventDataValues(
+ ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }]
+): Promise<WebsiteEventData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ filters: QueryFilters & { propertyName?: string },
+) {
+ const { rawQuery, parseFilters, getDateSQL } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ case
+ when data_type = 2 then replace(string_value, '.0000', '')
+ when data_type = 4 then ${getDateSQL('date_value', 'hour')}
+ else string_value
+ end as "value",
+ count(*) as "total"
+ from event_data
+ join website_event on website_event.event_id = event_data.website_event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ and event_data.data_key = {{propertyName}}
+ ${filterQuery}
+ group by value
+ order by 2 desc
+ limit 100
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters & { propertyName?: string },
+): Promise<{ value: string; total: number }[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+
+ return rawQuery(
+ `
+ select
+ multiIf(data_type = 2, replaceAll(string_value, '.0000', ''),
+ data_type = 4, toString(date_trunc('hour', date_value)),
+ string_value) as "value",
+ count(*) as "total"
+ from event_data
+ join website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.website_id = event_data.website_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${cohortQuery}
+ where event_data.website_id = {websiteId:UUID}
+ and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_data.data_key = {propertyName:String}
+ and event_data.event_name = {event:String}
+ ${filterQuery}
+ group by value
+ order by 2 desc
+ limit 100
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventExpandedMetrics.ts b/src/queries/sql/events/getEventExpandedMetrics.ts
new file mode 100644
index 0000000..f03a347
--- /dev/null
+++ b/src/queries/sql/events/getEventExpandedMetrics.ts
@@ -0,0 +1,132 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventExpandedMetrics';
+
+export interface EventExpandedMetricParameters {
+ type: string;
+ limit?: string;
+ offset?: string;
+}
+
+export interface EventExpandedMetricData {
+ name: string;
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}
+
+export async function getEventExpandedMetrics(
+ ...args: [websiteId: string, parameters: EventExpandedMetricParameters, filters: QueryFilters]
+): Promise<EventExpandedMetricData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: EventExpandedMetricParameters,
+ filters: QueryFilters,
+) {
+ const { type, limit = 500, offset = 0 } = parameters;
+ const column = FILTER_COLUMNS[type] || type;
+ const { rawQuery, parseFilters, getTimestampDiffSQL } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters(
+ {
+ ...filters,
+ websiteId,
+ eventType: EVENT_TYPE.customEvent,
+ },
+ { joinSession: SESSION_COLUMNS.includes(type) },
+ );
+
+ return rawQuery(
+ `
+ select
+ name,
+ sum(t.c) as "pageviews",
+ count(distinct t.session_id) as "visitors",
+ count(distinct t.visit_id) as "visits",
+ sum(case when t.c = 1 then 1 else 0 end) as "bounces",
+ sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
+ from (
+ select
+ ${column} name,
+ website_event.session_id,
+ website_event.visit_id,
+ count(*) as "c",
+ min(website_event.created_at) as "min_time",
+ max(website_event.created_at) as "max_time"
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by name, website_event.session_id, website_event.visit_id
+ ) as t
+ group by name
+ order by visitors desc, visits desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: EventExpandedMetricParameters,
+ filters: QueryFilters,
+): Promise<EventExpandedMetricData[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ const column = FILTER_COLUMNS[type] || type;
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ eventType: EVENT_TYPE.customEvent,
+ });
+
+ return rawQuery(
+ `
+ select
+ name,
+ sum(t.c) as "pageviews",
+ uniq(t.session_id) as "visitors",
+ uniq(t.visit_id) as "visits",
+ sum(if(t.c = 1, 1, 0)) as "bounces",
+ sum(max_time-min_time) as "totaltime"
+ from (
+ select
+ ${column} name,
+ session_id,
+ visit_id,
+ count(*) c,
+ min(created_at) min_time,
+ max(created_at) max_time
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and name != ''
+ ${filterQuery}
+ group by name, session_id, visit_id
+ ) as t
+ group by name
+ order by visitors desc, visits desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventMetrics.ts b/src/queries/sql/events/getEventMetrics.ts
new file mode 100644
index 0000000..500c67e
--- /dev/null
+++ b/src/queries/sql/events/getEventMetrics.ts
@@ -0,0 +1,97 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventMetrics';
+
+export interface EventMetricParameters {
+ type: string;
+ limit?: string;
+ offset?: string;
+}
+
+export interface EventMetricData {
+ x: string;
+ t: string;
+ y: number;
+}
+
+export async function getEventMetrics(
+ ...args: [websiteId: string, parameters: EventMetricParameters, filters: QueryFilters]
+): Promise<EventMetricData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: EventMetricParameters,
+ filters: QueryFilters,
+) {
+ const { type, limit = 500, offset = 0 } = parameters;
+ const column = FILTER_COLUMNS[type] || type;
+ const { rawQuery, parseFilters } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters(
+ {
+ ...filters,
+ websiteId,
+ eventType: EVENT_TYPE.customEvent,
+ },
+ { joinSession: SESSION_COLUMNS.includes(type) },
+ );
+
+ return rawQuery(
+ `
+ select ${column} x,
+ count(*) as y
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by 1
+ order by 2 desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: EventMetricParameters,
+ filters: QueryFilters,
+): Promise<EventMetricData[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ const column = FILTER_COLUMNS[type] || type;
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ eventType: EVENT_TYPE.customEvent,
+ });
+
+ return rawQuery(
+ `select ${column} x,
+ count(*) as y
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by x
+ order by y desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventStats.ts b/src/queries/sql/events/getEventStats.ts
new file mode 100644
index 0000000..81d12a0
--- /dev/null
+++ b/src/queries/sql/events/getEventStats.ts
@@ -0,0 +1,101 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_TYPE } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventStats';
+
+interface WebsiteEventMetric {
+ x: string;
+ t: string;
+ y: number;
+}
+
+export async function getEventStats(
+ ...args: [websiteId: string, filters: QueryFilters]
+): Promise<WebsiteEventMetric[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { timezone = 'utc', unit = 'day' } = filters;
+ const { rawQuery, getDateSQL, parseFilters } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ eventType: EVENT_TYPE.customEvent,
+ });
+
+ return rawQuery(
+ `
+ select
+ event_name x,
+ ${getDateSQL('website_event.created_at', unit, timezone)} t,
+ count(*) y
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by 1, 2
+ order by 2
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<{ x: string; t: string; y: number }[]> {
+ const { timezone = 'UTC', unit = 'day' } = filters;
+ const { rawQuery, getDateSQL, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ eventType: EVENT_TYPE.customEvent,
+ });
+
+ let sql = '';
+
+ if (filterQuery || cohortQuery) {
+ sql = `
+ select
+ event_name x,
+ ${getDateSQL('created_at', unit, timezone)} t,
+ count(*) y
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by x, t
+ order by t
+ `;
+ } else {
+ sql = `
+ select
+ event_name x,
+ ${getDateSQL('created_at', unit, timezone)} t,
+ count(*) y
+ from (
+ select arrayJoin(event_name) as event_name,
+ created_at
+ from website_event_stats_hourly website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = {eventType:UInt32}
+ ) as g
+ group by x, t
+ order by t
+ `;
+ }
+
+ return rawQuery(sql, queryParams, FUNCTION_NAME);
+}
diff --git a/src/queries/sql/events/getEventUsage.ts b/src/queries/sql/events/getEventUsage.ts
new file mode 100644
index 0000000..40f5a96
--- /dev/null
+++ b/src/queries/sql/events/getEventUsage.ts
@@ -0,0 +1,38 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, notImplemented, PRISMA, runQuery } from '@/lib/db';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventUsage';
+
+export function getEventUsage(...args: [websiteIds: string[], filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: notImplemented,
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+function clickhouseQuery(
+ websiteIds: string[],
+ filters: QueryFilters,
+): Promise<{ websiteId: string; count: number }[]> {
+ const { rawQuery } = clickhouse;
+ const { startDate, endDate } = filters;
+
+ return rawQuery(
+ `
+ select
+ website_id as websiteId,
+ count(*) as count
+ from website_event
+ where website_id in {websiteIds:Array(UUID)}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ group by website_id
+ `,
+ {
+ websiteIds,
+ startDate,
+ endDate,
+ },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getWebsiteEvents.ts b/src/queries/sql/events/getWebsiteEvents.ts
new file mode 100644
index 0000000..f11d3ff
--- /dev/null
+++ b/src/queries/sql/events/getWebsiteEvents.ts
@@ -0,0 +1,119 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getWebsiteEvents';
+
+export function getWebsiteEvents(...args: [websiteId: string, filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { pagedRawQuery, parseFilters } = prisma;
+ const { search } = filters;
+ const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ const searchQuery = search
+ ? `and ((event_name ilike {{search}} and event_type = 2)
+ or (url_path ilike {{search}} and event_type = 1))`
+ : '';
+
+ return pagedRawQuery(
+ `
+ select
+ website_event.event_id as "id",
+ website_event.website_id as "websiteId",
+ website_event.session_id as "sessionId",
+ website_event.created_at as "createdAt",
+ website_event.hostname,
+ website_event.url_path as "urlPath",
+ website_event.url_query as "urlQuery",
+ website_event.referrer_path as "referrerPath",
+ website_event.referrer_query as "referrerQuery",
+ website_event.referrer_domain as "referrerDomain",
+ session.country as country,
+ city as city,
+ device as device,
+ os as os,
+ browser as browser,
+ page_title as "pageTitle",
+ website_event.event_type as "eventType",
+ website_event.event_name as "eventName",
+ event_id IN (select website_event_id
+ from event_data
+ where website_id = {{websiteId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}) AS "hasData"
+ from website_event
+ ${cohortQuery}
+ join session on session.session_id = website_event.session_id
+ and session.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId::uuid}}
+ ${dateQuery}
+ ${filterQuery}
+ ${searchQuery}
+ order by website_event.created_at desc
+ `,
+ queryParams,
+ filters,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
+ const { pagedRawQuery, parseFilters } = clickhouse;
+ const { search } = filters;
+ const { queryParams, dateQuery, cohortQuery, filterQuery } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ const searchQuery = search
+ ? `and ((positionCaseInsensitive(event_name, {search:String}) > 0 and event_type = 2)
+ or (positionCaseInsensitive(url_path, {search:String}) > 0 and event_type = 1))`
+ : '';
+
+ return pagedRawQuery(
+ `
+ select
+ event_id as id,
+ website_id as websiteId,
+ session_id as sessionId,
+ created_at as createdAt,
+ hostname,
+ url_path as urlPath,
+ url_query as urlQuery,
+ referrer_path as referrerPath,
+ referrer_query as referrerQuery,
+ referrer_domain as referrerDomain,
+ country as country,
+ city as city,
+ device as device,
+ os as os,
+ browser as browser,
+ page_title as pageTitle,
+ event_type as eventType,
+ event_name as eventName,
+ event_id IN (select event_id
+ from event_data
+ where website_id = {websiteId:UUID}
+ ${dateQuery}) as hasData
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ ${dateQuery}
+ ${filterQuery}
+ ${searchQuery}
+ order by created_at desc
+ `,
+ queryParams,
+ filters,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/saveEvent.ts b/src/queries/sql/events/saveEvent.ts
new file mode 100644
index 0000000..7313fe4
--- /dev/null
+++ b/src/queries/sql/events/saveEvent.ts
@@ -0,0 +1,249 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_NAME_LENGTH, PAGE_TITLE_LENGTH, URL_LENGTH } from '@/lib/constants';
+import { uuid } from '@/lib/crypto';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import kafka from '@/lib/kafka';
+import prisma from '@/lib/prisma';
+import { saveEventData } from './saveEventData';
+import { saveRevenue } from './saveRevenue';
+
+export interface SaveEventArgs {
+ websiteId: string;
+ sessionId: string;
+ visitId: string;
+ eventType: number;
+ createdAt?: Date;
+
+ // Page
+ pageTitle?: string;
+ hostname?: string;
+ urlPath: string;
+ urlQuery?: string;
+ referrerPath?: string;
+ referrerQuery?: string;
+ referrerDomain?: string;
+
+ // Session
+ distinctId?: string;
+ browser?: string;
+ os?: string;
+ device?: string;
+ screen?: string;
+ language?: string;
+ country?: string;
+ region?: string;
+ city?: string;
+
+ // Events
+ eventName?: string;
+ eventData?: any;
+ tag?: string;
+
+ // UTM
+ utmSource?: string;
+ utmMedium?: string;
+ utmCampaign?: string;
+ utmContent?: string;
+ utmTerm?: string;
+
+ // Click IDs
+ gclid?: string;
+ fbclid?: string;
+ msclkid?: string;
+ ttclid?: string;
+ lifatid?: string;
+ twclid?: string;
+}
+
+export async function saveEvent(args: SaveEventArgs) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(args),
+ [CLICKHOUSE]: () => clickhouseQuery(args),
+ });
+}
+
+async function relationalQuery({
+ websiteId,
+ sessionId,
+ visitId,
+ eventType,
+ createdAt,
+ pageTitle,
+ hostname,
+ urlPath,
+ urlQuery,
+ referrerPath,
+ referrerQuery,
+ referrerDomain,
+ eventName,
+ eventData,
+ tag,
+ utmSource,
+ utmMedium,
+ utmCampaign,
+ utmContent,
+ utmTerm,
+ gclid,
+ fbclid,
+ msclkid,
+ ttclid,
+ lifatid,
+ twclid,
+}: SaveEventArgs) {
+ const websiteEventId = uuid();
+
+ await prisma.client.websiteEvent.create({
+ data: {
+ id: websiteEventId,
+ websiteId,
+ sessionId,
+ visitId,
+ urlPath: urlPath?.substring(0, URL_LENGTH),
+ urlQuery: urlQuery?.substring(0, URL_LENGTH),
+ utmSource,
+ utmMedium,
+ utmCampaign,
+ utmContent,
+ utmTerm,
+ referrerPath: referrerPath?.substring(0, URL_LENGTH),
+ referrerQuery: referrerQuery?.substring(0, URL_LENGTH),
+ referrerDomain: referrerDomain?.substring(0, URL_LENGTH),
+ pageTitle: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
+ gclid,
+ fbclid,
+ msclkid,
+ ttclid,
+ lifatid,
+ twclid,
+ eventType,
+ eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
+ tag,
+ hostname,
+ createdAt,
+ },
+ });
+
+ if (eventData) {
+ await saveEventData({
+ websiteId,
+ sessionId,
+ eventId: websiteEventId,
+ urlPath: urlPath?.substring(0, URL_LENGTH),
+ eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
+ eventData,
+ createdAt,
+ });
+
+ const { revenue, currency } = eventData;
+
+ if (revenue > 0 && currency) {
+ await saveRevenue({
+ websiteId,
+ sessionId,
+ eventId: websiteEventId,
+ eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
+ currency,
+ revenue,
+ createdAt,
+ });
+ }
+ }
+}
+
+async function clickhouseQuery({
+ websiteId,
+ sessionId,
+ visitId,
+ eventType,
+ createdAt,
+ pageTitle,
+ hostname,
+ urlPath,
+ urlQuery,
+ referrerPath,
+ referrerQuery,
+ referrerDomain,
+ distinctId,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ eventName,
+ eventData,
+ tag,
+ utmSource,
+ utmMedium,
+ utmCampaign,
+ utmContent,
+ utmTerm,
+ gclid,
+ fbclid,
+ msclkid,
+ ttclid,
+ lifatid,
+ twclid,
+}: SaveEventArgs) {
+ const { insert, getUTCString } = clickhouse;
+ const { sendMessage } = kafka;
+ const eventId = uuid();
+
+ const message = {
+ website_id: websiteId,
+ session_id: sessionId,
+ visit_id: visitId,
+ event_id: eventId,
+ country: country,
+ region: country && region ? (region.includes('-') ? region : `${country}-${region}`) : null,
+ city: city,
+ url_path: urlPath?.substring(0, URL_LENGTH),
+ url_query: urlQuery?.substring(0, URL_LENGTH),
+ utm_source: utmSource,
+ utm_medium: utmMedium,
+ utm_campaign: utmCampaign,
+ utm_content: utmContent,
+ utm_term: utmTerm,
+ referrer_path: referrerPath?.substring(0, URL_LENGTH),
+ referrer_query: referrerQuery?.substring(0, URL_LENGTH),
+ referrer_domain: referrerDomain?.substring(0, URL_LENGTH),
+ page_title: pageTitle?.substring(0, PAGE_TITLE_LENGTH),
+ gclid: gclid,
+ fbclid: fbclid,
+ msclkid: msclkid,
+ ttclid: ttclid,
+ li_fat_id: lifatid,
+ twclid: twclid,
+ event_type: eventType,
+ event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
+ tag: tag,
+ distinct_id: distinctId,
+ created_at: getUTCString(createdAt),
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ hostname,
+ };
+
+ if (kafka.enabled) {
+ await sendMessage('event', message);
+ } else {
+ await insert('website_event', [message]);
+ }
+
+ if (eventData) {
+ await saveEventData({
+ websiteId,
+ sessionId,
+ eventId,
+ urlPath: urlPath?.substring(0, URL_LENGTH),
+ eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
+ eventData,
+ createdAt,
+ });
+ }
+}
diff --git a/src/queries/sql/events/saveEventData.ts b/src/queries/sql/events/saveEventData.ts
new file mode 100644
index 0000000..b8b0e02
--- /dev/null
+++ b/src/queries/sql/events/saveEventData.ts
@@ -0,0 +1,79 @@
+import clickhouse from '@/lib/clickhouse';
+import { DATA_TYPE } from '@/lib/constants';
+import { uuid } from '@/lib/crypto';
+import { flattenJSON, getStringValue } from '@/lib/data';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import kafka from '@/lib/kafka';
+import prisma from '@/lib/prisma';
+import type { DynamicData } from '@/lib/types';
+
+export interface SaveEventDataArgs {
+ websiteId: string;
+ eventId: string;
+ sessionId?: string;
+ urlPath?: string;
+ eventName?: string;
+ eventData: DynamicData;
+ createdAt?: Date;
+}
+
+export async function saveEventData(data: SaveEventDataArgs) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(data),
+ [CLICKHOUSE]: () => clickhouseQuery(data),
+ });
+}
+
+async function relationalQuery(data: SaveEventDataArgs) {
+ const { websiteId, eventId, eventData, createdAt } = data;
+
+ const jsonKeys = flattenJSON(eventData);
+
+ // id, websiteEventId, eventStringValue
+ const flattenedData = jsonKeys.map(a => ({
+ id: uuid(),
+ websiteEventId: eventId,
+ websiteId,
+ dataKey: a.key,
+ stringValue: getStringValue(a.value, a.dataType),
+ numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
+ dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
+ dataType: a.dataType,
+ createdAt,
+ }));
+
+ await prisma.client.eventData.createMany({
+ data: flattenedData,
+ });
+}
+
+async function clickhouseQuery(data: SaveEventDataArgs) {
+ const { websiteId, sessionId, eventId, urlPath, eventName, eventData, createdAt } = data;
+
+ const { insert, getUTCString } = clickhouse;
+ const { sendMessage } = kafka;
+
+ const jsonKeys = flattenJSON(eventData);
+
+ const messages = jsonKeys.map(({ key, value, dataType }) => {
+ return {
+ website_id: websiteId,
+ session_id: sessionId,
+ event_id: eventId,
+ url_path: urlPath,
+ event_name: eventName,
+ data_key: key,
+ data_type: dataType,
+ string_value: getStringValue(value, dataType),
+ number_value: dataType === DATA_TYPE.number ? value : null,
+ date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
+ created_at: getUTCString(createdAt),
+ };
+ });
+
+ if (kafka.enabled) {
+ await sendMessage('event_data', messages);
+ } else {
+ await insert('event_data', messages);
+ }
+}
diff --git a/src/queries/sql/events/saveRevenue.ts b/src/queries/sql/events/saveRevenue.ts
new file mode 100644
index 0000000..a38df83
--- /dev/null
+++ b/src/queries/sql/events/saveRevenue.ts
@@ -0,0 +1,36 @@
+import { uuid } from '@/lib/crypto';
+import { PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+
+export interface SaveRevenueArgs {
+ websiteId: string;
+ sessionId: string;
+ eventId: string;
+ eventName: string;
+ currency: string;
+ revenue: number;
+ createdAt: Date;
+}
+
+export async function saveRevenue(data: SaveRevenueArgs) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(data),
+ });
+}
+
+async function relationalQuery(data: SaveRevenueArgs) {
+ const { websiteId, sessionId, eventId, eventName, currency, revenue, createdAt } = data;
+
+ await prisma.client.revenue.create({
+ data: {
+ id: uuid(),
+ websiteId,
+ sessionId,
+ eventId,
+ eventName,
+ currency,
+ revenue,
+ createdAt,
+ },
+ });
+}
diff --git a/src/queries/sql/getActiveVisitors.ts b/src/queries/sql/getActiveVisitors.ts
new file mode 100644
index 0000000..d763c12
--- /dev/null
+++ b/src/queries/sql/getActiveVisitors.ts
@@ -0,0 +1,50 @@
+import { subMinutes } from 'date-fns';
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+
+const FUNCTION_NAME = 'getActiveVisitors';
+
+export async function getActiveVisitors(...args: [websiteId: string]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string) {
+ const { rawQuery } = prisma;
+ const startDate = subMinutes(new Date(), 5);
+
+ const result = await rawQuery(
+ `
+ select count(distinct session_id) as "visitors"
+ from website_event
+ where website_id = {{websiteId::uuid}}
+ and created_at >= {{startDate}}
+ `,
+ { websiteId, startDate },
+ FUNCTION_NAME,
+ );
+
+ return result?.[0] ?? null;
+}
+
+async function clickhouseQuery(websiteId: string): Promise<{ x: number }> {
+ const { rawQuery } = clickhouse;
+ const startDate = subMinutes(new Date(), 5);
+
+ const result = await rawQuery(
+ `
+ select
+ count(distinct session_id) as "visitors"
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at >= {startDate:DateTime64}
+ `,
+ { websiteId, startDate },
+ FUNCTION_NAME,
+ );
+
+ return result[0] ?? null;
+}
diff --git a/src/queries/sql/getChannelExpandedMetrics.ts b/src/queries/sql/getChannelExpandedMetrics.ts
new file mode 100644
index 0000000..33640d5
--- /dev/null
+++ b/src/queries/sql/getChannelExpandedMetrics.ts
@@ -0,0 +1,190 @@
+import clickhouse from '@/lib/clickhouse';
+import {
+ EMAIL_DOMAINS,
+ PAID_AD_PARAMS,
+ SEARCH_DOMAINS,
+ SHOPPING_DOMAINS,
+ SOCIAL_DOMAINS,
+ VIDEO_DOMAINS,
+} from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getChannelExpandedMetrics';
+
+export interface ChannelExpandedMetricsParameters {
+ limit?: number | string;
+ offset?: number | string;
+}
+
+export interface ChannelExpandedMetricsData {
+ name: string;
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}
+
+export async function getChannelExpandedMetrics(
+ ...args: [websiteId: string, filters?: QueryFilters]
+): Promise<ChannelExpandedMetricsData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<ChannelExpandedMetricsData[]> {
+ const { rawQuery, parseFilters, getTimestampDiffSQL } = prisma;
+ const { queryParams, filterQuery, joinSessionQuery, cohortQuery, dateQuery } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ WITH prefix AS (
+ select case when website_event.utm_medium LIKE 'p%' OR
+ website_event.utm_medium LIKE '%ppc%' OR
+ website_event.utm_medium LIKE '%retargeting%' OR
+ website_event.utm_medium LIKE '%paid%' then 'paid' else 'organic' end prefix,
+ website_event.referrer_domain,
+ website_event.url_query,
+ website_event.utm_medium,
+ website_event.utm_source,
+ website_event.session_id,
+ website_event.visit_id,
+ count(*) c,
+ min(website_event.created_at) min_time,
+ max(website_event.created_at) max_time
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.event_type != 2
+ ${dateQuery}
+ ${filterQuery}
+ group by prefix,
+ website_event.referrer_domain,
+ website_event.url_query,
+ website_event.utm_medium,
+ website_event.utm_source,
+ website_event.session_id,
+ website_event.visit_id),
+
+ channels as (
+ select case
+ when referrer_domain = '' and url_query = '' then 'direct'
+ when ${toPostgresPositionClause('url_query', PAID_AD_PARAMS)} then 'paidAds'
+ when ${toPostgresPositionClause('utm_medium', ['referral', 'app', 'link'])} then 'referral'
+ when utm_medium ilike '%affiliate%' then 'affiliate'
+ when utm_medium ilike '%sms%' or utm_source ilike '%sms%' then 'sms'
+ when ${toPostgresPositionClause('referrer_domain', SEARCH_DOMAINS)} or utm_medium ilike '%organic%' then concat(prefix, 'Search')
+ when ${toPostgresPositionClause('referrer_domain', SOCIAL_DOMAINS)} then concat(prefix, 'Social')
+ when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
+ when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
+ when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
+ else '' end AS name,
+ session_id,
+ visit_id,
+ c,
+ min_time,
+ max_time
+ from prefix)
+
+ select
+ name,
+ sum(c) as "pageviews",
+ count(distinct session_id) as "visitors",
+ count(distinct visit_id) as "visits",
+ sum(case when c = 1 then 1 else 0 end) as "bounces",
+ sum(${getTimestampDiffSQL('min_time', 'max_time')}) as "totaltime"
+ from channels
+ where name != ''
+ group by name
+ order by visitors desc, visits desc
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ ).then(results => results.map(item => ({ ...item, y: Number(item.y) })));
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<ChannelExpandedMetricsData[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { queryParams, filterQuery, cohortQuery } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ name,
+ sum(t.c) as "pageviews",
+ uniq(t.session_id) as "visitors",
+ uniq(t.visit_id) as "visits",
+ sum(if(t.c = 1, 1, 0)) as "bounces",
+ sum(max_time-min_time) as "totaltime"
+ from (
+ select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
+ case
+ when referrer_domain = '' and url_query = '' then 'direct'
+ when multiSearchAny(url_query, [${toClickHouseStringArray(
+ PAID_AD_PARAMS,
+ )}]) != 0 then 'paidAds'
+ when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral'
+ when position(utm_medium, 'affiliate') > 0 then 'affiliate'
+ when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms'
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ SEARCH_DOMAINS,
+ )}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search')
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ SOCIAL_DOMAINS,
+ )}]) != 0 then concat(prefix, 'Social')
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ EMAIL_DOMAINS,
+ )}]) != 0 or position(utm_medium, 'mail') > 0 then 'email'
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ SHOPPING_DOMAINS,
+ )}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping')
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ VIDEO_DOMAINS,
+ )}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video')
+ else '' end AS name,
+ session_id,
+ visit_id,
+ count(*) c,
+ min(created_at) min_time,
+ max(created_at) max_time
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ and name != ''
+ ${filterQuery}
+ group by prefix, name, session_id, visit_id
+ ) as t
+ group by name
+ order by visitors desc, visits desc;
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+function toClickHouseStringArray(arr: string[]): string {
+ return arr.map(p => `'${p.replace(/'/g, "\\'")}'`).join(', ');
+}
+
+function toPostgresPositionClause(column: string, arr: string[]) {
+ return arr.map(val => `${column} ilike '%${val.replace(/'/g, "''")}%'`).join(' OR\n ');
+}
diff --git a/src/queries/sql/getChannelMetrics.ts b/src/queries/sql/getChannelMetrics.ts
new file mode 100644
index 0000000..78e4142
--- /dev/null
+++ b/src/queries/sql/getChannelMetrics.ts
@@ -0,0 +1,142 @@
+import clickhouse from '@/lib/clickhouse';
+import {
+ EMAIL_DOMAINS,
+ PAID_AD_PARAMS,
+ SEARCH_DOMAINS,
+ SHOPPING_DOMAINS,
+ SOCIAL_DOMAINS,
+ VIDEO_DOMAINS,
+} from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getChannelMetrics';
+
+export async function getChannelMetrics(...args: [websiteId: string, filters?: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { rawQuery, parseFilters } = prisma;
+ const { queryParams, filterQuery, joinSessionQuery, cohortQuery, dateQuery } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ WITH prefix AS (
+ select case when website_event.utm_medium LIKE 'p%' OR
+ website_event.utm_medium LIKE '%ppc%' OR
+ website_event.utm_medium LIKE '%retargeting%' OR
+ website_event.utm_medium LIKE '%paid%' then 'paid' else 'organic' end prefix,
+ website_event.referrer_domain,
+ website_event.url_query,
+ website_event.utm_medium,
+ website_event.utm_source,
+ website_event.session_id
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.event_type != 2
+ ${dateQuery}
+ ${filterQuery}),
+
+ channels as (
+ select case
+ when referrer_domain = '' and url_query = '' then 'direct'
+ when ${toPostgresLikeClause('url_query', PAID_AD_PARAMS)} then 'paidAds'
+ when ${toPostgresLikeClause('utm_medium', ['referral', 'app', 'link'])} then 'referral'
+ when utm_medium ilike '%affiliate%' then 'affiliate'
+ when utm_medium ilike '%sms%' or utm_source ilike '%sms%' then 'sms'
+ when ${toPostgresLikeClause('referrer_domain', SEARCH_DOMAINS)} or utm_medium ilike '%organic%' then concat(prefix, 'Search')
+ when ${toPostgresLikeClause('referrer_domain', SOCIAL_DOMAINS)} then concat(prefix, 'Social')
+ when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
+ when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
+ when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
+ else '' end AS x,
+ count(distinct session_id) y
+ from prefix
+ group by 1
+ order by y desc)
+
+ select x, sum(y) y
+ from channels
+ where x != ''
+ group by x
+ order by y desc;
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ ).then(results => results.map(item => ({ ...item, y: Number(item.y) })));
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<{ x: string; y: number }[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ const sql = `
+ WITH channels as (
+ select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix,
+ case
+ when referrer_domain = '' and url_query = '' then 'direct'
+ when multiSearchAny(url_query, [${toClickHouseStringArray(
+ PAID_AD_PARAMS,
+ )}]) != 0 then 'paidAds'
+ when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral'
+ when position(utm_medium, 'affiliate') > 0 then 'affiliate'
+ when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms'
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ SEARCH_DOMAINS,
+ )}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search')
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ SOCIAL_DOMAINS,
+ )}]) != 0 then concat(prefix, 'Social')
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ EMAIL_DOMAINS,
+ )}]) != 0 or position(utm_medium, 'mail') > 0 then 'email'
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ SHOPPING_DOMAINS,
+ )}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping')
+ when multiSearchAny(referrer_domain, [${toClickHouseStringArray(
+ VIDEO_DOMAINS,
+ )}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video')
+ else '' end AS x,
+ count(distinct session_id) y
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and event_type != 2
+ ${dateQuery}
+ ${filterQuery}
+ group by 1, 2
+ order by y desc)
+
+ select x, sum(y) y
+ from channels
+ where x != ''
+ group by x
+ order by y desc;
+ `;
+
+ return rawQuery(sql, queryParams, FUNCTION_NAME);
+}
+
+function toClickHouseStringArray(arr: string[]): string {
+ return arr.map(p => `'${p.replace(/'/g, "\\'")}'`).join(', ');
+}
+
+function toPostgresLikeClause(column: string, arr: string[]) {
+ return arr.map(val => `${column} ilike '%${val.replace(/'/g, "''")}%'`).join(' OR\n ');
+}
diff --git a/src/queries/sql/getRealtimeActivity.ts b/src/queries/sql/getRealtimeActivity.ts
new file mode 100644
index 0000000..075b65e
--- /dev/null
+++ b/src/queries/sql/getRealtimeActivity.ts
@@ -0,0 +1,80 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getRealtimeActivity';
+
+export async function getRealtimeActivity(...args: [websiteId: string, filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { rawQuery, parseFilters } = prisma;
+ const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ website_event.session_id as "sessionId",
+ website_event.event_name as "eventName",
+ website_event.created_at as "createdAt",
+ session.browser,
+ session.os,
+ session.device,
+ session.country,
+ website_event.url_path as "urlPath",
+ website_event.referrer_domain as "referrerDomain"
+ from website_event
+ ${cohortQuery}
+ inner join session
+ on session.session_id = website_event.session_id
+ and session.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId::uuid}}
+ ${filterQuery}
+ ${dateQuery}
+ order by website_event.created_at desc
+ limit 100
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promise<{ x: number }> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ session_id as sessionId,
+ event_name as eventName,
+ created_at as createdAt,
+ browser,
+ os,
+ device,
+ country,
+ url_path as urlPath,
+ referrer_domain as referrerDomain
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ ${filterQuery}
+ ${dateQuery}
+ order by createdAt desc
+ limit 100
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/getRealtimeData.ts b/src/queries/sql/getRealtimeData.ts
new file mode 100644
index 0000000..4b97cb0
--- /dev/null
+++ b/src/queries/sql/getRealtimeData.ts
@@ -0,0 +1,78 @@
+import type { QueryFilters } from '@/lib/types';
+import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
+import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
+import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
+
+function increment(data: object, key: string) {
+ if (key) {
+ if (!data[key]) {
+ data[key] = 1;
+ } else {
+ data[key] += 1;
+ }
+ }
+}
+
+export async function getRealtimeData(websiteId: string, filters: QueryFilters) {
+ const [activity, pageviews, sessions] = await Promise.all([
+ getRealtimeActivity(websiteId, filters),
+ getPageviewStats(websiteId, filters),
+ getSessionStats(websiteId, filters),
+ ]);
+
+ const uniques = new Set();
+
+ const { countries, urls, referrers, events } = activity.reverse().reduce(
+ (
+ obj: { countries: any; urls: any; referrers: any; events: any },
+ event: {
+ sessionId: string;
+ urlPath: string;
+ referrerDomain: string;
+ country: string;
+ eventName: string;
+ },
+ ) => {
+ const { countries, urls, referrers, events } = obj;
+ const { sessionId, urlPath, referrerDomain, country, eventName } = event;
+
+ if (!uniques.has(sessionId)) {
+ uniques.add(sessionId);
+ increment(countries, country);
+
+ events.push({ __type: 'session', ...event });
+ }
+
+ increment(urls, urlPath);
+ increment(referrers, referrerDomain);
+
+ events.push({ __type: eventName ? 'event' : 'pageview', ...event });
+
+ return obj;
+ },
+ {
+ countries: {},
+ urls: {},
+ referrers: {},
+ events: [],
+ },
+ );
+
+ return {
+ countries,
+ urls,
+ referrers,
+ events: events.reverse(),
+ series: {
+ views: pageviews,
+ visitors: sessions,
+ },
+ totals: {
+ views: pageviews.reduce((sum: number, { y }: { y: number }) => Number(sum) + Number(y), 0),
+ visitors: sessions.reduce((sum: number, { y }: { y: number }) => Number(sum) + Number(y), 0),
+ events: activity.filter(e => e.eventName).length,
+ countries: Object.keys(countries).length,
+ },
+ timestamp: Date.now(),
+ };
+}
diff --git a/src/queries/sql/getValues.ts b/src/queries/sql/getValues.ts
new file mode 100644
index 0000000..cc6bb7d
--- /dev/null
+++ b/src/queries/sql/getValues.ts
@@ -0,0 +1,129 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getValues';
+
+export async function getValues(
+ ...args: [websiteId: string, column: string, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, column: string, filters: QueryFilters) {
+ const { rawQuery, getSearchSQL } = prisma;
+ const params = {};
+ const { startDate, endDate, search } = filters;
+
+ let searchQuery = '';
+ let excludeDomain = '';
+
+ if (column === 'referrer_domain') {
+ excludeDomain = `and website_event.referrer_domain != website_event.hostname
+ and website_event.referrer_domain != ''`;
+ }
+
+ if (search) {
+ if (decodeURIComponent(search).includes(',')) {
+ searchQuery = `AND (${decodeURIComponent(search)
+ .split(',')
+ .slice(0, 5)
+ .map((value: string, index: number) => {
+ const key = `search${index}`;
+
+ params[key] = value;
+
+ return getSearchSQL(column, key).replace('and ', '');
+ })
+ .join(' OR ')})`;
+ } else {
+ searchQuery = getSearchSQL(column);
+ }
+ }
+
+ return rawQuery(
+ `
+ select ${column} as "value", count(*) as "count"
+ from website_event
+ inner join session
+ on session.session_id = website_event.session_id
+ and session.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${searchQuery}
+ ${excludeDomain}
+ group by 1
+ order by 2 desc
+ limit 10
+ `,
+ {
+ websiteId,
+ startDate,
+ endDate,
+ search: `%${search}%`,
+ ...params,
+ },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(websiteId: string, column: string, filters: QueryFilters) {
+ const { rawQuery, getSearchSQL } = clickhouse;
+ const params = {};
+ const { startDate, endDate, search } = filters;
+
+ let searchQuery = '';
+ let excludeDomain = '';
+
+ if (column === 'referrer_domain') {
+ excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`;
+ }
+
+ if (search) {
+ searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`;
+ }
+
+ if (search) {
+ if (decodeURIComponent(search).includes(',')) {
+ searchQuery = `AND (${decodeURIComponent(search)
+ .split(',')
+ .slice(0, 5)
+ .map((value: string, index: number) => {
+ const key = `search${index}`;
+
+ params[key] = value;
+
+ return getSearchSQL(column, key).replace('and ', '');
+ })
+ .join(' OR ')})`;
+ } else {
+ searchQuery = getSearchSQL(column);
+ }
+ }
+
+ return rawQuery(
+ `
+ select ${column} as "value", count(*) as "count"
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${searchQuery}
+ ${excludeDomain}
+ group by 1
+ order by 2 desc
+ limit 10
+ `,
+ {
+ websiteId,
+ startDate,
+ endDate,
+ search,
+ ...params,
+ },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/getWebsiteDateRange.ts b/src/queries/sql/getWebsiteDateRange.ts
new file mode 100644
index 0000000..d6333ad
--- /dev/null
+++ b/src/queries/sql/getWebsiteDateRange.ts
@@ -0,0 +1,55 @@
+import clickhouse from '@/lib/clickhouse';
+import { DEFAULT_RESET_DATE } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+
+export async function getWebsiteDateRange(...args: [websiteId: string]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string) {
+ const { rawQuery, parseFilters } = prisma;
+ const { queryParams } = parseFilters({
+ startDate: new Date(DEFAULT_RESET_DATE),
+ websiteId,
+ });
+
+ const result = await rawQuery(
+ `
+ select
+ min(created_at) as "startDate",
+ max(created_at) as "endDate"
+ from website_event
+ where website_id = {{websiteId::uuid}}
+ and created_at >= {{startDate}}
+ `,
+ queryParams,
+ );
+
+ return result[0] ?? null;
+}
+
+async function clickhouseQuery(websiteId: string) {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { queryParams } = parseFilters({
+ startDate: new Date(DEFAULT_RESET_DATE),
+ websiteId,
+ });
+
+ const result = await rawQuery(
+ `
+ select
+ min(created_at) as startDate,
+ max(created_at) as endDate
+ from website_event_stats_hourly
+ where website_id = {websiteId:UUID}
+ and created_at >= {startDate:DateTime64}
+ `,
+ queryParams,
+ );
+
+ return result[0] ?? null;
+}
diff --git a/src/queries/sql/getWebsiteStats.ts b/src/queries/sql/getWebsiteStats.ts
new file mode 100644
index 0000000..6906839
--- /dev/null
+++ b/src/queries/sql/getWebsiteStats.ts
@@ -0,0 +1,128 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getWebsiteStats';
+
+export interface WebsiteStatsData {
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}
+
+export async function getWebsiteStats(
+ ...args: [websiteId: string, filters: QueryFilters]
+): Promise<WebsiteStatsData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<WebsiteStatsData[]> {
+ const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ cast(coalesce(sum(t.c), 0) as bigint) as "pageviews",
+ count(distinct t.session_id) as "visitors",
+ count(distinct t.visit_id) as "visits",
+ coalesce(sum(case when t.c = 1 then 1 else 0 end), 0) as "bounces",
+ cast(coalesce(sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}), 0) as bigint) as "totaltime"
+ from (
+ select
+ website_event.session_id,
+ website_event.visit_id,
+ count(*) as "c",
+ min(website_event.created_at) as "min_time",
+ max(website_event.created_at) as "max_time"
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ ${filterQuery}
+ group by 1, 2
+ ) as t
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ ).then(result => result?.[0]);
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<WebsiteStatsData[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ let sql = '';
+
+ if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) {
+ sql = `
+ select
+ sum(t.c) as "pageviews",
+ uniq(t.session_id) as "visitors",
+ uniq(t.visit_id) as "visits",
+ sum(if(t.c = 1, 1, 0)) as "bounces",
+ sum(max_time-min_time) as "totaltime"
+ from (
+ select
+ session_id,
+ visit_id,
+ count(*) c,
+ min(created_at) min_time,
+ max(created_at) max_time
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${filterQuery}
+ group by session_id, visit_id
+ ) as t;
+ `;
+ } else {
+ sql = `
+ select
+ sum(t.c) as "pageviews",
+ uniq(session_id) as "visitors",
+ uniq(visit_id) as "visits",
+ sumIf(1, t.c = 1) as "bounces",
+ sum(max_time-min_time) as "totaltime"
+ from (select
+ session_id,
+ visit_id,
+ sum(views) c,
+ min(min_time) min_time,
+ max(max_time) max_time
+ from website_event_stats_hourly "website_event"
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${filterQuery}
+ group by session_id, visit_id
+ ) as t;
+ `;
+ }
+
+ return rawQuery(sql, queryParams, FUNCTION_NAME).then(result => result?.[0]);
+}
diff --git a/src/queries/sql/getWeeklyTraffic.ts b/src/queries/sql/getWeeklyTraffic.ts
new file mode 100644
index 0000000..7bbe78a
--- /dev/null
+++ b/src/queries/sql/getWeeklyTraffic.ts
@@ -0,0 +1,97 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getWeeklyTraffic';
+
+export async function getWeeklyTraffic(...args: [websiteId: string, filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const timezone = 'utc';
+ const { rawQuery, getDateWeeklySQL, parseFilters } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ ${getDateWeeklySQL('website_event.created_at', timezone)} as time,
+ count(distinct website_event.session_id) as value
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by time
+ order by 2
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ ).then(formatResults);
+}
+
+async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
+ const { timezone = 'utc' } = filters;
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = await parseFilters({ ...filters, websiteId });
+
+ let sql = '';
+
+ if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) {
+ sql = `
+ select
+ formatDateTime(toDateTime(created_at, '${timezone}'), '%w:%H') as time,
+ count(distinct session_id) as value
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by time
+ order by time
+ `;
+ } else {
+ sql = `
+ select
+ formatDateTime(toDateTime(created_at, '${timezone}'), '%w:%H') as time,
+ count(distinct session_id) as value
+ from website_event_stats_hourly website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by time
+ order by time
+ `;
+ }
+
+ return rawQuery(sql, queryParams, FUNCTION_NAME).then(formatResults);
+}
+
+function formatResults(data: any) {
+ const days = [];
+
+ for (let i = 0; i < 7; i++) {
+ days.push([]);
+
+ for (let j = 0; j < 24; j++) {
+ days[i].push(
+ Number(
+ data.find(({ time }) => time === `${i}:${j.toString().padStart(2, '0')}`)?.value || 0,
+ ),
+ );
+ }
+ }
+
+ return days;
+}
diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts
new file mode 100644
index 0000000..1573bde
--- /dev/null
+++ b/src/queries/sql/index.ts
@@ -0,0 +1,41 @@
+export * from './events/getEventDataEvents';
+export * from './events/getEventDataFields';
+export * from './events/getEventDataProperties';
+export * from './events/getEventDataStats';
+export * from './events/getEventDataUsage';
+export * from './events/getEventDataValues';
+export * from './events/getEventExpandedMetrics';
+export * from './events/getEventMetrics';
+export * from './events/getEventStats';
+export * from './events/getEventUsage';
+export * from './events/getWebsiteEvents';
+export * from './events/saveEvent';
+export * from './getActiveVisitors';
+export * from './getChannelExpandedMetrics';
+export * from './getChannelMetrics';
+export * from './getRealtimeActivity';
+export * from './getRealtimeData';
+export * from './getValues';
+export * from './getWebsiteDateRange';
+export * from './getWebsiteStats';
+export * from './getWeeklyTraffic';
+export * from './pageviews/getPageviewExpandedMetrics';
+export * from './pageviews/getPageviewMetrics';
+export * from './pageviews/getPageviewStats';
+export * from './reports/getBreakdown';
+export * from './reports/getFunnel';
+export * from './reports/getJourney';
+export * from './reports/getRetention';
+export * from './reports/getUTM';
+export * from './sessions/createSession';
+export * from './sessions/getSessionActivity';
+export * from './sessions/getSessionData';
+export * from './sessions/getSessionDataProperties';
+export * from './sessions/getSessionDataValues';
+export * from './sessions/getSessionExpandedMetrics';
+export * from './sessions/getSessionMetrics';
+export * from './sessions/getSessionStats';
+export * from './sessions/getWebsiteSession';
+export * from './sessions/getWebsiteSessionStats';
+export * from './sessions/getWebsiteSessions';
+export * from './sessions/saveSessionData';
diff --git a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts
new file mode 100644
index 0000000..986d7d5
--- /dev/null
+++ b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts
@@ -0,0 +1,227 @@
+import clickhouse from '@/lib/clickhouse';
+import { FILTER_COLUMNS, GROUPED_DOMAINS, SESSION_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getPageviewExpandedMetrics';
+
+export interface PageviewExpandedMetricsParameters {
+ type: string;
+ limit?: number | string;
+ offset?: number | string;
+}
+
+export interface PageviewExpandedMetricsData {
+ name: string;
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}
+
+export async function getPageviewExpandedMetrics(
+ ...args: [websiteId: string, parameters: PageviewExpandedMetricsParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: PageviewExpandedMetricsParameters,
+ filters: QueryFilters,
+): Promise<PageviewExpandedMetricsData[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ let column = FILTER_COLUMNS[type] || type;
+ const { rawQuery, parseFilters, getTimestampDiffSQL } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters(
+ {
+ ...filters,
+ websiteId,
+ },
+ { joinSession: SESSION_COLUMNS.includes(type) },
+ );
+
+ let entryExitQuery = '';
+ let excludeDomain = '';
+
+ if (column === 'referrer_domain') {
+ excludeDomain = `and website_event.referrer_domain != website_event.hostname
+ and website_event.referrer_domain != ''`;
+ if (type === 'domain') {
+ column = toPostgresGroupedReferrer(GROUPED_DOMAINS);
+ }
+ }
+
+ if (type === 'entry' || type === 'exit') {
+ const aggregrate = type === 'entry' ? 'min' : 'max';
+
+ entryExitQuery = `
+ join (
+ select visit_id,
+ ${aggregrate}(created_at) target_created_at
+ from website_event
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ group by visit_id
+ ) x
+ on x.visit_id = website_event.visit_id
+ and x.target_created_at = website_event.created_at
+ `;
+ }
+
+ return rawQuery(
+ `
+ select
+ name,
+ sum(t.c) as "pageviews",
+ count(distinct t.session_id) as "visitors",
+ count(distinct t.visit_id) as "visits",
+ sum(case when t.c = 1 then 1 else 0 end) as "bounces",
+ sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
+ from (
+ select
+ ${column} as name,
+ website_event.session_id,
+ website_event.visit_id,
+ count(*) as "c",
+ min(website_event.created_at) as "min_time",
+ max(website_event.created_at) as "max_time"
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ ${entryExitQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ ${excludeDomain}
+ ${filterQuery}
+ group by ${column}, website_event.session_id, website_event.visit_id
+ ) as t
+ where name != ''
+ group by name
+ order by visitors desc, visits desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: PageviewExpandedMetricsParameters,
+ filters: QueryFilters,
+): Promise<{ x: string; y: number }[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ let column = FILTER_COLUMNS[type] || type;
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ let excludeDomain = '';
+ let entryExitQuery = '';
+
+ if (column === 'referrer_domain') {
+ excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`;
+ if (type === 'domain') {
+ column = toClickHouseGroupedReferrer(GROUPED_DOMAINS);
+ }
+ }
+
+ if (type === 'entry' || type === 'exit') {
+ const aggregrate = type === 'entry' ? 'argMin' : 'argMax';
+ column = `x.${column}`;
+
+ entryExitQuery = `
+ JOIN (select visit_id,
+ ${aggregrate}(url_path, created_at) url_path
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ group by visit_id) x
+ ON x.visit_id = website_event.visit_id`;
+ }
+
+ return rawQuery(
+ `
+ select
+ name,
+ sum(t.c) as "pageviews",
+ uniq(t.session_id) as "visitors",
+ uniq(t.visit_id) as "visits",
+ sum(if(t.c = 1, 1, 0)) as "bounces",
+ sum(max_time-min_time) as "totaltime"
+ from (
+ select
+ ${column} name,
+ session_id,
+ visit_id,
+ count(*) c,
+ min(created_at) min_time,
+ max(created_at) max_time
+ from website_event
+ ${cohortQuery}
+ ${entryExitQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ and name != ''
+ ${excludeDomain}
+ ${filterQuery}
+ group by name, session_id, visit_id
+ ) as t
+ group by name
+ order by visitors desc, visits desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ FUNCTION_NAME,
+ );
+}
+
+export function toClickHouseGroupedReferrer(
+ domains: any[],
+ column: string = 'referrer_domain',
+): string {
+ return [
+ 'CASE',
+ ...domains.map(group => {
+ const matches = Array.isArray(group.match) ? group.match : [group.match];
+ const formattedArray = matches.map(m => `'${m}'`).join(', ');
+ return ` WHEN multiSearchAny(${column}, [${formattedArray}]) != 0 THEN '${group.domain}'`;
+ }),
+ " ELSE 'Other'",
+ 'END',
+ ].join('\n');
+}
+
+export function toPostgresGroupedReferrer(
+ domains: any[],
+ column: string = 'referrer_domain',
+): string {
+ return [
+ 'CASE',
+ ...domains.map(group => {
+ const matches = Array.isArray(group.match) ? group.match : [group.match];
+
+ return `WHEN ${toPostgresLikeClause(column, matches)} THEN '${group.domain}'`;
+ }),
+ " ELSE 'Other'",
+ 'END',
+ ].join('\n');
+}
+
+function toPostgresLikeClause(column: string, arr: string[]) {
+ return arr.map(val => `${column} ilike '%${val.replace(/'/g, "''")}%'`).join(' OR\n ');
+}
diff --git a/src/queries/sql/pageviews/getPageviewMetrics.ts b/src/queries/sql/pageviews/getPageviewMetrics.ts
new file mode 100644
index 0000000..9d4f627
--- /dev/null
+++ b/src/queries/sql/pageviews/getPageviewMetrics.ts
@@ -0,0 +1,191 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getPageviewMetrics';
+
+export interface PageviewMetricsParameters {
+ type: string;
+ limit?: number | string;
+ offset?: number | string;
+}
+
+export interface PageviewMetricsData {
+ x: string;
+ y: number;
+}
+
+export async function getPageviewMetrics(
+ ...args: [websiteId: string, parameters: PageviewMetricsParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: PageviewMetricsParameters,
+ filters: QueryFilters,
+): Promise<PageviewMetricsData[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ let column = FILTER_COLUMNS[type] || type;
+ const { rawQuery, parseFilters } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters(
+ {
+ ...filters,
+ websiteId,
+ },
+ { joinSession: SESSION_COLUMNS.includes(type) },
+ );
+
+ let entryExitQuery = '';
+ let excludeDomain = '';
+
+ if (column === 'referrer_domain') {
+ excludeDomain = `and website_event.referrer_domain != website_event.hostname
+ and website_event.referrer_domain != ''`;
+ }
+
+ if (type === 'entry' || type === 'exit') {
+ const order = type === 'entry' ? 'asc' : 'desc';
+ column = `x.${column}`;
+
+ entryExitQuery = `
+ join (
+ select distinct on (visit_id)
+ visit_id,
+ url_path
+ from website_event
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ order by visit_id, created_at ${order}
+ ) x
+ on x.visit_id = website_event.visit_id
+ `;
+ }
+
+ return rawQuery(
+ `
+ select ${column} x,
+ count(distinct website_event.session_id) as y
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ ${entryExitQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ ${excludeDomain}
+ ${filterQuery}
+ group by 1
+ order by 2 desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: PageviewMetricsParameters,
+ filters: QueryFilters,
+): Promise<{ x: string; y: number }[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ let column = FILTER_COLUMNS[type] || type;
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ let sql = '';
+ let excludeDomain = '';
+
+ if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) {
+ let entryExitQuery = '';
+
+ if (column === 'referrer_domain') {
+ excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`;
+ }
+
+ if (type === 'entry' || type === 'exit') {
+ const aggregrate = type === 'entry' ? 'argMin' : 'argMax';
+ column = `x.${column}`;
+
+ entryExitQuery = `
+ JOIN (select visit_id,
+ ${aggregrate}(url_path, created_at) url_path
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ group by visit_id) x
+ ON x.visit_id = website_event.visit_id`;
+ }
+
+ sql = `
+ select ${column} x,
+ uniq(website_event.session_id) as y
+ from website_event
+ ${cohortQuery}
+ ${entryExitQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${excludeDomain}
+ ${filterQuery}
+ group by x
+ order by y desc
+ limit ${limit}
+ offset ${offset}
+ `;
+ } else {
+ let groupByQuery = '';
+ let columnQuery = `arrayJoin(${column})`;
+
+ if (column === 'referrer_domain') {
+ excludeDomain = `and t != ''`;
+ }
+
+ if (type === 'entry') {
+ columnQuery = `argMinMerge(entry_url)`;
+ }
+
+ if (type === 'exit') {
+ columnQuery = `argMaxMerge(exit_url)`;
+ }
+
+ if (type === 'entry' || type === 'exit') {
+ groupByQuery = 'group by s';
+ }
+
+ sql = `
+ select g.t as x,
+ uniq(s) as y
+ from (
+ select session_id s,
+ ${columnQuery} as t
+ from website_event_stats_hourly as website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${excludeDomain}
+ ${filterQuery}
+ ${groupByQuery}) as g
+ group by x
+ order by y desc
+ limit ${limit}
+ offset ${offset}
+ `;
+ }
+
+ return rawQuery(sql, { ...queryParams, ...parameters }, FUNCTION_NAME);
+}
diff --git a/src/queries/sql/pageviews/getPageviewStats.ts b/src/queries/sql/pageviews/getPageviewStats.ts
new file mode 100644
index 0000000..251d5b1
--- /dev/null
+++ b/src/queries/sql/pageviews/getPageviewStats.ts
@@ -0,0 +1,98 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getPageviewStats';
+
+export async function getPageviewStats(...args: [websiteId: string, filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { timezone = 'utc', unit = 'day' } = filters;
+ const { getDateSQL, parseFilters, rawQuery } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ ${getDateSQL('website_event.created_at', unit, timezone)} x,
+ count(*) y
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ ${filterQuery}
+ group by 1
+ order by 1
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<{ x: string; y: number }[]> {
+ const { timezone = 'UTC', unit = 'day' } = filters;
+ const { parseFilters, rawQuery, getDateSQL } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ let sql = '';
+
+ if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item)) || unit === 'minute') {
+ sql = `
+ select
+ g.t as x,
+ g.y as y
+ from (
+ select
+ ${getDateSQL('website_event.created_at', unit, timezone)} as t,
+ count(*) as y
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${filterQuery}
+ group by t
+ ) as g
+ order by t
+ `;
+ } else {
+ sql = `
+ select
+ g.t as x,
+ g.y as y
+ from (
+ select
+ ${getDateSQL('website_event.created_at', unit, timezone)} as t,
+ sum(views) as y
+ from website_event_stats_hourly as website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${filterQuery}
+ group by t
+ ) as g
+ order by t
+ `;
+ }
+
+ return rawQuery(sql, queryParams, FUNCTION_NAME);
+}
diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts
new file mode 100644
index 0000000..1d04078
--- /dev/null
+++ b/src/queries/sql/reports/getAttribution.ts
@@ -0,0 +1,514 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_TYPE } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export interface AttributionParameters {
+ startDate: Date;
+ endDate: Date;
+ model: string;
+ type: string;
+ step: string;
+ currency?: string;
+}
+
+export interface AttributionResult {
+ referrer: { name: string; value: number }[];
+ paidAds: { name: string; value: number }[];
+ utm_source: { name: string; value: number }[];
+ utm_medium: { name: string; value: number }[];
+ utm_campaign: { name: string; value: number }[];
+ utm_content: { name: string; value: number }[];
+ utm_term: { name: string; value: number }[];
+ total: { pageviews: number; visitors: number; visits: number };
+}
+
+export async function getAttribution(
+ ...args: [websiteId: string, parameters: AttributionParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: AttributionParameters,
+ filters: QueryFilters,
+): Promise<AttributionResult> {
+ const { model, type, currency } = parameters;
+ const { rawQuery, parseFilters } = prisma;
+ const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
+ const column = type === 'path' ? 'url_path' : 'event_name';
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ ...parameters,
+ websiteId,
+ eventType,
+ });
+
+ function getUTMQuery(utmColumn: string) {
+ return `
+ select
+ coalesce(we.${utmColumn}, '') name,
+ ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
+ from model m
+ join website_event we
+ on we.created_at = m.created_at
+ and we.session_id = m.session_id
+ ${currency ? 'join events e on e.session_id = m.session_id' : ''}
+ where we.website_id = {{websiteId::uuid}}
+ and we.created_at between {{startDate}} and {{endDate}}
+ ${currency ? '' : `and we.${utmColumn} != ''`}
+ group by 1
+ order by 2 desc
+ limit 20`;
+ }
+
+ const eventQuery = `WITH events AS (
+ select distinct
+ website_event.session_id,
+ max(website_event.created_at) max_dt
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.${column} = {{step}}
+ ${filterQuery}
+ group by 1),`;
+
+ const revenueEventQuery = `WITH events AS (
+ select
+ revenue.session_id,
+ max(revenue.created_at) max_dt,
+ sum(revenue.revenue) value
+ from revenue
+ join website_event
+ on website_event.website_id = revenue.website_id
+ and website_event.session_id = revenue.session_id
+ and website_event.event_id = revenue.event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where revenue.website_id = {{websiteId::uuid}}
+ and revenue.created_at between {{startDate}} and {{endDate}}
+ and revenue.${column} = {{step}}
+ and revenue.currency = {{currency}}
+ ${filterQuery}
+ group by 1),`;
+
+ function getModelQuery(model: string) {
+ return model === 'first-click'
+ ? `\n
+ model AS (select e.session_id,
+ min(we.created_at) created_at
+ from events e
+ join website_event we
+ on we.session_id = e.session_id
+ where we.website_id = {{websiteId::uuid}}
+ and we.created_at between {{startDate}} and {{endDate}}
+ group by e.session_id)`
+ : `\n
+ model AS (select e.session_id,
+ max(we.created_at) created_at
+ from events e
+ join website_event we
+ on we.session_id = e.session_id
+ where we.website_id = {{websiteId::uuid}}
+ and we.created_at between {{startDate}} and {{endDate}}
+ and we.created_at < e.max_dt
+ group by e.session_id)`;
+ }
+
+ const referrerRes = await rawQuery(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ select coalesce(we.referrer_domain, '') name,
+ ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
+ from model m
+ join website_event we
+ on we.created_at = m.created_at
+ and we.session_id = m.session_id
+ join session s
+ on s.session_id = m.session_id
+ ${currency ? 'join events e on e.session_id = m.session_id' : ''}
+ where we.website_id = {{websiteId::uuid}}
+ and we.created_at between {{startDate}} and {{endDate}}
+ ${
+ currency
+ ? ''
+ : `and we.referrer_domain != hostname
+ and we.referrer_domain != ''`
+ }
+ group by 1
+ order by 2 desc
+ limit 20
+ `,
+ queryParams,
+ );
+
+ const paidAdsres = await rawQuery(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)},
+
+ results AS (
+ select case
+ when coalesce(gclid, '') != '' then 'Google Ads'
+ when coalesce(fbclid, '') != '' then 'Facebook / Meta'
+ when coalesce(msclkid, '') != '' then 'Microsoft Ads'
+ when coalesce(ttclid, '') != '' then 'TikTok Ads'
+ when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads'
+ when coalesce(twclid, '') != '' then 'Twitter Ads (X)'
+ else ''
+ end name,
+ ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
+ from model m
+ join website_event we
+ on we.created_at = m.created_at
+ and we.session_id = m.session_id
+ ${currency ? 'join events e on e.session_id = m.session_id' : ''}
+ where we.website_id = {{websiteId::uuid}}
+ and we.created_at between {{startDate}} and {{endDate}}
+ group by 1
+ order by 2 desc
+ limit 20)
+ SELECT *
+ FROM results
+ ${currency ? '' : `WHERE name != ''`}
+ `,
+ queryParams,
+ );
+
+ const sourceRes = await rawQuery(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_source')}
+ `,
+ queryParams,
+ );
+
+ const mediumRes = await rawQuery(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_medium')}
+ `,
+ queryParams,
+ );
+
+ const campaignRes = await rawQuery(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_campaign')}
+ `,
+ queryParams,
+ );
+
+ const contentRes = await rawQuery(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_content')}
+ `,
+ queryParams,
+ );
+
+ const termRes = await rawQuery(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_term')}
+ `,
+ queryParams,
+ );
+
+ const totalRes = await rawQuery(
+ `
+ select
+ count(*) as "pageviews",
+ count(distinct website_event.session_id) as "visitors",
+ count(distinct website_event.visit_id) as "visits"
+ from website_event
+ ${joinSessionQuery}
+ ${cohortQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.${column} = {{step}}
+ ${filterQuery}
+ `,
+ queryParams,
+ ).then(result => result?.[0]);
+
+ return {
+ referrer: referrerRes,
+ paidAds: paidAdsres,
+ utm_source: sourceRes,
+ utm_medium: mediumRes,
+ utm_campaign: campaignRes,
+ utm_content: contentRes,
+ utm_term: termRes,
+ total: totalRes,
+ };
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: AttributionParameters,
+ filters: QueryFilters,
+): Promise<AttributionResult> {
+ const { model, type, currency } = parameters;
+ const { rawQuery, parseFilters } = clickhouse;
+ const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
+ const column = type === 'path' ? 'url_path' : 'event_name';
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ ...parameters,
+ websiteId,
+ eventType,
+ });
+
+ function getUTMQuery(utmColumn: string) {
+ return `
+ select
+ we.${utmColumn} name,
+ ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
+ from model m
+ join website_event we
+ on we.created_at = m.created_at
+ and we.session_id = m.session_id
+ ${currency ? 'join events e on e.session_id = m.session_id' : ''}
+ where we.website_id = {websiteId:UUID}
+ and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${currency ? '' : `and we.${utmColumn} != ''`}
+ group by 1
+ order by 2 desc
+ limit 20
+ `;
+ }
+
+ function getModelQuery(model: string) {
+ if (model === 'first-click') {
+ return `
+ model AS (select e.session_id,
+ min(we.created_at) created_at
+ from events e
+ join website_event we
+ on we.session_id = e.session_id
+ where we.website_id = {websiteId:UUID}
+ and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ group by e.session_id)
+ `;
+ }
+
+ return `
+ model AS (select e.session_id,
+ max(we.created_at) created_at
+ from events e
+ join website_event we
+ on we.session_id = e.session_id
+ where we.website_id = {websiteId:UUID}
+ and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and we.created_at < e.max_dt
+ group by e.session_id)
+ `;
+ }
+
+ const eventQuery = `WITH events AS (
+ select distinct
+ session_id,
+ max(created_at) max_dt
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and ${column} = {step:String}
+ ${filterQuery}
+ group by 1),`;
+
+ const revenueEventQuery = `WITH events AS (
+ select
+ website_revenue.session_id,
+ max(website_revenue.created_at) max_dt,
+ sum(website_revenue.revenue) as value
+ from website_revenue
+ join website_event
+ on website_event.website_id = website_revenue.website_id
+ and website_event.session_id = website_revenue.session_id
+ and website_event.event_id = website_revenue.event_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${cohortQuery}
+ where website_revenue.website_id = {websiteId:UUID}
+ and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and website_revenue.${column} = {step:String}
+ and website_revenue.currency = {currency:String}
+ ${filterQuery}
+ group by 1),`;
+
+ const referrerRes = await rawQuery<
+ {
+ name: string;
+ value: number;
+ }[]
+ >(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ select we.referrer_domain name,
+ ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
+ from model m
+ join website_event we
+ on we.created_at = m.created_at
+ and we.session_id = m.session_id
+ ${currency ? 'join events e on e.session_id = m.session_id' : ''}
+ where we.website_id = {websiteId:UUID}
+ and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${
+ currency
+ ? ''
+ : `and we.referrer_domain != hostname
+ and we.referrer_domain != ''`
+ }
+ group by 1
+ order by 2 desc
+ limit 20
+ `,
+ queryParams,
+ );
+
+ const paidAdsres = await rawQuery<
+ {
+ name: string;
+ value: number;
+ }[]
+ >(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ select multiIf(gclid != '', 'Google Ads',
+ fbclid != '', 'Facebook / Meta',
+ msclkid != '', 'Microsoft Ads',
+ ttclid != '', 'TikTok Ads',
+ li_fat_id != '', 'LinkedIn Ads',
+ twclid != '', 'Twitter Ads (X)','') name,
+ ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value
+ from model m
+ join website_event we
+ on we.created_at = m.created_at
+ and we.session_id = m.session_id
+ ${currency ? 'join events e on e.session_id = m.session_id' : ''}
+ where we.website_id = {websiteId:UUID}
+ and we.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${currency ? '' : `and name != ''`}
+ group by 1
+ order by 2 desc
+ limit 20
+ `,
+ queryParams,
+ );
+
+ const sourceRes = await rawQuery<
+ {
+ name: string;
+ value: number;
+ }[]
+ >(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_source')}
+ `,
+ queryParams,
+ );
+
+ const mediumRes = await rawQuery<
+ {
+ name: string;
+ value: number;
+ }[]
+ >(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_medium')}
+ `,
+ queryParams,
+ );
+
+ const campaignRes = await rawQuery<
+ {
+ name: string;
+ value: number;
+ }[]
+ >(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_campaign')}
+ `,
+ queryParams,
+ );
+
+ const contentRes = await rawQuery<
+ {
+ name: string;
+ value: number;
+ }[]
+ >(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_content')}
+ `,
+ queryParams,
+ );
+
+ const termRes = await rawQuery<
+ {
+ name: string;
+ value: number;
+ }[]
+ >(
+ `
+ ${currency ? revenueEventQuery : eventQuery}
+ ${getModelQuery(model)}
+ ${getUTMQuery('utm_term')}
+ `,
+ queryParams,
+ );
+
+ const totalRes = await rawQuery<{ pageviews: number; visitors: number; visits: number }>(
+ `
+ select
+ count(*) as "pageviews",
+ uniqExact(session_id) as "visitors",
+ uniqExact(visit_id) as "visits"
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and ${column} = {step:String}
+ ${filterQuery}
+ `,
+ queryParams,
+ ).then(result => result?.[0]);
+
+ return {
+ referrer: referrerRes,
+ paidAds: paidAdsres,
+ utm_source: sourceRes,
+ utm_medium: mediumRes,
+ utm_campaign: campaignRes,
+ utm_content: contentRes,
+ utm_term: termRes,
+ total: totalRes,
+ };
+}
diff --git a/src/queries/sql/reports/getBreakdown.ts b/src/queries/sql/reports/getBreakdown.ts
new file mode 100644
index 0000000..51773d8
--- /dev/null
+++ b/src/queries/sql/reports/getBreakdown.ts
@@ -0,0 +1,135 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export interface BreakdownParameters {
+ startDate: Date;
+ endDate: Date;
+ fields: string[];
+}
+
+export interface BreakdownData {
+ x: string;
+ y: number;
+}
+
+export async function getBreakdown(
+ ...args: [websiteId: string, parameters: BreakdownParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: BreakdownParameters,
+ filters: QueryFilters,
+): Promise<BreakdownData[]> {
+ const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma;
+ const { startDate, endDate, fields } = parameters;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters(
+ {
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ eventType: EVENT_TYPE.pageView,
+ },
+ {
+ joinSession: !!fields.find((name: string) => SESSION_COLUMNS.includes(name)),
+ },
+ );
+
+ return rawQuery(
+ `
+ select
+ sum(t.c) as "views",
+ count(distinct t.session_id) as "visitors",
+ count(distinct t.visit_id) as "visits",
+ sum(case when t.c = 1 then 1 else 0 end) as "bounces",
+ sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime",
+ ${parseFieldsByName(fields)}
+ from (
+ select
+ ${parseFields(fields)},
+ website_event.session_id,
+ website_event.visit_id,
+ count(*) as "c",
+ min(website_event.created_at) as "min_time",
+ max(website_event.created_at) as "max_time"
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by ${parseFieldsByName(fields)},
+ website_event.session_id, website_event.visit_id
+ ) as t
+ group by ${parseFieldsByName(fields)}
+ order by 1 desc, 2 desc
+ limit 500
+ `,
+ queryParams,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: BreakdownParameters,
+ filters: QueryFilters,
+): Promise<BreakdownData[]> {
+ const { parseFilters, rawQuery } = clickhouse;
+ const { startDate, endDate, fields } = parameters;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ eventType: EVENT_TYPE.pageView,
+ });
+
+ return rawQuery(
+ `
+ select
+ sum(t.c) as "views",
+ count(distinct t.session_id) as "visitors",
+ count(distinct t.visit_id) as "visits",
+ sum(if(t.c = 1, 1, 0)) as "bounces",
+ sum(max_time-min_time) as "totaltime",
+ ${parseFieldsByName(fields)}
+ from (
+ select
+ ${parseFields(fields)},
+ session_id,
+ visit_id,
+ count(*) c,
+ min(created_at) min_time,
+ max(created_at) max_time
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by ${parseFieldsByName(fields)},
+ session_id, visit_id
+ ) as t
+ group by ${parseFieldsByName(fields)}
+ order by 1 desc, 2 desc
+ limit 500
+ `,
+ queryParams,
+ );
+}
+
+function parseFields(fields: string[]) {
+ return fields.map(name => `${FILTER_COLUMNS[name]} as "${name}"`).join(',');
+}
+
+function parseFieldsByName(fields: string[]) {
+ return `${fields.map(name => name).join(',')}`;
+}
diff --git a/src/queries/sql/reports/getFunnel.ts b/src/queries/sql/reports/getFunnel.ts
new file mode 100644
index 0000000..4840123
--- /dev/null
+++ b/src/queries/sql/reports/getFunnel.ts
@@ -0,0 +1,255 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export interface FunnelParameters {
+ startDate: Date;
+ endDate: Date;
+ window: number;
+ steps: { type: string; value: string }[];
+}
+
+export interface FunnelResult {
+ value: string;
+ visitors: number;
+ dropoff: number;
+}
+
+export async function getFunnel(
+ ...args: [websiteId: string, parameters: FunnelParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: FunnelParameters,
+ filters: QueryFilters,
+): Promise<FunnelResult[]> {
+ const { startDate, endDate, window, steps } = parameters;
+ const { rawQuery, getAddIntervalQuery, parseFilters } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ });
+ const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, window);
+
+ function getFunnelQuery(
+ steps: { type: string; value: string }[],
+ window: number,
+ ): {
+ levelOneQuery: string;
+ levelQuery: string;
+ sumQuery: string;
+ params: string[];
+ } {
+ return steps.reduce(
+ (pv, cv, i) => {
+ const levelNumber = i + 1;
+ const startSum = i > 0 ? 'union ' : '';
+ const isURL = cv.type === 'path';
+ const column = isURL ? 'url_path' : 'event_name';
+
+ let operator = '=';
+ let paramValue = cv.value;
+
+ if (cv.value.startsWith('*') || cv.value.endsWith('*')) {
+ operator = 'like';
+ paramValue = cv.value.replace(/^\*|\*$/g, '%');
+ }
+
+ if (levelNumber === 1) {
+ pv.levelOneQuery = `
+ WITH level1 AS (
+ select distinct website_event.session_id, website_event.created_at
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and ${column} ${operator} {{${i}}}
+ ${filterQuery}
+ )`;
+ } else {
+ pv.levelQuery += `
+ , level${levelNumber} AS (
+ select distinct we.session_id, we.created_at
+ from level${i} l
+ join website_event we
+ on l.session_id = we.session_id
+ where we.website_id = {{websiteId::uuid}}
+ and we.created_at between l.created_at and ${getAddIntervalQuery(
+ `l.created_at `,
+ `${window} minute`,
+ )}
+ and we.${column} ${operator} {{${i}}}
+ and we.created_at <= {{endDate}}
+ )`;
+ }
+
+ pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
+ pv.params.push(paramValue);
+
+ return pv;
+ },
+ {
+ levelOneQuery: '',
+ levelQuery: '',
+ sumQuery: '',
+ params: [],
+ },
+ );
+ }
+
+ return rawQuery(
+ `
+ ${levelOneQuery}
+ ${levelQuery}
+ ${sumQuery}
+ ORDER BY level;
+ `,
+ {
+ ...params,
+ ...queryParams,
+ },
+ ).then(formatResults(steps));
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: FunnelParameters,
+ filters: QueryFilters,
+): Promise<
+ {
+ value: string;
+ visitors: number;
+ dropoff: number;
+ }[]
+> {
+ const { startDate, endDate, window, steps } = parameters;
+ const { rawQuery, parseFilters } = clickhouse;
+ const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery(
+ steps,
+ window,
+ );
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ function getFunnelQuery(
+ steps: { type: string; value: string }[],
+ window: number,
+ ): {
+ levelOneQuery: string;
+ levelQuery: string;
+ sumQuery: string;
+ stepFilterQuery: string;
+ params: Record<string, string>;
+ } {
+ return steps.reduce(
+ (pv, cv, i) => {
+ const levelNumber = i + 1;
+ const startSum = i > 0 ? 'union all ' : '';
+ const startFilter = i > 0 ? 'or' : '';
+ const isURL = cv.type === 'path';
+ const column = isURL ? 'url_path' : 'event_name';
+
+ let operator = '=';
+ let paramValue = cv.value;
+
+ if (cv.value.startsWith('*') || cv.value.endsWith('*')) {
+ operator = 'like';
+ paramValue = cv.value.replace(/^\*|\*$/g, '%');
+ }
+
+ if (levelNumber === 1) {
+ pv.levelOneQuery = `\n
+ level1 AS (
+ select *
+ from level0
+ where ${column} ${operator} {param${i}:String}
+ )`;
+ } else {
+ pv.levelQuery += `\n
+ , level${levelNumber} AS (
+ select distinct y.session_id as session_id,
+ y.url_path as url_path,
+ y.referrer_path as referrer_path,
+ y.event_name,
+ y.created_at as created_at
+ from level${i} x
+ join level0 y
+ on x.session_id = y.session_id
+ where y.created_at between x.created_at and x.created_at + interval ${window} minute
+ and y.${column} ${operator} {param${i}:String}
+ )`;
+ }
+
+ pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
+ pv.stepFilterQuery += `${startFilter} ${column} ${operator} {param${i}:String} `;
+ pv.params[`param${i}`] = paramValue;
+
+ return pv;
+ },
+ {
+ levelOneQuery: '',
+ levelQuery: '',
+ sumQuery: '',
+ stepFilterQuery: '',
+ params: {},
+ },
+ );
+ }
+
+ return rawQuery(
+ `
+ WITH level0 AS (
+ select distinct session_id, url_path, referrer_path, event_name, created_at
+ from website_event
+ ${cohortQuery}
+ where (${stepFilterQuery})
+ and website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ ),
+ ${levelOneQuery}
+ ${levelQuery}
+ select *
+ from (
+ ${sumQuery}
+ ) ORDER BY level;
+ `,
+ {
+ ...params,
+ ...queryParams,
+ },
+ ).then(formatResults(steps));
+}
+
+const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
+ return steps.map((step: { type: string; value: string }, i: number) => {
+ const visitors = Number(results[i]?.count) || 0;
+ const previous = Number(results[i - 1]?.count) || 0;
+ const dropped = previous > 0 ? previous - visitors : 0;
+ const dropoff = 1 - visitors / previous;
+ const remaining = visitors / Number(results[0].count);
+
+ return {
+ ...step,
+ visitors,
+ previous,
+ dropped,
+ dropoff,
+ remaining,
+ };
+ });
+};
diff --git a/src/queries/sql/reports/getGoal.ts b/src/queries/sql/reports/getGoal.ts
new file mode 100644
index 0000000..7e790ff
--- /dev/null
+++ b/src/queries/sql/reports/getGoal.ts
@@ -0,0 +1,105 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_TYPE } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export interface GoalParameters {
+ startDate: Date;
+ endDate: Date;
+ type: string;
+ value: string;
+ operator?: string;
+ property?: string;
+}
+
+export async function getGoal(
+ ...args: [websiteId: string, params: GoalParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: GoalParameters,
+ filters: QueryFilters,
+) {
+ const { startDate, endDate, type, value } = parameters;
+ const { rawQuery, parseFilters } = prisma;
+ const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
+ const column = type === 'path' ? 'url_path' : 'event_name';
+ const { filterQuery, dateQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ value,
+ startDate,
+ endDate,
+ eventType,
+ });
+
+ return rawQuery(
+ `
+ select count(distinct website_event.session_id) as num,
+ (
+ select count(distinct website_event.session_id)
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ ${dateQuery}
+ ${filterQuery}
+ ) as total
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and ${column} = {{value}}
+ ${dateQuery}
+ ${filterQuery}
+ `,
+ queryParams,
+ ).then(results => results?.[0]);
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: GoalParameters,
+ filters: QueryFilters,
+) {
+ const { startDate, endDate, type, value } = parameters;
+ const { rawQuery, parseFilters } = clickhouse;
+ const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
+ const column = type === 'path' ? 'url_path' : 'event_name';
+ const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ value,
+ startDate,
+ endDate,
+ eventType,
+ });
+
+ return rawQuery(
+ `
+ select count(distinct session_id) as num,
+ (
+ select count(distinct session_id)
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ ${dateQuery}
+ ${filterQuery}
+ ) as total
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and ${column} = {value:String}
+ ${dateQuery}
+ ${filterQuery}
+ `,
+ queryParams,
+ ).then(results => results?.[0]);
+}
diff --git a/src/queries/sql/reports/getJourney.ts b/src/queries/sql/reports/getJourney.ts
new file mode 100644
index 0000000..283e0fa
--- /dev/null
+++ b/src/queries/sql/reports/getJourney.ts
@@ -0,0 +1,275 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export interface JourneyParameters {
+ startDate: Date;
+ endDate: Date;
+ steps: number;
+ startStep?: string;
+ endStep?: string;
+}
+
+export interface JourneyResult {
+ e1: string;
+ e2: string;
+ e3: string;
+ e4: string;
+ e5: string;
+ e6: string;
+ e7: string;
+ count: number;
+}
+
+export async function getJourney(
+ ...args: [websiteId: string, parameters: JourneyParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: JourneyParameters,
+ filters: QueryFilters,
+): Promise<JourneyResult[]> {
+ const { startDate, endDate, steps, startStep, endStep } = parameters;
+ const { rawQuery, parseFilters } = prisma;
+ const { sequenceQuery, startStepQuery, endStepQuery, params } = getJourneyQuery(
+ steps,
+ startStep,
+ endStep,
+ );
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ function getJourneyQuery(
+ steps: number,
+ startStep?: string,
+ endStep?: string,
+ ): {
+ sequenceQuery: string;
+ startStepQuery: string;
+ endStepQuery: string;
+ params: Record<string, string>;
+ } {
+ const params = {};
+ let sequenceQuery = '';
+ let startStepQuery = '';
+ let endStepQuery = '';
+
+ // create sequence query
+ let selectQuery = '';
+ let maxQuery = '';
+ let groupByQuery = '';
+
+ for (let i = 1; i <= steps; i++) {
+ const endQuery = i < steps ? ',' : '';
+ selectQuery += `s.e${i},`;
+ maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`;
+ groupByQuery += `s.e${i}${endQuery} `;
+ }
+
+ sequenceQuery = `\nsequences as (
+ select ${selectQuery}
+ count(*) count
+ FROM (
+ select visit_id,
+ ${maxQuery}
+ FROM events
+ group by visit_id) s
+ group by ${groupByQuery})
+ `;
+
+ // create start Step params query
+ if (startStep) {
+ startStepQuery = `and e1 = {{startStep}}`;
+ params.startStep = startStep;
+ }
+
+ // create end Step params query
+ if (endStep) {
+ for (let i = 1; i < steps; i++) {
+ const startQuery = i === 1 ? 'and (' : '\nor ';
+ endStepQuery += `${startQuery}(e${i} = {{endStep}} and e${i + 1} is null) `;
+ }
+ endStepQuery += `\nor (e${steps} = {{endStep}}))`;
+
+ params.endStep = endStep;
+ }
+
+ return {
+ sequenceQuery,
+ startStepQuery,
+ endStepQuery,
+ params,
+ };
+ }
+
+ return rawQuery(
+ `
+ WITH events AS (
+ select distinct
+ website_event.visit_id,
+ website_event.referrer_path,
+ coalesce(nullIf(website_event.event_name, ''), website_event.url_path) event,
+ row_number() OVER (PARTITION BY visit_id ORDER BY website_event.created_at) AS event_number
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}),
+ ${sequenceQuery}
+ select *
+ from sequences
+ where 1 = 1
+ ${startStepQuery}
+ ${endStepQuery}
+ order by count desc
+ limit 100
+ `,
+ {
+ ...params,
+ ...queryParams,
+ },
+ ).then(parseResult);
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: JourneyParameters,
+ filters: QueryFilters,
+): Promise<JourneyResult[]> {
+ const { startDate, endDate, steps, startStep, endStep } = parameters;
+ const { rawQuery, parseFilters } = clickhouse;
+ const { sequenceQuery, startStepQuery, endStepQuery, params } = getJourneyQuery(
+ steps,
+ startStep,
+ endStep,
+ );
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ function getJourneyQuery(
+ steps: number,
+ startStep?: string,
+ endStep?: string,
+ ): {
+ sequenceQuery: string;
+ startStepQuery: string;
+ endStepQuery: string;
+ params: Record<string, string>;
+ } {
+ const params = {};
+ let sequenceQuery = '';
+ let startStepQuery = '';
+ let endStepQuery = '';
+
+ // create sequence query
+ let selectQuery = '';
+ let maxQuery = '';
+ let groupByQuery = '';
+
+ for (let i = 1; i <= steps; i++) {
+ const endQuery = i < steps ? ',' : '';
+ selectQuery += `s.e${i},`;
+ maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`;
+ groupByQuery += `s.e${i}${endQuery} `;
+ }
+
+ sequenceQuery = `\nsequences as (
+ select ${selectQuery}
+ count(*) count
+ FROM (
+ select visit_id,
+ ${maxQuery}
+ FROM events
+ group by visit_id) s
+ group by ${groupByQuery})
+ `;
+
+ // create start Step params query
+ if (startStep) {
+ startStepQuery = `and e1 = {startStep:String}`;
+ params.startStep = startStep;
+ }
+
+ // create end Step params query
+ if (endStep) {
+ for (let i = 1; i < steps; i++) {
+ const startQuery = i === 1 ? 'and (' : '\nor ';
+ endStepQuery += `${startQuery}(e${i} = {endStep:String} and e${i + 1} is null) `;
+ }
+ endStepQuery += `\nor (e${steps} = {endStep:String}))`;
+
+ params.endStep = endStep;
+ }
+
+ return {
+ sequenceQuery,
+ startStepQuery,
+ endStepQuery,
+ params,
+ };
+ }
+
+ return rawQuery(
+ `
+ WITH events AS (
+ select distinct
+ visit_id,
+ coalesce(nullIf(event_name, ''), url_path) "event",
+ row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ ${filterQuery}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}),
+ ${sequenceQuery}
+ select *
+ from sequences
+ where 1 = 1
+ ${startStepQuery}
+ ${endStepQuery}
+ order by count desc
+ limit 100
+ `,
+ {
+ ...params,
+ ...queryParams,
+ },
+ ).then(parseResult);
+}
+
+function combineSequentialDuplicates(array: any) {
+ if (array.length === 0) return array;
+
+ const result = [array[0]];
+
+ for (let i = 1; i < array.length; i++) {
+ if (array[i] !== array[i - 1]) {
+ result.push(array[i]);
+ }
+ }
+
+ return result;
+}
+
+function parseResult(data: any) {
+ return data.map(({ e1, e2, e3, e4, e5, e6, e7, count }) => ({
+ items: combineSequentialDuplicates([e1, e2, e3, e4, e5, e6, e7]),
+ count: +Number(count),
+ }));
+}
diff --git a/src/queries/sql/reports/getRetention.ts b/src/queries/sql/reports/getRetention.ts
new file mode 100644
index 0000000..87b55e0
--- /dev/null
+++ b/src/queries/sql/reports/getRetention.ts
@@ -0,0 +1,173 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export interface RetentionParameters {
+ startDate: Date;
+ endDate: Date;
+ timezone?: string;
+}
+
+export interface RetentionResult {
+ date: string;
+ day: number;
+ visitors: number;
+ returnVisitors: number;
+ percentage: number;
+}
+
+export async function getRetention(
+ ...args: [websiteId: string, parameters: RetentionParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: RetentionParameters,
+ filters: QueryFilters,
+): Promise<RetentionResult[]> {
+ const { startDate, endDate, timezone } = parameters;
+ const { getDateSQL, getDayDiffQuery, getCastColumnQuery, rawQuery, parseFilters } = prisma;
+ const unit = 'day';
+
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ timezone,
+ });
+
+ return rawQuery(
+ `
+ WITH cohort_items AS (
+ select
+ min(${getDateSQL('website_event.created_at', unit, timezone)}) as cohort_date,
+ website_event.session_id
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by website_event.session_id
+ ),
+ user_activities AS (
+ select distinct
+ website_event.session_id,
+ ${getDayDiffQuery(getDateSQL('created_at', unit, timezone), 'cohort_items.cohort_date')} as day_number
+ from website_event
+ join cohort_items
+ on website_event.session_id = cohort_items.session_id
+ where website_id = {{websiteId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}
+
+ ),
+ cohort_size as (
+ select cohort_date,
+ count(*) as visitors
+ from cohort_items
+ group by 1
+ order by 1
+ ),
+ cohort_date as (
+ select
+ c.cohort_date,
+ a.day_number,
+ count(*) as visitors
+ from user_activities a
+ join cohort_items c
+ on a.session_id = c.session_id
+ group by 1, 2
+ )
+ select
+ c.cohort_date as date,
+ c.day_number as day,
+ s.visitors,
+ c.visitors as "returnVisitors",
+ ${getCastColumnQuery('c.visitors', 'float')} * 100 / s.visitors as percentage
+ from cohort_date c
+ join cohort_size s
+ on c.cohort_date = s.cohort_date
+ where c.day_number <= 31
+ order by 1, 2`,
+ queryParams,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: RetentionParameters,
+ filters: QueryFilters,
+): Promise<RetentionResult[]> {
+ const { startDate, endDate, timezone } = parameters;
+ const { getDateSQL, rawQuery, parseFilters } = clickhouse;
+ const unit = 'day';
+
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ timezone,
+ });
+
+ return rawQuery(
+ `
+ WITH cohort_items AS (
+ select
+ min(${getDateSQL('created_at', unit, timezone)}) as cohort_date,
+ session_id
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ group by session_id
+ ),
+ user_activities AS (
+ select distinct
+ website_event.session_id,
+ toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number
+ from website_event
+ join cohort_items
+ on website_event.session_id = cohort_items.session_id
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ),
+ cohort_size as (
+ select cohort_date,
+ count(*) as visitors
+ from cohort_items
+ group by 1
+ order by 1
+ ),
+ cohort_date as (
+ select
+ c.cohort_date,
+ a.day_number,
+ count(*) as visitors
+ from user_activities a
+ join cohort_items c
+ on a.session_id = c.session_id
+ group by 1, 2
+ )
+ select
+ c.cohort_date as date,
+ c.day_number as day,
+ s.visitors as visitors,
+ c.visitors returnVisitors,
+ c.visitors * 100 / s.visitors as percentage
+ from cohort_date c
+ join cohort_size s
+ on c.cohort_date = s.cohort_date
+ where c.day_number <= 31
+ order by 1, 2`,
+ queryParams,
+ );
+}
diff --git a/src/queries/sql/reports/getRevenue.ts b/src/queries/sql/reports/getRevenue.ts
new file mode 100644
index 0000000..fa25078
--- /dev/null
+++ b/src/queries/sql/reports/getRevenue.ts
@@ -0,0 +1,217 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export interface RevenuParameters {
+ startDate: Date;
+ endDate: Date;
+ unit: string;
+ timezone: string;
+ currency: string;
+}
+
+export interface RevenueResult {
+ chart: { x: string; t: string; y: number }[];
+ country: { name: string; value: number }[];
+ total: { sum: number; count: number; average: number; unique_count: number };
+}
+
+export async function getRevenue(
+ ...args: [websiteId: string, parameters: RevenuParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: RevenuParameters,
+ filters: QueryFilters,
+): Promise<RevenueResult> {
+ const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters;
+ const { getDateSQL, rawQuery, parseFilters } = prisma;
+ const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ currency,
+ });
+
+ const joinQuery = filterQuery
+ ? `join website_event
+ on website_event.website_id = revenue.website_id
+ and website_event.session_id = revenue.session_id
+ and website_event.event_id = revenue.event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}`
+ : '';
+
+ const chart = await rawQuery(
+ `
+ select
+ revenue.event_name x,
+ ${getDateSQL('revenue.created_at', unit, timezone)} t,
+ sum(revenue.revenue) y
+ from revenue
+ ${joinQuery}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where revenue.website_id = {{websiteId::uuid}}
+ and revenue.created_at between {{startDate}} and {{endDate}}
+ and revenue.currency = upper({{currency}})
+ ${filterQuery}
+ group by x, t
+ order by t
+ `,
+ queryParams,
+ );
+
+ const country = await rawQuery(
+ `
+ select
+ session.country as name,
+ sum(revenue) value
+ from revenue
+ ${joinQuery}
+ join session
+ on session.website_id = revenue.website_id
+ and session.session_id = revenue.session_id
+ ${cohortQuery}
+ where revenue.website_id = {{websiteId::uuid}}
+ and revenue.created_at between {{startDate}} and {{endDate}}
+ and revenue.currency = upper({{currency}})
+ ${filterQuery}
+ group by session.country
+ `,
+ queryParams,
+ );
+
+ const total = await rawQuery(
+ `
+ select
+ sum(revenue.revenue) as sum,
+ count(distinct revenue.event_id) as count,
+ count(distinct revenue.session_id) as unique_count
+ from revenue
+ ${joinQuery}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where revenue.website_id = {{websiteId::uuid}}
+ and revenue.created_at between {{startDate}} and {{endDate}}
+ and revenue.currency = upper({{currency}})
+ ${filterQuery}
+ `,
+ queryParams,
+ ).then(result => result?.[0]);
+
+ total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0;
+
+ return { chart, country, total };
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: RevenuParameters,
+ filters: QueryFilters,
+): Promise<RevenueResult> {
+ const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters;
+ const { getDateSQL, rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ currency,
+ });
+
+ const joinQuery = filterQuery
+ ? `join website_event
+ on website_event.website_id = website_revenue.website_id
+ and website_event.session_id = website_revenue.session_id
+ and website_event.event_id = website_revenue.event_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}`
+ : '';
+
+ const chart = await rawQuery<
+ {
+ x: string;
+ t: string;
+ y: number;
+ }[]
+ >(
+ `
+ select
+ website_revenue.event_name x,
+ ${getDateSQL('website_revenue.created_at', unit, timezone)} t,
+ sum(website_revenue.revenue) y
+ from website_revenue
+ ${joinQuery}
+ ${cohortQuery}
+ where website_revenue.website_id = {websiteId:UUID}
+ and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and website_revenue.currency = upper({currency:String})
+ ${filterQuery}
+ group by x, t
+ order by t
+ `,
+ queryParams,
+ );
+
+ const country = await rawQuery<
+ {
+ name: string;
+ value: number;
+ }[]
+ >(
+ `
+ select
+ website_event.country as name,
+ sum(website_revenue.revenue) as value
+ from website_revenue
+ join website_event
+ on website_event.website_id = website_revenue.website_id
+ and website_event.session_id = website_revenue.session_id
+ and website_event.event_id = website_revenue.event_id
+ and website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${cohortQuery}
+ where website_revenue.website_id = {websiteId:UUID}
+ and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and website_revenue.currency = upper({currency:String})
+ ${filterQuery}
+ group by website_event.country
+ order by value desc
+ `,
+ queryParams,
+ );
+
+ const total = await rawQuery<{
+ sum: number;
+ count: number;
+ unique_count: number;
+ }>(
+ `
+ select
+ sum(website_revenue.revenue) as sum,
+ uniqExact(website_revenue.event_id) as count,
+ uniqExact(website_revenue.session_id) as unique_count
+ from website_revenue
+ ${joinQuery}
+ ${cohortQuery}
+ where website_revenue.website_id = {websiteId:UUID}
+ and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and website_revenue.currency = upper({currency:String})
+ ${filterQuery}
+ `,
+ queryParams,
+ ).then(result => result?.[0]);
+
+ total.average = total.count > 0 ? total.sum / total.count : 0;
+
+ return { chart, country, total };
+}
diff --git a/src/queries/sql/reports/getUTM.ts b/src/queries/sql/reports/getUTM.ts
new file mode 100644
index 0000000..4d43eb4
--- /dev/null
+++ b/src/queries/sql/reports/getUTM.ts
@@ -0,0 +1,84 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_TYPE } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+export interface UTMParameters {
+ column: string;
+ startDate: Date;
+ endDate: Date;
+}
+
+export async function getUTM(
+ ...args: [websiteId: string, parameters: UTMParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: UTMParameters,
+ filters: QueryFilters,
+) {
+ const { column, startDate, endDate } = parameters;
+ const { parseFilters, rawQuery } = prisma;
+
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ eventType: EVENT_TYPE.pageView,
+ });
+
+ return rawQuery(
+ `
+ select website_event.${column} utm, count(*) as views
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and coalesce(website_event.${column}, '') != ''
+ ${filterQuery}
+ group by 1
+ order by 2 desc
+ `,
+ queryParams,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: UTMParameters,
+ filters: QueryFilters,
+) {
+ const { column, startDate, endDate } = parameters;
+ const { parseFilters, rawQuery } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ startDate,
+ endDate,
+ eventType: EVENT_TYPE.pageView,
+ });
+
+ return rawQuery(
+ `
+ select ${column} utm, count(*) as views
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and ${column} != ''
+ ${filterQuery}
+ group by 1
+ order by 2 desc
+ `,
+ queryParams,
+ );
+}
diff --git a/src/queries/sql/sessions/createSession.ts b/src/queries/sql/sessions/createSession.ts
new file mode 100644
index 0000000..8d07a55
--- /dev/null
+++ b/src/queries/sql/sessions/createSession.ts
@@ -0,0 +1,44 @@
+import type { Prisma } from '@/generated/prisma/client';
+import prisma from '@/lib/prisma';
+
+const FUNCTION_NAME = 'createSession';
+
+export async function createSession(data: Prisma.SessionCreateInput) {
+ const { rawQuery } = prisma;
+
+ await rawQuery(
+ `
+ insert into session (
+ session_id,
+ website_id,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ distinct_id,
+ created_at
+ )
+ values (
+ {{id}},
+ {{websiteId}},
+ {{browser}},
+ {{os}},
+ {{device}},
+ {{screen}},
+ {{language}},
+ {{country}},
+ {{region}},
+ {{city}},
+ {{distinctId}},
+ {{createdAt}}
+ )
+ on conflict (session_id) do nothing
+ `,
+ data,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/sessions/getSessionActivity.ts b/src/queries/sql/sessions/getSessionActivity.ts
new file mode 100644
index 0000000..af31fca
--- /dev/null
+++ b/src/queries/sql/sessions/getSessionActivity.ts
@@ -0,0 +1,78 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getSessionActivity';
+
+export async function getSessionActivity(
+ ...args: [websiteId: string, sessionId: string, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, sessionId: string, filters: QueryFilters) {
+ const { rawQuery } = prisma;
+ const { startDate, endDate } = filters;
+
+ return rawQuery(
+ `
+ select
+ created_at as "createdAt",
+ url_path as "urlPath",
+ url_query as "urlQuery",
+ referrer_domain as "referrerDomain",
+ event_id as "eventId",
+ event_type as "eventType",
+ event_name as "eventName",
+ visit_id as "visitId",
+ event_id IN (select website_event_id
+ from event_data
+ where website_id = {{websiteId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}) AS "hasData"
+ from website_event
+ where website_id = {{websiteId::uuid}}
+ and session_id = {{sessionId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}
+ order by created_at desc
+ limit 500
+ `,
+ { websiteId, sessionId, startDate, endDate },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(websiteId: string, sessionId: string, filters: QueryFilters) {
+ const { rawQuery } = clickhouse;
+ const { startDate, endDate } = filters;
+
+ return rawQuery(
+ `
+ select
+ created_at as createdAt,
+ url_path as urlPath,
+ url_query as urlQuery,
+ referrer_domain as referrerDomain,
+ event_id as eventId,
+ event_type as eventType,
+ event_name as eventName,
+ visit_id as visitId,
+ event_id IN (select event_id
+ from event_data
+ where website_id = {websiteId:UUID}
+ and session_id = {sessionId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}) AS hasData
+ from website_event
+ where website_id = {websiteId:UUID}
+ and session_id = {sessionId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ order by created_at desc
+ limit 500
+ `,
+ { websiteId, sessionId, startDate, endDate },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/sessions/getSessionData.ts b/src/queries/sql/sessions/getSessionData.ts
new file mode 100644
index 0000000..8f1e493
--- /dev/null
+++ b/src/queries/sql/sessions/getSessionData.ts
@@ -0,0 +1,60 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+
+const FUNCTION_NAME = 'getSessionData';
+
+export async function getSessionData(...args: [websiteId: string, sessionId: string]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, sessionId: string) {
+ const { rawQuery } = prisma;
+
+ return rawQuery(
+ `
+ select
+ website_id as "websiteId",
+ session_id as "sessionId",
+ data_key as "dataKey",
+ data_type as "dataType",
+ replace(string_value, '.0000', '') as "stringValue",
+ number_value as "numberValue",
+ date_value as "dateValue",
+ created_at as "createdAt"
+ from session_data
+ where website_id = {{websiteId::uuid}}
+ and session_id = {{sessionId::uuid}}
+ order by data_key asc
+ `,
+ { websiteId, sessionId },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(websiteId: string, sessionId: string) {
+ const { rawQuery } = clickhouse;
+
+ return rawQuery(
+ `
+ select
+ website_id as websiteId,
+ session_id as sessionId,
+ data_key as dataKey,
+ data_type as dataType,
+ replace(string_value, '.0000', '') as stringValue,
+ number_value as numberValue,
+ date_value as dateValue,
+ created_at as createdAt
+ from session_data final
+ where website_id = {websiteId:UUID}
+ and session_id = {sessionId:UUID}
+ order by data_key asc
+ `,
+ { websiteId, sessionId },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/sessions/getSessionDataProperties.ts b/src/queries/sql/sessions/getSessionDataProperties.ts
new file mode 100644
index 0000000..9b429f9
--- /dev/null
+++ b/src/queries/sql/sessions/getSessionDataProperties.ts
@@ -0,0 +1,75 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getSessionDataProperties';
+
+export async function getSessionDataProperties(
+ ...args: [websiteId: string, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { rawQuery, parseFilters } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ data_key as "propertyName",
+ count(distinct session_data.session_id) as "total"
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ join session_data
+ on session_data.session_id = website_event.session_id
+ and session_data.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ group by 1
+ order by 2 desc
+ limit 500
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<{ propertyName: string; total: number }[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+
+ return rawQuery(
+ `
+ select
+ data_key as propertyName,
+ count(distinct session_data.session_id) as total
+ from website_event
+ ${cohortQuery}
+ join session_data final
+ on session_data.session_id = website_event.session_id
+ and session_data.website_id = {websiteId:UUID}
+ where website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and session_data.data_key != ''
+ ${filterQuery}
+ group by 1
+ order by 2 desc
+ limit 500
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/sessions/getSessionDataValues.ts b/src/queries/sql/sessions/getSessionDataValues.ts
new file mode 100644
index 0000000..5790141
--- /dev/null
+++ b/src/queries/sql/sessions/getSessionDataValues.ts
@@ -0,0 +1,85 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getSessionDataValues';
+
+export async function getSessionDataValues(
+ ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ filters: QueryFilters & { propertyName?: string },
+) {
+ const { rawQuery, parseFilters, getDateSQL } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ case
+ when data_type = 2 then replace(string_value, '.0000', '')
+ when data_type = 4 then ${getDateSQL('date_value', 'hour')}
+ else string_value
+ end as "value",
+ count(distinct session_data.session_id) as "total"
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ join session_data
+ on session_data.session_id = website_event.session_id
+ and session_data.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and session_data.data_key = {{propertyName}}
+ ${filterQuery}
+ group by value
+ order by 2 desc
+ limit 100
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters & { propertyName?: string },
+): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+
+ return rawQuery(
+ `
+ select
+ multiIf(data_type = 2, replaceAll(string_value, '.0000', ''),
+ data_type = 4, toString(date_trunc('hour', date_value)),
+ string_value) as "value",
+ uniq(session_data.session_id) as "total"
+ from website_event
+ ${cohortQuery}
+ join session_data final
+ on session_data.session_id = website_event.session_id
+ and session_data.website_id = {websiteId:UUID}
+ where website_event.website_id = {websiteId:UUID}
+ and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and session_data.data_key = {propertyName:String}
+ ${filterQuery}
+ group by value
+ order by 2 desc
+ limit 100
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/sessions/getSessionExpandedMetrics.ts b/src/queries/sql/sessions/getSessionExpandedMetrics.ts
new file mode 100644
index 0000000..85c1293
--- /dev/null
+++ b/src/queries/sql/sessions/getSessionExpandedMetrics.ts
@@ -0,0 +1,152 @@
+import clickhouse from '@/lib/clickhouse';
+import { FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getSessionExpandedMetrics';
+
+export interface SessionExpandedMetricsParameters {
+ type: string;
+ limit?: number | string;
+ offset?: number | string;
+}
+
+export interface SessionExpandedMetricsData {
+ name: string;
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}
+
+export async function getSessionExpandedMetrics(
+ ...args: [websiteId: string, parameters: SessionExpandedMetricsParameters, filters: QueryFilters]
+): Promise<SessionExpandedMetricsData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: SessionExpandedMetricsParameters,
+ filters: QueryFilters,
+): Promise<SessionExpandedMetricsData[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ let column = FILTER_COLUMNS[type] || type;
+ const { parseFilters, rawQuery, getTimestampDiffSQL } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters(
+ {
+ ...filters,
+ websiteId,
+ },
+ {
+ joinSession: SESSION_COLUMNS.includes(type),
+ },
+ );
+ const includeCountry = column === 'city' || column === 'region';
+
+ if (type === 'language') {
+ column = `lower(left(${type}, 2))`;
+ }
+
+ return rawQuery(
+ `
+ select
+ name,
+ ${includeCountry ? 'country,' : ''}
+ sum(t.c) as "pageviews",
+ count(distinct t.session_id) as "visitors",
+ count(distinct t.visit_id) as "visits",
+ sum(case when t.c = 1 then 1 else 0 end) as "bounces",
+ sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
+ from (
+ select
+ ${column} name,
+ ${includeCountry ? 'country,' : ''}
+ website_event.session_id,
+ website_event.visit_id,
+ count(*) as "c",
+ min(website_event.created_at) as "min_time",
+ max(website_event.created_at) as "max_time"
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ ${filterQuery}
+ group by name, website_event.session_id, website_event.visit_id
+ ${includeCountry ? ', country' : ''}
+ ) as t
+ group by name
+ ${includeCountry ? ', country' : ''}
+ order by visitors desc, visits desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: SessionExpandedMetricsParameters,
+ filters: QueryFilters,
+): Promise<SessionExpandedMetricsData[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ let column = FILTER_COLUMNS[type] || type;
+ const { parseFilters, rawQuery } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+ const includeCountry = column === 'city' || column === 'region';
+
+ if (type === 'language') {
+ column = `lower(left(${type}, 2))`;
+ }
+
+ return rawQuery(
+ `
+ select
+ name,
+ ${includeCountry ? 'country,' : ''}
+ sum(t.c) as "pageviews",
+ uniq(t.session_id) as "visitors",
+ uniq(t.visit_id) as "visits",
+ sum(if(t.c = 1, 1, 0)) as "bounces",
+ sum(max_time-min_time) as "totaltime"
+ from (
+ select
+ ${column} name,
+ ${includeCountry ? 'country,' : ''}
+ session_id,
+ visit_id,
+ count(*) c,
+ min(created_at) min_time,
+ max(created_at) max_time
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ and name != ''
+ ${filterQuery}
+ group by name, session_id, visit_id
+ ${includeCountry ? ', country' : ''}
+ ) as t
+ group by name
+ ${includeCountry ? ', country' : ''}
+ order by visitors desc, visits desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/sessions/getSessionMetrics.ts b/src/queries/sql/sessions/getSessionMetrics.ts
new file mode 100644
index 0000000..c519bdd
--- /dev/null
+++ b/src/queries/sql/sessions/getSessionMetrics.ts
@@ -0,0 +1,130 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getSessionMetrics';
+
+export interface SessionMetricsParameters {
+ type: string;
+ limit?: number | string;
+ offset?: number | string;
+}
+
+export async function getSessionMetrics(
+ ...args: [websiteId: string, parameters: SessionMetricsParameters, filters: QueryFilters]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ parameters: SessionMetricsParameters,
+ filters: QueryFilters,
+) {
+ const { type, limit = 500, offset = 0 } = parameters;
+ let column = FILTER_COLUMNS[type] || type;
+ const { parseFilters, rawQuery } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters(
+ {
+ ...filters,
+ websiteId,
+ },
+ {
+ joinSession: SESSION_COLUMNS.includes(type),
+ },
+ );
+ const includeCountry = column === 'city' || column === 'region';
+
+ if (type === 'language') {
+ column = `lower(left(${type}, 2))`;
+ }
+
+ return rawQuery(
+ `
+ select
+ ${column} x,
+ count(distinct website_event.session_id) y
+ ${includeCountry ? ', country' : ''}
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ ${filterQuery}
+ group by 1
+ ${includeCountry ? ', 3' : ''}
+ order by 2 desc
+ limit ${limit}
+ offset ${offset}
+ `,
+ { ...queryParams, ...parameters },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ parameters: SessionMetricsParameters,
+ filters: QueryFilters,
+): Promise<{ x: string; y: number }[]> {
+ const { type, limit = 500, offset = 0 } = parameters;
+ let column = FILTER_COLUMNS[type] || type;
+ const { parseFilters, rawQuery } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+ const includeCountry = column === 'city' || column === 'region';
+
+ if (type === 'language') {
+ column = `lower(left(${type}, 2))`;
+ }
+
+ let sql = '';
+
+ if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) {
+ sql = `
+ select
+ ${column} x,
+ count(distinct session_id) y
+ ${includeCountry ? ', country' : ''}
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${filterQuery}
+ group by x
+ ${includeCountry ? ', country' : ''}
+ order by y desc
+ limit ${limit}
+ offset ${offset}
+ `;
+ } else {
+ sql = `
+ select
+ ${column} x,
+ uniq(session_id) y
+ ${includeCountry ? ', country' : ''}
+ from website_event_stats_hourly as website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${filterQuery}
+ group by x
+ ${includeCountry ? ', country' : ''}
+ order by y desc
+ limit ${limit}
+ offset ${offset}
+ `;
+ }
+
+ return rawQuery(sql, { ...queryParams, ...parameters }, FUNCTION_NAME);
+}
diff --git a/src/queries/sql/sessions/getSessionStats.ts b/src/queries/sql/sessions/getSessionStats.ts
new file mode 100644
index 0000000..fd45772
--- /dev/null
+++ b/src/queries/sql/sessions/getSessionStats.ts
@@ -0,0 +1,98 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getSessionStats';
+
+export async function getSessionStats(...args: [websiteId: string, filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { timezone = 'utc', unit = 'day' } = filters;
+ const { getDateSQL, parseFilters, rawQuery } = prisma;
+ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ ${getDateSQL('website_event.created_at', unit, timezone)} x,
+ count(distinct website_event.session_id) y
+ from website_event
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type != 2
+ ${filterQuery}
+ group by 1
+ order by 1
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<{ x: string; y: number }[]> {
+ const { timezone = 'UTC', unit = 'day' } = filters;
+ const { parseFilters, rawQuery, getDateSQL } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ let sql = '';
+
+ if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item)) || unit === 'minute') {
+ sql = `
+ select
+ g.t as x,
+ g.y as y
+ from (
+ select
+ ${getDateSQL('website_event.created_at', unit, timezone)} as t,
+ count(distinct session_id) as y
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${filterQuery}
+ group by t
+ ) as g
+ order by t
+ `;
+ } else {
+ sql = `
+ select
+ g.t as x,
+ g.y as y
+ from (
+ select
+ ${getDateSQL('website_event.created_at', unit, timezone)} as t,
+ uniq(session_id) as y
+ from website_event_stats_hourly as website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type != 2
+ ${filterQuery}
+ group by t
+ ) as g
+ order by t
+ `;
+ }
+
+ return rawQuery(sql, queryParams, FUNCTION_NAME);
+}
diff --git a/src/queries/sql/sessions/getWebsiteSession.ts b/src/queries/sql/sessions/getWebsiteSession.ts
new file mode 100644
index 0000000..3c16087
--- /dev/null
+++ b/src/queries/sql/sessions/getWebsiteSession.ts
@@ -0,0 +1,113 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+
+const FUNCTION_NAME = 'getWebsiteSession';
+
+export async function getWebsiteSession(...args: [websiteId: string, sessionId: string]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, sessionId: string) {
+ const { rawQuery, getTimestampDiffSQL } = prisma;
+
+ return rawQuery(
+ `
+ select id,
+ distinct_id as "distinctId",
+ website_id as "websiteId",
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ min(min_time) as "firstAt",
+ max(max_time) as "lastAt",
+ count(distinct visit_id) as visits,
+ sum(views) as views,
+ sum(events) as events,
+ sum(${getTimestampDiffSQL('min_time', 'max_time')}) as "totaltime"
+ from (select
+ session.session_id as id,
+ session.distinct_id,
+ website_event.visit_id,
+ session.website_id,
+ session.browser,
+ session.os,
+ session.device,
+ session.screen,
+ session.language,
+ session.country,
+ session.region,
+ session.city,
+ min(website_event.created_at) as min_time,
+ max(website_event.created_at) as max_time,
+ sum(case when website_event.event_type = 1 then 1 else 0 end) as views,
+ sum(case when website_event.event_type = 2 then 1 else 0 end) as events
+ from session
+ join website_event on website_event.session_id = session.session_id
+ where session.website_id = {{websiteId::uuid}}
+ and session.session_id = {{sessionId::uuid}}
+ group by session.session_id, session.distinct_id, visit_id, session.website_id, session.browser, session.os, session.device, session.screen, session.language, session.country, session.region, session.city) t
+ group by id, distinct_id, website_id, browser, os, device, screen, language, country, region, city;
+ `,
+ { websiteId, sessionId },
+ FUNCTION_NAME,
+ ).then(result => result?.[0]);
+}
+
+async function clickhouseQuery(websiteId: string, sessionId: string) {
+ const { rawQuery, getDateStringSQL } = clickhouse;
+
+ return rawQuery(
+ `
+ select id,
+ websiteId,
+ distinctId,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ ${getDateStringSQL('min(min_time)')} as firstAt,
+ ${getDateStringSQL('max(max_time)')} as lastAt,
+ uniq(visit_id) visits,
+ sum(views) as views,
+ sum(events) as events,
+ sum(max_time-min_time) as totaltime
+ from (select
+ session_id as id,
+ distinct_id as distinctId,
+ visit_id,
+ website_id as websiteId,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ min(min_time) as min_time,
+ max(max_time) as max_time,
+ sum(views) as views,
+ length(groupArrayArray(event_name)) as events
+ from website_event_stats_hourly
+ where website_id = {websiteId:UUID}
+ and session_id = {sessionId:UUID}
+ group by session_id, distinct_id, visit_id, website_id, browser, os, device, screen, language, country, region, city) t
+ group by id, websiteId, distinctId, browser, os, device, screen, language, country, region, city;
+ `,
+ { websiteId, sessionId },
+ FUNCTION_NAME,
+ ).then(result => result?.[0]);
+}
diff --git a/src/queries/sql/sessions/getWebsiteSessionStats.ts b/src/queries/sql/sessions/getWebsiteSessionStats.ts
new file mode 100644
index 0000000..a12e6c6
--- /dev/null
+++ b/src/queries/sql/sessions/getWebsiteSessionStats.ts
@@ -0,0 +1,97 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getWebsiteSessionStats';
+
+export interface WebsiteSessionStatsData {
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ countries: number;
+ events: number;
+}
+
+export async function getWebsiteSessionStats(
+ ...args: [websiteId: string, filters: QueryFilters]
+): Promise<WebsiteSessionStatsData[]> {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<WebsiteSessionStatsData[]> {
+ const { parseFilters, rawQuery } = prisma;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ return rawQuery(
+ `
+ select
+ count(*) as "pageviews",
+ count(distinct website_event.session_id) as "visitors",
+ count(distinct website_event.visit_id) as "visits",
+ count(distinct session.country) as "countries",
+ sum(case when website_event.event_type = 2 then 1 else 0 end) as "events"
+ from website_event
+ ${cohortQuery}
+ join session on website_event.session_id = session.session_id
+ and website_event.website_id = session.website_id
+ where website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ ${filterQuery}
+ `,
+ queryParams,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: QueryFilters,
+): Promise<WebsiteSessionStatsData[]> {
+ const { rawQuery, parseFilters } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+
+ let sql = '';
+
+ if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) {
+ sql = `
+ select
+ sumIf(1, event_type = 1) as "pageviews",
+ uniq(session_id) as "visitors",
+ uniq(visit_id) as "visits",
+ uniq(country) as "countries",
+ sum(length(event_name)) as "events"
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ `;
+ } else {
+ sql = `
+ select
+ sum(views) as "pageviews",
+ uniq(session_id) as "visitors",
+ uniq(visit_id) as "visits",
+ uniq(country) as "countries",
+ sum(length(event_name)) as "events"
+ from website_event_stats_hourly website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ${filterQuery}
+ `;
+ }
+
+ return rawQuery(sql, queryParams, FUNCTION_NAME);
+}
diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts
new file mode 100644
index 0000000..df640d6
--- /dev/null
+++ b/src/queries/sql/sessions/getWebsiteSessions.ts
@@ -0,0 +1,156 @@
+import clickhouse from '@/lib/clickhouse';
+import { EVENT_COLUMNS } from '@/lib/constants';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+import type { QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getWebsiteSessions';
+
+export async function getWebsiteSessions(...args: [websiteId: string, filters: QueryFilters]) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(websiteId: string, filters: QueryFilters) {
+ const { pagedRawQuery, parseFilters } = prisma;
+ const { search } = filters;
+ const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ search: search ? `%${search}%` : undefined,
+ });
+
+ const searchQuery = search
+ ? `and (distinct_id ilike {{search}}
+ or city ilike {{search}}
+ or browser ilike {{search}}
+ or os ilike {{search}}
+ or device ilike {{search}})`
+ : '';
+
+ return pagedRawQuery(
+ `
+ select
+ session.session_id as "id",
+ session.website_id as "websiteId",
+ website_event.hostname,
+ session.browser,
+ session.os,
+ session.device,
+ session.screen,
+ session.language,
+ session.country,
+ session.region,
+ session.city,
+ min(website_event.created_at) as "firstAt",
+ max(website_event.created_at) as "lastAt",
+ count(distinct website_event.visit_id) as "visits",
+ sum(case when website_event.event_type = 1 then 1 else 0 end) as "views",
+ max(website_event.created_at) as "createdAt"
+ from website_event
+ ${cohortQuery}
+ join session on session.session_id = website_event.session_id
+ and session.website_id = website_event.website_id
+ where website_event.website_id = {{websiteId::uuid}}
+ ${dateQuery}
+ ${filterQuery}
+ ${searchQuery}
+ group by session.session_id,
+ session.website_id,
+ website_event.hostname,
+ session.browser,
+ session.os,
+ session.device,
+ session.screen,
+ session.language,
+ session.country,
+ session.region,
+ session.city
+ order by max(website_event.created_at) desc
+ `,
+ queryParams,
+ filters,
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
+ const { pagedRawQuery, parseFilters, getDateStringSQL } = clickhouse;
+ const { search } = filters;
+ const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+
+ const searchQuery = search
+ ? `and ((positionCaseInsensitive(distinct_id, {search:String}) > 0)
+ or (positionCaseInsensitive(city, {search:String}) > 0)
+ or (positionCaseInsensitive(browser, {search:String}) > 0)
+ or (positionCaseInsensitive(os, {search:String}) > 0)
+ or (positionCaseInsensitive(device, {search:String}) > 0))`
+ : '';
+
+ let sql = '';
+
+ if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) {
+ sql = `
+ select
+ session_id as id,
+ website_id as websiteId,
+ hostname,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ ${getDateStringSQL('min(created_at)')} as firstAt,
+ ${getDateStringSQL('max(created_at)')} as lastAt,
+ uniq(visit_id) as visits,
+ sumIf(1, event_type = 1) as views,
+ lastAt as createdAt
+ from website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ ${dateQuery}
+ ${filterQuery}
+ ${searchQuery}
+ group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city
+ order by lastAt desc
+ `;
+ } else {
+ sql = `
+ select
+ session_id as id,
+ website_id as websiteId,
+ arrayFirst(x -> 1, hostname) hostname,
+ browser,
+ os,
+ device,
+ screen,
+ language,
+ country,
+ region,
+ city,
+ ${getDateStringSQL('min(min_time)')} as firstAt,
+ ${getDateStringSQL('max(max_time)')} as lastAt,
+ uniq(visit_id) as visits,
+ sumIf(views, event_type = 1) as views,
+ lastAt as createdAt
+ from website_event_stats_hourly as website_event
+ ${cohortQuery}
+ where website_id = {websiteId:UUID}
+ ${dateQuery}
+ ${filterQuery}
+ ${searchQuery}
+ group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city
+ order by lastAt desc
+ `;
+ }
+
+ return pagedRawQuery(sql, queryParams, filters, FUNCTION_NAME);
+}
diff --git a/src/queries/sql/sessions/saveSessionData.ts b/src/queries/sql/sessions/saveSessionData.ts
new file mode 100644
index 0000000..7409317
--- /dev/null
+++ b/src/queries/sql/sessions/saveSessionData.ts
@@ -0,0 +1,112 @@
+import clickhouse from '@/lib/clickhouse';
+import { DATA_TYPE } from '@/lib/constants';
+import { uuid } from '@/lib/crypto';
+import { flattenJSON, getStringValue } from '@/lib/data';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import kafka from '@/lib/kafka';
+import prisma from '@/lib/prisma';
+import type { DynamicData } from '@/lib/types';
+
+export interface SaveSessionDataArgs {
+ websiteId: string;
+ sessionId: string;
+ sessionData: DynamicData;
+ distinctId?: string;
+ createdAt?: Date;
+}
+
+export async function saveSessionData(data: SaveSessionDataArgs) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(data),
+ [CLICKHOUSE]: () => clickhouseQuery(data),
+ });
+}
+
+export async function relationalQuery({
+ websiteId,
+ sessionId,
+ sessionData,
+ distinctId,
+ createdAt,
+}: SaveSessionDataArgs) {
+ const { client } = prisma;
+
+ const jsonKeys = flattenJSON(sessionData);
+
+ const flattenedData = jsonKeys.map(a => ({
+ id: uuid(),
+ websiteId,
+ sessionId,
+ dataKey: a.key,
+ stringValue: getStringValue(a.value, a.dataType),
+ numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
+ dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
+ dataType: a.dataType,
+ distinctId,
+ createdAt,
+ }));
+
+ const existing = await client.sessionData.findMany({
+ where: {
+ sessionId,
+ },
+ select: {
+ id: true,
+ sessionId: true,
+ dataKey: true,
+ },
+ });
+
+ for (const data of flattenedData) {
+ const { sessionId, dataKey, ...props } = data;
+ const record = existing.find(e => e.sessionId === sessionId && e.dataKey === dataKey);
+
+ if (record) {
+ await client.sessionData.update({
+ where: {
+ id: record.id,
+ },
+ data: {
+ ...props,
+ },
+ });
+ } else {
+ await client.sessionData.create({
+ data,
+ });
+ }
+ }
+}
+
+async function clickhouseQuery({
+ websiteId,
+ sessionId,
+ sessionData,
+ distinctId,
+ createdAt,
+}: SaveSessionDataArgs) {
+ const { insert, getUTCString } = clickhouse;
+ const { sendMessage } = kafka;
+
+ const jsonKeys = flattenJSON(sessionData);
+
+ const messages = jsonKeys.map(({ key, value, dataType }) => {
+ return {
+ website_id: websiteId,
+ session_id: sessionId,
+ data_key: key,
+ data_type: dataType,
+ string_value: getStringValue(value, dataType),
+ number_value: dataType === DATA_TYPE.number ? value : null,
+ date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
+ distinct_id: distinctId,
+ created_at: getUTCString(createdAt),
+ };
+ });
+
+ if (kafka.enabled) {
+ await sendMessage('session_data', messages);
+ } else {
+ await insert('session_data', messages);
+ }
+}
diff --git a/src/store/app.ts b/src/store/app.ts
new file mode 100644
index 0000000..bb54e56
--- /dev/null
+++ b/src/store/app.ts
@@ -0,0 +1,50 @@
+import { create } from 'zustand';
+import {
+ DATE_RANGE_CONFIG,
+ DEFAULT_DATE_RANGE_VALUE,
+ DEFAULT_LOCALE,
+ DEFAULT_THEME,
+ LOCALE_CONFIG,
+ THEME_CONFIG,
+ TIMEZONE_CONFIG,
+} from '@/lib/constants';
+import { getTimezone } from '@/lib/date';
+import { getItem } from '@/lib/storage';
+
+const initialState = {
+ locale: getItem(LOCALE_CONFIG) || process.env.defaultLocale || DEFAULT_LOCALE,
+ theme: getItem(THEME_CONFIG) || DEFAULT_THEME,
+ timezone: getItem(TIMEZONE_CONFIG) || getTimezone(),
+ dateRangeValue: getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
+ shareToken: null,
+ user: null,
+ config: null,
+};
+
+const store = create(() => ({ ...initialState }));
+
+export function setTimezone(timezone: string) {
+ store.setState({ timezone });
+}
+
+export function setLocale(locale: string) {
+ store.setState({ locale });
+}
+
+export function setShareToken(shareToken: string) {
+ store.setState({ shareToken });
+}
+
+export function setUser(user: object) {
+ store.setState({ user });
+}
+
+export function setConfig(config: object) {
+ store.setState({ config });
+}
+
+export function setDateRangeValue(dateRangeValue: string) {
+ store.setState({ dateRangeValue });
+}
+
+export const useApp = store;
diff --git a/src/store/cache.ts b/src/store/cache.ts
new file mode 100644
index 0000000..8ac9384
--- /dev/null
+++ b/src/store/cache.ts
@@ -0,0 +1,9 @@
+import { create } from 'zustand';
+
+const store = create(() => ({}));
+
+export function setValue(key: string, value: any) {
+ store.setState({ [key]: value });
+}
+
+export const useCache = store;
diff --git a/src/store/dashboard.ts b/src/store/dashboard.ts
new file mode 100644
index 0000000..93f59ed
--- /dev/null
+++ b/src/store/dashboard.ts
@@ -0,0 +1,22 @@
+import { create } from 'zustand';
+import { DASHBOARD_CONFIG, DEFAULT_WEBSITE_LIMIT } from '@/lib/constants';
+import { getItem, setItem } from '@/lib/storage';
+
+export const initialState = {
+ showCharts: true,
+ limit: DEFAULT_WEBSITE_LIMIT,
+ websiteOrder: [],
+ websiteActive: [],
+ editing: false,
+ isEdited: false,
+};
+
+const store = create(() => ({ ...initialState, ...getItem(DASHBOARD_CONFIG) }));
+
+export function saveDashboard(settings) {
+ store.setState(settings);
+
+ setItem(DASHBOARD_CONFIG, store.getState());
+}
+
+export const useDashboard = store;
diff --git a/src/store/version.ts b/src/store/version.ts
new file mode 100644
index 0000000..95367af
--- /dev/null
+++ b/src/store/version.ts
@@ -0,0 +1,55 @@
+import { produce } from 'immer';
+import semver from 'semver';
+import { create } from 'zustand';
+import { CURRENT_VERSION, UPDATES_URL, VERSION_CHECK } from '@/lib/constants';
+import { getItem } from '@/lib/storage';
+
+const initialState = {
+ current: CURRENT_VERSION,
+ latest: null,
+ hasUpdate: false,
+ checked: false,
+ releaseUrl: null,
+};
+
+const store = create(() => ({ ...initialState }));
+
+export async function checkVersion() {
+ const { current } = store.getState();
+
+ const data = await fetch(`${UPDATES_URL}?v=${current}`, {
+ method: 'GET',
+ headers: {
+ Accept: 'application/json',
+ },
+ }).then(res => {
+ if (res.ok) {
+ return res.json();
+ }
+
+ return null;
+ });
+
+ if (!data) {
+ return;
+ }
+
+ store.setState(
+ produce(state => {
+ const { latest, url } = data;
+ const lastCheck = getItem(VERSION_CHECK);
+
+ const hasUpdate = !!(latest && lastCheck?.version !== latest && semver.gt(latest, current));
+
+ state.current = current;
+ state.latest = latest;
+ state.hasUpdate = hasUpdate;
+ state.checked = true;
+ state.releaseUrl = url;
+
+ return state;
+ }),
+ );
+}
+
+export const useVersion = store;
diff --git a/src/store/websites.ts b/src/store/websites.ts
new file mode 100644
index 0000000..4ddcab0
--- /dev/null
+++ b/src/store/websites.ts
@@ -0,0 +1,35 @@
+import { produce } from 'immer';
+import { create } from 'zustand';
+import type { DateRange } from '@/lib/types';
+
+const store = create(() => ({}));
+
+export function setWebsiteDateRange(websiteId: string, dateRange: DateRange) {
+ store.setState(
+ produce(state => {
+ if (!state[websiteId]) {
+ state[websiteId] = {};
+ }
+
+ state[websiteId].dateRange = { ...dateRange, modified: Date.now() };
+
+ return state;
+ }),
+ );
+}
+
+export function setWebsiteDateCompare(websiteId: string, dateCompare: string) {
+ store.setState(
+ produce(state => {
+ if (!state[websiteId]) {
+ state[websiteId] = {};
+ }
+
+ state[websiteId].dateCompare = dateCompare;
+
+ return state;
+ }),
+ );
+}
+
+export const useWebsites = store;
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..e9fca9f
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,43 @@
+html,
+body {
+ font-family: var(--font-family), sans-serif;
+ color: var(--font-color);
+ font-size: var(--font-size);
+ background-color: var(--base-color-2);
+ width: 100%;
+ min-height: 100vh;
+}
+
+html[style*="padding-right"] {
+ padding-right: 0 !important;
+}
+
+a,
+a:active,
+a:hover {
+ color: var(--font-color);
+ text-decoration: none;
+}
+
+::-webkit-scrollbar {
+ width: 15px;
+ background: transparent;
+}
+
+::-webkit-scrollbar-track {
+ border: 7px solid rgba(0, 0, 0, 0);
+ background-color: var(--base-color-4);
+ background-clip: padding-box;
+}
+
+::-webkit-scrollbar-thumb {
+ border: 7px solid rgba(0, 0, 0, 0);
+ background-color: var(--base-color-9);
+ border-radius: var(--border-radius-full);
+ background-clip: padding-box;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ border: 4px solid rgba(0, 0, 0, 0);
+ background-clip: padding-box;
+}
diff --git a/src/styles/variables.css b/src/styles/variables.css
new file mode 100644
index 0000000..f7ebb02
--- /dev/null
+++ b/src/styles/variables.css
@@ -0,0 +1,4 @@
+html body {
+ --primary-color: #147af3;
+ --primary-font-color: var(--light-color);
+}
diff --git a/src/tracker/index.d.ts b/src/tracker/index.d.ts
new file mode 100644
index 0000000..32fbee9
--- /dev/null
+++ b/src/tracker/index.d.ts
@@ -0,0 +1,153 @@
+export type TrackedProperties = {
+ /**
+ * Hostname of server
+ *
+ * @description extracted from `window.location.hostname`
+ * @example 'analytics.umami.is'
+ */
+ hostname: string;
+
+ /**
+ * Browser language
+ *
+ * @description extracted from `window.navigator.language`
+ * @example 'en-US', 'fr-FR'
+ */
+ language: string;
+
+ /**
+ * Page referrer
+ *
+ * @description extracted from `window.navigator.language`
+ * @example 'https://analytics.umami.is/docs/getting-started'
+ */
+ referrer: string;
+
+ /**
+ * Screen dimensions
+ *
+ * @description extracted from `window.screen.width` and `window.screen.height`
+ * @example '1920x1080', '2560x1440'
+ */
+ screen: string;
+
+ /**
+ * Page title
+ *
+ * @description extracted from `document.querySelector('head > title')`
+ * @example 'umami'
+ */
+ title: string;
+
+ /**
+ * Page url
+ *
+ * @description built from `${window.location.pathname}${window.location.search}`
+ * @example 'docs/getting-started'
+ */
+ url: string;
+
+ /**
+ * Website ID (required)
+ *
+ * @example 'b59e9c65-ae32-47f1-8400-119fcf4861c4'
+ */
+ website: string;
+};
+
+export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
+
+/**
+ *
+ * Event Data can work with any JSON data. There are a few rules in place to maintain performance.
+ * - Numbers have a max precision of 4.
+ * - Strings have a max length of 500.
+ * - Arrays are converted to a String, with the same max length of 500.
+ * - Objects have a max of 50 properties. Arrays are considered 1 property.
+ */
+export interface EventData {
+ [key: string]: number | string | EventData | number[] | string[] | EventData[];
+}
+
+export type EventProperties = {
+ /**
+ * NOTE: event names will be truncated past 50 characters
+ */
+ name: string;
+ data?: EventData;
+} & WithRequired<TrackedProperties, 'website'>;
+export type PageViewProperties = WithRequired<TrackedProperties, 'website'>;
+export type CustomEventFunction = (
+ props: PageViewProperties,
+) => EventProperties | PageViewProperties;
+
+export type UmamiTracker = {
+ track: {
+ /**
+ * Track a page view
+ *
+ * @example ```
+ * umami.track();
+ * ```
+ */
+ (): Promise<string>;
+
+ /**
+ * Track an event with a given name
+ *
+ * NOTE: event names will be truncated past 50 characters
+ *
+ * @example ```
+ * umami.track('signup-button');
+ * ```
+ */
+ (eventName: string): Promise<string>;
+
+ /**
+ * Tracks an event with dynamic data.
+ *
+ * NOTE: event names will be truncated past 50 characters
+ *
+ * When tracking events, the default properties are included in the payload. This is equivalent to running:
+ *
+ * ```js
+ * umami.track(props => ({
+ * ...props,
+ * name: 'signup-button',
+ * data: {
+ * name: 'newsletter',
+ * id: 123
+ * }
+ * }));
+ * ```
+ *
+ * @example ```
+ * umami.track('signup-button', { name: 'newsletter', id: 123 });
+ * ```
+ */
+ (eventName: string, obj: EventData): Promise<string>;
+
+ /**
+ * Tracks a page view with custom properties
+ *
+ * @example ```
+ * umami.track({ website: 'e676c9b4-11e4-4ef1-a4d7-87001773e9f2', url: '/home', title: 'Home page' });
+ * ```
+ */
+ (properties: PageViewProperties): Promise<string>;
+
+ /**
+ * Tracks an event with fully customizable dynamic data
+ * If you don't specify any `name` and/or `data`, it will be treated as a page view
+ *
+ * @example ```
+ * umami.track((props) => ({ ...props, url: path }));
+ * ```
+ */
+ (eventFunction: CustomEventFunction): Promise<string>;
+ };
+};
+
+export interface Window {
+ umami: UmamiTracker;
+}
diff --git a/src/tracker/index.js b/src/tracker/index.js
new file mode 100644
index 0000000..ad3648a
--- /dev/null
+++ b/src/tracker/index.js
@@ -0,0 +1,240 @@
+(window => {
+ const {
+ screen: { width, height },
+ navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt },
+ location,
+ document,
+ history,
+ top,
+ doNotTrack,
+ } = window;
+ const { currentScript, referrer } = document;
+ if (!currentScript) return;
+
+ const { hostname, href, origin } = location;
+ const localStorage = href.startsWith('data:') ? undefined : window.localStorage;
+
+ const _data = 'data-';
+ const _false = 'false';
+ const _true = 'true';
+ const attr = currentScript.getAttribute.bind(currentScript);
+
+ const website = attr(`${_data}website-id`);
+ const hostUrl = attr(`${_data}host-url`);
+ const beforeSend = attr(`${_data}before-send`);
+ const tag = attr(`${_data}tag`) || undefined;
+ const autoTrack = attr(`${_data}auto-track`) !== _false;
+ const dnt = attr(`${_data}do-not-track`) === _true;
+ const excludeSearch = attr(`${_data}exclude-search`) === _true;
+ const excludeHash = attr(`${_data}exclude-hash`) === _true;
+ const domain = attr(`${_data}domains`) || '';
+ const credentials = attr(`${_data}fetch-credentials`) || 'omit';
+
+ const domains = domain.split(',').map(n => n.trim());
+ const host =
+ hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/');
+ const endpoint = `${host.replace(/\/$/, '')}__COLLECT_API_ENDPOINT__`;
+ const screen = `${width}x${height}`;
+ const eventRegex = /data-umami-event-([\w-_]+)/;
+ const eventNameAttribute = `${_data}umami-event`;
+ const delayDuration = 300;
+
+ /* Helper functions */
+
+ const normalize = raw => {
+ if (!raw) return raw;
+ try {
+ const u = new URL(raw, location.href);
+ if (excludeSearch) u.search = '';
+ if (excludeHash) u.hash = '';
+ return u.toString();
+ } catch {
+ return raw;
+ }
+ };
+
+ const getPayload = () => ({
+ website,
+ screen,
+ language,
+ title: document.title,
+ hostname,
+ url: currentUrl,
+ referrer: currentRef,
+ tag,
+ id: identity ? identity : undefined,
+ });
+
+ const hasDoNotTrack = () => {
+ const dnt = doNotTrack || ndnt || msdnt;
+ return dnt === 1 || dnt === '1' || dnt === 'yes';
+ };
+
+ /* Event handlers */
+
+ const handlePush = (_state, _title, url) => {
+ if (!url) return;
+
+ currentRef = currentUrl;
+ currentUrl = normalize(new URL(url, location.href).toString());
+
+ if (currentUrl !== currentRef) {
+ setTimeout(track, delayDuration);
+ }
+ };
+
+ const handlePathChanges = () => {
+ const hook = (_this, method, callback) => {
+ const orig = _this[method];
+ return (...args) => {
+ callback.apply(null, args);
+ return orig.apply(_this, args);
+ };
+ };
+
+ history.pushState = hook(history, 'pushState', handlePush);
+ history.replaceState = hook(history, 'replaceState', handlePush);
+ };
+
+ const handleClicks = () => {
+ const trackElement = async el => {
+ const eventName = el.getAttribute(eventNameAttribute);
+ if (eventName) {
+ const eventData = {};
+
+ el.getAttributeNames().forEach(name => {
+ const match = name.match(eventRegex);
+ if (match) eventData[match[1]] = el.getAttribute(name);
+ });
+
+ return track(eventName, eventData);
+ }
+ };
+ const onClick = async e => {
+ const el = e.target;
+ const parentElement = el.closest('a,button');
+ if (!parentElement) return trackElement(el);
+
+ const { href, target } = parentElement;
+ if (!parentElement.getAttribute(eventNameAttribute)) return;
+
+ if (parentElement.tagName === 'BUTTON') {
+ return trackElement(parentElement);
+ }
+ if (parentElement.tagName === 'A' && href) {
+ const external =
+ target === '_blank' ||
+ e.ctrlKey ||
+ e.shiftKey ||
+ e.metaKey ||
+ (e.button && e.button === 1);
+ if (!external) e.preventDefault();
+ return trackElement(parentElement).then(() => {
+ if (!external) {
+ (target === '_top' ? top.location : location).href = href;
+ }
+ });
+ }
+ };
+ document.addEventListener('click', onClick, true);
+ };
+
+ /* Tracking functions */
+
+ const trackingDisabled = () =>
+ disabled ||
+ !website ||
+ localStorage?.getItem('umami.disabled') ||
+ (domain && !domains.includes(hostname)) ||
+ (dnt && hasDoNotTrack());
+
+ const send = async (payload, type = 'event') => {
+ if (trackingDisabled()) return;
+
+ const callback = window[beforeSend];
+
+ if (typeof callback === 'function') {
+ payload = await Promise.resolve(callback(type, payload));
+ }
+
+ if (!payload) return;
+
+ try {
+ const res = await fetch(endpoint, {
+ keepalive: true,
+ method: 'POST',
+ body: JSON.stringify({ type, payload }),
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }),
+ },
+ credentials,
+ });
+
+ const data = await res.json();
+ if (data) {
+ disabled = !!data.disabled;
+ cache = data.cache;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ } catch (_e) {
+ /* no-op */
+ }
+ };
+
+ const init = () => {
+ if (!initialized) {
+ initialized = true;
+ track();
+ handlePathChanges();
+ handleClicks();
+ }
+ };
+
+ const track = (name, data) => {
+ if (typeof name === 'string') return send({ ...getPayload(), name, data });
+ if (typeof name === 'object') return send({ ...name });
+ if (typeof name === 'function') return send(name(getPayload()));
+ return send(getPayload());
+ };
+
+ const identify = (id, data) => {
+ if (typeof id === 'string') {
+ identity = id;
+ }
+
+ cache = '';
+ return send(
+ {
+ ...getPayload(),
+ data: typeof id === 'object' ? id : data,
+ },
+ 'identify',
+ );
+ };
+
+ /* Start */
+
+ if (!window.umami) {
+ window.umami = {
+ track,
+ identify,
+ };
+ }
+
+ let currentUrl = normalize(href);
+ let currentRef = normalize(referrer.startsWith(origin) ? '' : referrer);
+
+ let initialized = false;
+ let disabled = false;
+ let cache;
+ let identity;
+
+ if (autoTrack && !trackingDisabled()) {
+ if (document.readyState === 'complete') {
+ init();
+ } else {
+ document.addEventListener('readystatechange', init, true);
+ }
+ }
+})(window);