aboutsummaryrefslogtreecommitdiff
path: root/src/app
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/app
downloadumami-main.tar.xz
umami-main.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/app')
-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
325 files changed, 14625 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>
+ );
+}