From 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b Mon Sep 17 00:00:00 2001
From: Fuwn <50817549+Fuwn@users.noreply.github.com>
Date: Sat, 24 Jan 2026 13:09:50 +0000
Subject: Initial commit
Created from https://vercel.com/new
---
src/app/(main)/App.tsx | 62 +++++
src/app/(main)/MobileNav.tsx | 71 +++++
src/app/(main)/SideNav.tsx | 87 ++++++
src/app/(main)/TopNav.tsx | 26 ++
src/app/(main)/UpdateNotice.tsx | 61 +++++
src/app/(main)/admin/AdminLayout.tsx | 33 +++
src/app/(main)/admin/AdminNav.tsx | 48 ++++
src/app/(main)/admin/layout.tsx | 17 ++
src/app/(main)/admin/teams/AdminTeamsDataTable.tsx | 19 ++
src/app/(main)/admin/teams/AdminTeamsPage.tsx | 19 ++
src/app/(main)/admin/teams/AdminTeamsTable.tsx | 86 ++++++
.../(main)/admin/teams/[teamId]/AdminTeamPage.tsx | 11 +
src/app/(main)/admin/teams/[teamId]/page.tsx | 12 +
src/app/(main)/admin/teams/page.tsx | 9 +
src/app/(main)/admin/users/UserAddButton.tsx | 32 +++
src/app/(main)/admin/users/UserAddForm.tsx | 71 +++++
src/app/(main)/admin/users/UserDeleteButton.tsx | 35 +++
src/app/(main)/admin/users/UserDeleteForm.tsx | 41 +++
src/app/(main)/admin/users/UsersDataTable.tsx | 14 +
src/app/(main)/admin/users/UsersPage.tsx | 24 ++
src/app/(main)/admin/users/UsersTable.tsx | 84 ++++++
.../(main)/admin/users/[userId]/UserEditForm.tsx | 73 +++++
src/app/(main)/admin/users/[userId]/UserHeader.tsx | 9 +
src/app/(main)/admin/users/[userId]/UserPage.tsx | 19 ++
.../(main)/admin/users/[userId]/UserProvider.tsx | 20 ++
.../(main)/admin/users/[userId]/UserSettings.tsx | 25 ++
.../(main)/admin/users/[userId]/UserWebsites.tsx | 15 ++
src/app/(main)/admin/users/[userId]/page.tsx | 12 +
src/app/(main)/admin/users/page.tsx | 9 +
.../admin/websites/AdminWebsitesDataTable.tsx | 13 +
.../(main)/admin/websites/AdminWebsitesPage.tsx | 19 ++
.../(main)/admin/websites/AdminWebsitesTable.tsx | 89 +++++++
.../websites/[websiteId]/AdminWebsitePage.tsx | 14 +
src/app/(main)/admin/websites/[websiteId]/page.tsx | 12 +
src/app/(main)/admin/websites/page.tsx | 9 +
src/app/(main)/boards/BoardAddButton.tsx | 32 +++
src/app/(main)/boards/BoardAddForm.tsx | 60 +++++
src/app/(main)/boards/BoardsPage.tsx | 17 ++
src/app/(main)/boards/[boardId]/Board.tsx | 10 +
src/app/(main)/boards/[boardId]/page.tsx | 12 +
src/app/(main)/boards/page.tsx | 10 +
.../(main)/console/[websiteId]/TestConsolePage.tsx | 207 +++++++++++++++
src/app/(main)/console/[websiteId]/page.tsx | 22 ++
src/app/(main)/dashboard/DashboardPage.tsx | 17 ++
src/app/(main)/dashboard/page.tsx | 10 +
src/app/(main)/layout.tsx | 18 ++
src/app/(main)/links/LinkAddButton.tsx | 19 ++
src/app/(main)/links/LinkDeleteButton.tsx | 57 ++++
src/app/(main)/links/LinkEditButton.tsx | 16 ++
src/app/(main)/links/LinkEditForm.tsx | 148 +++++++++++
src/app/(main)/links/LinkProvider.tsx | 21 ++
src/app/(main)/links/LinksDataTable.tsx | 14 +
src/app/(main)/links/LinksPage.tsx | 26 ++
src/app/(main)/links/LinksTable.tsx | 51 ++++
src/app/(main)/links/[linkId]/LinkControls.tsx | 32 +++
src/app/(main)/links/[linkId]/LinkHeader.tsx | 19 ++
src/app/(main)/links/[linkId]/LinkMetricsBar.tsx | 70 +++++
src/app/(main)/links/[linkId]/LinkPage.tsx | 34 +++
src/app/(main)/links/[linkId]/LinkPanels.tsx | 83 ++++++
src/app/(main)/links/[linkId]/page.tsx | 12 +
src/app/(main)/links/page.tsx | 10 +
src/app/(main)/pixels/PixelAddButton.tsx | 19 ++
src/app/(main)/pixels/PixelDeleteButton.tsx | 57 ++++
src/app/(main)/pixels/PixelEditButton.tsx | 21 ++
src/app/(main)/pixels/PixelEditForm.tsx | 129 +++++++++
src/app/(main)/pixels/PixelProvider.tsx | 21 ++
src/app/(main)/pixels/PixelsDataTable.tsx | 14 +
src/app/(main)/pixels/PixelsPage.tsx | 26 ++
src/app/(main)/pixels/PixelsTable.tsx | 48 ++++
src/app/(main)/pixels/[pixelId]/PixelControls.tsx | 32 +++
src/app/(main)/pixels/[pixelId]/PixelHeader.tsx | 19 ++
.../(main)/pixels/[pixelId]/PixelMetricsBar.tsx | 70 +++++
src/app/(main)/pixels/[pixelId]/PixelPage.tsx | 34 +++
src/app/(main)/pixels/[pixelId]/PixelPanels.tsx | 83 ++++++
src/app/(main)/pixels/[pixelId]/page.tsx | 12 +
src/app/(main)/pixels/page.tsx | 10 +
src/app/(main)/settings/SettingsLayout.tsx | 26 ++
src/app/(main)/settings/SettingsNav.tsx | 53 ++++
src/app/(main)/settings/layout.tsx | 17 ++
.../settings/preferences/DateRangeSetting.tsx | 28 ++
.../settings/preferences/LanguageSetting.tsx | 48 ++++
.../settings/preferences/PreferenceSettings.tsx | 36 +++
.../settings/preferences/PreferencesPage.tsx | 22 ++
.../(main)/settings/preferences/ThemeSetting.tsx | 21 ++
.../settings/preferences/TimezoneSetting.tsx | 44 +++
src/app/(main)/settings/preferences/page.tsx | 10 +
.../settings/profile/PasswordChangeButton.tsx | 29 ++
.../(main)/settings/profile/PasswordEditForm.tsx | 67 +++++
src/app/(main)/settings/profile/ProfileHeader.tsx | 8 +
src/app/(main)/settings/profile/ProfilePage.tsx | 22 ++
.../(main)/settings/profile/ProfileSettings.tsx | 51 ++++
src/app/(main)/settings/profile/page.tsx | 10 +
.../(main)/settings/teams/TeamsSettingsPage.tsx | 16 ++
.../settings/teams/[teamId]/TeamSettingsPage.tsx | 11 +
src/app/(main)/settings/teams/[teamId]/page.tsx | 12 +
src/app/(main)/settings/teams/page.tsx | 10 +
.../settings/websites/WebsitesSettingsPage.tsx | 16 ++
.../websites/[websiteId]/WebsiteSettingsPage.tsx | 16 ++
.../(main)/settings/websites/[websiteId]/page.tsx | 12 +
src/app/(main)/settings/websites/page.tsx | 12 +
src/app/(main)/teams/TeamAddForm.tsx | 39 +++
src/app/(main)/teams/TeamJoinForm.tsx | 40 +++
src/app/(main)/teams/TeamLeaveButton.tsx | 41 +++
src/app/(main)/teams/TeamLeaveForm.tsx | 48 ++++
src/app/(main)/teams/TeamProvider.tsx | 21 ++
src/app/(main)/teams/TeamsAddButton.tsx | 33 +++
src/app/(main)/teams/TeamsDataTable.tsx | 27 ++
src/app/(main)/teams/TeamsHeader.tsx | 26 ++
src/app/(main)/teams/TeamsJoinButton.tsx | 31 +++
src/app/(main)/teams/TeamsPage.tsx | 19 ++
src/app/(main)/teams/TeamsTable.tsx | 29 ++
src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx | 40 +++
src/app/(main)/teams/[teamId]/TeamEditForm.tsx | 89 +++++++
src/app/(main)/teams/[teamId]/TeamManage.tsx | 32 +++
.../(main)/teams/[teamId]/TeamMemberEditButton.tsx | 46 ++++
.../(main)/teams/[teamId]/TeamMemberEditForm.tsx | 62 +++++
.../teams/[teamId]/TeamMemberRemoveButton.tsx | 60 +++++
.../(main)/teams/[teamId]/TeamMembersDataTable.tsx | 19 ++
src/app/(main)/teams/[teamId]/TeamMembersTable.tsx | 55 ++++
src/app/(main)/teams/[teamId]/TeamSettings.tsx | 49 ++++
.../teams/[teamId]/TeamWebsiteRemoveButton.tsx | 25 ++
.../teams/[teamId]/TeamWebsitesDataTable.tsx | 19 ++
.../(main)/teams/[teamId]/TeamWebsitesTable.tsx | 50 ++++
src/app/(main)/teams/page.tsx | 10 +
src/app/(main)/websites/WebsiteAddButton.tsx | 28 ++
src/app/(main)/websites/WebsiteAddForm.tsx | 60 +++++
src/app/(main)/websites/WebsiteProvider.tsx | 27 ++
src/app/(main)/websites/WebsitesDataTable.tsx | 47 ++++
src/app/(main)/websites/WebsitesHeader.tsx | 18 ++
src/app/(main)/websites/WebsitesPage.tsx | 26 ++
src/app/(main)/websites/WebsitesTable.tsx | 41 +++
.../(reports)/attribution/Attribution.tsx | 128 +++++++++
.../(reports)/attribution/AttributionPage.tsx | 63 +++++
.../[websiteId]/(reports)/attribution/page.tsx | 12 +
.../[websiteId]/(reports)/breakdown/Breakdown.tsx | 91 +++++++
.../(reports)/breakdown/BreakdownPage.tsx | 51 ++++
.../(reports)/breakdown/FieldSelectForm.tsx | 46 ++++
.../[websiteId]/(reports)/breakdown/page.tsx | 12 +
.../[websiteId]/(reports)/funnels/Funnel.tsx | 134 ++++++++++
.../(reports)/funnels/FunnelAddButton.tsx | 28 ++
.../(reports)/funnels/FunnelEditForm.tsx | 141 ++++++++++
.../[websiteId]/(reports)/funnels/FunnelsPage.tsx | 36 +++
.../[websiteId]/(reports)/funnels/page.tsx | 12 +
.../websites/[websiteId]/(reports)/goals/Goal.tsx | 99 +++++++
.../[websiteId]/(reports)/goals/GoalAddButton.tsx | 28 ++
.../[websiteId]/(reports)/goals/GoalEditForm.tsx | 104 ++++++++
.../[websiteId]/(reports)/goals/GoalsPage.tsx | 36 +++
.../websites/[websiteId]/(reports)/goals/page.tsx | 12 +
.../(reports)/journeys/Journey.module.css | 267 +++++++++++++++++++
.../[websiteId]/(reports)/journeys/Journey.tsx | 294 +++++++++++++++++++++
.../(reports)/journeys/JourneysPage.tsx | 67 +++++
.../[websiteId]/(reports)/journeys/page.tsx | 12 +
.../[websiteId]/(reports)/retention/Retention.tsx | 140 ++++++++++
.../(reports)/retention/RetentionPage.tsx | 22 ++
.../[websiteId]/(reports)/retention/page.tsx | 12 +
.../[websiteId]/(reports)/revenue/Revenue.tsx | 152 +++++++++++
.../[websiteId]/(reports)/revenue/RevenuePage.tsx | 18 ++
.../[websiteId]/(reports)/revenue/RevenueTable.tsx | 21 ++
.../[websiteId]/(reports)/revenue/page.tsx | 12 +
.../websites/[websiteId]/(reports)/utm/UTM.tsx | 71 +++++
.../websites/[websiteId]/(reports)/utm/UTMPage.tsx | 18 ++
.../websites/[websiteId]/(reports)/utm/page.tsx | 12 +
.../websites/[websiteId]/ExpandedViewModal.tsx | 52 ++++
.../(main)/websites/[websiteId]/WebsiteChart.tsx | 61 +++++
.../websites/[websiteId]/WebsiteControls.tsx | 40 +++
.../websites/[websiteId]/WebsiteExpandedMenu.tsx | 183 +++++++++++++
.../websites/[websiteId]/WebsiteExpandedView.tsx | 57 ++++
.../(main)/websites/[websiteId]/WebsiteHeader.tsx | 57 ++++
.../(main)/websites/[websiteId]/WebsiteLayout.tsx | 30 +++
.../(main)/websites/[websiteId]/WebsiteMenu.tsx | 56 ++++
.../websites/[websiteId]/WebsiteMetricsBar.tsx | 88 ++++++
src/app/(main)/websites/[websiteId]/WebsiteNav.tsx | 180 +++++++++++++
.../(main)/websites/[websiteId]/WebsitePage.tsx | 22 ++
.../(main)/websites/[websiteId]/WebsitePanels.tsx | 140 ++++++++++
.../(main)/websites/[websiteId]/WebsiteTabs.tsx | 64 +++++
.../[websiteId]/cohorts/CohortAddButton.tsx | 21 ++
.../[websiteId]/cohorts/CohortDeleteButton.tsx | 60 +++++
.../[websiteId]/cohorts/CohortEditButton.tsx | 37 +++
.../[websiteId]/cohorts/CohortEditForm.tsx | 135 ++++++++++
.../[websiteId]/cohorts/CohortsDataTable.tsx | 24 ++
.../websites/[websiteId]/cohorts/CohortsPage.tsx | 16 ++
.../websites/[websiteId]/cohorts/CohortsTable.tsx | 41 +++
.../(main)/websites/[websiteId]/cohorts/page.tsx | 12 +
.../websites/[websiteId]/compare/ComparePage.tsx | 20 ++
.../websites/[websiteId]/compare/CompareTables.tsx | 171 ++++++++++++
.../(main)/websites/[websiteId]/compare/page.tsx | 12 +
.../[websiteId]/events/EventProperties.tsx | 127 +++++++++
.../[websiteId]/events/EventsDataTable.tsx | 48 ++++
.../[websiteId]/events/EventsMetricsBar.tsx | 40 +++
.../websites/[websiteId]/events/EventsPage.tsx | 59 +++++
.../websites/[websiteId]/events/EventsTable.tsx | 107 ++++++++
.../(main)/websites/[websiteId]/events/page.tsx | 12 +
src/app/(main)/websites/[websiteId]/layout.tsx | 21 ++
src/app/(main)/websites/[websiteId]/page.tsx | 12 +
.../[websiteId]/realtime/RealtimeCountries.tsx | 31 +++
.../[websiteId]/realtime/RealtimeHeader.tsx | 17 ++
.../websites/[websiteId]/realtime/RealtimeLog.tsx | 206 +++++++++++++++
.../websites/[websiteId]/realtime/RealtimePage.tsx | 58 ++++
.../[websiteId]/realtime/RealtimePaths.tsx | 45 ++++
.../[websiteId]/realtime/RealtimeReferrers.tsx | 45 ++++
.../(main)/websites/[websiteId]/realtime/page.tsx | 12 +
.../[websiteId]/segments/SegmentAddButton.tsx | 21 ++
.../[websiteId]/segments/SegmentDeleteButton.tsx | 60 +++++
.../[websiteId]/segments/SegmentEditButton.tsx | 37 +++
.../[websiteId]/segments/SegmentEditForm.tsx | 86 ++++++
.../[websiteId]/segments/SegmentsDataTable.tsx | 24 ++
.../websites/[websiteId]/segments/SegmentsPage.tsx | 16 ++
.../[websiteId]/segments/SegmentsTable.tsx | 38 +++
.../(main)/websites/[websiteId]/segments/page.tsx | 12 +
.../[websiteId]/sessions/SessionActivity.tsx | 94 +++++++
.../websites/[websiteId]/sessions/SessionData.tsx | 32 +++
.../websites/[websiteId]/sessions/SessionInfo.tsx | 85 ++++++
.../websites/[websiteId]/sessions/SessionModal.tsx | 41 +++
.../[websiteId]/sessions/SessionProfile.tsx | 84 ++++++
.../[websiteId]/sessions/SessionProperties.tsx | 97 +++++++
.../websites/[websiteId]/sessions/SessionStats.tsx | 21 ++
.../[websiteId]/sessions/SessionsDataTable.tsx | 15 ++
.../[websiteId]/sessions/SessionsMetricsBar.tsx | 40 +++
.../websites/[websiteId]/sessions/SessionsPage.tsx | 43 +++
.../[websiteId]/sessions/SessionsTable.tsx | 58 ++++
.../(main)/websites/[websiteId]/sessions/page.tsx | 12 +
.../websites/[websiteId]/settings/SettingsPage.tsx | 6 +
.../websites/[websiteId]/settings/WebsiteData.tsx | 104 ++++++++
.../[websiteId]/settings/WebsiteDeleteForm.tsx | 40 +++
.../[websiteId]/settings/WebsiteEditForm.tsx | 55 ++++
.../[websiteId]/settings/WebsiteResetForm.tsx | 37 +++
.../[websiteId]/settings/WebsiteSettings.tsx | 28 ++
.../[websiteId]/settings/WebsiteSettingsHeader.tsx | 22 ++
.../[websiteId]/settings/WebsiteShareForm.tsx | 93 +++++++
.../[websiteId]/settings/WebsiteTrackingCode.tsx | 40 +++
.../[websiteId]/settings/WebsiteTransferForm.tsx | 102 +++++++
.../(main)/websites/[websiteId]/settings/page.tsx | 12 +
src/app/(main)/websites/page.tsx | 10 +
233 files changed, 10611 insertions(+)
create mode 100644 src/app/(main)/App.tsx
create mode 100644 src/app/(main)/MobileNav.tsx
create mode 100644 src/app/(main)/SideNav.tsx
create mode 100644 src/app/(main)/TopNav.tsx
create mode 100644 src/app/(main)/UpdateNotice.tsx
create mode 100644 src/app/(main)/admin/AdminLayout.tsx
create mode 100644 src/app/(main)/admin/AdminNav.tsx
create mode 100644 src/app/(main)/admin/layout.tsx
create mode 100644 src/app/(main)/admin/teams/AdminTeamsDataTable.tsx
create mode 100644 src/app/(main)/admin/teams/AdminTeamsPage.tsx
create mode 100644 src/app/(main)/admin/teams/AdminTeamsTable.tsx
create mode 100644 src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx
create mode 100644 src/app/(main)/admin/teams/[teamId]/page.tsx
create mode 100644 src/app/(main)/admin/teams/page.tsx
create mode 100644 src/app/(main)/admin/users/UserAddButton.tsx
create mode 100644 src/app/(main)/admin/users/UserAddForm.tsx
create mode 100644 src/app/(main)/admin/users/UserDeleteButton.tsx
create mode 100644 src/app/(main)/admin/users/UserDeleteForm.tsx
create mode 100644 src/app/(main)/admin/users/UsersDataTable.tsx
create mode 100644 src/app/(main)/admin/users/UsersPage.tsx
create mode 100644 src/app/(main)/admin/users/UsersTable.tsx
create mode 100644 src/app/(main)/admin/users/[userId]/UserEditForm.tsx
create mode 100644 src/app/(main)/admin/users/[userId]/UserHeader.tsx
create mode 100644 src/app/(main)/admin/users/[userId]/UserPage.tsx
create mode 100644 src/app/(main)/admin/users/[userId]/UserProvider.tsx
create mode 100644 src/app/(main)/admin/users/[userId]/UserSettings.tsx
create mode 100644 src/app/(main)/admin/users/[userId]/UserWebsites.tsx
create mode 100644 src/app/(main)/admin/users/[userId]/page.tsx
create mode 100644 src/app/(main)/admin/users/page.tsx
create mode 100644 src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx
create mode 100644 src/app/(main)/admin/websites/AdminWebsitesPage.tsx
create mode 100644 src/app/(main)/admin/websites/AdminWebsitesTable.tsx
create mode 100644 src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx
create mode 100644 src/app/(main)/admin/websites/[websiteId]/page.tsx
create mode 100644 src/app/(main)/admin/websites/page.tsx
create mode 100644 src/app/(main)/boards/BoardAddButton.tsx
create mode 100644 src/app/(main)/boards/BoardAddForm.tsx
create mode 100644 src/app/(main)/boards/BoardsPage.tsx
create mode 100644 src/app/(main)/boards/[boardId]/Board.tsx
create mode 100644 src/app/(main)/boards/[boardId]/page.tsx
create mode 100644 src/app/(main)/boards/page.tsx
create mode 100644 src/app/(main)/console/[websiteId]/TestConsolePage.tsx
create mode 100644 src/app/(main)/console/[websiteId]/page.tsx
create mode 100644 src/app/(main)/dashboard/DashboardPage.tsx
create mode 100644 src/app/(main)/dashboard/page.tsx
create mode 100644 src/app/(main)/layout.tsx
create mode 100644 src/app/(main)/links/LinkAddButton.tsx
create mode 100644 src/app/(main)/links/LinkDeleteButton.tsx
create mode 100644 src/app/(main)/links/LinkEditButton.tsx
create mode 100644 src/app/(main)/links/LinkEditForm.tsx
create mode 100644 src/app/(main)/links/LinkProvider.tsx
create mode 100644 src/app/(main)/links/LinksDataTable.tsx
create mode 100644 src/app/(main)/links/LinksPage.tsx
create mode 100644 src/app/(main)/links/LinksTable.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkControls.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkHeader.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkPage.tsx
create mode 100644 src/app/(main)/links/[linkId]/LinkPanels.tsx
create mode 100644 src/app/(main)/links/[linkId]/page.tsx
create mode 100644 src/app/(main)/links/page.tsx
create mode 100644 src/app/(main)/pixels/PixelAddButton.tsx
create mode 100644 src/app/(main)/pixels/PixelDeleteButton.tsx
create mode 100644 src/app/(main)/pixels/PixelEditButton.tsx
create mode 100644 src/app/(main)/pixels/PixelEditForm.tsx
create mode 100644 src/app/(main)/pixels/PixelProvider.tsx
create mode 100644 src/app/(main)/pixels/PixelsDataTable.tsx
create mode 100644 src/app/(main)/pixels/PixelsPage.tsx
create mode 100644 src/app/(main)/pixels/PixelsTable.tsx
create mode 100644 src/app/(main)/pixels/[pixelId]/PixelControls.tsx
create mode 100644 src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
create mode 100644 src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
create mode 100644 src/app/(main)/pixels/[pixelId]/PixelPage.tsx
create mode 100644 src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
create mode 100644 src/app/(main)/pixels/[pixelId]/page.tsx
create mode 100644 src/app/(main)/pixels/page.tsx
create mode 100644 src/app/(main)/settings/SettingsLayout.tsx
create mode 100644 src/app/(main)/settings/SettingsNav.tsx
create mode 100644 src/app/(main)/settings/layout.tsx
create mode 100644 src/app/(main)/settings/preferences/DateRangeSetting.tsx
create mode 100644 src/app/(main)/settings/preferences/LanguageSetting.tsx
create mode 100644 src/app/(main)/settings/preferences/PreferenceSettings.tsx
create mode 100644 src/app/(main)/settings/preferences/PreferencesPage.tsx
create mode 100644 src/app/(main)/settings/preferences/ThemeSetting.tsx
create mode 100644 src/app/(main)/settings/preferences/TimezoneSetting.tsx
create mode 100644 src/app/(main)/settings/preferences/page.tsx
create mode 100644 src/app/(main)/settings/profile/PasswordChangeButton.tsx
create mode 100644 src/app/(main)/settings/profile/PasswordEditForm.tsx
create mode 100644 src/app/(main)/settings/profile/ProfileHeader.tsx
create mode 100644 src/app/(main)/settings/profile/ProfilePage.tsx
create mode 100644 src/app/(main)/settings/profile/ProfileSettings.tsx
create mode 100644 src/app/(main)/settings/profile/page.tsx
create mode 100644 src/app/(main)/settings/teams/TeamsSettingsPage.tsx
create mode 100644 src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx
create mode 100644 src/app/(main)/settings/teams/[teamId]/page.tsx
create mode 100644 src/app/(main)/settings/teams/page.tsx
create mode 100644 src/app/(main)/settings/websites/WebsitesSettingsPage.tsx
create mode 100644 src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx
create mode 100644 src/app/(main)/settings/websites/[websiteId]/page.tsx
create mode 100644 src/app/(main)/settings/websites/page.tsx
create mode 100644 src/app/(main)/teams/TeamAddForm.tsx
create mode 100644 src/app/(main)/teams/TeamJoinForm.tsx
create mode 100644 src/app/(main)/teams/TeamLeaveButton.tsx
create mode 100644 src/app/(main)/teams/TeamLeaveForm.tsx
create mode 100644 src/app/(main)/teams/TeamProvider.tsx
create mode 100644 src/app/(main)/teams/TeamsAddButton.tsx
create mode 100644 src/app/(main)/teams/TeamsDataTable.tsx
create mode 100644 src/app/(main)/teams/TeamsHeader.tsx
create mode 100644 src/app/(main)/teams/TeamsJoinButton.tsx
create mode 100644 src/app/(main)/teams/TeamsPage.tsx
create mode 100644 src/app/(main)/teams/TeamsTable.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamEditForm.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamManage.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamMembersTable.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamSettings.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx
create mode 100644 src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx
create mode 100644 src/app/(main)/teams/page.tsx
create mode 100644 src/app/(main)/websites/WebsiteAddButton.tsx
create mode 100644 src/app/(main)/websites/WebsiteAddForm.tsx
create mode 100644 src/app/(main)/websites/WebsiteProvider.tsx
create mode 100644 src/app/(main)/websites/WebsitesDataTable.tsx
create mode 100644 src/app/(main)/websites/WebsitesHeader.tsx
create mode 100644 src/app/(main)/websites/WebsitesPage.tsx
create mode 100644 src/app/(main)/websites/WebsitesTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteChart.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteNav.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsitePage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/compare/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/events/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/layout.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/realtime/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/segments/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/sessions/page.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx
create mode 100644 src/app/(main)/websites/[websiteId]/settings/page.tsx
create mode 100644 src/app/(main)/websites/page.tsx
(limited to 'src/app/(main)')
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 ;
+ }
+
+ if (error) {
+ window.location.href = config.cloudMode
+ ? `${process.env.cloudUrl}/login`
+ : `${process.env.basePath || ''}/login`;
+ return null;
+ }
+
+ if (!user || !config) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+ {process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
+
+ )}
+
+ );
+}
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: ,
+ },
+ {
+ id: 'links',
+ label: formatMessage(labels.links),
+ path: '/links',
+ icon: ,
+ },
+ {
+ id: 'pixels',
+ label: formatMessage(labels.pixels),
+ path: '/pixels',
+ icon: ,
+ },
+ ];
+
+ return (
+
+
+ {({ close }) => {
+ return (
+ <>
+
+
+ {links.map(link => {
+ return (
+
+
+
+
+
+ );
+ })}
+
+ {websiteId && }
+ {isAdmin && }
+ {isSettings && }
+ >
+ );
+ }}
+
+
+ } style={{ width: 'auto' }}>
+ umami
+
+
+
+ );
+}
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: ,
+ },
+ {
+ id: 'links',
+ label: formatMessage(labels.links),
+ path: '/links',
+ icon: ,
+ },
+ {
+ id: 'pixels',
+ label: formatMessage(labels.pixels),
+ path: '/pixels',
+ icon: ,
+ },
+ ];
+
+ const handleSelect = (id: Key) => {
+ router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
+ };
+
+ return (
+
+ setIsCollapsed(false)}>
+ : }
+ style={{ maxHeight: 40 }}
+ >
+ {!isCollapsed && !hasNav && }
+
+
+
+
+
+
+ {links.map(({ id, path, label, icon }) => {
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+ {children}
+
+
+ );
+}
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: ,
+ },
+ {
+ id: 'websites',
+ label: formatMessage(labels.websites),
+ path: '/admin/websites',
+ icon: ,
+ },
+ {
+ id: 'teams',
+ label: formatMessage(labels.teams),
+ path: '/admin/teams',
+ icon: ,
+ },
+ ],
+ },
+ ];
+
+ const selectedKey = items
+ .flatMap(e => e.items)
+ ?.find(({ path }) => path && pathname.startsWith(path))?.id;
+
+ return (
+
+ );
+}
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 {children};
+}
+
+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 (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+ <>
+
+
+ {(row: any) => {row.name}}
+
+
+ {(row: any) => row?._count?.members}
+
+
+ {(row: any) => row?._count?.websites}
+
+
+ {(row: any) => {
+ const name = row?.members?.[0]?.user?.username;
+
+ return (
+
+ {name}
+
+ );
+ }}
+
+
+ {(row: any) => }
+
+ {showActions && (
+
+ {(row: any) => {
+ const { id } = row;
+
+ return (
+
+
+
+
+ );
+ }}
+
+ )}
+
+
+
+
+ >
+ );
+}
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 (
+
+
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+ {formatMessage(messages.confirmDelete, { target: username })}
+
+ );
+}
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 (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+ );
+}
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 (
+ <>
+
+
+ {(row: any) => {row.username}}
+
+
+ {(row: any) =>
+ formatMessage(
+ labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
+ )
+ }
+
+
+ {(row: any) => row._count.websites}
+
+
+ {(row: any) => }
+
+ {showActions && (
+
+ {(row: any) => {
+ const { id } = row;
+
+ return (
+
+
+
+
+ );
+ }}
+
+ )}
+
+
+ {
+ setDeleteUser(null);
+ }}
+ />
+
+ >
+ );
+}
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 (
+
+ );
+}
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 } />;
+}
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 (
+
+
+
+
+
+
+
+
+ );
+}
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(null);
+
+export function UserProvider({ userId, children }: { userId: string; children: ReactNode }) {
+ const { data: user, isFetching, isLoading } = useUserQuery(userId);
+
+ if (isFetching && isLoading) {
+ return ;
+ }
+
+ if (!user) {
+ return null;
+ }
+
+ return {children};
+}
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 (
+
+
+
+ {formatMessage(labels.details)}
+ {formatMessage(labels.websites)}
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+ {({ data }) => (
+
+ )}
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+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 (
+
+ {props => }
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+ <>
+
+
+ {(row: any) => (
+
+ {row.name}
+
+ )}
+
+
+ {(row: any) => {row.domain}}
+
+
+ {(row: any) => {
+ if (row?.team) {
+ return (
+
+
+
+
+
+ {row?.team?.name}
+
+
+ );
+ }
+ return (
+
+ {row?.user?.username}
+
+ );
+ }}
+
+
+ {(row: any) => }
+
+
+ {(row: any) => {
+ const { id } = row;
+
+ return (
+
+
+
+
+ );
+ }}
+
+
+
+
+
+ >
+ );
+}
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 (
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+
+
+
+
+
+
+ );
+}
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 (
+
+ Board title
+ {boardId}
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+
+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 (
+
+
+ {data.name}
+
+
+
+
+
+
+ Page links
+
+ page one
+
+
+ page two
+
+
+
+
+
+ Click events
+
+
+
+
+ DIV with attribute
+
+
+
+ Javascript events
+
+
+
+
+
+
+ Pageviews
+
+ Events
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 (
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 (
+
+ {children}
+
+ );
+}
+
+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 (
+ }
+ label={formatMessage(labels.addLink)}
+ variant="primary"
+ width="600px"
+ >
+ {({ close }) => }
+
+ );
+}
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 (
+ }
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ {name},
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
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 (
+ } title={formatMessage(labels.link)} variant="quiet" width="800px">
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+}
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 ;
+ }
+
+ return (
+
+ );
+}
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(null);
+
+export function LinkProvider({ linkId, children }: { linkId?: string; children: ReactNode }) {
+ const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
+
+ if (isFetching && isLoading) {
+ return ;
+ }
+
+ if (!link) {
+ return null;
+ }
+
+ return {children};
+}
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 (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {({ id, name }: any) => {
+ return {name};
+ }}
+
+
+ {({ slug }: any) => {
+ const url = getSlugUrl(slug);
+ return (
+
+ {url}
+
+ );
+ }}
+
+
+ {({ url }: any) => {
+ return {url};
+ }}
+
+
+ {(row: any) => }
+
+
+ {({ id, name }: any) => {
+ return (
+
+
+
+
+ );
+ }}
+
+
+ );
+}
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 (
+
+
+ {allowFilter ? : }
+ {allowDateFilter && }
+ {allowDownload && }
+ {allowMonthFilter && }
+
+ {allowFilter && }
+
+ );
+}
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 (
+ }>
+
+ } label={formatMessage(labels.view)} />
+
+
+ );
+}
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 (
+
+
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ {formatMessage(labels.sources)}
+
+
+ {formatMessage(labels.referrers)}
+ {formatMessage(labels.channels)}
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.environment)}
+
+
+ {formatMessage(labels.browsers)}
+ {formatMessage(labels.os)}
+ {formatMessage(labels.devices)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.location)}
+
+
+ {formatMessage(labels.countries)}
+ {formatMessage(labels.regions)}
+ {formatMessage(labels.cities)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+
+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 (
+ }
+ label={formatMessage(labels.addPixel)}
+ variant="primary"
+ width="600px"
+ >
+ {({ close }) => }
+
+ );
+}
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 (
+ }
+ variant="quiet"
+ title={formatMessage(labels.confirm)}
+ width="400px"
+ >
+ {({ close }) => (
+ {name},
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={getErrorMessage(error)}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
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 (
+ }
+ title={formatMessage(labels.addPixel)}
+ variant="quiet"
+ width="600px"
+ >
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+}
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 ;
+ }
+
+ return (
+
+ );
+}
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(null);
+
+export function PixelProvider({ pixelId, children }: { pixelId?: string; children: ReactNode }) {
+ const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId);
+
+ if (isFetching && isLoading) {
+ return ;
+ }
+
+ if (!pixel) {
+ return null;
+ }
+
+ return {children};
+}
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 (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {({ id, name }: any) => {
+ return {name};
+ }}
+
+
+ {({ slug }: any) => {
+ const url = getSlugUrl(slug);
+ return (
+
+ {url}
+
+ );
+ }}
+
+
+ {(row: any) => }
+
+
+ {(row: any) => {
+ const { id, name } = row;
+
+ return (
+
+
+
+
+ );
+ }}
+
+
+ );
+}
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 (
+
+
+ {allowFilter ? : }
+ {allowDateFilter && }
+ {allowDownload && }
+ {allowMonthFilter && }
+
+ {allowFilter && }
+
+ );
+}
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 (
+ }>
+
+ } label={formatMessage(labels.view)} />
+
+
+ );
+}
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 (
+
+
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ {formatMessage(labels.sources)}
+
+
+ {formatMessage(labels.referrers)}
+ {formatMessage(labels.channels)}
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.environment)}
+
+
+ {formatMessage(labels.browsers)}
+ {formatMessage(labels.os)}
+ {formatMessage(labels.devices)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.location)}
+
+
+ {formatMessage(labels.countries)}
+ {formatMessage(labels.regions)}
+ {formatMessage(labels.cities)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+
+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 (
+
+
+
+
+
+ {children}
+
+
+ );
+}
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: ,
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.account),
+ items: [
+ {
+ id: 'profile',
+ label: formatMessage(labels.profile),
+ path: renderUrl('/settings/profile'),
+ icon: ,
+ },
+ {
+ id: 'teams',
+ label: formatMessage(labels.teams),
+ path: renderUrl('/settings/teams'),
+ icon: ,
+ },
+ ],
+ },
+ ];
+
+ const selectedKey = items
+ .flatMap(e => e.items)
+ .find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id;
+
+ return (
+
+ );
+}
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 {children};
+}
+
+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 (
+
+
+
+
+ );
+}
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 (
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+ );
+}
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 (
+
+
+
+
+ );
+}
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 ;
+}
+
+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 (
+
+
+
+
+
+
+ );
+}
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) => {
+ if (value !== values.newPassword) {
+ return formatMessage(messages.noMatchPassword);
+ }
+ return true;
+ };
+
+ return (
+
+ );
+}
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 ;
+}
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 (
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ {username}
+
+
+
+ {renderRole(role)}
+
+ {!cloudMode && (
+
+
+
+
+
+
+ )}
+
+ );
+}
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 ;
+}
+
+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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+
+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 (
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+
+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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+ {teamName},
+ }}
+ />
+ }
+ 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(null);
+
+export function TeamProvider({ teamId, children }: { teamId?: string; children: ReactNode }) {
+ const { data: team, isLoading, isFetching } = useTeamQuery(teamId);
+
+ if (isFetching && isLoading) {
+ return ;
+ }
+
+ if (!team) {
+ return null;
+ }
+
+ return {children};
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+ {row.name}
+
+ );
+ };
+
+ return (
+
+ {({ data }) => {
+ return ;
+ }}
+
+ );
+}
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 (
+
+
+ {allowJoin && }
+ {allowCreate && user.role !== ROLES.viewOnly && }
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {renderLink}
+
+
+ {(row: any) => row?.members?.find(({ role }) => role === ROLES.teamOwner)?.user?.username}
+
+
+ {(row: any) => row?._count?.members}
+
+
+ {(row: any) => row?._count?.websites}
+
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+ );
+}
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 (
+ }
+ title={formatMessage(labels.editMember)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+
+ )}
+
+ );
+}
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 (
+
+ );
+}
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 (
+ }
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="400px"
+ >
+ {({ close }) => (
+ {userName},
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.remove)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
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 (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+ {(row: any) => row?.user?.username}
+
+
+ {(row: any) => roles[row?.role]}
+
+ {allowEdit && (
+
+ {(row: any) => {
+ if (row?.role === ROLES.teamOwner) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+ }}
+
+ )}
+
+ );
+}
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 (
+
+ }>
+ {!isTeamOwner && !isAdmin && }
+
+
+
+
+
+
+
+ {isTeamOwner && (
+
+
+
+ )}
+
+ );
+}
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 (
+ handleRemoveTeamMember()}>
+
+
+
+ {formatMessage(labels.remove)}
+
+ );
+}
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 (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+ {(row: any) => {row.name}}
+
+
+
+ {(row: any) => row?.createUser?.username}
+
+ {allowEdit && (
+
+ {(row: any) => {
+ if (row?.role === ROLES.teamOwner) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+ }}
+
+ )}
+
+ );
+}
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 ;
+}
+
+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 (
+ }
+ label={formatMessage(labels.addWebsite)}
+ variant="primary"
+ width="400px"
+ >
+ {({ close }) => }
+
+ );
+}
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 (
+
+ );
+}
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(null);
+
+export function WebsiteProvider({
+ websiteId,
+ children,
+}: {
+ websiteId: string;
+ children: ReactNode;
+}) {
+ const { data: website, isFetching, isLoading } = useWebsiteQuery(websiteId);
+
+ if (isFetching && isLoading) {
+ return ;
+ }
+
+ if (!website) {
+ return null;
+ }
+
+ return {children};
+}
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.name}
+
+ );
+
+ return (
+
+ {({ data }) => (
+
+ )}
+
+ );
+}
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 (
+
+ {allowCreate && }
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {renderLink}
+
+
+ {showActions && (
+
+ {(row: any) => {
+ const websiteId = row.id;
+
+ return (
+
+
+
+
+
+ );
+ }}
+
+ )}
+
+ );
+}
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('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 (
+ ({
+ label: x,
+ count: y,
+ percent: z,
+ }))}
+ />
+ );
+ }
+
+ return (
+
+ {data && (
+
+
+ {metrics?.map(({ label, value, formatValue }) => {
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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(
+ 'breakdown',
+ {
+ websiteId,
+ startDate,
+ endDate,
+ fields: selectedFields,
+ },
+ { enabled: !!selectedFields.length },
+ );
+
+ return (
+
+
+
+ {selectedFields.map(field => {
+ return (
+ f.name === field)?.label}
+ width="minmax(120px, 1fr)"
+ >
+ {row => {
+ const value = formatValue(row[field], field);
+ return (
+
+ {value}
+
+ );
+ }}
+
+ );
+ })}
+
+ {row => row?.visitors?.toLocaleString()}
+
+
+ {row => row?.visits?.toLocaleString()}
+
+
+ {row => row?.views?.toLocaleString()}
+
+
+ {row => {
+ const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
+ return `${Math.round(+n)}%`;
+ }}
+
+
+ {row => {
+ const n = row?.totaltime / row?.visits;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const FieldsButton = ({ value, onChange }) => {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ }
+ label={formatMessage(labels.fields)}
+ width="400px"
+ minHeight="300px"
+ variant="outline"
+ >
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+};
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 (
+
+
+ {fields.map(({ name, label }) => {
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 (
+
+
+
+
+
+
+ {name}
+
+
+
+
+
+ {({ close }) => {
+ return (
+
+ );
+ }}
+
+
+
+ {data?.map(
+ (
+ { type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
+ index: number,
+ ) => {
+ const isPage = type === 'path';
+ return (
+
+
+
+
+ {index + 1}
+
+
+ {index > 0 && (
+
+ )}
+
+
+
+
+ {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
+
+ {formatMessage(labels.conversionRate)}
+
+
+
+ {type === 'path' ? : }
+ {value}
+
+
+ {index > 0 && (
+
+ {formatLongNumber(dropped)}
+
+ )}
+
+
+
+
+ {`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`}
+
+
+
+
+
+
+
+ {Math.round(remaining * 100)}%
+
+
+
+
+
+ );
+ },
+ )}
+
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 ;
+ }
+
+ const defaultValues = {
+ name: data?.name || '',
+ window: data?.parameters?.window || 60,
+ steps: data?.parameters?.steps || [{ type: 'path', value: '' }],
+ };
+
+ return (
+
+ );
+}
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 (
+
+
+
+
+
+
+ {data && (
+
+ {data.data?.map((report: any) => (
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
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 ;
+}
+
+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(type, {
+ websiteId,
+ startDate,
+ endDate,
+ ...parameters,
+ });
+ const isPage = parameters?.type === 'path';
+
+ return (
+
+ {data && (
+
+
+
+
+
+ {name}
+
+
+
+
+
+ {({ close }) => {
+ return (
+
+ );
+ }}
+
+
+
+
+
+ {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
+
+ {formatMessage(labels.conversionRate)}
+
+
+
+ {parameters.type === 'path' ? : }
+ {parameters.value}
+
+
+
+
+
+ {`${formatLongNumber(
+ data?.num,
+ )} / ${formatLongNumber(data?.total)}`}
+
+
+
+
+
+ {data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
+
+
+
+ )}
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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) => {
+ await mutateAsync(
+ { ...formData, type: 'goal', websiteId },
+ {
+ onSuccess: async () => {
+ if (id) touch(`report:${id}`);
+ touch('reports:goal');
+ onSave?.();
+ onClose?.();
+ },
+ },
+ );
+ };
+
+ if (id && !data) {
+ return ;
+ }
+
+ const defaultValues = {
+ name: '',
+ parameters: { type: 'path', value: '' },
+ };
+
+ return (
+
+ );
+}
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 (
+
+
+
+
+
+
+ {data && (
+
+ {data.data.map((report: any) => (
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
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 ;
+}
+
+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('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 (
+
+
+
+ {columns.map(({ visitorCount, nodes }, columnIndex) => {
+ return (
+
+
+
{columnIndex + 1}
+
+
+ {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)}
+
+
+
+
+ {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 (
+
+ selected && setActiveNode({ name, columnIndex, paths })
+ }
+ onMouseLeave={() => selected && setActiveNode(null)}
+ >
+
handleClick(name, columnIndex, paths)}
+ >
+
+ {name.startsWith('/') ? : }
+ {name}
+
+
+
+
+ {formatLongNumber(nodeCount)}
+
+
+
+ {`${dropped}% ${formatMessage(labels.dropoff)}`}
+
+
+
+ {`${remaining}% ${formatMessage(labels.conversion)}`}
+
+
+
+
+
+ {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 (
+
+ path.items[columnIndex] === name &&
+ path.items[columnIndex - 1] === nodeName,
+ ),
+ [styles.up]: fromIndex < nodeIndex,
+ [styles.down]: fromIndex > nodeIndex,
+ [styles.flat]: fromIndex === nodeIndex,
+ })}
+ style={{ height }}
+ >
+
+
+
+
+ );
+ })}
+
+
+ );
+ },
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 ;
+}
+
+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 (
+
+ {data && (
+
+
+
+
+
+
+ {formatMessage(labels.cohort)}
+
+
+ {days.map(n => (
+
+
+ {formatMessage(labels.day)} {n}
+
+
+ ))}
+
+ {rows.map(({ date, visitors, records }: any, rowIndex: number) => {
+ return (
+
+
+ {formatDate(date, 'PP', locale)}
+
+
+
+
+ {formatLongNumber(visitors)}
+
+
+ {days.map(day => {
+ if (totalDays - rowIndex < day) {
+ return null;
+ }
+ const percentage = records.filter(a => a.day === day)[0]?.percentage;
+ return (
+
+ {percentage ? `${Number(percentage).toFixed(2)}%` : ''}
+ |
+ );
+ })}
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
+
+const Cell = ({ children }: { children: ReactNode }) => {
+ return (
+
+ {children}
+
+ );
+};
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 (
+
+
+
+
+ );
+}
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 ;
+}
+
+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('revenue', {
+ websiteId,
+ startDate,
+ endDate,
+ currency,
+ });
+
+ const renderCountryName = useCallback(
+ ({ label: code }) => (
+
+
+ {countryNames[code] || formatMessage(labels.unknown)}
+
+ ),
+ [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 (
+
+
+
+
+
+ {data && (
+
+
+ {metrics?.map(({ label, value, formatValue }) => {
+ return (
+
+ );
+ })}
+
+
+
+
+
+ ({
+ label: name,
+ count: Number(value),
+ percent: (value / data?.total.sum) * 100,
+ }))}
+ currency={currency}
+ renderLabel={renderCountryName}
+ />
+
+
+ )}
+
+
+ );
+}
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 (
+
+
+
+
+ );
+}
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 (
+
+
+
+ {(row: any) => formatLongCurrency(row.sum, row.currency)}
+
+
+ {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)}
+
+
+
+
+ );
+}
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 ;
+}
+
+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('utm', {
+ websiteId,
+ startDate,
+ endDate,
+ });
+
+ return (
+
+ {data && (
+
+ {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 (
+
+
+
+
+ {param.replace(/^utm_/, '')}
+
+ ({
+ label: utm,
+ count: views,
+ percent: (views / total) * 100,
+ }))}
+ />
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
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 (
+
+
+
+
+ );
+}
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 ;
+}
+
+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 (
+
+
+
+ );
+}
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 (
+
+
+
+ );
+}
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 (
+
+
+
+ {allowFilter ? : }
+
+
+ {allowDateFilter && (
+
+ )}
+ {allowDownload && }
+ {allowMonthFilter && }
+
+
+ {allowFilter && }
+
+ );
+}
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: ,
+ },
+ {
+ id: 'entry',
+ label: formatMessage(labels.entry),
+ path: updateParams({ view: 'entry' }),
+ icon: ,
+ },
+ {
+ id: 'exit',
+ label: formatMessage(labels.exit),
+ path: updateParams({ view: 'exit' }),
+ icon: ,
+ },
+ {
+ id: 'title',
+ label: formatMessage(labels.title),
+ path: updateParams({ view: 'title' }),
+ icon: ,
+ },
+ {
+ id: 'query',
+ label: formatMessage(labels.query),
+ path: updateParams({ view: 'query' }),
+ icon: ,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.sources),
+ items: [
+ {
+ id: 'referrer',
+ label: formatMessage(labels.referrer),
+ path: updateParams({ view: 'referrer' }),
+ icon: ,
+ },
+ {
+ id: 'channel',
+ label: formatMessage(labels.channel),
+ path: updateParams({ view: 'channel' }),
+ icon: ,
+ },
+ {
+ id: 'domain',
+ label: formatMessage(labels.domain),
+ path: updateParams({ view: 'domain' }),
+ icon: ,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.location),
+ items: [
+ {
+ id: 'country',
+ label: formatMessage(labels.country),
+ path: updateParams({ view: 'country' }),
+ icon: ,
+ },
+ {
+ id: 'region',
+ label: formatMessage(labels.region),
+ path: updateParams({ view: 'region' }),
+ icon: ,
+ },
+ {
+ id: 'city',
+ label: formatMessage(labels.city),
+ path: updateParams({ view: 'city' }),
+ icon: ,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.environment),
+ items: [
+ {
+ id: 'browser',
+ label: formatMessage(labels.browser),
+ path: updateParams({ view: 'browser' }),
+ icon: ,
+ },
+ {
+ id: 'os',
+ label: formatMessage(labels.os),
+ path: updateParams({ view: 'os' }),
+ icon: ,
+ },
+ {
+ id: 'device',
+ label: formatMessage(labels.device),
+ path: updateParams({ view: 'device' }),
+ icon: ,
+ },
+ {
+ id: 'language',
+ label: formatMessage(labels.language),
+ path: updateParams({ view: 'language' }),
+ icon: ,
+ },
+ {
+ id: 'screen',
+ label: formatMessage(labels.screen),
+ path: updateParams({ view: 'screen' }),
+ icon: ,
+ },
+ ].filter(filterExcluded),
+ },
+ {
+ label: formatMessage(labels.other),
+ items: [
+ {
+ id: 'event',
+ label: formatMessage(labels.event),
+ path: updateParams({ view: 'event' }),
+ icon: ,
+ },
+ {
+ id: 'hostname',
+ label: formatMessage(labels.hostname),
+ path: updateParams({ view: 'hostname' }),
+ icon: ,
+ },
+ {
+ id: 'tag',
+ label: formatMessage(labels.tag),
+ path: updateParams({ view: 'tag' }),
+ icon: ,
+ },
+ ].filter(filterExcluded),
+ },
+ ];
+
+ return ;
+}
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 (
+
+
+
+ {({ close }) => {
+ return (
+
+
+
+ );
+ }}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+ }
+ titleHref={renderUrl(`/websites/${website.id}`, false)}
+ >
+
+
+
+ {showActions && (
+
+
+
+
+
+
+ {formatMessage(labels.edit)}
+
+
+ )}
+
+
+ );
+}
+
+const ShareButton = ({ websiteId, shareId }) => {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ } label={formatMessage(labels.share)} width="800px">
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+};
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 (
+
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
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: },
+ { id: 'edit', label: formatMessage(labels.edit), icon: , seperator: true },
+ ];
+
+ const handleAction = (id: any) => {
+ if (id === 'compare') {
+ router.push(updateParams({ compare: 'prev' }));
+ } else if (id === 'edit') {
+ router.push(renderUrl(`/websites/${websiteId}`));
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
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: ,
+ path: renderPath(''),
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ icon: ,
+ path: renderPath('/events'),
+ },
+ {
+ id: 'sessions',
+ label: formatMessage(labels.sessions),
+ icon: ,
+ path: renderPath('/sessions'),
+ },
+ {
+ id: 'realtime',
+ label: formatMessage(labels.realtime),
+ icon: ,
+ path: renderPath('/realtime'),
+ },
+ {
+ id: 'compare',
+ label: formatMessage(labels.compare),
+ icon: ,
+ path: renderPath('/compare'),
+ },
+ {
+ id: 'breakdown',
+ label: formatMessage(labels.breakdown),
+ icon: ,
+ path: renderPath('/breakdown'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.behavior),
+ items: [
+ {
+ id: 'goals',
+ label: formatMessage(labels.goals),
+ icon: ,
+ path: renderPath('/goals'),
+ },
+ {
+ id: 'funnel',
+ label: formatMessage(labels.funnels),
+ icon: ,
+ path: renderPath('/funnels'),
+ },
+ {
+ id: 'journeys',
+ label: formatMessage(labels.journeys),
+ icon: ,
+ path: renderPath('/journeys'),
+ },
+ {
+ id: 'retention',
+ label: formatMessage(labels.retention),
+ icon: ,
+ path: renderPath('/retention'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.audience),
+ items: [
+ {
+ id: 'segments',
+ label: formatMessage(labels.segments),
+ icon: ,
+ path: renderPath('/segments'),
+ },
+ {
+ id: 'cohorts',
+ label: formatMessage(labels.cohorts),
+ icon: ,
+ path: renderPath('/cohorts'),
+ },
+ ],
+ },
+ {
+ label: formatMessage(labels.growth),
+ items: [
+ {
+ id: 'utm',
+ label: formatMessage(labels.utm),
+ icon: ,
+ path: renderPath('/utm'),
+ },
+ {
+ id: 'revenue',
+ label: formatMessage(labels.revenue),
+ icon: ,
+ path: renderPath('/revenue'),
+ },
+ {
+ id: 'attribution',
+ label: formatMessage(labels.attribution),
+ icon: ,
+ path: renderPath('/attribution'),
+ },
+ ],
+ },
+ ];
+
+ const handleChange = (value: string) => {
+ router.push(renderUrl(`/websites/${value}`));
+ };
+
+ const renderValue = (value: any) => {
+ return (
+
+ {value?.selectedItem?.name}
+
+ );
+ };
+
+ const selectedKey = items
+ .flatMap(e => e.items)
+ .find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id;
+
+ return (
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ {formatMessage(labels.pages)}
+
+
+ {formatMessage(labels.path)}
+ {formatMessage(labels.entry)}
+ {formatMessage(labels.exit)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.sources)}
+
+
+ {formatMessage(labels.referrers)}
+ {formatMessage(labels.channels)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.environment)}
+
+
+ {formatMessage(labels.browsers)}
+ {formatMessage(labels.os)}
+ {formatMessage(labels.devices)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.location)}
+
+
+ {formatMessage(labels.countries)}
+ {formatMessage(labels.regions)}
+ {formatMessage(labels.cities)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.traffic)}
+
+
+
+
+ {isSharePage && (
+
+
+ {formatMessage(labels.events)}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
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: ,
+ path: '',
+ },
+ {
+ id: 'events',
+ label: formatMessage(labels.events),
+ icon: ,
+ path: '/events',
+ },
+ {
+ id: 'sessions',
+ label: formatMessage(labels.sessions),
+ icon: ,
+ path: '/sessions',
+ },
+ {
+ id: 'realtime',
+ label: formatMessage(labels.realtime),
+ icon: ,
+ path: '/realtime',
+ },
+ {
+ id: 'reports',
+ label: formatMessage(labels.reports),
+ icon: ,
+ path: '/reports',
+ },
+ ];
+
+ const selectedKey = links.find(({ path }) => path && pathname.includes(path))?.id || 'overview';
+
+ return (
+
+
+
+ {links.map(({ id, label, icon, path }) => {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ );
+ })}
+
+
+
+ );
+}
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 (
+ }
+ label={formatMessage(labels.cohort)}
+ variant="primary"
+ width="800px"
+ >
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+}
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 (
+ }
+ variant="quiet"
+ title={formatMessage(labels.confirm)}
+ width="400px"
+ >
+ {({ close }) => (
+ {name},
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
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 (
+ }
+ variant="quiet"
+ title={formatMessage(labels.cohort)}
+ width="800px"
+ >
+ {({ close }) => {
+ return (
+
+ );
+ }}
+
+ );
+}
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 ;
+ }
+
+ const defaultValues = {
+ parameters: { filters, dateRange: '30day', action: { type: 'path', value: '' } },
+ };
+
+ return (
+
+ );
+}
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 ;
+ };
+
+ return (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {(row: any) => (
+ {row.name}
+ )}
+
+
+ {(row: any) => }
+
+
+ {(row: any) => {
+ const { id, name, parameters } = row;
+
+ return (
+
+
+
+
+ );
+ }}
+
+
+ );
+}
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 ;
+}
+
+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 (
+
+
+
+
+
+
+
+
+ );
+}
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) && (
+
+ {formatNumber(change)}%
+
+ )
+ );
+ };
+
+ const handleChange = (id: any) => {
+ router.push(renderPath(id));
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {formatMessage(labels.previous)}
+
+
+
+
+
+
+ {formatMessage(labels.current)}
+
+
+
+
+
+
+ >
+ );
+}
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 ;
+}
+
+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 (
+
+
+ {data && (
+
+
+
+
+ )}
+ {eventName && propertyName && (
+
+ )}
+
+
+ );
+}
+
+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 (
+
+ {values && (
+
+
+
+
+ )}
+
+ );
+};
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 ;
+ };
+
+ return (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+ {data && (
+
+
+
+
+
+
+ )}
+
+ );
+}
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 (
+
+
+
+ handleSelect(key)}>
+
+ {formatMessage(labels.chart)}
+ {formatMessage(labels.activity)}
+ {formatMessage(labels.properties)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {(row: any) => {
+ return (
+
+
+ : }
+ label={formatMessage(row.eventName ? labels.triggeredEvent : labels.viewedPage)}
+ />
+
+
+ {row.eventName || row.urlPath}
+
+ {row.hasData > 0 && }
+
+ );
+ }}
+
+
+ {(row: any) => {
+ return (
+
+
+
+ );
+ }}
+
+
+ {(row: any) => (
+
+ {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')}
+
+ )}
+
+
+ {(row: any) => (
+
+ {formatValue(row.browser, 'browser')}
+
+ )}
+
+
+ {(row: any) => (
+
+ {formatValue(row.device, 'device')}
+
+ )}
+
+
+ {(row: any) => }
+
+
+ );
+}
+
+const PropertiesButton = props => {
+ return (
+
+
+
+
+
+
+ );
+};
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 ;
+}
+
+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 {children};
+}
+
+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 ;
+}
+
+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 }) => (
+ } label={countryNames[code]} />
+ ),
+ [countryNames, locale],
+ );
+
+ return (
+ ({
+ 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 (
+
+
+
+
+
+
+ );
+}
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]: ,
+ [TYPE_SESSION]: ,
+ [TYPE_EVENT]: ,
+};
+
+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 (
+ {eventName || formatMessage(labels.unknown)},
+ url: (
+
+ {urlPath}
+
+ ),
+ }}
+ />
+ );
+ }
+
+ if (__type === TYPE_PAGEVIEW) {
+ return (
+
+ {urlPath}
+
+ );
+ }
+
+ if (__type === TYPE_SESSION) {
+ return (
+ {countryNames[country] || formatMessage(labels.unknown)},
+ browser: {BROWSERS[browser]},
+ os: {OS_NAMES[os] || os},
+ device: {formatMessage(labels[device] || labels.unknown)},
+ }}
+ />
+ );
+ }
+ };
+
+ const TableRow = ({ index, style }) => {
+ const row = logs[index];
+ return (
+
+
+
+
+
+
+
+ {getTime(row)}
+
+
+
+ {getDetail(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 (
+
+ {formatMessage(labels.activity)}
+ {isPhone ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
+
+ )}
+
+
+ {logs?.length === 0 && }
+ {logs?.length > 0 && (
+
+ {TableRow}
+
+ )}
+
+
+
+ );
+}
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 ;
+ }
+
+ const countries = percentFilter(
+ Object.keys(data.countries)
+ .map(key => ({ x: key, y: data.countries[key] }))
+ .sort(firstBy('y', -1)),
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+ {x}
+
+ );
+ };
+
+ const pages = percentFilter(
+ Object.keys(urls)
+ .map(key => {
+ return {
+ x: key,
+ y: urls[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ ({
+ 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 (
+
+ {x}
+
+ );
+ };
+
+ const domains = percentFilter(
+ Object.keys(referrers)
+ .map(key => {
+ return {
+ x: key,
+ y: referrers[key],
+ };
+ })
+ .sort(thenby.firstBy('y', -1))
+ .slice(0, limit),
+ );
+
+ return (
+ ({
+ 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 ;
+}
+
+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 (
+ }
+ label={formatMessage(labels.segment)}
+ variant="primary"
+ width="800px"
+ >
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+}
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 (
+ }
+ title={formatMessage(labels.confirm)}
+ variant="quiet"
+ width="600px"
+ >
+ {({ close }) => (
+ {name},
+ }}
+ />
+ }
+ isLoading={isPending}
+ error={error}
+ onConfirm={handleConfirm.bind(null, close)}
+ onClose={close}
+ buttonLabel={formatMessage(labels.delete)}
+ buttonVariant="danger"
+ />
+ )}
+
+ );
+}
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 (
+ }
+ title={formatMessage(labels.segment)}
+ variant="quiet"
+ width="800px"
+ >
+ {({ close }) => {
+ return (
+
+ );
+ }}
+
+ );
+}
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 ;
+ }
+
+ return (
+
+ );
+}
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 ;
+ };
+
+ return (
+
+ {({ data }) => }
+
+ );
+}
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 (
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {(row: any) => (
+
+ {row.name}
+
+ )}
+
+
+ {(row: any) => }
+
+
+ {(row: any) => {
+ const { id, name } = row;
+
+ return (
+
+
+
+
+ );
+ }}
+
+
+ );
+}
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 ;
+}
+
+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 (
+
+
+ {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => {
+ const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
+ lastDay = createdAt;
+
+ return (
+
+ {showHeader && {formatTimezoneDate(createdAt, 'PPPP')}}
+
+
+ {formatTimezoneDate(createdAt, 'pp')}
+
+
+ {eventName ? : }
+
+ {eventName
+ ? formatMessage(labels.triggeredEvent)
+ : formatMessage(labels.viewedPage)}
+
+
+ {eventName || urlPath}
+
+ {hasData > 0 && }
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+const PropertiesButton = props => {
+ return (
+
+
+
+
+
+
+ );
+};
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 (
+
+ {!data?.length && }
+
+ {data?.map(({ dataKey, dataType, stringValue }) => {
+ return (
+
+
+
+ {stringValue}
+
+
+ {DATA_TYPES[dataType]}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
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 (
+
+ }>
+ {data?.distinctId}
+
+
+ }>
+
+
+
+ }>
+
+
+
+ }
+ >
+ {formatValue(data?.country, 'country')}
+
+
+ }>
+ {getRegionName(data?.region)}
+
+
+ }>
+ {data?.city}
+
+
+ }
+ >
+ {formatValue(data?.browser, 'browser')}
+
+
+ }
+ >
+ {formatValue(data?.os, 'os')}
+
+
+ }
+ >
+ {formatValue(data?.device, 'device')}
+
+
+ );
+}
+
+const Info = ({
+ label,
+ icon,
+ children,
+}: {
+ label: string;
+ icon?: ReactNode;
+ children: ReactNode;
+}) => {
+ return (
+
+
+
+ {icon && {icon}}
+ {children || '—'}
+
+
+ );
+};
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 (
+
+
+
+
+
+ );
+}
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 (
+
+ {data && (
+
+ {onClose && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatMessage(labels.activity)}
+ {formatMessage(labels.properties)}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
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 (
+
+
+ {data && (
+
+
+
+ )}
+ {propertyName && }
+
+
+ );
+}
+
+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 (
+
+ {data && (
+
+
+
+
+ )}
+
+ );
+};
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 (
+
+
+
+
+ `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
+ />
+
+ );
+}
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 (
+
+ {({ data }) => {
+ return ;
+ }}
+
+ );
+}
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 (
+
+ {data && (
+
+
+
+
+
+
+ )}
+
+ );
+}
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 (
+
+
+
+
+
+ {formatMessage(labels.activity)}
+ {formatMessage(labels.properties)}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+ {(row: any) => (
+
+
+
+ )}
+
+
+
+
+ {(row: any) => (
+
+ {formatValue(row.country, 'country')}
+
+ )}
+
+
+
+ {(row: any) => (
+
+ {formatValue(row.browser, 'browser')}
+
+ )}
+
+
+ {(row: any) => (
+
+ {formatValue(row.os, 'os')}
+
+ )}
+
+
+ {(row: any) => (
+
+ {formatValue(row.device, 'device')}
+
+ )}
+
+
+ {(row: any) => }
+
+
+ );
+}
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 ;
+}
+
+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 ;
+}
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 (
+
+ {!isAdmin && (
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (
+ <>
+
+
+ } label={formatMessage(labels.website)} />
+
+
+ } />
+ >
+ );
+}
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 (
+
+ );
+}
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 = ``;
+
+ return (
+
+
+ {formatMessage(messages.trackingCode)}
+
+
+ );
+}
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(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 ;
+ }
+
+ return (
+
+ );
+}
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 ;
+}
+
+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 ;
+}
+
+export const metadata: Metadata = {
+ title: 'Websites',
+};
--
cgit v1.2.3